Spring MVC容器启动及请求处理流程详解

1,423 阅读11分钟

文章主要分为MVC容器的启动与对外提供服务两个部分,概括介绍了Tomcat容器、IOC容器与MVC容器这三者在上述两个部分中的关系;正好今天的面试问到了这个父子容器流程,顺便在这里一起做个整理。

平常开发中,诸如入参下划线转驼峰(camel_demo -> camelDemo)入参XSS过滤这类需要对请求参数做转换、检验的场景需要对于MVC处理请求流程有一个比较具体的了解,我们才可以灵活运用SpringMVC框架提供的可扩展点完成上述代码的开发。

1. mvc容器启动过程

我们一般会通过web.xml文件配置Tomcat容器、applicationContext.xml文件配置IOC容器、springmvc-servlet.xml文件配置MVC容器;

因此故事还是需要从Tomcat容器的web.xml配置文件说起,最核心的问题是在Tomcat启动的过程中,是如何依次启动IOC容器和MVC容器的,大家可以带入这个问题看博客:

<!--Spring IOC 配置文件,主要是配置包扫描路径以及一些注解驱动 -->
<context-param>
    <param-name>contextConfigLocation</param-name>
    <param-value>classpath*:spring/applicationContext-*.xml</param-value>
</context-param>

<!--Spring IOC 载入器 -->
<listener>
    <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>

<servlet>
    <servlet-name>springmvc</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <init-param>
        <param-name>contextConfigLocation</param-name>
        <!-- springmvc 容器的配置文件,主要是配置包扫描路径以及一些注解驱动-->
        <param-value>classpath*:spring/springmvc-servlet.xml</param-value>
    </init-param>
    <!-- load-on-startup=1 表示Tomcat容器在启动时,会首先加载该servlet-->
    <load-on-startup>1</load-on-startup>
</servlet>
<!-------------->
<servlet-mapping>
    <servlet-name>springmvc</servlet-name>
    <!-- 该servlet会处理所有路径的请求 -->
    <url-pattern>/</url-pattern>
</servlet-mapping>

1.1 Spring IOC容器启动

Tomcat使用观察者模式,在Tomcat容器初始化StandardContext组件时(该组件层级对应我们的Web应用),会产生相应的生命周期事件,对应不同的事件类型,会通过不同的事件处理器进行处理;

与IOC容器启动相关的则为ServletContextEvent事件,而对应的处理器为在上述web.xml配置文件中我们定义的ContextLoaderListener监听器:

public class StandardContext extends ContainerBase implements Context{
    
    // 该方法会在Tomcat启动Context组件时调用
    protected synchronized void startInternal() throws LifecycleException {
        // 调用相应的流程的监听器方法
        listenerStart();
    }

    public boolean listenerStart() {
        // 1. 获取生命周期类的事件处理器
        Object instances[] = getApplicationLifecycleListeners();
        // 2. 产生相应的事件
        ServletContextEvent event = new ServletContextEvent(getServletContext());
        // 3. 依次调用Tomcat容器中ServletContextListener的监听器处理当前ServletContextEvent事件
        for (Object instance : instances) {
                if (!(instance instanceof ServletContextListener)) {
                    continue;
                }
                ServletContextListener listener = (ServletContextListener) instance;
                // 调用监听器的contextInitialized()方法
                listener.contextInitialized(event);
            }
        }
        return ok;
    }
}

而Spring IOC容器便是在ContextLoaderListener监听器的contextInitialized接口方法中进行初始化的,主要通过调用子方法initWebApplicationContext()进行实现;

容器的启动主要分为创建IOC容器、初始化IOC容器中的bean对象这两个步骤,我们先看一下宏观的代码,感受一下大概的流程后,再进行每个流程对应方法的分析:

public WebApplicationContext initWebApplicationContext(ServletContext servletContext) {
      // 1. 创建Spring IOC容器
      if (this.context == null) {
         this.context = createWebApplicationContext(servletContext);
      }
      if (this.context instanceof ConfigurableWebApplicationContext) {
         ConfigurableWebApplicationContext cwac = (ConfigurableWebApplicationContext) this.context;
         if (!cwac.isActive()) {
            // 2. 调用onRefresh()方法进行IOC容器bean的初始化
            configureAndRefreshWebApplicationContext(cwac, servletContext);
         }
      }
     // 3. 将IOC容器的引用放入当前Web应用对应的ServletContext对象中
     servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, this.context);
     
     // 4. 返回容器的引用
      return this.context;
}

