圣旨(金科玉律)
前端人员接旨:
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年。