从springboot文件上传配置不生效学习springboot的servlet加载机制
记得很早之前,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,这样就能享受到全局默认配置带来的方便了。