IOC容器用于管理Service、Dao层的类对象,而Controller层的类对象则交由Spring MVC容器进行管理,至于为什么需要这样去进行使用,在讲MVC容器的时候会进行分析;

1.1.1 创建IOC容器

使用createWebApplicationContext方法实例化指定的IOC容器,如果web.xml配置文件中没有通过<context-param>contextClass属性定义指定的容器类,则会默认实例化XmlWebApplicationContext类做为IOC容器:

protected WebApplicationContext createWebApplicationContext(ServletContext sc) {
   // 1. 依据配置文件决定实例化哪一种类型的ApplicationContext
   Class<?> contextClass = determineContextClass(sc);
   // 2. 实例化具体的ApplicationContext并进行返回
   return (ConfigurableWebApplicationContext) BeanUtils.instantiateClass(contextClass);
}
protected Class<?> determineContextClass(ServletContext servletContext) {
   // 1. 对应web.xml文件中<context-param>配置的<param-name>为contextClass的属性
   String contextClassName = servletContext.getInitParameter("contextClass");
   // 2.1 设置了contextClass属性则使用该类型进行实例化IOC容器
   if (contextClassName != null) {
      return ClassUtils.forName(contextClassName, ClassUtils.getDefaultClassLoader());
   }
   // 2.2 未设置该属性,则默认加载`XmlWebApplicationContext`类做为IOC容器
   contextClassName = defaultStrategies.getProperty(WebApplicationContext.class.getName());
   // 3. 返回IOC容器引用
   return ClassUtils.forName(contextClassName, ContextLoader.class.getClassLoader());
}

1.1.2 IOC容器初始化

使用configureAndRefreshWebApplicationContext方法初始化IOC容器,主要通过获取在web.xml文件中<context-param>contextConfigLocation地址属性对应的IOC容器的applicationContext.xml配置文件进行容器的初始化:

protected void configureAndRefreshWebApplicationContext(ConfigurableWebApplicationContext wac, ServletContext sc) {

   wac.setServletContext(sc);
   // 1. web.xml文件中<context-param>配置的`contextConfigLocation`属性,做为IOC容器启动的初始化文件
   // 主要包括IOC容器中Service、Manager、Dao以及其他非Controller层的Bean包扫描信息及注解驱动
   String configLocationParam = sc.getInitParameter(CONFIG_LOCATION_PARAM);
   if (configLocationParam != null) {
      wac.setConfigLocation(configLocationParam);
   }
   
   // 2. 可扩展点,可以用于在IOC容器初始化前做一些自定义的操作
   customizeContext(sc, wac);
   // 3. 调用refresh()方法进行IOC容器的初始化
   wac.refresh();
}

1.2 Spring MVC容器启动

上述IOC容器启动是在Tomcat容器在启动StandardContext(Web应用层面)时通过监听器进行初始化,而MVC容器的启动则是在Tomcat容器初始化StandardWrapper(Servlet层面)时通过Servlet接口的init()方法进行初始化,因此IOC容器的初始化与MVC容器的初始化存在先后顺序关系;

因为在web.xml文件中我们只定义了DispatcherServlet这一个servlet,而Tomcat默认是懒加载的,我们通过设置 <load-on-startup>1</load-on-startup>使得Tomcat在启动时便会进行该servlet的加载;我们知道Tomcat加载servlet会通过init()方法:

public abstract class HttpServletBean extends HttpServlet {
    public final void init() throws ServletException {
       ...
       // 留给子类的扩展点
       initServletBean();
    }
}
@Override
protected final void initServletBean() throws ServletException {
       // 1. 初始化mvc容器
       this.webApplicationContext = initWebApplicationContext();
   }
}

initWebApplicationContext()方法为最核心的MVC容器启动方法:

