文件下载

普通方式:自己写 OutputStream

  1. import org.springframework.http.MediaType;
  2. import org.springframework.web.bind.annotation.GetMapping;
  3. import org.springframework.web.bind.annotation.PathVariable;
  4. import org.springframework.web.bind.annotation.RestController;
  5. import java.io.IOException;
  6. import java.net.URLEncoder;
  7. import java.nio.charset.StandardCharsets;
  8. import java.nio.file.Files;
  9. import java.nio.file.Path;
  10. import java.nio.file.Paths;
  11. import javax.servlet.http.HttpServletResponse;
  12. /**
  13. * @author mrcode
  14. * @date 2021/6/1 18:53
  15. */
  16. @RestController
  17. public class Testxx {
  18. @GetMapping("/download/{id}")
  19. public void download(
  20. @PathVariable Integer id,
  21. HttpServletResponse response) throws IOException {
  22. // 如果有错误信息,则可通过改变响应类型和状态完成提示
  23. if (true) {
  24. response.setContentType(MediaType.APPLICATION_JSON_VALUE);
  25. response.getWriter().write("资源下载失败");
  26. response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
  27. return;
  28. }
  29. // 开始文件下载
  30. String fileName = "中文名称.xml";
  31. final Path path = Paths.get("d:\\xx.xml");
  32. // 下载就直接给二进制类型
  33. response.setContentType(MediaType.APPLICATION_OCTET_STREAM.toString());
  34. // 设置文件大小
  35. response.setContentLength((int) Files.size(path));
  36. // 文件名有中文,先编码
  37. fileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString());
  38. // 解决中文文件名乱码关键行:兼容 mac safari 浏览器
  39. response.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"; filename*=utf-8''" + fileName);
  40. Files.copy(path, response.getOutputStream());
  41. }
  42. }

文件缓存功能

关于文件缓存的可以通过 HTTP 缓存头来实现
Spring 开发中的实战

浏览器行为:文件下载、浏览器内部预览

根据文件名获取 mimeType

ResponseEntity 返回值方式

原理:利用了 SpringMvc 提供的 ResponseEntity 返回值实现了比较优雅的文件下载写法 ResponseEntity 的 body 使用 HttpMessageConverter 来处理如何将 body 写入到响应中,而文件下载场景中这利用了 ResourceRegionHttpMessageConverter 转换器的功能 使用它的优点:代码优雅、并且支持HTTP Range 功能(应该是分段传输加快下载速度的功能,具体百度下这个)

下面是一个例子:缓存 eTag + ResponseEntity 结合的案例

  1. @ApiOperation(value = "获取人工诊断体检报告(PDF 概述文件 )")
  2. @GetMapping("/{id}/download-pdf-overview")
  3. public ResponseEntity downloadPdfOverview(WebRequest request,
  4. @PathVariable Integer id,
  5. @ApiParam("1 浏览器内部打开,2 下载") @RequestParam(defaultValue = "1") Integer type) throws IOException {
  6. final ConsumerData cd = consumerDataService.getById(id);
  7. if (cd == null) {
  8. return ResponseEntity.ok(ResultHelper.fail(ErrorCodes.E1003));
  9. }
  10. final String pdfOverviewFilePath = cd.getPdfOverviewFilePath();
  11. if (StrUtil.isBlank(pdfOverviewFilePath)) {
  12. return ResponseEntity.ok(ResultHelper.fail("未配置体检报告"));
  13. }
  14. // 这里以文件名作为 eTag 值
  15. final String fileName = Paths.get(pdfOverviewFilePath).getFileName().toString();
  16. final String eTag = fileName;
  17. if (request.checkNotModified(eTag)) {
  18. return null;
  19. }
  20. // 这里是从远程存储下载到本地磁盘,并获得本地磁盘文件的路径
  21. final Path path = minioClientHelper.downloadOrCache(pdfOverviewFilePath);
  22. String contentDispositionValue = StrUtil.format("{}; filename=\"{}\"; filename*=utf-8''{}",
  23. type == 1 ? "inline" : "attachment", // inline 浏览器内部打开,attachment 下载
  24. path.getFileName(), path.getFileName()
  25. );
  26. return ResponseEntity.ok()
  27. .eTag(eTag) // 设置 eTag
  28. // 下面设置相应的头
  29. .contentLength((int) Files.size(path))
  30. .contentType(MediaType.APPLICATION_PDF)
  31. .header("Content-Disposition", contentDispositionValue)
  32. // 这里构造 ResourceRegionHttpMessageConverter 支持的序列化格式
  33. .body(new FileSystemResource(path));
  34. }

