文件下载
普通方式:自己写 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*/@RestControllerpublic 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*/@RestControllerpublic 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.documentFileif (!uploadFile) {return}const file = uploadFile.files[0]const fileType = file.typeconst 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 > 10if (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-streamcontentType = 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=WebAppBoundaryAuthorization: bearer {{access_token}}--WebAppBoundaryContent-Disposition: form-data; name="jsonBody"; filename="blob"Content-Type: application/json{"labelId": 1,"isEnable": true}--WebAppBoundaryContent-Disposition: form-data; name="images"; filename="2.jpg"< d:\Users\mrcode\Pictures\2.jpg--WebAppBoundaryContent-Disposition: form-data; name="images"; filename="3.gif"< d:\Users\mrcode\Pictures\3.gif--WebAppBoundary--