protected WebApplicationContext initWebApplicationContext() {
   // 在第二步Spring IOC容器启动过程中,最后将IOC容器的引用放入了当前Web对应的servletContext中
   // 1. 通过ServletContext对象获取对应的IOC容器引用
   WebApplicationContext rootContext =
         WebApplicationContextUtils.getWebApplicationContext(getServletContext());
   WebApplicationContext wac = null;
   
   // 2. 启动MVC容器
   if (wac == null) {
      // 2.1 初始化MVC容器,并将IOC容器设置为MVC容器的父容器
      wac = createWebApplicationContext(rootContext);
   }
   
   if (!this.refreshEventReceived) {
      // 2.2 初始化DispactcherServlet对应的组件,如HandlerMapping、ViewResolver等等
      synchronized (this.onRefreshMonitor) {
         onRefresh(wac);
      }
   }

   if (this.publishContext) {
      // 2.3 将MVC容器的引用放入ServletContext对象中
      String attrName = getServletContextAttributeName();
      getServletContext().setAttribute(attrName, wac);
   }

   // 3. 对外返回MVC容器的引用
   return wac;
}

1.2.1 MVC容器启动

protected WebApplicationContext createWebApplicationContext(@Nullable ApplicationContext parent) {
   // 1. 将IOC容器设置为MVC容器的父容器
   wac.setParent(parent);
   // 在DispatcherServlet的<init-param>参数中定义的contextConfigLocation属性
   // 2. 获取mvc容器配置文件的地址,配置文件中主要定义了Controller层Bean的自动扫描以及与MVC容器相关的Bean
   String configLocation = getContextConfigLocation();
   if (configLocation != null) {
      wac.setConfigLocation(configLocation);
   }
   // 3. 依据mvc配置文件地址进行mvc容器的启动,主要是调用了refresh()方法,不再展开叙述
   configureAndRefreshWebApplicationContext(wac);

   return wac;
}

1.2.2 为什么要分别启动MVC容器与IOC容器?

大家可能会有这样的疑问,我们明明可以将所有的Bean都交由MVC容器进行管理,为什么要大费周章的启动两个容器呢?

我觉得最大的原因是解耦合,因为MVC容器是web容器服务的一种解决方案,类似的还有诸如strut2等等,这样如果我们需要切换web的解决方案时,只需要将spring-servlet.xml替换成Struts的配置文件struts.xml即可,影响范围降低,提高了可扩展性。

1.3 总结

至此,我们梳理了容器的启动流程,主要为:Tomcat在初始化Web应用时会通过事件监听器进行IOC容器的初始化,接着Tomcat在初始化Servlet时,会通过Servlet的init()方法进行mvc容器的初始化,并设置MVC容器与IOC容器的父子关系;

在大体介绍完MVC的启动流程后,接着我们会通过一个请求的处理链路分析MVC处理请求的过程。

2. 请求处理链路

2.1 DispatchServlet

作为在Tomcat中注册的servlet,处理外界请求时,首先会为每个请求生成对应的Request Response对象,最后会通过StandardWrapperPipeline的基础阀调用servlet实例的service()方法对请求进行处理(Pipeline基于责任链模式,基础阀即为链尾);

抽象类HttpServlet实现了servlet接口,并且基于HTTP的特性-通过GET、POST、HEAD表示请求类型,并且需要对应不同的处理方法,因此HttpServletservice()方法中解析HTTP请求头信息,并转发到相应的doGet()、doPost()方法中;

因此继承HttpServlet的类,需要实现doGet、doPost()等方法;而SpringMVC框架中FrameworkServlet抽象类通过模板模式定义了默认处理流程,并定义了抽象方法doService()交由子类DispatchServlet实现;

下图即为上述描述的类结构图:

image.png

2.2 DispatchServlet.doService

doService()方法主要为当前请求对象Request添加MVC容器与Resolver引用,这个会在用到的时候讲;

接着doService()调用了 doDispatch()方法,该方法定义了SpringMVC处理请求的核心流程: 通过HandlerMapping找到对应的Handler与拦截器链,并通过HandlerAdapter适配器接口方法handle()使用Handler,处理完后将生成的ModelAndView交由视图解析器进行页面渲染;

