文件下载
普通方式:自己写 OutputStream
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import javax.servlet.http.HttpServletResponse;
/**
* @author mrcode
* @date 2021/6/1 18:53
*/
@RestController
public class Testxx {
@GetMapping("/download/{id}")
public void download(
@PathVariable Integer id,
HttpServletResponse response) throws IOException {
// 如果有错误信息,则可通过改变响应类型和状态完成提示
if (true) {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("资源下载失败");
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return;
}
// 开始文件下载
String fileName = "中文名称.xml";
final Path path = Paths.get("d:\\xx.xml");
// 下载就直接给二进制类型
response.setContentType(MediaType.APPLICATION_OCTET_STREAM.toString());
// 设置文件大小
response.setContentLength((int) Files.size(path));
// 文件名有中文,先编码
fileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString());
// 解决中文文件名乱码关键行:兼容 mac safari 浏览器
response.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"; filename*=utf-8''" + fileName);
Files.copy(path, response.getOutputStream());
}
}
文件缓存功能
关于文件缓存的可以通过 HTTP 缓存头来实现
Spring 开发中的实战
浏览器行为:文件下载、浏览器内部预览
ResponseEntity 返回值方式
原理:利用了 SpringMvc 提供的 ResponseEntity 返回值实现了比较优雅的文件下载写法 ResponseEntity 的 body 使用 HttpMessageConverter 来处理如何将 body 写入到响应中,而文件下载场景中这利用了 ResourceRegionHttpMessageConverter 转换器的功能 使用它的优点:代码优雅、并且支持HTTP Range 功能(应该是分段传输加快下载速度的功能,具体百度下这个)
下面是一个例子:缓存 eTag + ResponseEntity 结合的案例
@ApiOperation(value = "获取人工诊断体检报告(PDF 概述文件 )")
@GetMapping("/{id}/download-pdf-overview")
public ResponseEntity downloadPdfOverview(WebRequest request,
@PathVariable Integer id,
@ApiParam("1 浏览器内部打开,2 下载") @RequestParam(defaultValue = "1") Integer type) throws IOException {
final ConsumerData cd = consumerDataService.getById(id);
if (cd == null) {
return ResponseEntity.ok(ResultHelper.fail(ErrorCodes.E1003));
}
final String pdfOverviewFilePath = cd.getPdfOverviewFilePath();
if (StrUtil.isBlank(pdfOverviewFilePath)) {
return ResponseEntity.ok(ResultHelper.fail("未配置体检报告"));
}
// 这里以文件名作为 eTag 值
final String fileName = Paths.get(pdfOverviewFilePath).getFileName().toString();
final String eTag = fileName;
if (request.checkNotModified(eTag)) {
return null;
}
// 这里是从远程存储下载到本地磁盘,并获得本地磁盘文件的路径
final Path path = minioClientHelper.downloadOrCache(pdfOverviewFilePath);
String contentDispositionValue = StrUtil.format("{}; filename=\"{}\"; filename*=utf-8''{}",
type == 1 ? "inline" : "attachment", // inline 浏览器内部打开,attachment 下载
path.getFileName(), path.getFileName()
);
return ResponseEntity.ok()
.eTag(eTag) // 设置 eTag
// 下面设置相应的头
.contentLength((int) Files.size(path))
.contentType(MediaType.APPLICATION_PDF)
.header("Content-Disposition", contentDispositionValue)
// 这里构造 ResourceRegionHttpMessageConverter 支持的序列化格式
.body(new FileSystemResource(path));
}
下面看看响应头,所以在 contentLength 里面可以不设置了,因为会有 ResourceRegionHttpMessageConverter 针对 Ranges 的功能去填充它
文件上传
普通的 multipart/form-data 表单提交方式
MultipartFile 相关支持在 spring mvc 的官方文档 中就有具体的说明
文件上传使用指定的 MultipartFile 类来接收就行了
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
/**
* @author mrcode
* @date 2021/6/1 18:53
*/
@RestController
public class Testxx {
@PostMapping("/download/{id}")
public void download(
@PathVariable Integer id,
@RequestParam List<MultipartFile> files) throws IOException {
}
}
文件和 JSON 混合提交
这个知识点在 SpringMvc 官方文档中有讲解到,不过没有讲如何构造前端请求
当你的接口本来使用 json 方式提交参数的时候,这个时候加了一个需求需要和文件一起提交,那么就不能使用 Content-Type:application/json
方式提交了,需要使用 Content-Type: multipart/form-data;
提交
但是一旦使用 multipart 方式提交的话,就不能使用这种方式接受参数了
public Result search(
@Validated @RequestBody ChannelSearchRequest params) {
这个时候需要改用
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
// RequestPart 注解就是 spring 提供的解析方式,可以从 multipart/form-data 中提取参数的方式
public Result search(
@RequestPart("jsonBody") @Validated CustomerContractCreateRequest params,
@RequestPart("document") MultipartFile document) {
最终解决方案
后端 controller
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
// RequestPart 注解就是 spring 提供的解析方式,可以从 multipart/form-data 中提取参数的方式
public Result search(
@RequestPart("jsonBody") @Validated CustomerContractCreateRequest params,
@RequestPart("document") MultipartFile document) {
前端构建提交参数
const req = new FormData()
req.append('document', document)
// 需要手动指定,Blob 里面将 JSON 对象格式化为字符串传递
req.append('jsonBody', new Blob([JSON.stringify(data.jsonBody)], { type: 'application/json' }))
这里使用 axios 发起请求
return axios.request({
url: '/api/tools/build',
method: 'post',
data: req
})
// axios 会从 data 参数中去判定是什么类型,从而自动设置请求头中的 Content-Type ; Content-Type: multipart/form-data;
尝试解决问题的过程
下面是尝试解决此问题的过程
那么前端发起的请求就类似下面这样(注意:这个不是最终的构建方式,这种方式会有问题)
JavaScript 代码如下
// 使用 input 收集提交的文件 document
// 这里代码很长,其实原理就很简单:
// 1. 从 change 事件中,直接通过 ref 拿到 input 中上传的文件对象
// 2. 其他的代码是根据自己的业务进行了后缀校验、文件大小校验
// supportFilesType: 'application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/msword',
<input :accept="supportFilesType"
ref="documentFile" type="file" @change="documentFileChange"/>
documentFileChange () {
const uploadFile = this.$refs.documentFile
if (!uploadFile) {
return
}
const file = uploadFile.files[0]
const fileType = file.type
const fileTyps = _.filter(this.supportFilesType.split(','), item => fileType === item)
if (fileTyps.length === 0) {
this.$message.error('文档只支持 pdf、docx、doc 格式')
return
}
const isLtSize = file.size / 1024 / 1024 > 10
if (isLtSize) {
this.$message.error('文档不能超过 10MB!')
return
}
this.form.document = file
}
}
// 构建参数
const params = new FormData()
params.append('document', document)
// JSON.stringify(jsonBody) 可以使用此函数将对象转换为 json 字符串
params.append('jsonBody', '{"name":"xxxx"}') // json 字符串
axios.request({
url: '/api/customer/contract/',
method: 'post',
data: params
})
如果按照上述方式前端进行构建的话,你会得到一个错误提示
Content type ' application/octet-stream' not supported
这个时候就很奇怪,看前端发起的请求(上面的截图)和代码似乎并没有什么错误,到底是哪里的问题呢?通过 debug 出错堆栈,最终确认的关键源码如下
org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodArgumentResolver#readWithMessageConverters(org.springframework.http.HttpInputMessage, org.springframework.core.MethodParameter, java.lang.reflect.Type)
protected <T> Object readWithMessageConverters(HttpInputMessage inputMessage, MethodParameter parameter,
Type targetType) throws IOException, HttpMediaTypeNotSupportedException, HttpMessageNotReadableException {
MediaType contentType;
boolean noContentType = false;
try {
contentType = inputMessage.getHeaders().getContentType();
}
catch (InvalidMediaTypeException ex) {
throw new HttpMediaTypeNotSupportedException(ex.getMessage());
}
if (contentType == null) {
noContentType = true;
// application/octet-stream
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
上面源码,会为每一个 multipart/form-data
中的参数进行解析,这里当解析到 jsonBody 参数的时候,发现没有 content-type 就默认给定了一个 Content-Type: application/octet-stream
所以就提示报错了。
那么正确的前端构造参数如下:
const req = new FormData()
req.append('document', document)
// 需要手动指定,Blob 里面将 JSON 对象格式化为字符串传递
req.append('jsonBody', new Blob([JSON.stringify(data.jsonBody)], { type: 'application/json' }))
发出后的截图如下,直接看类型是 binary
IDEA RESTful 测试语法
controller 中如下写
@PostMapping
@ApiOperation(value = "添加图片")
public Result add(
@RequestPart("jsonBody") @Validated PictureAddRequest params,
@RequestPart("images") List<MultipartFile> images
) {
return ResultHelper.ok();
}
在 IDEA RESTful (xx.http 文件) 中下面这样写
### 图片管理 - 添加图片
POST {{host}}/admin/picture/
Content-Type: multipart/form-data; boundary=WebAppBoundary
Authorization: bearer {{access_token}}
--WebAppBoundary
Content-Disposition: form-data; name="jsonBody"; filename="blob"
Content-Type: application/json
{"labelId": 1,"isEnable": true}
--WebAppBoundary
Content-Disposition: form-data; name="images"; filename="2.jpg"
< d:\Users\mrcode\Pictures\2.jpg
--WebAppBoundary
Content-Disposition: form-data; name="images"; filename="3.gif"
< d:\Users\mrcode\Pictures\3.gif
--WebAppBoundary--