“SpringMVC搭建应用”这个标题看起来平平无奇,但背后牵扯的东西其实不少。从XML配置到零XML配置,中间隔着一个Servlet 3.0的SPI规范;从会用DispatcherServlet到看懂它初始化了哪些组件,中间隔着源码分析;从看懂源码到手写一个像模像样的MVC框架并发布成jar,中间隔着对反射、注解、类扫描这些基本功的掌握。
这篇文章就沿着这条线往下走:先把两种搭建方式完整过一遍,再拆Servlet 3.0 SPI这个“零配置”的底层原理,接着进源码看DispatcherServlet的处理链路,最后落到实操——手写一个精简版SpringMVC框架,打成jar包在模拟项目里直接用。不管你是刚接触SSM整合的新手,还是已经在用SpringBoot但没搞懂“为什么加一个注解就能被扫描到”的开发者,这篇都值得花十分钟读完。
1. 从搭建开始:两条路线背后的设计选择
1.1 XML配置方式的完整搭建与适用场景
SpringMVC的XML配置方式,本质上是把“组件注册、映射规则、扫描范围、视图解析”这些信息全部写进两个XML文件:一个是web.xml,一个是springmvc.xml。这套玩法到今天仍然有大量老项目在用,尤其是那些从Struts2迁移过来的系统,几乎清一色是这种结构。
web.xml里要做的核心事情有两个:注册DispatcherServlet,配置它的映射路径和初始化参数。我举个最常见的写法:
<servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:springmvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping>这里有个很多人容易忽略的点:为什么DispatcherServlet要配load-on-startup为1?因为这个数字决定了Spring容器在Web应用启动阶段的初始化时机,数字越小优先级越高,1表示随着容器启动立刻创建并初始化DispatcherServlet。如果不配置这个参数,首次请求到来时才会实例化Servlet,那项目的启动错误就会延迟到第一个请求才暴露,排障成本直接翻倍。
springmvc.xml里,最核心的是这三件事:组件扫描、注解驱动、视图解析器。
<context:component-scan base-package="com.example.controller"/> <mvc:annotation-driven/> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean>组件扫描指定Controller包路径;mvc:annotation-driven这一行很多人只会用不理解,它背后其实是注册了RequestMappingHandlerMapping和RequestMappingHandlerAdapter这两个关键Bean;视图解析器决定了Controller返回的字符串怎么和物理视图路径拼接。这段配置虽然简单,但每一行背后都有对应的工作机制,后面讲源码时会串起来。
1.2 零XML配置方式:它到底省掉了什么
零XML配置不是指不用写任何配置,而是把硬编码的XML配置换成了Java代码。基于Servlet 3.0的SPI机制,Web容器在启动时会自动查找实现了特定接口的类并调用它,SpringMVC正是利用这一点,把web.xml里那段Servlet注册逻辑搬进了Java类。
最典型的写法是继承AbstractAnnotationConfigDispatcherServletInitializer:
public class WebInitializer extends AbstractAnnotationConfigDispatcherServletInitializer { @Override protected Class<?>[] getRootConfigClasses() { return new Class<?>[]{RootConfig.class}; } @Override protected Class<?>[] getServletConfigClasses() { return new Class<?>[]{WebConfig.class}; } @Override protected String[] getServletMappings() { return new String[]{"/"}; } }WebConfig用@EnableWebMvc和@ComponentScan注解代替了springmvc.xml:
@Configuration @EnableWebMvc @ComponentScan("com.example.controller") public class WebConfig { @Bean public ViewResolver viewResolver() { InternalResourceViewResolver resolver = new InternalResourceViewResolver(); resolver.setPrefix("/WEB-INF/views/"); resolver.setSuffix(".jsp"); return resolver; } }对比一下就能发现,它省掉的不是“配置”本身,而是XML这个载体。所有注册逻辑变成了类型安全的Java代码,IDE里能直接点进去看实现,重构时也不会因为改个包名漏掉XML里的字符串。但代价是,如果不理解Servlet 3.0 SPI这套机制,第一次见到这个Initializer类会觉得它“凭空出现”——为什么容器会找到它?为什么重写这几个方法就能生效?这就是下一节要展开的问题。
1.3 两种方式如何共存:老项目迁移的思路
这里说一个实际经验:老项目从XML迁移到零XML,不要想着一步到位。最稳的做法是让两者并存,web.xml里保留监听器和过滤器,只把DispatcherServlet的配置逐步替换掉。因为Servlet 3.0的ServletContainerInitializer和web.xml是可以同时工作的,容器会先扫描类路径下的Initializer,再加载web.xml中的声明,二者注册的组件并不会互相覆盖,只是同名时会按规则决定谁生效。
我在某公司的模拟迁移项目里做过一次这样的改造:先让WebInitializer和web.xml里的DispatcherServlet同时映射到/,确认初始化顺序正常后再删掉XML里的Servlet声明。这样每一步的回归范围都可控,出了问题也能快速回退。这种过渡思路放在任何框架升级场景里都适用,核心原则就一条:不要让新旧两套机制产生临界状态。
2. Servlet 3.0 SPI规范:零配置的基石
2.1 从web.xml到ServletContainerInitializer
Servlet 3.0规范引入了一个叫ServletContainerInitializer的接口,它的作用翻译成大白话就是:Web容器在启动时,会扫描classpath下META-INF/services/jakarta.servlet.ServletContainerInitializer(老版本是javax.servlet)文件中声明的类,然后实例化并调用它的onStartup方法。
这个机制和JDK的SPI(Service Provider Interface)如出一辙。SpringBoot的自动装配、各种第三方starter能被容器发现,底层都是同一套思路。区别只是JDK SPI用ServiceLoader加载实现类,而Servlet容器在这里是被规范要求必须扫描这个指定路径。
一个最简单的Initializer实现长这样:
public class MyInitializer implements ServletContainerInitializer { @Override public void onStartup(Set<Class<?>> c, ServletContext ctx) { ctx.addServlet("myServlet", new HttpServlet() { ... }).addMapping("/my/*"); } }注意onStartup的第一个参数:带@HandlesTypes注解指定的类的集合。比如Spring的SpringServletContainerInitializer标注了@HandlesTypes(WebApplicationInitializer.class),那么容器启动时会把所有实现了WebApplicationInitializer接口的类都收集到这个Set里传进来。这就是为什么前面那个WebInitializer类不需要在任何XML里注册,只要放在classpath下就能被自动调用。
2.2 Spring对SPI的封装:WebApplicationInitializer
Spring在拿到WebApplicationInitializer的实现类集合后,会逐个调用它们的onStartup方法。AbstractAnnotationConfigDispatcherServletInitializer就是这套封装体系的产物:它本身实现了WebApplicationInitializer接口,模板方法里完成了两件事——创建AnnotationConfigWebApplicationContext作为根容器,再创建DispatcherServlet并注册到ServletContext。
这里有一个值得记下来的细节:getRootConfigClasses和getServletConfigClasses返回的Class数组,对应的是两个不同层级的Spring容器。根容器放Service、Mapper这些业务Bean,Servlet容器放Controller、HandlerMapping这些Web组件。父子容器的隔离设计从这套API里就能看出来,很多人在整合SSM时遇到的“Service注入不到Controller里”的问题,本质上就是Bean被注册到了错误的容器层级。
如果你想看更底层的实现,可以直接翻SpringServletContainerInitializer的源码,整个类只有几十行,核心逻辑就是遍历传入的WebApplicationInitializer集合,并按顺序调用。这个过程没有经过任何反射之外的魔法,理解它之后,“为什么我的Initializer没执行”这类问题就只剩下一种可能:类没有被扫描到。
2.3 初始化器的加载顺序与容错细节
onStartup方法的调用顺序规范里没有严格保证,Spring的容器初始化因为用了@Order注解做排序,大多数情况下是稳定的,但依赖顺序的代码仍然要谨慎。我在一个模拟项目中遇到过这样的情况:两个Initializer都往ServletContext里添加Filter,后添加的反而先执行,导致过滤器的执行顺序和预期不符。真相是Servlet规范对Filter的执行顺序有自己的一套规则,和注册顺序并不完全一致。
还有一个细节:ServletContainerInitializer的onStartup抛出的异常会影响整个Web应用的启动。这就意味着Initializer里的代码要做足够的防御,比如Bean不存在时的空指针、配置类循环依赖,都会在部署阶段直接引爆。在实际排查问题的时候,看到启动日志里一串诡异的异常栈,优先想到的应该是初始化器执行链的问题,而不是业务代码。
3. SpringMVC源码分析:DispatcherServlet到底做了什么
3.1 初始化阶段:initStrategies与九大组件
DispatcherServlet的继承关系是:DispatcherServlet extends FrameworkServlet extends HttpServletBean。HttpServletBean在init()方法里把Servlet的init-param绑定到Bean属性上,FrameworkServlet创建WebApplicationContext,最后DispatcherServlet通过initStrategies初始化Web层的核心组件。
关键就在initStrategies这一段代码,它按顺序调用了九个初始化方法:initMultipartResolver、initLocaleResolver、initThemeResolver、initHandlerMappings、initHandlerAdapters、initHandlerExceptionResolvers、initRequestToViewNameTranslator、initViewResolvers、initFlashMapManager。这九种组件统称SpringMVC的九大组件,每个组件接口都有默认实现,所以即使你不做任何配置,DispatcherServlet也能以一套默认策略跑起来。
这九个初始化方法里有个共通的模式:都先从容器里按类型查找Bean,找不到就用getDefaultStrategy加载DispatcherServlet.properties里定义的默认实现类。这个properties文件在Spring-webmvc的jar包里,路径是org/springframework/web/servlet/DispatcherServlet.properties。我之前排查过一个“inintHandlerMappings为什么用了BeanNameUrlHandlerMapping”的问题,最后就是通过读这个默认配置文件定位到的。
initStrategies的执行时机是FrameworkServlet#initServletBean里调用的,也就是在Servlet的init阶段。如果容器里正好也配置了名为handlerMapping的Bean,那么initHandlerMappings会优先使用这个具体Bean而不是默认策略。这个细节在自定义HandlerMapping时很有用,但也是容易踩坑的地方——名字匹配错误会静默使用默认实现,表现就是你的自定义映射规则不生效。
3.2 请求处理阶段:doDispatch的完整链路
请求进入DispatcherServlet后,核心逻辑全在doDispatch方法里。我把这条链路拆成几个关键节点:
第一步,通过getHandler(request)获取HandlerExecutionChain。这步的本质是遍历容器内所有的HandlerMapping,逐个调用getHandler方法,返回第一个非空结果。HandlerExecutionChain包含两部分:目标处理器(Handler)和拦截器列表(HandlerInterceptor集合)。
第二步,拿到HandlerExecutionChain后调用getHandlerAdapter方法,找到一个能支持当前Handler的HandlerAdapter。这里体现的是适配器模式:Handler可能是方法、也可能是Controller类,Adapter负责把不同类型的Handler包装成统一的可执行形式。
第三步,依次执行拦截器的preHandle方法。这里有个明显的业务影响:如果某个preHandle返回了false,请求会被直接短路,后面的Handler和视图渲染都不会执行。很多权限控制功能就是在这个环节实现的。
第四步,调用HandlerAdapter#handle方法,真正执行Controller方法并拿到ModelAndView对象。在这之后执行postHandle——注意,这个回调对@ResponseBody形式的方法来说,经常拿到一个空的ModelAndView,所以在postHandle里做全局统一处理时要判断空值。
第五步,调用processDispatchResult完成视图渲染,这一步会把ModelAndView交给ViewResolver解析成View对象,再调用view.render把模型数据填充到视图里。同时它会在此过程中处理异常,HandlerExceptionResolver在这里登场。
整个doDispatch方法最需要反复读的就是第3.2.1附近的mappedHandler.applyPreHandle和后续的triggerAfterCompletion这一段的异常处理逻辑。对于发生异常的情况,Spring会倒序触发已执行拦截器的afterCompletion方法,这时候如果有资源需要清理,写在这个回调里是最稳妥的。
3.3 HandlerMapping与HandlerAdapter为什么分离
这个问题是面试常考题,也是理解SpringMVC设计思想的关键。用生活化的类比解释:HandlerMapping负责“找谁干活”,HandlerAdapter负责“让这个人把活干出来”。两者分离的好处是——Controller的写法怎么变,DispatcherServlet的主流程都不用改。
在纯注解驱动模式下,核心组合是RequestMappingHandlerMapping和RequestMappingHandlerAdapter。前者在容器初始化阶段,扫描所有@Controller类里的@RequestMapping方法,构建一个从请求条件到HandlerMethod的映射表;后者在请求到来时,通过反射调用目标方法,并负责参数绑定、返回值处理。
如果框架不拆开这两个角色,每次新增一种Controller写法,比如从实现Controller接口改成用注解,都要改动分发逻辑的核心代码。有了适配器隔离之后,新增一种Handler类型只需要实现对应的HandlerAdapter,SpringMVC的主流程一行代码都不用动,这就是适配器模式在这个框架里的价值。
3.4 参数绑定与返回值处理的设计思路
Controller方法上的参数怎么被填充的?答案在HandlerMethodArgumentResolver这个接口里。每一种参数类型都对应一个ArgumentResolver:@RequestParam参数由RequestParamMethodArgumentResolver处理,@PathVariable由PathVariableMethodArgumentResolver处理,@RequestBody则交给RequestResponseBodyMethodProcessor处理。
这些Resolver之所以要设计成独立的策略类,原因和HandlerAdapter分离是同一个道理:参数的来源太复杂了,可能是URL参数、请求头、Cookie、Session、请求体,如果全部写死在调用逻辑里,这个类会膨胀到难以维护。
返回值处理也是同样的策略模式。方法上标注了@ResponseBody时,Spring会使用RequestResponseBodyMethodProcessor里的MessageConverter把返回值序列化成JSON;没有这个注解时,返回的字符串会被当成视图名,交给ViewResolver解析成物理视图。很多新手在这个环节出错,是因为返回了一个字符串,却忘了它到底是“视图名”还是“响应体”,这取决于有没有写@ResponseBody注解。
4. 手写SpringMVC框架:从源码到可运行jar
4.1 框架设计:先定最小功能集
手写框架的第一步不是写代码,而是划定最小功能集。我给自己定的目标是:支持@Controller、@RequestMapping、@RequestParam三个注解,实现URL到方法的映射、参数绑定、视图转发和JSON输出,最后打包成jar让外部模拟项目引入使用。
这个功能集对应的核心组件包括:一个注解定义模块、一个包扫描器、一个Bean容器(简化版)、一个前端控制器(继承HttpServlet)、一个参数解析器、一个视图渲染器。没必要把Spring的ApplicationContext、AOP、事务那一整套都搬过来,否则工作量完全失控。先跑通主流程,后续再逐步加能力,这个思路在真实项目里一样适用。
设计时还要想清楚一件事:框架被外部项目使用时,怎么让别人最省事?基于Servlet 3.0 SPI机制,可以仿照Spring写一个ServletContainerInitializer,让外部项目只要引入jar包、添加一个注解或一个小配置,前端控制器就能自动注册。这个设计思路直接复用前面分析的Spring源码经验,等于把“零配置”的能力也抄了过来。
4.2 自定义注解与包扫描的实现
三个注解定义非常简单:
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface MyController {} @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface MyRequestMapping { String value(); } @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface MyRequestParam { String value(); }注意Retention必须设为RUNTIME,否则运行时反射拿不到注解信息。包扫描器实现的是“扫描指定包下所有.class文件,判断类是否有@MyController注解,有则注册到容器”。这一步可以用ClassPathScanningCandidateComponentProvider,也可以自己遍历classpath下的文件。自己写的话要小心:Java的类文件路径和包名之间是目录层级关系,遍历时要正确处理,包名里的点号要转成文件分隔符。
4.3 请求分发核心:仿DispatcherServlet实现
自定义前端控制器MyDispatcherServlet继承HttpServlet,在init方法里完成三件事:扫描包、构建URL映射、初始化参数解析器。构建URL映射是最核心的逻辑:遍历所有Controller类,读取类上的@MyRequestMapping拿到基础路径,再遍历方法上的@MyRequestMapping拿到子路径,拼接后作为key,方法对象作为value,存进一个ConcurrentHashMap。这个阶段如果出现路径冲突,要主动抛异常提示,避免运行时才暴露问题。
doGet和doPost统一委托给一个dispatch方法。dispatch的流程是:从request.getRequestURI()取出请求路径,替换掉上下文路径,查询映射表得到目标方法;然后反射调用,把参数绑定和返回值处理交给对应的解析器。
这个过程中要特别处理一个问题:路径匹配时不能只看完全一致。比如定义/user/list,实际请求可能是/user/list?page=1,要把query string先剥离掉。还有就是路径结尾斜杠的处理、大小写敏感性,这些边界条件全部要测试覆盖到,否则打出来的jar在真实项目里很容易出现404。
4.4 参数绑定与结果渲染
参数绑定时,最简单的方式是获取方法所有参数,依次判断是否有@MyRequestParam注解:有则按注解声明的名字从request参数里取值;没有则按参数名直接匹配。但这里有个反射的坑:编译期如果没用-parameters参数,反射拿到的参数名是arg0这种,所以必须依赖注解声明参数名,这也是SpringMVC推荐显式写@RequestParam("xxx")的原因。
结果渲染分两种情况:方法有@ResponseBody注解时,直接手动利用Jackson把返回值序列化成JSON写入response;没有时,把返回字符串当作逻辑视图名,拼上前缀后缀后做forward转发。手写这一步能直观感受到SpringMVC在ViewResolver里做的事情——字符串到物理视图的转换逻辑其实没那么神秘。
为了支持@ResponseBody,还要在类路径上引入Jackson依赖,同时在框架里判断返回值类型。如果返回值是void,则不写内容;如果是String且没有注解,走转发;其余情况默认走JSON输出。边界分支越多越要提前规划,我第一版就只处理了前两种,结果业务方法里返回Map时直接被当成视图名去转发了,错误信息看得人一头雾水。
4.5 发布成jar并在模拟项目中复用
打包成jar之前,要做两件事:第一,在META-INF/services/javax.servlet.ServletContainerInitializer文件里写上自己的Initializer全限定名,这是能被Servlet容器自动发现的必要条件;第二,确保Initializer的onStartup方法里完成MyDispatcherServlet的注册。
在外部模拟项目里,只需要引入框架jar,然后建一个类实现WebApplicationInitializer(如果框架自己实现了Initializer,甚至不需要这个类),写上要扫描的包名,打包部署后请求就能直接打到自定义DispatcherServlet上。我在某公司内部模拟项目里试过,整个接入过程不超过十行代码,效果和SpringMVC的接入体验非常接近。
打包时的依赖问题需要特别注意:框架本身依赖Jackson和Servlet API,但外部项目的Servlet容器已经提供了Servlet API,所以这个依赖在POM里应该声明为provided,否则打出来的war包会出现Servlet API版本冲突。这种错误通常在部署阶段才暴露,报错内容又长又绕,排查起来很浪费时间。
5. 实操中的坑与排查实录
5.1 Controller扫描不到的几种原因
组件扫描是SpringMVC搭建过程中最容易出问题的一环。根据我的经验,Controller扫描不到的原因集中在三类:
第一,component-scan的base-package写错了包路径,或者包名和实际类路径不一致,这在重构包名之后特别容易出现。第二,类上没有加@Controller注解,或者注解写成了@Component——后者能被扫描器注册为Bean,但不会被RequestMappingHandlerMapping识别为处理器,结果就是Spring容器启动正常,请求全部404。第三,扫描路径和类所在的jar包没有关联,尤其是同一个类在多个jar里存在时,按包扫描会把不该加载的也加载进来。
排查这类问题的顺序是:先看启动日志里有没有注册Controller的相关输出,再检查Bean容器里有没有对应的实例,最后才是各种依赖关系的排查。推荐在启动阶段打印一份所有注册的Handler映射,方便和最终请求对齐。
5.2 404与路径匹配的排查思路
请求404首先要区分是容器层面的404还是SpringMVC层面的404。如果服务器根本没有匹配到Servlet,返回的是应用服务器的默认404页面;如果请求通过了DispatcherServlet但找不到对应的Handler,返回的通常是NoHandlerFoundException,但默认情况下这个异常不会被抛出,而是直接以404处理。
我遇到过最隐蔽的一次是路径大小写问题:Linux服务器上文件路径和URL路径大小写敏感,Windows开发环境则不敏感。开发时一切正常,部署到Linux就404,最后发现Controller上的@RequestMapping路径用的是驼峰,URL请求里用了全小写。这个问题在Windows下永远不会暴露,换环境就炸。
另一个常见原因是静态资源路径冲突。配置了<mvc:default-servlet-handler/>之后,默认交给容器处理静态资源,但如果这个配置和/的Servlet映射叠加,会出现静态资源被DispatcherServlet拦截的情况。线上环境里CSS/JS加载不出来,十有八九是这个问题。
5.3 参数绑定失败的隐蔽细节
参数绑定失败时,Spring默认会抛出TypeMismatchException或MissingServletRequestParameterException,但这类异常如果被全局异常处理器吃掉了,真正的参数问题会被伪装成其他错误。排查参数问题时,最直接的方法是先在Controller方法入口处加一行断点看入参,确认进入Controller前值是否符合预期。
还有一个新手常忽略的点:@RequestParam在参数类型是String时可以缺省,但类型是int、long这类基本类型时,如果请求没有传参,会直接触发异常。解决方法是使用包装类型Integer、Long,或者给@RequestParam配置defaultValue。这个细节在写框架的反射调用时同样要处理:当参数的默认值方案没有定义时,按类型自动填充一个默认值,否则整条调用链都会被一个空指针打断。
5.4 jar包发布后的类加载问题
手写框架发布成jar后,最容易出现的不是编译错误,而是运行时类加载问题。第一个是重复类冲突:框架jar里如果打包了Servlet API或Jackson的类,外部项目的容器里也有同名的类,哪个先加载取决于类加载器的双亲委派结果,表现出来就是明明写了@ResponseBody,返回的JSON却是空字符串,因为序列化时用了两个不同版本的Jackson类。
第二个是配置文件没进jar:META-INF/services文件在打包时容易被过滤掉。排查方法是直接用解压工具打开jar包,看META-INF/services下是否有对应文件,内容是否写了正确的类全限定名。我最初用某个构建工具打包时遇到过一次,文件确实在,但编码是UTF-8带BOM头,导致容器解析失败,后来把它改成无BOM的UTF-8才解决。
发布jar前最好在干净环境里做一个最小复现验证,而不是只在自己机器上的Tomcat里测试通过就发布。换一个没有装过SpringMVC的模拟项目,引入框架jar之后跑一遍全流程,很多依赖冲突会在这一步现出原形。
6. 最后分享一点个人体会
这轮从搭建到源码再到手写框架的完整过程走下来,我对SpringMVC的理解和最开始“会用注解”时完全是两个层面。以前遇到404只会加debug日志看请求路径,现在第一反应是猜它发生在HandlerMapping环节还是HandlerAdapter环节;以前看到@EnableWebMvc就无脑加,现在知道它背后注册了哪几个Bean,什么时候不加反而更合适。
如果你也想走一遍这个流程,我的建议是别急着复制框架代码,先把Spring的DispatcherServlet源码里的initStrategies和doDispatch两个方法完整读三遍,再开始动手。手写框架时遇到的所有设计问题,几乎都能在源码里找到对应答案,而且写出来的框架功能越接近SpringMVC,你对源码的理解就越深。这个方法的通用性很强,放到其他框架上一样适用:先用起来,再拆开来,最后重新拼一个属于自己的。