2.2.1 通过HandlerMapping决定当前request请求对应的Handler

初始化:

spring/springmvc-servlet.xml文件中添加了 <mvc:annotation-driven/> 命名空间的配置,会通过该命名空间对应的NameSpaceHandler初始化RequestMappingHandlerMappingBeanName-UrlHandlerMapping两个HandlerMapping;

mvc容器启动时会触发生命周期事件,异步调用监听者DispatchServletonRefresh()方法,并触发initHandlerMappings()将默认的HandlerMapping注入List<HandlerMapping> handlerMappings属性中;


通过handlerMapping的匹配策略找到对应的Handler用于处理请求:

doDispatch方法会调用getHandler(Request request)方法获取对应的Handler:

@Nullable
protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
	if (this.handlerMappings != null) {
                // 依次遍历handlerMappings
		for (HandlerMapping mapping : this.handlerMappings) {
                        // 调用当前HandlerMapping获取handler的策略
                        // 返回对应的Handler与该request匹配的拦截器链
			HandlerExecutionChain handler = mapping.getHandler(request);
			if (handler != null) {
				return handler;
			}
		}
	}
	return null;
}

注意是依次调用,并且handler返回不为空便return,剩余的handlerMappings不会再被调用;因此如果我们需要自定义多个HandlerMapping时,需要通过实现Ordered接口进行排序,

默认首先通过RequestMappingHandlerMapping策略进行查找,即对于@RequestMapping注解中定义的value与请求的path进行匹配,找到对应的Controller层方法

/**
 * Look up a handler method for the given request.
 */
@Override
protected HandlerMethod getHandlerInternal(HttpServletRequest request) throws Exception {
        // 默认截取URL获取请求的相对路径(alwaysUseFullPath=false)
	String lookupPath = getUrlPathHelper().getLookupPathForRequest(request);
	request.setAttribute(LOOKUP_PATH, lookupPath);
        // 获取映射关系的读锁(用于支持@RequestMapping映射运行期间热更)
	this.mappingRegistry.acquireReadLock();
	try {
                // 通过相对路径找到Controller层中对应的处理方法
		HandlerMethod handlerMethod = lookupHandlerMethod(lookupPath, request);
		return (handlerMethod != null ? handlerMethod.createWithResolvedBean() : null);
	}
	finally {
		this.mappingRegistry.releaseReadLock();
	}
}

找到请求对应的处理方法后,还需要找到匹配该请求路径的HandlerInterceptor列表,对请求进行前置与后置处理;

protected HandlerExecutionChain getHandlerExecutionChain(Object handler, HttpServletRequest request) {
        // 此时handler为HandlerMethod对象
	HandlerExecutionChain chain = (handler instanceof HandlerExecutionChain ?
			(HandlerExecutionChain) handler : new HandlerExecutionChain(handler));
        // 获取请求的相对路径
	String lookupPath = this.urlPathHelper.getLookupPathForRequest(request, LOOKUP_PATH);
        // 依据路径查找对应的拦截器
	for (HandlerInterceptor interceptor : this.adaptedInterceptors) {
		if (interceptor instanceof MappedInterceptor) {
			MappedInterceptor mappedInterceptor = (MappedInterceptor) interceptor;
			if (mappedInterceptor.matches(lookupPath, this.pathMatcher)) {
				chain.addInterceptor(mappedInterceptor.getInterceptor());
			}
		}
		else {
			chain.addInterceptor(interceptor);
		}
	}
	return chain;
}

2.2.2 通过适配器包裹Handler对外提供服务

因为mvc框架中支持采用不同匹配策略并返回不同Handler对象(Object引用) 的HandlerMapping,而这些Handler的粒度可能是Method、Controller或其他,因此可以通过适配器模式隔离变化,调用方依赖对应的适配器对象,适配器对象则负责屏蔽内部实现细节对外暴露统一的调用相应处理方法的接口;

image.png

如上图所示:doDispatch只需要依赖HandlerAdapter接口,并通过support()方法找到适合处理的适配器对象,然后调用该适配器对象的handler()完成对于请求的处理;

