7 MultipartResolver

还记得我们在第二章讲DispatcherServlet时提到的MultipartResolver吗?

2.3.1节讲到 initMultipartResolver() 方法会尝试从web应用上下文中获取beanName为multipartResolverMultipartResolver实现类。

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功能直接获取内容列表,然后转化为自己的对象列表。

标签: java, springmvc, spring

添加新评论