使用Spring MVC实现优雅的文件下载
传统的,我们要进行文件下载,可以直接操作HttpServletRequest
和HttpServletResponse
来处理下载。那基本上就与Spring MVC
的关系不大了。 我们能看到形如下面的代码:
//设置响应头和客户端保存文件名 response.setCharacterEncoding("utf-8"); response.setContentType("multipart/form-data"); response.setHeader("Content-Disposition", "attachment;fileName=" + fileName); ... //打开本地文件流 InputStream inputStream = new FileInputStream(filePath); //激活下载操作 OutputStream os = response.getOutputStream(); //循环写入输出流 byte[] b = new byte[2048]; int length; while ((length = inputStream.read(b)) > 0) { os.write(b, 0, length); downloadedLength += b.length; } // 这里主要关闭。 os.close(); inputStream.close(); ...
显然这一大段处理起来还是比较麻烦的。本文另外一种方案:在Spring MVC
环境下能让你优雅的处理文件下载:使用ResponseEntity
方式
Demo如下:
// 处理下载 get/post/put请求等等都是可以的 但一般都用get请求 @RequestMapping(value = "/download", method = RequestMethod.GET) public ResponseEntity<Resource> downloadFile(@RequestParam String fileName) { // 构造下载对象 读取出一个Resource出来 此处以类路径下的logback.xml DownloadFileInfoDto downloadFile = new DownloadFileInfoDto(fileName, new ClassPathResource("logback.xml")); return downloadResponse(downloadFile); } @Getter @Setter @NoArgsConstructor @AllArgsConstructor private static class DownloadFileInfoDto { private String fileName; // 下载的文件 private Resource resource; // 下载的具体文件资源 } // 共用方法 兼容到了IE浏览器~~~ private static ResponseEntity<Resource> downloadResponse( DownloadFileInfoDto fileInfo) { String fileName = fileInfo.getFileName(); Resource body = fileInfo.getResource(); // ========通过User-Agent来判断浏览器类型 做一定的兼容~======== HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); String header = request.getHeader("User-Agent").toUpperCase(); HttpStatus status = HttpStatus.CREATED; try { if (header.contains("MSIE") || header.contains("TRIDENT") || header.contains("EDGE")) { fileName = URLEncoder.encode(fileName, "UTF-8"); fileName = fileName.replace("+", "%20"); // IE下载文件名空格变+号问题 status = HttpStatus.OK; } else { // 其它浏览器 比如谷歌浏览器等等~~~~ fileName = new String(fileName.getBytes("UTF-8"), "ISO8859-1"); } } catch (UnsupportedEncodingException e) { } // =====响应头需设置为MediaType.APPLICATION_OCTET_STREAM===== HttpHeaders headers = new HttpHeaders(); // 注意:若这个响应头不是必须的,但是如果你确定要下载,建议写上这个响应头~ headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); // 此处,如果你自己没有设置这个请求头,浏览器肯定就不会弹窗对话框了。它就会以body的形式直接显示在浏览器上 headers.setContentDispositionFormData("attachment", fileName); return new ResponseEntity<>(body, headers, status); }
备注:使用此种方式最终处理返回值的处理器为:
HttpEntityMethodProcessor
,使用的消息转换器为:ResourceHttpMessageConverter
这样请求就能弹出下载框了。响应头如下:
可以看到这里不仅设置了Content-Disposition请求头,还是设置了Content-type为application/octet- stream那就意味着你不想让浏览器直接显示内容,而是弹出一个”文件下载”的对话框。
关于application/octet-stream等响应头的解释,请看如下例子形象解释:
Content-Type: application/octet-stream Content-Disposition: attachment; filename="picture.png"
它表示对浏览器说:“我不清楚代码内容,请把其保存为一个文件,最好命名为picture.png”。
Content-Type: image/png Content-Disposition: attachment; filename="picture.png"
它表示对浏览器说:表示“这是一个PNG图像,请将其保存为一个文件,最好命名为picture.png”。
Content-Type: image/png Content-Disposition: inline; filename="picture.png"
它表示对浏览器说:“这是一个PNG图像,除非你不知道如何显示PNG图像,否则请显示它,如果用户选择保存它,我们建议文件名保存为picture.png”。(inline方式)
在能够识别内联的浏览器中,可议使用这个方法(现在绝大多数浏览器都能识别这种方式),少数浏览器会对它进行保存~~~~
所以当你给客户端传递的不知道是文本、图片、还是其它的格式时,使用application/octet-stream最佳。(是否弹出下载框不是由它决定的,主要是Content-Disposition这个请求头来决定的)
ResponseEntity方式对比传统Java方式
单单从代码上看ResponseEntity方式秒杀传统的Java方式,但是否我们想都不想就采用优雅方式呢?但其实非也,有些东西还是要注意的。
- **基于ResponseEntity的实现的局限性还是很大:**这种下载方式是一种一次性读取的下载方式,在文件较大的时候会直接抛出内存溢出(所以适合小文件下载,不超过1G吧)。还有就是这种下载方式因为是一次性全部输出,所以无法统计已下载量、未下载量等扩展功能,所以也就不能实现断点续传
- **传统Java通用实现在功能上能够更加的丰富:**对下载文件的大小无限制((循环读取一定量的字节写入到输出流中,因此不会造成内存溢出)。 因为是这种实现方式是基于循环写入的方式进行下载,在每次将字节块写入到输出流中的时都会进行输出流的合法性检测,在因为用户取消或者网络原因造成socket断开的时候,系统会抛出SocketWriteException。 当然我们可以捕获到这个异常,记录下当前已经下载的数据量、下载状态等。这样我们就可以实现断点续传的功能了
ResponseEntity方式的优点就是简洁,所以在比较小的文件下载时,它绝对是首选。若是有大量的下载需求,其实一般都建议使用ftp服务器而不是http了。
当然还有一种使用ResponseEntity<byte[]>的方式,也挺优雅的,这里就只提供代码参考了:
@RequestMapping("/testResponseEntity") public ResponseEntity<byte[]> testResponseEntity(HttpSession session) throws IOException { byte[] body = null; ServletContext servletContext = session.getServletContext(); InputStream in = servletContext.getResourceAsStream("/files/abc.txt"); body = new byte[in.available()]; in.read(body); HttpHeaders headers = new HttpHeaders(); headers.add("Content-Disposition","attachment;filename=abc.txt"); HttpStatus statusCode = HttpStatus.OK; ResponseEntity<byte[]> response = new ResponseEntity<byte[]>(body,headers,statusCode); return response; }
此处使用的返回值处理同上,消息转换器是:ByteArrayHttpMessageConverter
总结
自己写代码也要注意啊:代码中有顺序遍历匹配这种逻辑,或者叫责任链模式时,功能越具体的节点越是应该放在前面,功能最广最泛的节点应该放在最后面;
写程序就是这样,不断追求更好的解决方法,永远不满足于“能够运行”!
附:关于Spring MVC应用中自动下载f.txt问题
不知道小伙伴有没有遇见过这样的情况:你用浏览器访问一个rest请求,但是浏览器却总是自动弹出了一个下载框,然后给你下载了一个名字为f.txt的文件,里面内容为你的异常信息(或者body内容信息),简直一脸懵逼有木有
其实这个现象上面已经提到过了原因,但是一笔带过没有详细解释。下面就通过模拟这种现象,然后给大伙说说具体原因~~~
现象模拟
其实模拟出这种效果来,也是很大的一个学问。反而我觉得你得先知道原理、根本原因才好模拟,否则也是无头苍蝇,不知从哪儿下手。但是此处,我们还是先模拟一下吧:
基于上面的下载的例子(使用ResponseEntity方式):
// =====响应头需设置为MediaType.APPLICATION_OCTET_STREAM===== HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); //headers.setContentDispositionFormData("attachment", fileName);
相当于我只设置application/octet-stream,但是我并不设置Content-Disposition这个请求头。
我们这么访问:http://localhost:8080/demo_war_war/download?fileName=aaa.xml,会弹出下载框下载文件
请重点关注download的后缀名,第一次访问是无后缀名的形式,后面会有改变
并且文件里面的内容也是没有问题的。
但我们这么访问:xxx/download.json,弹出下载框,下载的文件如下名为:download.json
当我们这么访问:xxx/download.aaa,弹出下载框,下载的文件如下名为:f.txt
终于,这就是我们想演示的自动下载f.txt的case~~~~~
ContentType和ContentDisposition都不设置的case
// =====响应头需设置为MediaType.APPLICATION_OCTET_STREAM===== HttpHeaders headers = new HttpHeaders(); //headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); //headers.setContentDispositionFormData("attachment", fileName);
相当于什么都不设置,有如下效果:
当我们这么访问:xxx/download和xxx/download.aaa等,浏览器看到的结果是:
但是如果这样访问xxx/download.json/xxx/download.txt/xxx/download.png的话得到的效果如下:
xxx/download.json/xxx/download.txt:
/xxx/download.png:浏览器会以图片的形式进行展示
**我们发现后缀名不同,Spring MVC就自动给了一个合适的content-type,**原因下面再会解释
可以看到这两个请求头全都不设置的话,肯定是不会触发弹出下载的
原因分析
其实上面文件下载的Demo已经解释得差不多了,但是还没有搞明白为啥有的时候弹出的的文件名不是f.txt呢?
AbstractMessageConverterMethodProcessor#addContentDispositionHeader这个方法上,它会给响应只能的设置一个content-type和Content-Disposition:
private void addContentDispositionHeader(ServletServerHttpRequest request, ServletServerHttpResponse response) { ... // 默认它采用inline的模式 if (!safeExtension(servletRequest, ext) || !safeExtension(servletRequest, extInPathParams)) { headers.add(HttpHeaders.CONTENT_DISPOSITION, "inline;filename=f.txt"); } ... }
然后为啥后缀名不同,展示也不一样呢?比如xxx/download.json/xxx/download.txt/xxx/download.png等后缀名不一样,浏览器的展示效果也不一样~其实原理在这:
public abstract class AbstractMessageConverterMethodProcessor extends AbstractMessageConverterMethodArgumentResolver implements HandlerMethodReturnValueHandler { /* Extensions associated with the built-in message converters */ private static final Set<String> WHITELISTED_EXTENSIONS = new HashSet<>(Arrays.asList( "txt", "text", "yml", "properties", "csv", "json", "xml", "atom", "rss", "png", "jpe", "jpeg", "jpg", "gif", "wbmp", "bmp")); private static final Set<String> WHITELISTED_MEDIA_BASE_TYPES = new HashSet<>( Arrays.asList("audio", "image", "video")); ... }
简单的说就是请求的后缀名是上面例句出来的额话,*浏览器会采用内联、直接展示的方式~~~~*
备注:以上方案都是基于
Chrome
浏览器模拟的操作