Spring Boot应用上传文件时报错的原因及解决方案

Spring Boot应用上传文件时报错的原因及解决方案

一、问题描述

Spring Boot应用(使用默认的嵌入式Tomcat)在上传文件时,偶尔会出现上传失败的情况,后台报错日志信息如下:

“The temporary upload location is not valid”。

这个问题的根本原因是Tomcat的文件上传机制引起的!

Tomcat在处理文件上传时,会将客户端上传的文件写入临时目录,这个临时目录默认在/tmp路径下,如:“/tmp/tomcat.6574404581312272268.18333/work/Tomcat/localhost/ROOT”。

而操作系统对于/tmp目录会不定时进行清理,如果正好因为操作系统的清理导致对应的临时目录被删除,客户端再上传文件时就会报错:“The temporary upload location is not valid”。
实际上,追踪一下源码会发现,如果不明确设置Tomcat的文件上传临时目录,默认读取的是Servlet上下文对象的属性“javax.servlet.context.tempdir”值

如下源码:org.apache.catalina.connector.Request

private void parseParts(boolean explicit) {
  //...
  MultipartConfigElement mce = this.getWrapper().getMultipartConfigElement();
  //...
  // 读取MultipartConfigElement对象的location属性
  String locationStr = mce.getLocation();
  File location;
  if (locationStr != null && locationStr.length() != 0) {
    location = new File(locationStr);
    if (!location.isAbsolute()) {
      location = (new File((File)context.getServletContext().getAttribute("javax.servlet.context.tempdir"), locationStr)).getAbsoluteFile();
    }
  } else {
    // 如果location属性值为空,则读取Servlet上下文对象的属性“javax.servlet.context.tempdir”值
    // 如:/tmp/tomcat.6574404581312272268.18333/work/Tomcat/localhost/ROOT
    location = (File)context.getServletContext().getAttribute("javax.servlet.context.tempdir");
  }
  //...
}

二、解决办法

既然是因为上传文件的临时路径被删除导致的问题,就要确保改临时目录不会被删除。

2种解决方法

1、通过配置文件指定文件保存路径

(1)、通过Spring Boot的配置参数“spring.servlet.multipart.location”明确指定上传文件的临时目录,确保该路径已经存在,而且该目录不会被操作系统清除。

spring.servlet.multipart.location=/data/tmp

如上所示,将上传文件的临时目录指定到路径“/data/tmp”下。

实际上,在Spring Boot中关于上传文件的所有配置参数如下所示:

# MULTIPART (MultipartProperties)
# Whether to enable support of multipart uploads.
spring.servlet.multipart.enabled=true 
# Threshold after which files are written to disk.
spring.servlet.multipart.file-size-threshold=0B 
# Intermediate location of uploaded files.
spring.servlet.multipart.location=/data/tmp
# Max file size.
spring.servlet.multipart.max-file-size=1MB 
# Max request size.
spring.servlet.multipart.max-request-size=10MB 
# Whether to resolve the multipart request lazily at the time of file or parameter access.
spring.servlet.multipart.resolve-lazily=false 

2、配置Bean指定路径对象

(2)、在Spring容器中明确注册MultipartConfigElement对象,通过MultipartConfigFactory指定一个路径。

在上述源码追踪中就发现,Tomcat会使用MultipartConfigElement对象的location属性作为上传文件的临时目录。

/**
 * 配置上传文件临时目录
 * @return
 */
@Bean
public MultipartConfigElement multipartConfigElement() {
  MultipartConfigFactory factory = new MultipartConfigFactory();
  // tmp.dir参数在启动脚本中设置
  String path = System.getProperty("tmp.dir");
  if(path == null || "".equals(path.trim())) {
    path = System.getProperty("user.dir");
  }
  String location = path + "/tmp";
  File tmpFile = new File(location);
  // 如果临时目录不存在则创建
  if (!tmpFile.exists()) {
    tmpFile.mkdirs();
  }
  // 明确指定上传文件的临时目录
  factory.setLocation(location);
  return factory.createMultipartConfig();
}

三、源码分析

参考博客:

https://www.cnblogs.com/Xianhuii/p/16933750.html

3.1、前端伪代码

前端上传文件时,无论是使用比较传统的表单,还是使用FormData对象,其本质都是发送一个multipart/form-data请求。
例如,前端模拟上传代码如下:

var formdata = new FormData();
formdata.append("key1", "value1");
formdata.append("key2", "value2");
formdata.append("file1", fileInput.files[0], "/d:/Downloads/rfc1867.pdf");
formdata.append("file2", fileInput.files[0], "/d:/Downloads/rfc1314.pdf");

var requestOptions = {
  method: 'POST',
  body: formdata,
  redirect: 'follow'
};

fetch("http://localhost:10001/file/upload", requestOptions)
  .then(response => response.text())
  .then(result => console.log(result))
  .catch(error => console.log('error', error));

3.2、发送的HTTP请求报文

实际会发送如下HTTP请求:

POST /file/upload HTTP/1.1
Host: localhost:10001
Content-Length: 536
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="key1"

value1
----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="key2"

value2
----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file1"; filename="/d:/Downloads/rfc1867.pdf"
Content-Type: application/pdf

(data)
----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file2"; filename="/d:/Downloads/rfc1314.pdf"
Content-Type: application/pdf

(data)
----WebKitFormBoundary7MA4YWxkTrZu0gW


在后端可以通过MultipartHttpServletRequest接收文件:

@RestController  
@RequestMapping("file")  
public class FileUploadController {   
  @RequestMapping("/upload")  
  public String upload(MultipartHttpServletRequest request) {  
    // 获取非文件参数  
    String value1 = request.getParameter("key1");  
    System.out.println(value1); // value1  
    String value2 = request.getParameter("key2");  
    System.out.println(value2); // value2  
    // 获取文件  
    MultipartFile file1 = request.getFile("file1");  
    System.out.println(file1 != null ? file1.getOriginalFilename() : "null"); // rfc1867.pdf  
    MultipartFile file2 = request.getFile("file2");  
    System.out.println(file2 != null ? file2.getOriginalFilename() : "null"); // rfc1314.pdf  
    return "Hello MultipartResolver!";  
  }  
}

3.3、MultipartResolver接口

3.3.1、体系结构

3.3.2、MultipartResolver接口功能

org.springframework.web.multipart.MultipartResolver是Spring-Web根据RFC1867规范实现的多文件上传的策略接口。
同时,MultipartResolver是Spring对文件上传处理流程在接口层次的抽象。
也就是说,当涉及到文件上传时,Spring都会使用MultipartResolver接口进行处理,而不涉及具体实现类。
MultipartResolver接口源码如下:

public interface MultipartResolver {  
	/**
	* 判断当前HttpServletRequest请求是否是文件请求
	*/
    boolean isMultipart(HttpServletRequest request);  
	/**
	*  将当前HttpServletRequest请求的数据(文件和普通参数)封装成MultipartHttpServletRequest对象
	*/
    MultipartHttpServletRequest resolveMultipart(HttpServletRequest request) throws MultipartException;  
	/**
	*  清除文件上传产生的临时资源(如服务器本地临时文件)
	*/
    void cleanupMultipart(MultipartHttpServletRequest request);  
}

3.4、在DispatcherServlet中的使用

3.4.1、DispatcherServlet中找到MultipartResolver实例

DispatcherServlet中持有MultipartResolver成员变量:

public class DispatcherServlet extends FrameworkServlet {  
   /** Well-known name for the MultipartResolver object in the bean factory for this namespace. */  
   public static final String MULTIPART_RESOLVER_BEAN_NAME = "multipartResolver";
   /** MultipartResolver used by this servlet. */  
	@Nullable  
	private MultipartResolver multipartResolver;
}

DispatcherServlet在初始化时,会从Spring容器中获取名为multipartResolver的对象(该对象是MultipartResolver实现类),作为文件上传解析器:

/**  
 * Initialize the MultipartResolver used by this class. * <p>If no bean is defined with the given name in the BeanFactory for this namespace,  no multipart handling is provided. */
private void initMultipartResolver(ApplicationContext context) {  
      // 从容器中获取得到MultipartResolver类型且名称为multipartResolver的bean
      this.multipartResolver = context.getBean(MULTIPART_RESOLVER_BEAN_NAME, MultipartResolver.class);  
}

需要注意的是,如果Spring容器中不存在名为multipartResolver的对象,DispatcherServlet并不会额外指定默认的文件解析器。此时,DispatcherServlet不会对文件上传请求进行处理。也就是说,尽管当前请求是文件请求,也不会被处理成MultipartHttpServletRequest,如果我们在控制层进行强制类型转换,会抛异常。

3.4.2、DispatcherServlet中文件上传流程

DispatcherServlet在处理业务时,会按照顺序分别调用这些方法进行文件上传处理,相关核心源码如下:

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {  
   HttpServletRequest processedRequest = request;
   boolean multipartRequestParsed = false;
   try {
		// 判断&封装文件请求
         processedRequest = checkMultipart(request);  
         multipartRequestParsed = (processedRequest != request); 
         // 请求处理……
   }  
   finally {   
         // 清除文件上传产生的临时资源
         if (multipartRequestParsed) {  
            cleanupMultipart(processedRequest);  
         }  
   }  
}

checkMultipart()方法中,会进行判断、封装文件请求:

/**  
 * Convert the request into a multipart request, and make multipart resolver available. * <p>If no multipart resolver is set, simply use the existing request.  
 * @param request current HTTP request  
 * @return the processed request (multipart wrapper if necessary) * @see MultipartResolver#resolveMultipart  
 */
 protected HttpServletRequest checkMultipart(HttpServletRequest request) throws MultipartException {  
   if (this.multipartResolver != null && this.multipartResolver.isMultipart(request)) {  
      // 如果是文件上传请求
      if (WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class) != null) {  
         if (DispatcherType.REQUEST.equals(request.getDispatcherType())) {  
            logger.trace("Request already resolved to MultipartHttpServletRequest, e.g. by MultipartFilter");  
         }  
      }  
      else if (hasMultipartException(request)) {  
         logger.debug("Multipart resolution previously failed for current request - " +  
               "skipping re-resolution for undisturbed error rendering");  
      }  
      else {  
         try {  
            // 那么就来这里来进行处理,返回MultipartHttpServletRequest对象
            return this.multipartResolver.resolveMultipart(request);  
         }  
         catch (MultipartException ex) {  
            if (request.getAttribute(WebUtils.ERROR_EXCEPTION_ATTRIBUTE) != null) {  
               logger.debug("Multipart resolution failed for error dispatch", ex);  
               // Keep processing error dispatch with regular request handle below  
            }  
            else {  
               throw ex;  
            }  
         }  
      }  
   }  
   // If not returned before: return original request.  
   return request;  
}

总的来说,DispatcherServlet处理文件请求会经过以下步骤:

  1. 判断当前HttpServletRequest请求是否是文件请求
    1. 是:将当前HttpServletRequest请求的数据(文件和普通参数)封装成MultipartHttpServletRequest对象
    2. 不是:不处理
  2. DispatcherServlet对原始HttpServletRequestMultipartHttpServletRequest对象进行业务处理
  3. 业务处理完成,清除文件上传产生的临时资源

3.5、MultipartResolver实现类&配置方式

Spring提供了两个MultipartResolver实现类:

  • org.springframework.web.multipart.support.StandardServletMultipartResolver:根据Servlet 3.0+ Part Api实现
  • org.springframework.web.multipart.commons.CommonsMultipartResolver:根据Apache Commons FileUpload实现

在Spring Boot 2.0+中,默认会在org.springframework.boot.autoconfigure.web.servlet.MultipartAutoConfiguration中创建StandardServletMultipartResolver作为默认文件解析器

@AutoConfiguration  
@ConditionalOnClass({ Servlet.class, StandardServletMultipartResolver.class, MultipartConfigElement.class })  
@ConditionalOnProperty(prefix = "spring.servlet.multipart", name = "enabled", matchIfMissing = true)  
@ConditionalOnWebApplication(type = Type.SERVLET)  
@EnableConfigurationProperties(MultipartProperties.class)  
public class MultipartAutoConfiguration {  
  
   private final MultipartProperties multipartProperties;  
  
   public MultipartAutoConfiguration(MultipartProperties multipartProperties) {  
      this.multipartProperties = multipartProperties;  
   }  
   
   // 这个东西也有需要来进行配置
   @Bean  
   @ConditionalOnMissingBean({ MultipartConfigElement.class, CommonsMultipartResolver.class })  
   public MultipartConfigElement multipartConfigElement() {  
      return this.multipartProperties.createMultipartConfig();  
   }  
  
   @Bean(name = DispatcherServlet.MULTIPART_RESOLVER_BEAN_NAME)  
   @ConditionalOnMissingBean(MultipartResolver.class)  
   public StandardServletMultipartResolver multipartResolver() {  
      StandardServletMultipartResolver multipartResolver = new StandardServletMultipartResolver();  
      multipartResolver.setResolveLazily(this.multipartProperties.isResolveLazily());  
      return multipartResolver;  
   }  
}

当需要指定其他文件解析器时,只需要引入相关依赖,然后配置一个名为multipartResolverbean对象:

@Bean  
public MultipartResolver multipartResolver() {  
    MultipartResolver multipartResolver = ...;  
    return multipartResolver;  
}

接下来,我们分别详细介绍两种实现类的使用和原理。

3.5.1、StandardServletMultipartResolver解析器

1、StandardServletMultipartResolver#isMultipart

StandardServletMultipartResolver解析器的通过判断请求的Content-Type来判断是否是文件请求:

public boolean isMultipart(HttpServletRequest request) {  
   return StringUtils.startsWithIgnoreCase(request.getContentType(),  
         (this.strictServletCompliance ? "multipart/form-data" : "multipart/"));  
}

其中,strictServletComplianceStandardServletMultipartResolver的成员变量,默认false,表示是否严格遵守Servlet 3.0规范。简单来说就是对Content-Type校验的严格程度。如果strictServletCompliancefalse,请求头以multipart/开头就满足文件请求条件;如果strictServletCompliancetrue,则需要请求头以multipart/form-data开头。

2、StandardServletMultipartResolver#resolveMultipart

StandardServletMultipartResolver在解析文件请求时,会将原始请求封装成StandardMultipartHttpServletRequest对象:

public MultipartHttpServletRequest resolveMultipart(HttpServletRequest request) throws MultipartException {  
   return new StandardMultipartHttpServletRequest(request, this.resolveLazily);  
}

需要注意的是,这里传入this.resolveLazily成员变量,表示是否延迟解析。我们可以来看对应构造函数源码:

public StandardMultipartHttpServletRequest(HttpServletRequest request, boolean lazyParsing)  
      throws MultipartException {  
 
   super(request);  
   if (!lazyParsing) {  
      parseRequest(request);  
   }  
}

如果需要修改resolveLazily成员变量的值,需要在初始化StandardServletMultipartResolver时指定值。
在Spring Boot 2.0+中,默认会在org.springframework.boot.autoconfigure.web.servlet.MultipartAutoConfiguration中创建StandardServletMultipartResolver作为默认文件解析器,此时会从MultipartProperties中读取resolveLazily值。因此,如果是使用Spring Boot 2.0+默认配置的文件解析器,可以在properties.yml文件中指定resolveLazily值:

spring.servlet.multipart.resolve-lazily=true

如果是使用自定义配置的方式配置StandardServletMultipartResolver,则可以在初始化的手动赋值:

@Bean  
public MultipartResolver multipartResolver() {  
    StandardServletMultipartResolver multipartResolver = new StandardServletMultipartResolver();  
    multipartResolver.setResolveLazily(true);  
    return multipartResolver;  
}
3、StandardMultipartHttpServletRequest#parseRequest

resolveLazilyfalse时,会马上调用parseRequest()方法会对请求进行实际解析,该方法会完成两件事情:

  1. 使用Servlet 3.0的Part API,获取Part集合
  2. 解析Part对象,封装表单参数和表单文件
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) {  
      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);  
  }  
}

经过parseRequest()方法处理,我们在业务处理时,直接调用StandardMultipartHttpServletRequest接口的getXxx()方法就可以获取表单参数或表单文件信息。

resolveLazilytrue时,在MultipartResolver#resolveMultipart()阶段并不会进行文件请求解析。也就是说,此时StandardMultipartHttpServletRequest对象的成员变量都是空值。那么,resolveLazilyfalse时文件请求解析是在什么时候完成的呢?

实际上,在调用StandardMultipartHttpServletRequest接口的getXxx()方法时,内部会判断是否已经完成文件请求解析。如果未解析,就会调用partRequest()方法进行解析,例如:

@Override  
public Enumeration<String> getParameterNames() {  
   if (this.multipartParameterNames == null) {  
      initializeMultipart();  // parseRequest(getRequest());
   }  
   // 业务处理……
}
4、HttpServletRequest#getParts

根据StandardMultipartHttpServletRequest#parseRequest源码可以发现,StandardServletMultipartResolver解析文件请求依靠的是HttpServletRequest#getParts方法。
这是StandardServletMultipartResolver是根据标准Servlet 3.0实现的核心体现。
在Servlet 3.0中定义了javax.servlet.http.Part,用来表示multipart/form-data请求体中的表单数据或文件:

public interface Part {  
	public InputStream getInputStream() throws IOException;  
	public String getContentType();  
	public String getName();  
	public String getSubmittedFileName();  
	public long getSize();  
	public void write(String fileName) throws IOException;  
	public void delete() throws IOException;  
	public String getHeader(String name);  
	public Collection<String> getHeaders(String name);  
	public Collection<String> getHeaderNames();  
}

javax.servlet.http.HttpServletRequest,提供了获取multipart/form-data请求体各个part的方法:

public interface HttpServletRequest extends ServletRequest {    
    /**  
     * Return a collection of all uploaded Parts.     
     *     
     * @return A collection of all uploaded Parts.    
     * @throws IOException  
     *             if an I/O error occurs  
     * @throws IllegalStateException  
     *             if size limits are exceeded or no multipart configuration is  
     *             provided     
     * @throws ServletException  
     *             if the request is not multipart/form-data  
     * @since Servlet 3.0     
     */   
	public Collection<Part> getParts() throws IOException, ServletException;  
  
    /**  
     * Gets the named Part or null if the Part does not exist. Triggers upload     
     * of all Parts.    
     *     
     * @param name The name of the Part to obtain  
     *     
     * @return The named Part or null if the Part does not exist    
     * @throws IOException  
     *             if an I/O error occurs  
     * @throws IllegalStateException  
     *             if size limits are exceeded  
     * @throws ServletException  
     *             if the request is not multipart/form-data  
     * @since Servlet 3.0     
     */    
	public Part getPart(String name) throws IOException, ServletException;  
}

所有实现标准Servlet 3.0规范的Web服务器,都必须实现getPart()/getParts()方法。也就是说,这些Web服务器在解析请求时,会将multipart/form-data请求体中的表单数据或文件解析成Part对象集合。通过HttpServletRequestgetPart()/getParts()方法,可以获取这些Part对象,进而获取multipart/form-data请求体中的表单数据或文件。
每个Web服务器对Servlet 3.0规范都有自己的实现方式。对于Spring Boot来说,通常使用的是Tomcat/Undertow/Jetty内嵌Web服务器。通常只需要了解这三种服务器的实现方式即可。

5、StandardServletMultipartResolver#cleanupMultipart

StandardServletMultipartResolver#cleanupMultipart方法会将临时文件删除:

public void cleanupMultipart(MultipartHttpServletRequest request) {  
   if (!(request instanceof AbstractMultipartHttpServletRequest) ||  
         ((AbstractMultipartHttpServletRequest) request).isResolved()) {  
      // To be on the safe side: explicitly delete the parts,  
      // but only actual file parts (for Resin compatibility)      try {  
         for (Part part : request.getParts()) {  
            if (request.getFile(part.getName()) != null) {  
               part.delete();  
            }  
         }  
      }  
      catch (Throwable ex) {  
         LogFactory.getLog(getClass()).warn("Failed to perform cleanup of multipart items", ex);  
      }  
   }  
}

3.5.2、Tomcat实现

Tomcat是Spring Boot默认使用的内嵌Web服务器,只需要引入如下依赖:

<dependency>  
   <groupId>org.springframework.boot</groupId>  
   <artifactId>spring-boot-starter-web</artifactId>  
</dependency>

会默认引入Tomcat依赖:

<dependency>  
   <groupId>org.springframework.boot</groupId>  
   <artifactId>spring-boot-starter-tomcat</artifactId>  
</dependency>

Tomcat解析文件请求的核心在于org.apache.catalina.connector.Request#parseParts方法,核心代码如下:

// 1、创建ServletFileUpload文件上传对象
DiskFileItemFactory factory = new DiskFileItemFactory();  
try {  
    factory.setRepository(location.getCanonicalFile());  
} catch (IOException ioe) {  
    parameters.setParseFailedReason(FailReason.IO_ERROR);  
    partsParseException = ioe;  
    return;  
}

factory.setSizeThreshold(mce.getFileSizeThreshold());  
  
ServletFileUpload upload = new ServletFileUpload();  
upload.setFileItemFactory(factory);  
upload.setFileSizeMax(mce.getMaxFileSize());  
upload.setSizeMax(mce.getMaxRequestSize());
this.parts = new ArrayList<>();  
try {  
	// 2、解析文件请求
    List<FileItem> items =  
            upload.parseRequest(new ServletRequestContext(this));
    // 3、封装Part对象
    for (FileItem item : items) {  
        ApplicationPart part = new ApplicationPart(item, location);  
        this.parts.add(part);  
        }  
    }  
    success = true;  
}

核心步骤如下:

  1. 创建ServletFileUpload文件上传对象
  2. 解析文件请求
  3. 封装Part对象

org.apache.tomcat.util.http.fileupload.FileUploadBase#parseRequest会进行实际解析文件请求:

public List<FileItem> parseRequest(final RequestContext ctx) throws FileUploadException {  
  final List<FileItem> items = new ArrayList<>();  
  boolean successful = false;  
  try {  
    final FileItemIterator iter = getItemIterator(ctx);  
    final FileItemFactory fileItemFactory = Objects.requireNonNull(getFileItemFactory(),  
                                                                   "No FileItemFactory has been set.");  
    final byte[] buffer = new byte[Streams.DEFAULT_BUFFER_SIZE];  
    while (iter.hasNext()) {  
      final FileItemStream item = iter.next();  
      // Don't use getName() here to prevent an InvalidFileNameException.  
      final String fileName = item.getName();  
      final FileItem fileItem = fileItemFactory.createItem(item.getFieldName(), item.getContentType(),  
                                                           item.isFormField(), fileName);  
      items.add(fileItem);  
      try {  
        Streams.copy(item.openStream(), fileItem.getOutputStream(), true, buffer);  
      } catch (final FileUploadIOException e) {  
        throw (FileUploadException) e.getCause();  
      } catch (final IOException e) {  
        throw new IOFileUploadException(String.format("Processing of %s request failed. %s",  
                                                      MULTIPART_FORM_DATA, e.getMessage()), e);  
      }  
      final FileItemHeaders fih = item.getHeaders();  
      fileItem.setHeaders(fih);  
    }  
    successful = true;  
    return items;  
  }
}

简单来说,Tomcat会使用java.io.InputStreamjava.io.OutputStream(传统IO流)将multipart请求中的表单参数和文件保存到服务器本地临时文件,然后将本地临时文件信息封装成Part对象返回。
也就是说,我们在业务中获取到的文件实际上都来自服务器本地临时文件

3.5.3、Undertow实现

为了使用Undertow服务器,需要引入如下依赖:

<dependency>  
   <groupId>org.springframework.boot</groupId>  
   <artifactId>spring-boot-starter-web</artifactId>  
   <exclusions>  
      <exclusion>  
         <groupId>org.springframework.boot</groupId>  
         <artifactId>spring-boot-starter-tomcat</artifactId>  
      </exclusion>  
   </exclusions>  
</dependency>  
<dependency>  
   <groupId>org.springframework.boot</groupId>  
   <artifactId>spring-boot-starter-undertow</artifactId>  
</dependency>

Undertow解析文件请求的核心在于io.undertow.servlet.spec.HttpServletRequestImpl#loadParts方法,核心代码如下