下面看看响应头,所以在 contentLength 里面可以不设置了,因为会有 ResourceRegionHttpMessageConverter 针对 Ranges 的功能去填充它
image.png


文件上传

普通的 multipart/form-data 表单提交方式

MultipartFile 相关支持在 spring mvc 的官方文档 中就有具体的说明

文件上传使用指定的 MultipartFile 类来接收就行了

  1. import org.springframework.web.bind.annotation.PostMapping;
  2. import org.springframework.web.bind.annotation.PathVariable;
  3. import org.springframework.web.bind.annotation.RequestParam;
  4. import org.springframework.web.bind.annotation.RestController;
  5. import org.springframework.web.multipart.MultipartFile;
  6. import java.io.IOException;
  7. import java.util.List;
  8. /**
  9. * @author mrcode
  10. * @date 2021/6/1 18:53
  11. */
  12. @RestController
  13. public class Testxx {
  14. @PostMapping("/download/{id}")
  15. public void download(
  16. @PathVariable Integer id,
  17. @RequestParam List<MultipartFile> files) throws IOException {
  18. }
  19. }

文件和 JSON 混合提交

这个知识点在 SpringMvc 官方文档中有讲解到,不过没有讲如何构造前端请求

当你的接口本来使用 json 方式提交参数的时候,这个时候加了一个需求需要和文件一起提交,那么就不能使用 Content-Type:application/json 方式提交了,需要使用 Content-Type: multipart/form-data; 提交

