圣旨(金科玉律)
前端人员接旨:
1. 提交方式必须是:post
2. 文件上传的表单项必须是:<input type="file"/>
3. 表单类型必须是:enctype="multipart/form-data"
后端人员接旨:
1. 文件上传接口如果走auth网关,则必须配auth网关白名单
2. 最好指定文件在服务器的存储路径
3. 文件上传超时可考虑异步上传
4. 若涉及到文件存储,可考虑使用OSS(注意:文件的存储时长,存储的目录);可考虑将下载链接落库
一. 简介
早在1995年之前,HTTP的POST请求是不支持上传文件的,但随着《RFC 1867 -Form-based File Upload in HTML》的问世,开启了文件上传的新篇章,MIME(Multipurpose Internet Mail Extensions)新增了multipart/form-data类型,Content-Type HTTP Header就可以选择此类型,即通过“Content-Type: multipart/form-data”来支持上传文件,Spring MVC会通过Content-Type的MIME类型来判断是否为文件上传请求,再对文件的数据进行解析,解析好的文件会被存储到服务器,Spring Boot再从服务器获取文件,根据业务需求再对文件中的数据进行处理。
二. 文件是怎么被上传的
前端
-
同时可提交两个文件的HTML表单
<form action="http://www.baidu.com/" method="post" enctype="multipart/form-data"> <input type="file" name="file1" value="请选择文件1"/><br/> <input type="file" name="file2" value="请选择文件2"/><br/> <input type="submit"/> </form> 金科玉律: 1. 提交方式必须是:post 2. 文件上传的表单项必须是:<input type="file"/> 3. 表单类型必须是:enctype="multipart/form-data"
-
创建两个文件
文件名:file1.xlsx 文件内存储的内容:hello 文件名:file2.xlsx 文件内存储的内容:world
-
选择file1.xlsx和file2.xlsx两个文件并提交
给浏览器传递如下内容: POST http://www.baidu.com/ HTTP/1.1 Host: www.baidu.com Content-Length: 495 Content-Type: multipart/form-data; boundary=---------------------------7db2d1bcc50e6e -----------------------------7db2d1bcc50e6e Content-Disposition: form-data; name="file1"; filename="D:\file1.xlsx" Content-Type: text/plain hello -----------------------------7db2d1bcc50e6e Content-Disposition: form-data; name="file2"; filename="D:\file2.xlsx" Content-Type: text/plain world -----------------------------7db2d1bcc50e6e-- 解释: 1. HTTP头:第一个空行之前,即第2~5行为HTTP头 1.1 Content-Length:消息实体的传输长度(不是消息实体的长度,如:zip压缩的文件,消息实体的长度是压缩 前的长度,消息实体的传输长度为压缩后的长度)。 1.2 Content-Type:上传的附件 1.3 boundary:分隔符 2. Body:第一个空行之后,即第7~17行为Body 2.1 -----------------------------7db2d1bcc50e6e:每个文件以分隔符开始和结束 2.2 -----------------------------7db2d1bcc50e6e--:结束符=分隔符--,表示最后一个文件 2.3 Content-Disposition:附件的基本信息
Spring MVC
服务启动后,Spring MVC的Servlet容器会初始化DispatcherServlet,而DispatcherServlet初始化的同时,又会初始化MultipartResolver,用来解析文件数据。
-
MultipartResolver的isMultipart()方法会判断Content-Type的MIME的前缀是否为“multipart/”,是则为文件上传请求。
public boolean isMultipart(HttpServletRequest request) { return StringUtils.startsWithIgnoreCase(request.getContentType(), "multipart/"); }
-
如果是文件上传的请求,MultipartResolver的resolveMultipart()方法会把文件解析的工作交给StandardMultipartHttpServletRequest对象。
public MultipartHttpServletRequest resolveMultipart(HttpServletRequest request) throws MultipartException { return new StandardMultipartHttpServletRequest(request, this.resolveLazily); }
-
StandardMultipartHttpServletRequest对象对数据进行解析。解析后得到的是一组MultipartFile,每个MultipartFile对应文件上传数据中的一个part,并且存储于服务器的/tmp临时目录或自己指定的目录。
private void parseRequest(HttpServletRequest request) { try { Collection<Part> parts = request.getParts(); this.multipartParameterNames = new LinkedHashSet<>(parts.size()); MultiValueMap<String, MultipartFile> files = new LinkedMultiValueMap<>(parts.size()); for (Part part : parts) { // 获取part的Content-Disposition信息 String headerValue = part.getHeader(HttpHeaders.CONTENT_DISPOSITION); ContentDisposition disposition = ContentDisposition.parse(headerValue); // 获取文件名 String filename = disposition.getFilename(); if (filename != null) { if (filename.startsWith("=?") && filename.endsWith("?=")) { filename = MimeDelegate.decode(filename); } files.add(part.getName(), new StandardMultipartFile(part, filename)); } else { this.multipartParameterNames.add(part.getName()); } } setMultipartFiles(files); } catch (Throwable ex) { handleParseFailure(ex); } }
服务器
解析好的数据存储到服务器的临时目录或自己指定的目录。
Spring Boot
控制器处理单个文件代码如下:
@RestController
@RequestMapping("/upload")
public class UploadController {
@PostMapping("/file")
public void file(@RequestParam(value = "file") MultipartFile multipartFile) throws IOException {
ExcelReadModel<FileCmd> excelResult = ExcelUtils.read(multipartFile.getInputStream(), FileCmd.class);
// 获取文件名。如得到的文件名为:fiel1.xlsx
String fileName = multipartFile.getOriginalFilename();
........
}
}
处理多个文件也是类似的,修改形参为List即可,如:@ModelAttribute List fileList。虽然我们知道通过@RequestParam(value = “file”)可以获取文件的数据,但其中又是如何保证获取到的数据是所需的数据呢?其实Spring MVC通过RequestParamMethodArgumentResolver对@RequestParam中的参数进行了精准的解析,从而保证了“所供即所需”。
@Nullable
protected Object resolveName(String name, MethodParameter parameter, NativeWebRequest request) throws Exception {
HttpServletRequest servletRequest = (HttpServletRequest)request.getNativeRequest(HttpServletRequest.class);
Object arg;
if (servletRequest != null) {
arg = MultipartResolutionDelegate.resolveMultipartArgument(name, parameter, servletRequest);
if (arg != MultipartResolutionDelegate.UNRESOLVABLE) {
return arg;
}
}
arg = null;
MultipartRequest multipartRequest = (MultipartRequest)request.getNativeRequest(MultipartRequest.class);
if (multipartRequest != null) {
List<MultipartFile> files = multipartRequest.getFiles(name);
if (!files.isEmpty()) {
arg = files.size() == 1 ? files.get(0) : files;
}
}
if (arg == null) {
String[] paramValues = request.getParameterValues(name);
if (paramValues != null) {
arg = paramValues.length == 1 ? paramValues[0] : paramValues;
}
}
return arg;
}
三. 上传文件遇到过的坑
-
文件在服务器的存储目录找不到了,500报错???
那是一个睡意沉沉的清晨,准备上传一个包含了几个亿RMB的机密文件时,却抛了个500,顿时清醒了许多,急忙打开那该死的服务器(映入眼帘的):10.88.21.210 2020-12-03 20:03:09:480 ERROR [] c.s.d.f.i.a.WebErrorInterceptor 45 Failed to parse multipart servlet request; nested exception is java.lang.RuntimeException: java.nio.file.NoSuchFileException: /tmp org.springframework.web.multipart.MultipartException: Failed to parse multipart servlet request; nested exception is java.lang.RuntimeException: java.nio.file.NoSuchFileException: /tmp at org.springframework.web.multipart.support.StandardMultipartHttpServletRequest.handleParseFailure(StandardMultipartHttpServletRequest.java:124) at org.springframework.web.multipart.support.StandardMultipartHttpServletRequest.parseRequest(StandardMultipartHttpServletRequest.java:115) ....... Caused by: java.lang.RuntimeException: java.nio.file.NoSuchFileException: /tmp at io.undertow.server.handlers.form.MultiPartParserDefinition$MultiPartUploadHandler.beginPart(MultiPartParserDefinition.java:261) at io.undertow.util.MultipartParser$ParseState.headerName(MultipartParser.java:208) .......
一看,傻眼了,事情不太妙,这是神马情况?怎么就找不到这个/tmp目录了呢?便请教了百度大哥,他告诉我原因:Spring Boot在启动时,会在操作系统生成一个名为/tmp的临时目录,如果超过10天未使用该临时目录,则系统会自动执行systemd-tmpfiles-clean.service服务来清除该临时目录。当上传文件的请求到来时,Spring Boot会痴痴的去寻觅/tmp,找不到了也不会再去新建一个目录,这就是爱吧!我被感动了,我决心成人之美。给他想了以下解决方案:
a. 在服务器新建一个名为/tmp的临时目录(不可取:强“建”的目录不久,Spring Boot 超过10天不搭理/tmp,爱依旧会消失的)
b. 重启服务,会再此自动生产临时目录(不可取:今生不能再做情人,来世再做夫妻,奈何命运弄Spring Boot,依旧会重蹈覆辙)
c. Spring Boot官方回应,Spring Boot 2.1.4修复了该问题,我没有亲自尝试过(我信你个鬼,你个糟老头子坏得狠),但Spring Boot 2.2.6没有修复,可能只是2.1.4版本修复了。
d. 在yml文件配置一个目录,指定文件存储的目录,但必须保证这个目录存在,且具有读写的权限。spring: servlet: multipart: location: /uploadFile
e. 优化方案d,写一个配置类,即使yml文件中配置的目录不存在,也可以自动创建目录。
@Slf4j @Configuration public class UndertowConfig { @Value("${spring.servlet.multipart.location}") private String filePath; @Bean public MultipartConfigElement multipartConfigElement(){ MultipartConfigFactory factory = new MultipartConfigFactory(); if (!StringUtil.isNullOrEmpty(filePath)) { File file = new File(filePath); log.info("文件存储路径:{}", file); if (!file.exists()) { boolean dirCreated = file.mkdir(); if(! dirCreated){ throw new RuntimeException("create file " + file.getAbsolutePath() + " failed!"); } } // 需要写和执行的权限 if(! (file.canWrite() && file.canExecute())){ throw new RuntimeException(file.getAbsolutePath() + " Permission denied!"); } } factory.setLocation(filePath); return factory.createMultipartConfig(); } }
-
文件模版的下载链接失效了???
曾在OSS界面将文件模版进行上传,但一段时间后发现链接URL失效了,导致无法下载模版。打开OSS发现,每个URL的有效时间最长是32400秒,每间隔32400秒就会生成一个新的URL,旧的URL就会失效。
为了实现URL长期不失效,可通过OSSClient的generatePresignedUrl(String bucketName, String key, Date expiration)进行上传,Date expiration参数可设置URL的有效时长,如10年,20年。
参考:
http://blog.zhaojie.me/2011/03/html-form-file-uploading-programming.html
http://www.faqs.org/rfcs/rfc1867.html
https://github.com/spring-projects/spring-boot/issues/9616
https://www.codenong.com/cs105778648/