记得很早之前,java开发为了支持文件上传,需要引入其他依赖,做一些复杂的配置,才能实现文件上传。具体来说,有两种方式,一种是在HttpFilter上做文章,引入一个HttpFilter,在HttpFilter中判断请求中是不是上传文件,如果是上传文件的请求,就用MultipartHttpServletRequest包装原始HttpServletRequest,并透传到下游,这个类内部会解析文件,下游只需要用instanceof判断HttpServletRequest是不是MultipartHttpServletRequest,如果是的话,强转后调用相关方法即可获取文件;在引入springmvc后,spring将这个逻辑包装到了DispatcherServlet中,只需要在spring中配置MultipartResolver实例Bean,DispatcherServlet就会做这个包装,甚至可以在入参中定义MultipartFile参数,spring就会把文件映射到入参中。

而从 servlet 规范3.0 开始,HttpServletRequest接口本身就要求支持文件获取,也就是说凡是支持servlet规范3.0的产品,都应该内部支持文件解析获取。顺便查了一下,servlet3.0规范在2009年11月份就定稿了,tomcat 也在 7.0版本上支持了servlet3.0,这个时间是2011年3月份,也就是说2011年后,不需要引入其他依赖、额外配置就能实现文件解析获取。

springboot内部默认开启了文件上传支持,从 org.springframework.boot.autoconfigure.web.servlet.MultipartProperties#enabled 的默认值上就能看出来,也就意味着不用做任何配置,controller方法自然就支持MultipartFile入参映射。但也可以在properties文件中配置一些其他参数,例如文件上传后默认保存的磁盘位置,最大文件大小等等。例如:

spring.servlet.multipart.enabled=true
spring.servlet.multipart.location=/tmp/test_servlet
spring.servlet.multipart.max-file-size=1000MB

而出问题的系统所采用的文件上传方案就是servlet3.0规范中支持的文件获取功能,具体方法说明如下:

HttpServletRequest接口方法描述:

/**
 * 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;

最近有反馈系统获取不到上传的文件,也是奇怪,文件上传的功能之前都是本地、预发测试通过的,最近也没有升级过系统。最后经过深入排查后,笔者发现了其中的奥秘,本文便将其中奥秘娓娓道来。

WebServer创建

Springboot使用了嵌入的tomcat,这使得web应用部署变得更简单,springboot的逻辑是spring管理的controller需要对外提供服务,那么应该有人来提供一个WebServer,将指定端口的http请求映射到对应的controller服务上。

WebServer可以有多种实现,因此springboot希望有一套标准能够创建不同的WebServer,因此引入了WebServerFactory接口,而具体的WebServer实现目前并没有产品给springboot适配,因此需要springboot自己维护一些WebServerFactory。在用户指定WebServer实现后,springboot就会使用对应的WebServerFactory创建WebServer。

为了实现WebServerFactory及WebServer的自动创建,springboot引入了 ServletWebServerFactoryConfiguration,在此Configuration中,有三个WebServerFactory的配置,分别是tomcat、jetty和undertow,那如何由用户指定WebServer呢?看了源码就会发现是通过引入相关WebServer的实现依赖实现的,Configuration内部通过判断相关实现的核心类是否存在来决定创建哪个WebServerFactory,默认情况下,使用spring-boot-starter-web依赖就会使用tomcat:

<!--spring-boot-starter-web pom-->
<dependency>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter-tomcat</artifactId>  
    <version>2.2.1.RELEASE</version>  
    <scope>compile</scope>  
</dependency>

<!--spring-boot-starter-tomcat pom-->
<dependency>  
    <groupId>org.apache.tomcat.embed</groupId>  
    <artifactId>tomcat-embed-core</artifactId>  
    <version>9.0.27</version>  
    <scope>compile</scope>  
    <exclusions>  
        <exclusion>  
            <artifactId>tomcat-annotations-api</artifactId>  
            <groupId>org.apache.tomcat</groupId>  
        </exclusion>  
    </exclusions>  
</dependency>

以tomcat的WebServerFactory配置为例,ServletWebServerFactoryConfiguration中代码如下:

@Bean
public TomcatServletWebServerFactory tomcatServletWebServerFactory(
        ObjectProvider<TomcatConnectorCustomizer> connectorCustomizers,
        ObjectProvider<TomcatContextCustomizer> contextCustomizers,
        ObjectProvider<TomcatProtocolHandlerCustomizer<?>> protocolHandlerCustomizers) {
    TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory();
    factory.getTomcatConnectorCustomizers()
            .addAll(connectorCustomizers.orderedStream().collect(Collectors.toList()));
    factory.getTomcatContextCustomizers()
            .addAll(contextCustomizers.orderedStream().collect(Collectors.toList()));
    factory.getTomcatProtocolHandlerCustomizers()
            .addAll(protocolHandlerCustomizers.orderedStream().collect(Collectors.toList()));
    return factory;
}

可以看到TomcatServletWebServerFactory还贴心的提供了很多定制器,例如TomcatConnectorCustomizer可以为tomcat的Connector实例提供定制,具体能定制哪些内容可以查看Connector支持哪些配置,因为TomcatConnectorCustomizer的方法入参就是一个Connector实例,因此可以任意操纵Connector实例中的属性。而日常大家可能会定制的对象可能就是Context了,因为之前配置tomcat的web.xml时,配置Context的会多一些,而采用springboot后,就可以通过提供TomcatContextCustomizer实现来定制Context了。

一般不建议自己创建TomcatServletWebServerFactory实例,因为springboot有很多自定义Customizers,自己创建Factory,还得引入springboot的这些Customizers,否则在启动时可能会报错。

TomcatServletWebServerFactory又是在哪里使用的呢?在追溯一段代码引用后,就能得到以下关系:

  • 在执行SpringApplication#run方法后 ,内部会创建AnnotationConfigServletWebServerApplicationContext,它继承了ServletWebServerApplicationContext类,ServletWebServerApplicationContext类继承了AbstractApplicationContext
  • AbstractApplicationContext#onRefresh方法会从beanFactory中获取ServletWebServerFactory ,此时就会获取到TomcatServletWebServerFactory实例。
  • WebServerFactory#getWebServer方法入参是ServletContextInitializer ,ServletContextInitializer的作用是在创建Tomcat的ServletContext后,对context进行定制。ServletWebServerApplicationContext会将自身的selfInitialize方法作为入参传过去,触发getWebServer方法会得到WebServer,此时Factory的作用就结束了。

TomcatServletWebServerFactory#getWebServer方法就不具体展开了 ,内部主要是创建Tomcat、Host、Engine和Context。

在所有bean加载完后,会调用AbstractApplicationContext#finishRefresh方法 ,在这里就会真正启动WebServer了。

整理一下整个WebServer启动流程:在SpringApplication#run方法中会创建ApplicationContext ,然后会调用refreshContext方法,这里会触发AbstractApplicationContext#refresh方法 ,此时会触发WebServer的创建,在调用finishBeanFactoryInitialization(包含很多spring生命周期的钩子,这里不详细展开了)方法后,触发WebServer启动。

Servlet加载

Servlet的加载就隐藏在ServletWebServerApplicationContext#selfInitialize中 ,这里总结一些重点ServletContextInitializer的实现:

  • FilterRegistrationBean:向context中注册filter
  • ServletRegistrationBean:向context中注册servet
  • DispatcherServletRegistrationBean:是ServletRegistrationBean的继承类,向context中注册spring默认的DispatchServlet。

ServletRegistrationBean的继承实现关系如下:

ServletRegistrationBean -> DynamicRegistrationBean -> RegistrationBean -> ServletContextInitializer

ServletContextInitializer接口中包含onStartup方法;RegistrationBean中实现onStartup方法,并提出register抽象方法;DynamicRegistrationBean中实现register方法,并提出addRegistration和configure方法;ServletRegistrationBean中的方法就是我们重点关心的Servlet加载细节了。

ServletRegistrationBean代码细节:

@Override
protected ServletRegistration.Dynamic addRegistration(String description, ServletContext servletContext) {
    String name = getServletName();
    return servletContext.addServlet(name, this.servlet);
}

@Override
protected void configure(ServletRegistration.Dynamic registration) {
    super.configure(registration);
    String[] urlMapping = StringUtils.toStringArray(this.urlMappings);
    if (urlMapping.length == 0 && this.alwaysMapUrl) {
        urlMapping = DEFAULT_MAPPINGS;
    }
    if (!ObjectUtils.isEmpty(urlMapping)) {
        registration.addMapping(urlMapping);
    }
    registration.setLoadOnStartup(this.loadOnStartup);
    if (this.multipartConfig != null) {
        registration.setMultipartConfig(this.multipartConfig);
    }
}

addRegistration方法向serverContext中注册Servlet,注册完Servlet后,需要对这次注册做额外配置。而额外配置就在configure方法中,主要是配置两个内容,一个是urlmapping映射,其作用是表明此servlet作用到哪些uri上,另一个则是multipartConfig配置,multipartConfig配置就是文件上传配置。

返回来查看DispatcherServletRegistrationBean的默认配置,就会发现spring会自动注入multipartConfig。

DispatcherServletAutoConfiguration.class代码细节:

@Bean(name = {"dispatcherServletRegistration"})
@ConditionalOnBean(
    value = {DispatcherServlet.class},
    name = {"dispatcherServlet"}
)
public DispatcherServletRegistrationBean dispatcherServletRegistration(DispatcherServlet dispatcherServlet, WebMvcProperties webMvcProperties, ObjectProvider<MultipartConfigElement> multipartConfig) {
    DispatcherServletRegistrationBean registration = new DispatcherServletRegistrationBean(dispatcherServlet, webMvcProperties.getServlet().getPath());
    registration.setName("dispatcherServlet");
    registration.setLoadOnStartup(webMvcProperties.getServlet().getLoadOnStartup());
    multipartConfig.ifAvailable(registration::setMultipartConfig);
    return registration;
}

上传配置不生效的原因

看完上边的代码走读,大家已经了解到了multipartConfig是如何被配置并注入的tomcat中的了。

那系统为什么会出现multipartConfig配置不生效的问题呢?经过排查,原因就是是代码里通过ServletRegistrationBean手动向tomcat注入了一个自定义Servlet,由于不了解ServletRegistrationBean的使用特性,因此没有配置上全局的multipartConfig。在tomcat判断到请求归属于这个自定义Servlet后,Request中便没有包含multipartConfig,最后也不会真正解析处理文件。

而原先做测试的时候,使用的是没有归属这个自定义Servlet的URI,例如/CommonServlet/upload,tomcat判断到请求归属于SpringDispatcherServlet,因此文件解析才会正常。

自定义Servlet代码:

@Bean
public ServletRegistrationBean<CustomServlet> customServlet(MultipartConfigElement multipartConfigElement) {
    ServletRegistrationBean bean = new ServletRegistrationBean();
    bean.addUrlMappings("/CustomServlet/*");
    bean.setServlet(new CustomServlet());
    // 切记要设置此配置
    // bean.setMultipartConfig(multipartConfigElement);
    return bean;
}

更进一步

阅读tomcat的ServletContext源码,会发现tomcat的Context有一个全局默认配置allowCasualMultipartParsing,当没有配置multipartConfig时,tomcat的Request则会判断context中的allowCasualMultipartParsing是否为true来决定是否解析处理文件,因此也可以使用下面的方式配置全局开启解析,从而避免全局multipartConfig不生效了:

@Bean
public TomcatContextCustomizer customizer(){
    return (context -> context.setAllowCasualMultipartParsing(true));
}

使用此配置后,spring中配置的MultipartConfigElement就不会生效了,tomcat会使用connector上的配置来解析文件。Request实现代码如下:

private void parseParts(boolean explicit) {
    // Return immediately if the parts have already been parsed
    if (parts != null || partsParseException != null) {
        return;
    }

    Context context = getContext();
    MultipartConfigElement mce = getWrapper().getMultipartConfigElement();

    if (mce == null) {
        if(context.getAllowCasualMultipartParsing()) {
            // 使用connector上的配置
            mce = new MultipartConfigElement(null, connector.getMaxPostSize(),
                    connector.getMaxPostSize(), connector.getMaxPostSize());
        } else {
            if (explicit) {
                partsParseException = new IllegalStateException(
                        sm.getString("coyoteRequest.noMultipartConfig"));
                return;
            } else {
                parts = Collections.emptyList();
                return;
            }
        }
    }

    // ......
}

这里再和大家聊一聊springweb的逻辑,springweb希望管控servlet,即由它提供的DispatcherServlet对接tomcat,再由DispatcherServlet转发到对应的业务服务方法上。DispatcherServlet接收到请求后,从自己的管理的bean中找到应该处理此请求的bean(被称为handler),再找到能执行此bean的adaptor,由adaptor执行bean的调用。

基于上述这个理念,spring希望大家向它表明哪些bean应该可以处理哪些URI的请求,并将其抽象为HandlerMapping,因此提供了以下几种方式:

  • BeanNameUrlHandlerMapping:如果beanName自定义成了/开头,那么spring会认为此bean可以处理请求
  • RequestMappingHandlerMapping:加上Controller/RequestMapping注解的类和方法可以承接转发
  • SimpleUrlHandlerMapping:支持硬编码配置出URI和bean的映射
  • 还有一些就不具体展开了

针对不同方式提供出来用于处理请求的bean,spring也提供了很多adaptor:

  • HttpRequestHandlerAdapter:凡是实现此接口的都可以简单转发
  • SimpleControllerHandlerAdapter:实现Controller接口的可以简单转发
  • SimpleServletHandlerAdapter:实现Servlet接口的可以简单转发
  • RequestMappingHandlerAdapter:用于处理Controller/RequestMapping注解的方法
  • 还有一些就不具体展开了

在新的springboot中,其中一些handler和adaptor已经默认不能使用了,新代码还是建议使用Controller/RequestMapping注解的方式实现请求处理。

如果有需要自定义servlet的地方,建议优先考虑使用SpringMvc提供的一些接口或注解,将你的servlet包装为SpringDispatcherServlet下的Handler,这样就能享受到全局默认配置带来的方便了。

标签: java, springmvc, spring, springboot, servlet

添加新评论