protected HandlerAdapter getHandlerAdapter(Object handler) throws ServletException {
	if (this.handlerAdapters != null) {
                // 遍历HandlerAdapter列表
		for (HandlerAdapter adapter : this.handlerAdapters) {
                        // 判断当前Adapter是否支持当前粒度的handler,如果支持则返回
			if (adapter.supports(handler)) {
				return adapter;
			}
		}
	}
	throw new ServletException("No adapter for handler");
}

2.2.3 真正开始处理流程

对应当前请求的适配器与拦截器链都生成完毕后,便依次调用:拦截器的前置处理方法PreHandle()、适配器的handle()方法并返回ModelAndView、传递ModelAndView至对应的视图解析器(目前以REST风格为主,前后端分离,这部分便不再详细研究了)、拦截器的后置处理方法、拦截器的最终处理方法;

3. 扩展方法

3.1 Controller层参数实现下划线->驼峰

前端传入参数一般为camel_demo,但是目前后端很多可复用的代码都是以驼峰实现的,因此需要进行转换,最直观的方法是在Controller层方法中进行转换:

@RequestMapping(value = "/xxx", method = RequestMethod.GET)
public RespBO xxx (HttpServletRequest request, Queryunderline underlineQuery) {

        CamelQuery camelQuery = new CamelQuery();
        camelQuery.setUserName(underlineQuery.getUser_name());
        camelQuery.setUserId(underlineQuery.getUser_id);
        camelQuery.setUserAge(underlineQuery.getUser_age);
        
        // ....省略相关逻辑
        return new RespBO();
    }

但是所有Controller层的方法都要进行转换,优化的方式很明显,在Tomcat层面或者在SpringMVC容器层面进行统一处理转换,这里讲一下在SpringMVC容器中处理的方法:

因此按照上述流程,该Model入参的处理是在调用适配器的handler()流程中,通过 handler - > handleInternal() -> invokeHandlerMethod() -> invokeAndHandle()方法时,会调用getMethodArgumentValues()方法,该方法会遍历入参并对入参依次应用List<HandlerMethodArgumentResolver> argumentResolvers解析器列表,因此我们可以通过在mvc容器中注册一个定制化用于下划线->驼峰的HandlerMethodArgumentResolver 即可进行比较优雅的处理:

    <mvc:annotation-driven>
        <mvc:argument-resolvers>
            <!-- 用于将GET请求中,下划线格式属性转换成驼峰格式属性封装进对象中 -->
            <bean class="...UnderlineToCamelMethodProcessor">
                <constructor-arg name="annotationNotRequired" value="true" />
            </bean>
        </mvc:argument-resolvers>
    </mvc:annotation-driven>

自定义实现时,需要注意要直接或间接实现HandlerMethodArgumentResolver接口,否则无法生效,接着面向搜索引擎编程,查看Spring是否有封装过一些轮子,如果有则直接在轮子的基础上更方便的进行定制化:

public class UnderlineToCamelMethodProcessorToCamelMethodProcessor extends ServletModelAttributeMethodProcessor {

	@Resource(name="org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter")
	private RequestMappingHandlerAdapter requestMappingHandlerAdapter;
	
	public SnakeToCamelMethodProcessor(boolean annotationNotRequired) {
		super(annotationNotRequired);
	}

        // 自定义重写解析方法
	@Override
	protected void bindRequestParameters(WebDataBinder binder, NativeWebRequest request) {
            // 实现具体的格式转换方法,这个类具体的使用方式不展开讲解~
            UnderlineToCamelRequestDataBinder utcDataBinder = new UnderlineToCamelRequestDataBinder(binder.getTarget());
            requestMappingHandlerAdapter.getWebBindingInitializer().initBinder(utcDataBinder, request);
            utcDataBinder.bind(request.getNativeRequest(ServletRequest.class));
        }
}

3.2 xss攻击

对于xss攻击,我们可以通过对入参统一进行敏感字符检验;

如何有效预防XSS?这几招管用,可以参考这位大佬的文章,通过FilterChain在Tomcat层面对请求入参进行过滤;