但是一旦使用 multipart 方式提交的话,就不能使用这种方式接受参数了

  1. public Result search(
  2. @Validated @RequestBody ChannelSearchRequest params) {

这个时候需要改用

  1. import org.springframework.web.bind.annotation.RequestPart;
  2. import org.springframework.web.multipart.MultipartFile;
  3. // RequestPart 注解就是 spring 提供的解析方式,可以从 multipart/form-data 中提取参数的方式
  4. public Result search(
  5. @RequestPart("jsonBody") @Validated CustomerContractCreateRequest params,
  6. @RequestPart("document") MultipartFile document) {

那么前后端需要如何做?请看最终解决方案

最终解决方案

后端 controller

  1. import org.springframework.web.bind.annotation.RequestPart;
  2. import org.springframework.web.multipart.MultipartFile;
  3. // RequestPart 注解就是 spring 提供的解析方式,可以从 multipart/form-data 中提取参数的方式
  4. public Result search(
  5. @RequestPart("jsonBody") @Validated CustomerContractCreateRequest params,
  6. @RequestPart("document") MultipartFile document) {

前端构建提交参数

  1. const req = new FormData()
  2. req.append('document', document)
  3. // 需要手动指定,Blob 里面将 JSON 对象格式化为字符串传递
  4. req.append('jsonBody', new Blob([JSON.stringify(data.jsonBody)], { type: 'application/json' }))

这里使用 axios 发起请求

  1. return axios.request({
  2. url: '/api/tools/build',
  3. method: 'post',
  4. data: req
  5. })
  6. // axios 会从 data 参数中去判定是什么类型,从而自动设置请求头中的 Content-Type ; Content-Type: multipart/form-data;

尝试解决问题的过程

下面是尝试解决此问题的过程

那么前端发起的请求就类似下面这样(注意:这个不是最终的构建方式,这种方式会有问题)
image.png
JavaScript 代码如下

  1. // 使用 input 收集提交的文件 document
  2. // 这里代码很长,其实原理就很简单:
  3. // 1. 从 change 事件中,直接通过 ref 拿到 input 中上传的文件对象
  4. // 2. 其他的代码是根据自己的业务进行了后缀校验、文件大小校验
  5. // supportFilesType: 'application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/msword',
  6. <input :accept="supportFilesType"
  7. ref="documentFile" type="file" @change="documentFileChange"/>
  8. documentFileChange () {
  9. const uploadFile = this.$refs.documentFile
  10. if (!uploadFile) {
  11. return
  12. }
  13. const file = uploadFile.files[0]
  14. const fileType = file.type
  15. const fileTyps = _.filter(this.supportFilesType.split(','), item => fileType === item)
  16. if (fileTyps.length === 0) {
  17. this.$message.error('文档只支持 pdf、docx、doc 格式')
  18. return
  19. }
  20. const isLtSize = file.size / 1024 / 1024 > 10
  21. if (isLtSize) {
  22. this.$message.error('文档不能超过 10MB!')
  23. return
  24. }
  25. this.form.document = file
  26. }
  27. }
  28. // 构建参数
  29. const params = new FormData()
  30. params.append('document', document)
  31. // JSON.stringify(jsonBody) 可以使用此函数将对象转换为 json 字符串
  32. params.append('jsonBody', '{"name":"xxxx"}') // json 字符串
  33. axios.request({
  34. url: '/api/customer/contract/',
  35. method: 'post',
  36. data: params
  37. })

如果按照上述方式前端进行构建的话,你会得到一个错误提示

  1. Content type ' application/octet-stream' not supported

这个时候就很奇怪,看前端发起的请求(上面的截图)和代码似乎并没有什么错误,到底是哪里的问题呢?通过 debug 出错堆栈,最终确认的关键源码如下

  1. org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodArgumentResolver#readWithMessageConverters(org.springframework.http.HttpInputMessage, org.springframework.core.MethodParameter, java.lang.reflect.Type)
  2. protected <T> Object readWithMessageConverters(HttpInputMessage inputMessage, MethodParameter parameter,
  3. Type targetType) throws IOException, HttpMediaTypeNotSupportedException, HttpMessageNotReadableException {
  4. MediaType contentType;
  5. boolean noContentType = false;
  6. try {
  7. contentType = inputMessage.getHeaders().getContentType();
  8. }
  9. catch (InvalidMediaTypeException ex) {
  10. throw new HttpMediaTypeNotSupportedException(ex.getMessage());
  11. }
  12. if (contentType == null) {
  13. noContentType = true;
  14. // application/octet-stream
  15. contentType = MediaType.APPLICATION_OCTET_STREAM;
  16. }

上面源码,会为每一个 multipart/form-data 中的参数进行解析,这里当解析到 jsonBody 参数的时候,发现没有 content-type 就默认给定了一个 Content-Type: application/octet-stream 所以就提示报错了。
image.png
那么正确的前端构造参数如下:

  1. const req = new FormData()
  2. req.append('document', document)
  3. // 需要手动指定,Blob 里面将 JSON 对象格式化为字符串传递
  4. req.append('jsonBody', new Blob([JSON.stringify(data.jsonBody)], { type: 'application/json' }))

发出后的截图如下,直接看类型是 binary
image.png

IDEA RESTful 测试语法

controller 中如下写

  1. @PostMapping
  2. @ApiOperation(value = "添加图片")
  3. public Result add(
  4. @RequestPart("jsonBody") @Validated PictureAddRequest params,
  5. @RequestPart("images") List<MultipartFile> images
  6. ) {
  7. return ResultHelper.ok();
  8. }

IDEA RESTful (xx.http 文件) 中下面这样写

  1. ### 图片管理 - 添加图片
  2. POST {{host}}/admin/picture/
  3. Content-Type: multipart/form-data; boundary=WebAppBoundary
  4. Authorization: bearer {{access_token}}
  5. --WebAppBoundary
  6. Content-Disposition: form-data; name="jsonBody"; filename="blob"
  7. Content-Type: application/json
  8. {"labelId": 1,"isEnable": true}
  9. --WebAppBoundary
  10. Content-Disposition: form-data; name="images"; filename="2.jpg"
  11. < d:\Users\mrcode\Pictures\2.jpg
  12. --WebAppBoundary
  13. Content-Disposition: form-data; name="images"; filename="3.gif"
  14. < d:\Users\mrcode\Pictures\3.gif
  15. --WebAppBoundary--