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();
}
三、源码分析
参考博客:
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
处理文件请求会经过以下步骤:
- 判断当前HttpServletRequest请求是否是文件请求
- 是:将当前
HttpServletRequest
请求的数据(文件和普通参数)封装成MultipartHttpServletRequest
对象 - 不是:不处理
- 是:将当前
DispatcherServlet
对原始HttpServletRequest
或MultipartHttpServletRequest
对象进行业务处理- 业务处理完成,清除文件上传产生的临时资源
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;
}
}
当需要指定其他文件解析器时,只需要引入相关依赖,然后配置一个名为multipartResolver
的bean
对象:
@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/"));
}
其中,strictServletCompliance
是StandardServletMultipartResolver
的成员变量,默认false
,表示是否严格遵守Servlet 3.0规范。简单来说就是对Content-Type
校验的严格程度。如果strictServletCompliance
为false
,请求头以multipart/
开头就满足文件请求条件;如果strictServletCompliance
为true
,则需要请求头以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
当resolveLazily
为false
时,会马上调用parseRequest()
方法会对请求进行实际解析,该方法会完成两件事情:
- 使用Servlet 3.0的
Part
API,获取Part
集合 - 解析
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()
方法就可以获取表单参数或表单文件信息。
当resolveLazily
为true
时,在MultipartResolver#resolveMultipart()
阶段并不会进行文件请求解析。也就是说,此时StandardMultipartHttpServletRequest
对象的成员变量都是空值。那么,resolveLazily
为false
时文件请求解析是在什么时候完成的呢?
实际上,在调用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
对象集合。通过HttpServletRequest
的getPart()
/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;
}
核心步骤如下:
- 创建ServletFileUpload文件上传对象
- 解析文件请求
- 封装
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.InputStream
和java.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;
核心步骤如下:
- 解析文件请求,封装FormData对象
- 封装
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.InputStream
和java.io.OutputStream
(传统IO流),结合java.nio.ByteBuffer
将multipart
请求中的表单参数和文件保存到服务器本地临时文件,然后将本地临时文件信息封装成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.InputStream
和java.io.OutputStream
(传统IO流),结合java.nio.ByteBuffer
将multipart
请求中的表单参数和文件保存到服务器本地临时文件,然后将本地临时文件信息封装成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
,可以在初始化时指定。
当supportedMethods
为null
时,即在默认情况下,会调用ServletFileUpload.isMultipartContent()
方法进行判断。此时文件请求的满足条件为:
- 请求方法为
POST
- 请求头
Content-Type
为以multipart/
开头
当supportedMethods
不为null
时,文件请求满足条件为:
- 请求方法在
supportedMethods
列表中 - 请求头
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
相同,CommonsMultipartResolver
的resolveLazily
成员变量也表示是否会马上解析文件。
当resolveLazily
为true
时,即默认情况下,不会立即解析文件,只是会将原始请求进行简单封装。只有在调用DefaultMultipartHttpServletRequest#getXxx
方法时,会判断文件是否已经解析。如果没有解析,会调用DefaultMultipartHttpServletRequest#initializeMultipart
进行解析。
当resolveLazily
为false
时,会立即调用CommonsMultipartResolver#parseRequest
方法进行文件解析。
3、CommonsMultipartResolver#parseRequest
CommonsMultipartResolver#parseRequest
方法会进行文件请求解析,总的来说包括两个步骤:
- 解析文件请求
- 封装响应
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);
}
}