圣旨(金科玉律)

前端人员接旨:
      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再从服务器获取文件,根据业务需求再对文件中的数据进行处理。

二. 文件是怎么被上传的

java前端将文件发送给后端接收很慢 前端传输文件给后端_服务器


前端

  1. 同时可提交两个文件的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"
  1. 创建两个文件
文件名:file1.xlsx     文件内存储的内容:hello
文件名:file2.xlsx     文件内存储的内容:world
  1. 选择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,用来解析文件数据。

  1. MultipartResolver的isMultipart()方法会判断Content-Type的MIME的前缀是否为“multipart/”,是则为文件上传请求。
public boolean isMultipart(HttpServletRequest request) {
	return StringUtils.startsWithIgnoreCase(request.getContentType(), "multipart/");
}
  1. 如果是文件上传的请求,MultipartResolver的resolveMultipart()方法会把文件解析的工作交给StandardMultipartHttpServletRequest对象。
public MultipartHttpServletRequest resolveMultipart(HttpServletRequest request) throws MultipartException {
	return new StandardMultipartHttpServletRequest(request, this.resolveLazily);
}
  1. 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;
    }

三. 上传文件遇到过的坑

  1. 文件在服务器的存储目录找不到了,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版本修复了。

java前端将文件发送给后端接收很慢 前端传输文件给后端_文件上传_02


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();
    }

}
  1. 文件模版的下载链接失效了???
    曾在OSS界面将文件模版进行上传,但一段时间后发现链接URL失效了,导致无法下载模版。打开OSS发现,每个URL的有效时间最长是32400秒,每间隔32400秒就会生成一个新的URL,旧的URL就会失效。

    为了实现URL长期不失效,可通过OSSClient的generatePresignedUrl(String bucketName, String key, Date expiration)进行上传,Date expiration参数可设置URL的有效时长,如10年,20年。