final List<Part> parts = new ArrayList<>();  
String mimeType = exchange.getRequestHeaders().getFirst(Headers.CONTENT_TYPE);  
if (mimeType != null && mimeType.startsWith(MultiPartParserDefinition.MULTIPART_FORM_DATA)) {  
	// 1、解析文件请求,封装FormData对象
    FormData formData = parseFormData();  
    // 2、封装Part对象
    if(formData != null) {  
        for (final String namedPart : formData) {  
            for (FormData.FormValue part : formData.get(namedPart)) {  
                parts.add(new PartImpl(namedPart,  
                        part,  
                        requestContext.getOriginalServletPathMatch().getServletChain().getManagedServlet().getMultipartConfig(),  
                        servletContext, this));  
            }  
        }  
    }  
} else {  
    throw UndertowServletMessages.MESSAGES.notAMultiPartRequest();  
}  
this.parts = parts;

核心步骤如下:

  1. 解析文件请求,封装FormData对象
  2. 封装Part对象

io.undertow.servlet.spec.HttpServletRequestImpl#parseFormData方法会进行实际解析文件请求,核心代码如下:

final FormDataParser parser = originalServlet.getFormParserFactory().createParser(exchange) 
try {  
    return parsedFormData = parser.parseBlocking();
}

io.undertow.server.handlers.form.MultiPartParserDefinition.MultiPartUploadHandler#parseBlocking核心代码如下:

InputStream inputStream = exchange.getInputStream();
try (PooledByteBuffer pooled = exchange.getConnection().getByteBufferPool().getArrayBackedPool().allocate()){  
    ByteBuffer buf = pooled.getBuffer();  
    while (true) {  
        buf.clear();  
        int c = inputStream.read(buf.array(), buf.arrayOffset(), buf.remaining());  
        if (c == -1) {  
            if (parser.isComplete()) {  
                break;  
            } else {  
                throw UndertowMessages.MESSAGES.connectionTerminatedReadingMultiPartData();  
            }  
        } else if (c != 0) {  
            buf.limit(c);  
            parser.parse(buf);  
        }  
    }  
    exchange.putAttachment(FORM_DATA, data);  
} 
return exchange.getAttachment(FORM_DATA);

在这个过程中,Undertow会使用java.io.InputStreamjava.io.OutputStream(传统IO流),结合java.nio.ByteBuffermultipart请求中的表单参数和文件保存到服务器本地临时文件,然后将本地临时文件信息封装成Part对象返回(具体细节可以继续深入阅读相关源码)。
也就是说,我们在业务中获取到的文件实际上都来自服务器本地临时文件

3.5.4、Jetty实现

为了使用Jetty服务器,需要引入如下依赖:

<dependency>  
   <groupId>org.springframework.boot</groupId>  
   <artifactId>spring-boot-starter-web</artifactId>  
   <exclusions>  
      <exclusion>  
         <groupId>org.springframework.boot</groupId>  
         <artifactId>spring-boot-starter-tomcat</artifactId>  
      </exclusion>  
   </exclusions>  
</dependency>  
<dependency>  
   <groupId>org.springframework.boot</groupId>  
   <artifactId>spring-boot-starter-jetty</artifactId>  
</dependency>

Jetty解析文件请求的核心在于org.eclipse.jetty.server.Request#getParts方法,核心代码如下

MultipartConfigElement config = (MultipartConfigElement)this.getAttribute("org.eclipse.jetty.multipartConfig");  
this._multiParts = this.newMultiParts(config);
// 省略……
return this._multiParts.getParts();

org.eclipse.jetty.server.Request#newMultiParts会创建文件解析器:

private MultiParts newMultiParts(MultipartConfigElement config) throws IOException {  
  MultiPartFormDataCompliance compliance = this.getHttpChannel().getHttpConfiguration().getMultipartFormDataCompliance(); 

  switch(compliance) {  
    case RFC7578:  
      return new MultiPartsHttpParser(this.getInputStream(), this.getContentType(), config, this._context != null ? (File)this._context.getAttribute("javax.servlet.context.tempdir") : null, this);  
    case LEGACY:  
    default:  
      return new MultiPartsUtilParser(this.getInputStream(), this.getContentType(), config, this._context != null ? (File)this._context.getAttribute("javax.servlet.context.tempdir") : null, this);  
  }  
}

org.eclipse.jetty.server.MultiParts.MultiPartsHttpParser#getParts

org.eclipse.jetty.server.MultiParts.MultiPartsUtilParser#getParts则会进行文件请求解析:

public Collection<Part> getParts() throws IOException {  
    Collection<Part> parts = this._httpParser.getParts();  
    this.setNonComplianceViolationsOnRequest();  
    return parts;  
}

public Collection<Part> getParts() throws IOException {  
    Collection<Part> parts = this._utilParser.getParts();  
    this.setNonComplianceViolationsOnRequest();  
    return parts;  
}

