SpringMVC 解毒5
7 MultipartResolver
还记得我们在第二章讲DispatcherServlet
时提到的MultipartResolver
吗?
2.3.1节讲到 initMultipartResolver() 方法会尝试从web应用上下文中获取beanName为multipartResolver
的MultipartResolver
实现类。
2.4.2节讲到在真正处理request前会先判断请求是不是携带multipart文本格式,如果携带,则会把request转为MultipartHttpServletRequest
。在使用完MultipartHttpServletRequest
后需要清除生成MultipartHttpServletRequest
时产生的临时文件。
MultipartResolver
最重要的功能就是能够解析前端通过post请求上传的文件,接下来让我们深入了解MultipartResolver
。
7.1 MultipartResolver 分析
DispatcherServlet
使用的是MultipartResolver
接口,什么意思呢?我们只要把握住MultipartResolver
接口中的方法,那么就能掌握MultipartResolver
的使用。
MultipartResolver 接口提供了三个方法。isMultipart 方法是用来判断request是不是含有multipart内容,resolveMultipart 方法用来生成MultipartHttpServletRequest
,cleanupMultipart 方法则是在使用完MultipartHttpServletRequest
后清除临时资源文件的。
实际上DispatcherServlet
的 checkMultipart 方法先调用了 isMultipart 方法,然后调用了 resolveMultipart 方法生成 MultipartHttpServletRequest。来看代码:
/**
* 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) {
logger.debug("Request is already a MultipartHttpServletRequest - if not in a forward, " +
"this typically results from an additional MultipartFilter in web.xml");
}
else if (request.getAttribute(WebUtils.ERROR_EXCEPTION_ATTRIBUTE) instanceof MultipartException) {
logger.debug("Multipart resolution failed for current request before - " +
"skipping re-resolution for undisturbed error rendering");
}
else {
return this.multipartResolver.resolveMultipart(request);
}
}
// If not returned before: return original request.
return request;
}
而后,在2.4.2节doDispatch方法的第88行到第93行,判断如果这个请求没有使用异步响应技术,那么就会调用 cleanupMultipart 方法清除临时资源。代码如下
/**
* Clean up any resources used by the given multipart request (if any).
* @param request current HTTP request
* @see MultipartResolver#cleanupMultipart
*/
protected void cleanupMultipart(HttpServletRequest request) {
MultipartHttpServletRequest multipartRequest =
WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class);
if (multipartRequest != null) {
this.multipartResolver.cleanupMultipart(multipartRequest);
}
}
7.2 CommonsMultipartResolver 分析
在常见的web项目总中,我们都会使用CommonsMultipartResolver
这个实现类,它实际上使用了 Apache Commons FileUpload。
7.2.1 CommonsMultipartResolver 初始化分析
由于这个类也比较复杂,我们还是从初始化说起,看看它都会做哪些事情。
首先,因为它继承了抽象类CommonsFileUploadSupport
,而在这个类的构造方法中又会调用抽象方法protected abstract FileUpload newFileUpload(FileItemFactory fileItemFactory)
,所以,我们将目光转向这个方法。
/**
* Initialize the underlying {@code org.apache.commons.fileupload.servlet.ServletFileUpload}
* instance. Can be overridden to use a custom subclass, e.g. for testing purposes.
* @param fileItemFactory the Commons FileItemFactory to use
* @return the new ServletFileUpload instance
*/
@Override
protected FileUpload newFileUpload(FileItemFactory fileItemFactory) {
return new ServletFileUpload(fileItemFactory);
}
重写的方法还是很简单的,直接创建了一个新的ServletFileUpload类。
接下来,由于它还实现了ServletContextAware方法,所以我们来找找setServletContext方法。
@Override
public void setServletContext(ServletContext servletContext) {
if (!isUploadTempDirSpecified()) {
getFileItemFactory().setRepository(WebUtils.getTempDir(servletContext));
}
}
// CommonsFileUploadSupport.java
protected boolean isUploadTempDirSpecified() {
return this.uploadTempDirSpecified;
}
// WebUtils.java
/**
* Standard Servlet spec context attribute that specifies a temporary
* directory for the current web application, of type {@code java.io.File}.
*/
public static final String TEMP_DIR_CONTEXT_ATTRIBUTE = "javax.servlet.context.tempdir";
/**
* Return the temporary directory for the current web application,
* as provided by the servlet container.
* @param servletContext the servlet context of the web application
* @return the File representing the temporary directory
*/
public static File getTempDir(ServletContext servletContext) {
Assert.notNull(servletContext, "ServletContext must not be null");
return (File) servletContext.getAttribute(TEMP_DIR_CONTEXT_ATTRIBUTE);
}
可以看到,这个方法实际上是给FileItemFactory设置一个仓库地址,而仓库地址的类型是File,而这个File对象则是通过标准的servletContext属性javax.servlet.context.tempdir
取得。
而什么时候不从servletContext属性中取得呢?来看看代码
CommonsFileUploadSupport.java
/**
* Set the temporary directory where uploaded files get stored.
* Default is the servlet container's temporary directory for the web application.
* @see org.springframework.web.util.WebUtils#TEMP_DIR_CONTEXT_ATTRIBUTE
*/
public void setUploadTempDir(Resource uploadTempDir) throws IOException {
if (!uploadTempDir.exists() && !uploadTempDir.getFile().mkdirs()) {
throw new IllegalArgumentException("Given uploadTempDir [" + uploadTempDir + "] could not be created");
}
this.fileItemFactory.setRepository(uploadTempDir.getFile());
this.uploadTempDirSpecified = true;
}
如果我们设置了别的临时目录,就不会从servletContext属性中获取了。
7.2.2 CommonsMultipartResolver 属性设置分析
初始化代码我们已经看完了,接下来让我们再来了解了解可以设置的属性。
- 第一个就是uploadTempDirSpecified,它是通过setUploadTempDir方法设置的,也就是说,如果你设置了临时目录,那这个属性会变成true。
- 接下来有一个setDefaultEncoding方法,这个方法会指定multipart部分内容的字符编码格式,一般我们会设置成
UTF-8
。如果不设置并且从header中没有找到charset,默认是ISO-8859-1
,原因可以看代码:
CommonsFileUploadSupport.java
/**
* Set the default character encoding to use for parsing requests,
* to be applied to headers of individual parts and to form fields.
* Default is ISO-8859-1, according to the Servlet spec.
* <p>If the request specifies a character encoding itself, the request
* encoding will override this setting. This also allows for generically
* overriding the character encoding in a filter that invokes the
* {@code ServletRequest.setCharacterEncoding} method.
* @param defaultEncoding the character encoding to use
* @see javax.servlet.ServletRequest#getCharacterEncoding
* @see javax.servlet.ServletRequest#setCharacterEncoding
* @see WebUtils#DEFAULT_CHARACTER_ENCODING
* @see org.apache.commons.fileupload.FileUploadBase#setHeaderEncoding
*/
public void setDefaultEncoding(String defaultEncoding) {
this.fileUpload.setHeaderEncoding(defaultEncoding);
}
protected String getDefaultEncoding() {
String encoding = getFileUpload().getHeaderEncoding();
if (encoding == null) {
encoding = WebUtils.DEFAULT_CHARACTER_ENCODING;
}
return encoding;
}
WebUtils.java
/**
* Default character encoding to use when {@code request.getCharacterEncoding}
* returns {@code null}, according to the Servlet spec.
* @see ServletRequest#getCharacterEncoding
*/
public static final String DEFAULT_CHARACTER_ENCODING = "ISO-8859-1";
- 第三个是setMaxUploadSize方法,这个方法的作用是当content大小大于maxUploadSize或者读取到maxUploadSize大小字节数据后还有内容,会抛出
SizeLimitExceededException
异常。 - 第四个是setMaxInMemorySize方法,这个方法指定上传的文件如果没超过maxInMemorySize字节大小,则不会真正写到文件,而是保留在内存数组中。如果想了解具体如何操作的,可以参考
org.apache.commons.io.output.ThresholdingOutputStream
带阈值的outputstream。 - 第五个是setMaxUploadSizePerFile方法,这个方法限定每一个文件的最大大小,当其中的一个文件大小超过maxUploadSizePerFile或者读取到maxUploadSizePerFile大小字节数据后还有内容,会抛出
FileSizeLimitExceededException
。 - 第六个是setResolveLazily方法,这六个方法只有这个方法是CommonsMultipartResolver类所拥有的,其余均是CommonsFileUploadSupport类所拥有的。这个参数的作用是延后解析multipart数据,当resolveLazily属性为true,只有在真正用到multipart部分数据时才会进行解析。来看代码:
@Override
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());
}
}
7.2.3 CommonsMultipartResolver 处理请求分析
CommonsMultipartResolver 的初始化和可注入的属性我们已经分析过了,我们发现它可以限制单个文件大小和整个multipart大小,临时文件磁盘存放等等。接下来,我们就来看看它是如何解析请求的。
首先来看看如何判断是不是multipart
// CommonsMultipartResolver.java
@Override
public boolean isMultipart(HttpServletRequest request) {
return (request != null && ServletFileUpload.isMultipartContent(request));
}
// org.apache.commons.fileupload.servlet.ServletFileUpload.java
/**
* Utility method that determines whether the request contains multipart
* content.
*
* @param request The servlet request to be evaluated. Must be non-null.
*
* @return <code>true</code> if the request is multipart;
* <code>false</code> otherwise.
*/
public static final boolean isMultipartContent(
HttpServletRequest request) {
if (!POST_METHOD.equalsIgnoreCase(request.getMethod())) {
return false;
}
return FileUploadBase.isMultipartContent(new ServletRequestContext(request));
}
// org.apache.commons.fileupload.FileUploadBase
/**
* <p>Utility method that determines whether the request contains multipart
* content.</p>
*
* <p><strong>NOTE:</strong>This method will be moved to the
* <code>ServletFileUpload</code> class after the FileUpload 1.1 release.
* Unfortunately, since this method is static, it is not possible to
* provide its replacement until this method is removed.</p>
*
* @param ctx The request context to be evaluated. Must be non-null.
*
* @return <code>true</code> if the request is multipart;
* <code>false</code> otherwise.
*/
public static final boolean isMultipartContent(RequestContext ctx) {
String contentType = ctx.getContentType();
if (contentType == null) {
return false;
}
if (contentType.toLowerCase(Locale.ENGLISH).startsWith(MULTIPART)) {
return true;
}
return false;
}
/**
* Part of HTTP content type header.
*/
public static final String MULTIPART = "multipart/";
看源码的时候发现一个事,spring的源码都是用tab
缩进,而apache的源码采用空格缩进。
我们可以看到,判断规则交给了apache-common-fileupload,其规则就是:必须是post请求,content-Type必须以multipart/
开头。
看完判断接下来再看看如何解析请求。
@Override
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());
}
}
/**
* Parse the given servlet request, resolving its multipart elements.
* @param request the request to parse
* @return the parsing result
* @throws MultipartException if multipart resolution failed.
*/
protected MultipartParsingResult parseRequest(HttpServletRequest request) throws MultipartException {
String encoding = determineEncoding(request);
FileUpload fileUpload = prepareFileUpload(encoding);
try {
List<FileItem> fileItems = ((ServletFileUpload) fileUpload).parseRequest(request);
return parseFileItems(fileItems, encoding);
}
catch (FileUploadBase.SizeLimitExceededException ex) {
throw new MaxUploadSizeExceededException(fileUpload.getSizeMax(), ex);
}
catch (FileUploadException ex) {
throw new MultipartException("Could not parse multipart servlet request", ex);
}
}
我们这里以实时解析为例,实际上,DefaultMultipartHttpServletRequest会在调用multipart部分内容是判断有没有初始化,如果没有就会调用初始化方法。
CommonsMultipartResolver 在解析时调用 parseRequest 方法得到解析的内容,然后将解析的内容通过构造函数置入 DefaultMultipartHttpServletRequest。所以接下来继续看CommonsFileUploadSupport类。
/**
* Determine an appropriate FileUpload instance for the given encoding.
* <p>Default implementation returns the shared FileUpload instance
* if the encoding matches, else creates a new FileUpload instance
* with the same configuration other than the desired encoding.
* @param encoding the character encoding to use
* @return an appropriate FileUpload instance.
*/
protected FileUpload prepareFileUpload(String encoding) {
FileUpload fileUpload = getFileUpload();
FileUpload actualFileUpload = fileUpload;
// Use new temporary FileUpload instance if the request specifies
// its own encoding that does not match the default encoding.
if (encoding != null && !encoding.equals(fileUpload.getHeaderEncoding())) {
actualFileUpload = newFileUpload(getFileItemFactory());
actualFileUpload.setSizeMax(fileUpload.getSizeMax());
actualFileUpload.setFileSizeMax(fileUpload.getFileSizeMax());
actualFileUpload.setHeaderEncoding(encoding);
}
return actualFileUpload;
}
/**
* Parse the given List of Commons FileItems into a Spring MultipartParsingResult,
* containing Spring MultipartFile instances and a Map of multipart parameter.
* @param fileItems the Commons FileIterms to parse
* @param encoding the encoding to use for form fields
* @return the Spring MultipartParsingResult
* @see CommonsMultipartFile#CommonsMultipartFile(org.apache.commons.fileupload.FileItem)
*/
protected MultipartParsingResult parseFileItems(List<FileItem> fileItems, String encoding) {
MultiValueMap<String, MultipartFile> multipartFiles = new LinkedMultiValueMap<String, MultipartFile>();
Map<String, String[]> multipartParameters = new HashMap<String, String[]>();
Map<String, String> multipartParameterContentTypes = new HashMap<String, String>();
// Extract multipart files and multipart parameters.
for (FileItem fileItem : fileItems) {
if (fileItem.isFormField()) {
String value;
String partEncoding = determineEncoding(fileItem.getContentType(), encoding);
if (partEncoding != null) {
try {
value = fileItem.getString(partEncoding);
}
catch (UnsupportedEncodingException ex) {
if (logger.isWarnEnabled()) {
logger.warn("Could not decode multipart item '" + fileItem.getFieldName() +
"' with encoding '" + partEncoding + "': using platform default");
}
value = fileItem.getString();
}
}
else {
value = fileItem.getString();
}
String[] curParam = multipartParameters.get(fileItem.getFieldName());
if (curParam == null) {
// simple form field
multipartParameters.put(fileItem.getFieldName(), new String[] {value});
}
else {
// array of simple form fields
String[] newParam = StringUtils.addStringToArray(curParam, value);
multipartParameters.put(fileItem.getFieldName(), newParam);
}
multipartParameterContentTypes.put(fileItem.getFieldName(), fileItem.getContentType());
}
else {
// multipart file field
CommonsMultipartFile file = new CommonsMultipartFile(fileItem);
multipartFiles.add(file.getName(), file);
if (logger.isDebugEnabled()) {
logger.debug("Found multipart file [" + file.getName() + "] of size " + file.getSize() +
" bytes with original filename [" + file.getOriginalFilename() + "], stored " +
file.getStorageDescription());
}
}
}
return new MultipartParsingResult(multipartFiles, multipartParameters, multipartParameterContentTypes);
}
prepareFileUpload 方法很简单,入参是编码方式,获取的时FileUpload实现类对象。中间有这么一折,因为我们最开始置入了编码方式,如果从request请求中获得的编码方式和最开始设置的不一致,它会创建一个新的FileUpload实现类对象。所以,一定要设置默认编码方式,否则,如果请求和默认编码方式不一致,每次都要创建新的FileUpload实现类对象。
parseFileItems 方法是其最重要的方法,apache-common-fileupload中FileUpload实现类将请求中的content解析成FileItem列表,FileItem列表解析成multipart都在这里进行。
首先在代码第33行到第35行,声明了我们需要的multipartFile的map、multipart中的键值和multipart中的contentType。接下来就是从FileItem列表中解析数据。
代码第39行到第66行,处理的是键值,因为一个键可以对应多个值,所以Map的值类型为String[]
,它允许值有多个。每一个FileItem中只有一个值,所以这块代码要判断MultiValueMap
中key有没有值,有值就把新的值加入到旧的数组中,没有就创建一个新数组。
代码第68行到第77行,处理的是文件类型的数据。因为一个key可能对应多个文件,apache-common设计了一个MultiValueMap
,它的值是泛型列表,如果add时key已经有value了,新值会被加到value列表中。
读者请注意,我这里故意略过了实际的解析过程,如果你想了解最详细的解析过程,可以参考org.apache.commons.fileupload.FileUploadBase#parseRequest
,这个方法内是真正的解析过程。
7.2.4 CommonsMultipartResolver 结束请求分析
当DispathcerServlet
调用 cleanupMultipart 方法时,会调用 CommonsMultipartResolver 的 cleanupMultipart 方法,最终调用的则是 CommonsFileUploadSupport 的 cleanupFileItems 方法。
CommonsMultipartResolver.java
@Override
public void cleanupMultipart(MultipartHttpServletRequest request) {
if (request != null) {
try {
cleanupFileItems(request.getMultiFileMap());
}
catch (Throwable ex) {
logger.warn("Failed to perform multipart cleanup for servlet request", ex);
}
}
}
CommonsFileUploadSupport.java
/**
* Cleanup the Spring MultipartFiles created during multipart parsing,
* potentially holding temporary data on disk.
* <p>Deletes the underlying Commons FileItem instances.
* @param multipartFiles Collection of MultipartFile instances
* @see org.apache.commons.fileupload.FileItem#delete()
*/
protected void cleanupFileItems(MultiValueMap<String, MultipartFile> multipartFiles) {
for (List<MultipartFile> files : multipartFiles.values()) {
for (MultipartFile file : files) {
if (file instanceof CommonsMultipartFile) {
CommonsMultipartFile cmf = (CommonsMultipartFile) file;
cmf.getFileItem().delete();
if (logger.isDebugEnabled()) {
logger.debug("Cleaning up multipart file [" + cmf.getName() + "] with original filename [" +
cmf.getOriginalFilename() + "], stored " + cmf.getStorageDescription());
}
}
}
}
}
可以看到,我们之前把multipart解析成两中类型的数据,一种是键值,另一种是键文件。需要清除的是键文件,而在 7.2.2 节讲第四个属性setMaxInMemorySize方法时,提到过,如果文件大小超过指定字节数,会被写到临时文件目录中,所以这里做的就是清除临时目录文件。
7.2.5 什么是multipart请求
以上几个小节,我们只是简单的了解了如何判断是不是需要解析multipart,解析成FileItem后如何转为Spring中的MultipartFile和其他属性,请求结束时如何销毁临时文件等这样的功能。我略过了FileUpload
实现类解析请求成FileItem
列表的过程。
HTTP协议规范规定,如果想提交一个multipart请求,首先方法必须是post方法,header中Content-Type格式需为Content-Type: multipart/form-data; boundary=${bound}
,其Content内容格式如下:
--${bound}
Content-Disposition: form-data; name="submit-name"
Larry
--${bound}
Content-Disposition: form-data; name="files"
Content-Type: multipart/mixed; boundary=${sub_bound}
--${sub_bound}
Content-Disposition: file; filename="file1.txt"
Content-Type: text/plain
... contents of file1.txt ...
--${sub_bound}
Content-Disposition: file; filename="file2.gif"
Content-Type: image/gif
Content-Transfer-Encoding: binary
...contents of file2.gif...
--${sub_bound}--
--${bound}--
所以解析multipart请求就是根据http协议获取相应信息并转成相应的bean。
相关资料:What is http multipart request?
7.3 StandardServletMultipartResolver 分析
StandardServletMultipartResolver 是一个相对陌生的multipart解析器,为什么陌生?因为它是基于Servlet 3.0标准的,而tomcat自1.7开始才支持3.0标准。所以以前的很多教程都是基于 CommonsMultipartResolver 来讲解的。
那Servlet 3.0标准提供了什么呢?它提供了以下这些功能
其中需要我们关注的时最后两个方法,凡是实现Servlet 3.0标准的容器,都能够直接解析multipart请求,而解析的结果放到了Part接口实现类中。我们没法直接看到Part接口实现类,原因是实现类是容器实现的,也就是tomcat实现的。
而Spring做的事情是什么?判断是否是multipart请求,然后把Part列表中的数据文件放到他的bean结构中。
7.4 multipart数据映射
还记得第4章讲的HandlerAdapter吗?4.6.1节讲到 RequestMappingHandler 中 HandlerMethodArgumentResolver接口,它的作用就是从request请求中匹配合适的入参。
如果使用multipart请求对应的MultipartFile
文件对象,都是通过RequestParamMethodArgumentResolver
映射出来的。它实际上做的就是判断是不是multipart请求,如果是,尝试从multipartRequest中获取MultipartFile
对象。
如果你想直接使用MultipartRequest
对象,则是通过ServletRequestMethodArgumentResolver
映射得来的。
7.5 小结
这一章感觉讲的比较粗糙,但要点还是讲到了,实际上不管采用哪一个multipartResolver实现类,它的作用就是将multipart请求中的内容转化为Spring自己的参数,然后使用HandlerMethodArgumentResolver
实现类映射到开发者写的方法入参上。
CommonsMultipartResolver 使用的时apache-common-fileupload包中的功能实现multipart请求转化为apache-common-fileupload自己的对象列表,进而被Spring转化为自己的对象列表。
而 StandardServletMultipartResolver 直接利用了Servlet3.0标准提供的getParts功能直接获取内容列表,然后转化为自己的对象列表。