在这个过程中,Jetty会使用java.io.InputStreamjava.io.OutputStream(传统IO流),结合java.nio.ByteBuffermultipart请求中的表单参数和文件保存到服务器本地临时文件,然后将本地临时文件信息封装成Part对象返回。
也就是说,我们在业务中获取到的文件实际上都来自服务器本地临时文件

3.5.5、CommonsMultipartResolver解析器

为了使用CommonsMultipartResolver解析器,除了基础的spring-boot-starter-web,还需要额外引入如下依赖:

<dependency>  
   <groupId>commons-fileupload</groupId>  
   <artifactId>commons-fileupload</artifactId>  
   <version>1.4</version>  
</dependency>

然后,配置名为multipartResolver的bean(此时Spring Boot不会添加默认文件解析器):

@Bean  
public MultipartResolver multipartResolver() {  
    CommonsMultipartResolver multipartResolver = new CommonsMultipartResolver();  
    // 文件请求解析配置:multipartResolver.setXxx()  
    multipartResolver.setResolveLazily(true);  
    return multipartResolver;  
}
1、CommonsMultipartResolver#isMultipart

CommonsMultipartResolver解析器会根据请求方法和请求头来判断文件请求,源码如下:

public boolean isMultipart(HttpServletRequest request) {  
   return (this.supportedMethods != null ?  
         this.supportedMethods.contains(request.getMethod()) &&  
               FileUploadBase.isMultipartContent(new ServletRequestContext(request)) :  
         ServletFileUpload.isMultipartContent(request));  
}

supportedMethods成员变量表示支持的请求方法,默认为null,可以在初始化时指定。
supportedMethodsnull时,即在默认情况下,会调用ServletFileUpload.isMultipartContent()方法进行判断。此时文件请求的满足条件为:

  1. 请求方法为POST
  2. 请求头Content-Type为以multipart/开头

supportedMethods不为null时,文件请求满足条件为:

  1. 请求方法在supportedMethods列表中
  2. 请求头Content-Type为以multipart/开头
2、CommonsMultipartResolver#resolveMultipart

CommonsMultipartResolver在解析文件请求时,会将原始请求封装成DefaultMultipartHttpServletRequest对象:

public MultipartHttpServletRequest resolveMultipart(final HttpServletRequest request) throws MultipartException {  
  Assert.notNull(request, "Request must not be null");  
  if (this.resolveLazily) {  
    return new DefaultMultipartHttpServletRequest(request) {  
      @Override  
      protected void initializeMultipart() {  
        MultipartParsingResult parsingResult = parseRequest(request);  
        setMultipartFiles(parsingResult.getMultipartFiles());  
        setMultipartParameters(parsingResult.getMultipartParameters());  
        setMultipartParameterContentTypes(parsingResult.getMultipartParameterContentTypes());  
      }  
    };  
  }  
  else {  
    MultipartParsingResult parsingResult = parseRequest(request);  
    return new DefaultMultipartHttpServletRequest(request, parsingResult.getMultipartFiles(),  
                                                  parsingResult.getMultipartParameters(), parsingResult.getMultipartParameterContentTypes());  
  }  
}

StandardServletMultipartResolver相同,CommonsMultipartResolverresolveLazily成员变量也表示是否会马上解析文件。
resolveLazilytrue时,即默认情况下,不会立即解析文件,只是会将原始请求进行简单封装。只有在调用DefaultMultipartHttpServletRequest#getXxx方法时,会判断文件是否已经解析。如果没有解析,会调用DefaultMultipartHttpServletRequest#initializeMultipart进行解析。
resolveLazilyfalse时,会立即调用CommonsMultipartResolver#parseRequest方法进行文件解析。

3、CommonsMultipartResolver#parseRequest

CommonsMultipartResolver#parseRequest方法会进行文件请求解析,总的来说包括两个步骤:

  1. 解析文件请求
  2. 封装响应
List<FileItem> fileItems = ((ServletFileUpload) fileUpload).parseRequest(request);  
return parseFileItems(fileItems, encoding);

深入阅读源码可以发现,在解析文件请求时,会采用与StandardServletMultipartResolver+Tomcat相同的方式保存临时文件:

public List<FileItem> parseRequest(RequestContext ctx)  
        throws FileUploadException {  
    List<FileItem> items = new ArrayList<FileItem>();  
    boolean successful = false;  
    try {  
        FileItemIterator iter = getItemIterator(ctx);  
        FileItemFactory fac = getFileItemFactory();  
        if (fac == null) {  
            throw new NullPointerException("No FileItemFactory has been set.");  
        }  
        while (iter.hasNext()) {  
            final FileItemStream item = iter.next();  
            // Don't use getName() here to prevent an InvalidFileNameException.  
            final String fileName = ((FileItemIteratorImpl.FileItemStreamImpl) item).name;  
            FileItem fileItem = fac.createItem(item.getFieldName(), item.getContentType(),  
                                               item.isFormField(), fileName);  
            items.add(fileItem);  
            try {  
                Streams.copy(item.openStream(), fileItem.getOutputStream(), true);  
            } catch (FileUploadIOException e) {  
                throw (FileUploadException) e.getCause();  
            } catch (IOException e) {  
                throw new IOFileUploadException(format("Processing of %s request failed. %s",  
                                                       MULTIPART_FORM_DATA, e.getMessage()), e);  
            }  
            final FileItemHeaders fih = item.getHeaders();  
            fileItem.setHeaders(fih);  
        }  
        successful = true;  
        return items;  
    } catch (FileUploadIOException e) {  
        throw (FileUploadException) e.getCause();  
    } catch (IOException e) {  
        throw new FileUploadException(e.getMessage(), e);  
    } finally {  
        if (!successful) {  
            for (FileItem fileItem : items) {  
                try {  
                    fileItem.delete();  
                } catch (Exception ignored) {  
                    // ignored TODO perhaps add to tracker delete failure list somehow?  
                }  
            }  
        }  
    }  
}
4、CommonsMultipartResolver#cleanupMultipart

CommonsMultipartResolver#cleanupMultipart方法会将临时文件删除:

public void cleanupMultipart(MultipartHttpServletRequest request) {  
  if (!(request instanceof AbstractMultipartHttpServletRequest) ||  
      ((AbstractMultipartHttpServletRequest) request).isResolved()) {  
    try {  
      cleanupFileItems(request.getMultiFileMap());  
    }  
    catch (Throwable ex) {  
      logger.warn("Failed to perform multipart cleanup for servlet request", ex);  
    }  
  }  
}

四、总结

4.1、解决方案回顾

首先看一下最开始的解决方式

4.1.1、通过配置文件方式

# Intermediate location of uploaded files.
spring.servlet.multipart.location=/data/tmp

自动绑定给MultipartProperties中的location属性,然后在MultipartAutoConfiguration自动配置类中配置如下

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ Servlet.class, StandardServletMultipartResolver.class, MultipartConfigElement.class })
@ConditionalOnProperty(prefix = "spring.servlet.multipart", name = "enabled", matchIfMissing = true)
@ConditionalOnWebApplication(type = Type.SERVLET)
@EnableConfigurationProperties(MultipartProperties.class)
public class MultipartAutoConfiguration {

	private final MultipartProperties multipartProperties;

	public MultipartAutoConfiguration(MultipartProperties multipartProperties) {
		this.multipartProperties = multipartProperties;
	}
	
  	// 自动配置对应的bean:MultipartConfigElement
	@Bean
    // 如果没有配置这两个bean,就来自动配置
	@ConditionalOnMissingBean({ MultipartConfigElement.class, CommonsMultipartResolver.class })
    // 看下如何来实现自动配置的
	public MultipartConfigElement multipartConfigElement() {
		return this.multipartProperties.createMultipartConfig();
	}

	@Bean(name = DispatcherServlet.MULTIPART_RESOLVER_BEAN_NAME)
	@ConditionalOnMissingBean(MultipartResolver.class)
	public StandardServletMultipartResolver multipartResolver() {
		StandardServletMultipartResolver multipartResolver = new StandardServletMultipartResolver();
		multipartResolver.setResolveLazily(this.multipartProperties.isResolveLazily());
		return multipartResolver;
	}

}

文件上传的配置元素

public MultipartConfigElement createMultipartConfig() {
  MultipartConfigFactory factory = new MultipartConfigFactory();
  PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
  // 具体参数的设置
  map.from(this.fileSizeThreshold).to(factory::setFileSizeThreshold);
  map.from(this.location).whenHasText().to(factory::setLocation);
  map.from(this.maxRequestSize).to(factory::setMaxRequestSize);
  map.from(this.maxFileSize).to(factory::setMaxFileSize);
  return factory.createMultipartConfig();
}

4.1.2、配置MultipartConfigElement

/**
 * 配置上传文件临时目录
 * @return
 */
@Bean
public MultipartConfigElement multipartConfigElement() {
  MultipartConfigFactory factory = new MultipartConfigFactory();
  // tmp.dir参数在启动脚本中设置
  String path = System.getProperty("tmp.dir");
  if(path == null || "".equals(path.trim())) {
    path = System.getProperty("user.dir");
  }
  String location = path + "/tmp";
  File tmpFile = new File(location);
  // 如果临时目录不存在则创建
  if (!tmpFile.exists()) {
    tmpFile.mkdirs();
  }
  // 明确指定上传文件的临时目录
  factory.setLocation(location);
  return factory.createMultipartConfig();
}

所以第一种配置和第二种配置原理上来说是一致的

4.2、在DispatcherServlet中执行流程

try {
  // 检查且如果是文件上传将request进行转换
  processedRequest = checkMultipart(request);
  //......
}finally {
  //......
  // Clean up any resources used by a multipart request.
  if (multipartRequestParsed) {
    cleanupMultipart(processedRequest);
  }
}
posted @ 2023-04-11 18:50  写的代码很烂  阅读(2111)  评论(0编辑  收藏  举报