资讯动态

从Servlet到SpringMVC:拆解请求处理链路并手写迷你框架

发布时间:2026/10/16 2:20:23 来源:尧图企业网站定制
1. 从纯Servlet到SpringMVC它替你扛下的四件事我之前有大概三年的时间都在用SpringMVCXML配置写过零XML配置也写过Controller一写浏览器一刷新数据就出来了。真正让我停下来重新审视这东西的是一次线上接口的诡异404。那个接口明明在Controller里写了RequestMapping(/order/detail)但访问的时候一直404控制台也没有任何报错。我排查了一个下午从Nginx日志看到请求确实进来了最后只能把DispatcherServlet的源码翻出来一行一行走才定位到是视图解析链路里一个异常被吞了。那次之后我做了两件事第一把SpringMVC从配置到请求分发的整个源码链路认真过了一遍第二干脆手写了一个迷你版SpringMVC框架打包成jar在另一个空Web项目里接进来跑通。整个过程走下来对所谓框架封装的理解完全不一样了。1.1 纯Servlet时代的路由与参数解析在SpringMVC还没成为默认选择的年代写一个处理表单提交的接口大概是这样protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String action request.getRequestURI(); if (/user/save.equals(action)) { String name request.getParameter(name); Integer age Integer.parseInt(request.getParameter(age)); User user new User(); user.setName(name); user.setAge(age); userService.save(user); response.sendRedirect(/user/list); } else if (/user/delete.equals(action)) { Long id Long.parseLong(request.getParameter(id)); userService.delete(id); response.sendRedirect(/user/list); } }这样写的问题很快会暴露路由判断全靠手工if/else参数类型转换散落在每个分支里每个接口都要重复解析URI、取参、转类型、跳转的逻辑。一旦接口数量到了几十个这个Servlet就变成一座屎山。更别说后续要加拦截器、参数校验、JSON序列化几乎无从下手。SpringMVC的贡献不在于它有多高级而在于它把这件事里最重复、最机械的部分抽象成了几条稳定的链路。1.2 SpringMVC介入之后四个动作被取代我把纯Servlet时代的最原始工作拆成四件事后面分析源码、手写框架的时候都围绕这四件事展开手工Servlet时代要做的事SpringMVC里的对应组件在doGet/doPost里解析URI并分发DispatcherServletHandlerMapping从request里取参并做类型转换HandlerMethodArgumentResolver调用业务方法并收集结果HandlerAdapterHandlerMethod手动跳转JSP或拼JSONModelAndViewViewResolver/HttpMessageConverter换句话说SpringMVC做的事情就是写一个统一的Servlet接收所有请求根据配置或者注解找到该执行的Java方法自动完成参数绑定再把方法返回值渲染成视图或者JSON。理解了这个再看XML配置搭建和零XML配置就会非常通透——它们只是在用不同方式完成同一件事告诉Spring容器DispatcherServlet在哪里Controller在哪里如何映射。1.3 为什么值得把源码链路扒完配置可以照抄但排查问题不能。我发现身边不少同事的SpringMVC水平停留在会写注解、会配视图解析器一旦出现404、参数无法绑定、JSON返回乱码这类问题就束手无策。其实这些问题的根源都在几条固定的代码路径里DispatcherServlet.doDispatch()方法里发生了循环、某个HandlerMethodArgumentResolver没有匹配上参数、某个ViewResolver没有正确解析逻辑视图名。搞清楚这些底层机制最直接的好处是以后再遇到SpringMVC相关的问题不再是盲目搜百度而是能顺着请求进来了吗 - HandlerMapping找到了吗 - 参数绑定成功了吗 - 视图渲染成功了吗这条链路去定位。手写一个迷你框架就是把这个链路完整复现一遍工作量不大收获却很大。2. XML配置搭建web.xml加注解驱动是绕不开的地基虽然现在新项目很少会从XML配置开始了但我仍然建议把XML配置方式完整跑一遍。因为它是SpringMVC整个运行机制的最小可运行骨架零XML配置只是在这个骨架上做了一层程序化注册。2.1 一份可运行的最小配置长什么样一个XML配置方式的SpringMVC应用至少需要两个文件web.xml和springmvc.xml。先看web.xml?xml version1.0 encodingUTF-8? web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd version3.1 servlet servlet-namedispatcherServlet/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:springmvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcherServlet/servlet-name url-pattern//url-pattern /servlet-mapping /web-app这里最核心的三行DispatcherServlet作为唯一入口ServletcontextConfigLocation指向SpringMVC自己的配置文件url-pattern使用/表示接管所有非JSP请求。再看springmvc.xmlbeans xmlnshttp://www.springframework.org/schema/beans xmlns:contexthttp://www.springframework.org/schema/context xmlns:mvchttp://www.springframework.org/schema/mvc xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc.xsd !-- 1. 扫描Controller -- context:component-scan base-packagecom.example.controller/ !-- 2. 开启SpringMVC注解驱动 -- mvc:annotation-driven/ !-- 3. JSP视图解析器 -- bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views// property namesuffix value.jsp/ /bean /beans三个配置各有其不可替代的作用。component-scan负责把Controller、RequestMapping这些注解扫描进容器mvc:annotation-driven是关键开关没有它RequestMapping根本不会被识别因为RequestMappingHandlerMapping与RequestMappingHandlerAdapter这两个组件不会被自动注册视图解析器则负责把Controller返回的index这类逻辑视图名翻译成物理的JSP路径。我见过不少人把mvc:annotation-driven当成标配顺手写上却不知道它到底注册了什么。这一步到底做了什么到第四章源码部分会揭晓。2.2 为什么DispatcherServlet要用斜杠而不是星号以及load-on-startup的含义url-pattern用/而不是/*这是一个非常经典的问题。/会让DispatcherServlet接管除JSP之外的所有请求而/*会把JSP也拦截。一旦拦截JSPController返回的逻辑视图名经过视图解析后InternalResourceViewResolver会把请求转发到对应的.jsp页面但这个转发请求再次进入容器时又被/*拦住结果就是死循环页面显示空白或者报错。load-on-startup的值也很关键。如果不配这个参数Servlet容器会在第一次收到匹配该Servlet的请求时才去初始化它。SpringMVC的初始化过程要加载WebApplicationContext、扫描Bean、解析HandlerMethod这一系列动作放在首次请求时做往往会导致第一个请求响应特别慢而且一旦初始化出错错误信息会被包装成404或者500排查起来非常困难。设置load-on-startup1可以让容器启动时就完成初始化把问题提前暴露。2.3 XML方式下我踩过的三个惯性坑坑一只写component-scan不写mvc:annotation-driven。现象是Controller能进容器但请求打进来永远404。原因在于RequestMappingHandlerMapping没有被注册注解映射不生效。我后来排查问题时习惯先确认HandlerMapping是否存在、是否包含这个URL比看一堆报错日志更直接。坑二扫描包范围写得太宽。比如base-packagecom.example把Service、Configuration这类Bean全部扫进DispatcherServlet的上下文。虽然多数情况下不会出事但SpringMVC官方推荐在SpringMVC配置里只扫描Controller相关组件业务层的Service交给Root容器去扫。否则可能出现事务代理、AOP切面在父子容器间重复初始化的问题。坑三视图解析器的前缀后缀配错。InternalResourceViewResolver的prefix和suffix决定逻辑视图名怎么拼成物理路径。有人把前缀写成/WEB-INF/views少了末尾的斜杠结果拼出来的路径就变成了/WEB-INF/viewsindex.jsp排查半天才发现少了一个斜杠。3. 零XML配置的关键Servlet3.0 SPI与WebApplicationInitializerXML配置方式虽然能跑但项目里总有一堆XML要维护。从Servlet3.0开始一个Web应用可以完全没有web.xml也能启动这就是零XML配置的技术前提。想真正理解SpringMVC的纯Java配置必须先搞懂Servlet3.0规范里的SPI机制。3.1 Servlet3.0带来的一项关键能力程序化注册Servlet3.0规范最重要的变化之一是允许开发者不再依赖web.xml而是在容器启动阶段通过代码动态注册Servlet、Filter、Listener。这套机制的底层是ServletContainerInitializer接口和SPIService Provider Interface加载约定。简单说Servlet容器启动时会自动扫描classpath下所有jar包里的META-INF/services/javax.servlet.ServletContainerInitializer文件根据文件里写的类名加载实现类然后调用它的onStartup(SetClass?, ServletContext)方法。所有需要参与启动阶段的框架都在这个文件里自荐自己。onStartup方法的第一个参数是SetClass?它来自另一个关键注解HandlesTypes。容器会扫描classpath下所有匹配HandlesTypes指定类型或其子类的类把这些类收集起来传给onStartup。这对框架开发者来说极其友好不需要自己遍历整个classpath容器已经帮你把感兴趣的类都筛出来了。3.2 SpringMVC的零XML初始化器长什么样Spring-web.jar里自带了一个SpringServletContainerInitializer源码核心大致是HandlesTypes(WebApplicationInitializer.class) public class SpringServletContainerInitializer implements ServletContainerInitializer { Override public void onStartup(SetClass? webAppInitializerClasses, ServletContext servletContext) throws ServletException { ListWebApplicationInitializer initializers new ArrayList(); for (Class? clazz : webAppInitializerClasses) { if (!clazz.isInterface() !Modifier.isAbstract(clazz.getModifiers()) WebApplicationInitializer.class.isAssignableFrom(clazz)) { initializers.add((WebApplicationInitializer) ReflectionUtils.accessibleConstructor(clazz).newInstance()); } } // 排序后依次调用onStartup for (WebApplicationInitializer initializer : initializers) { initializer.onStartup(servletContext); } } }也就是说SpringMVC的零XML配置核心是我们写一个WebApplicationInitializer接口的实现类容器启动时SpringServletContainerInitializer通过HandlesTypes找到这个类实例化并调用它的onStartup方法。在这个方法里我们可以拿到ServletContext然后注册DispatcherServlet、配置参数完全替代web.xml的职责。Spring还贴心地提供了抽象类简化开发最常见的就是AbstractAnnotationConfigDispatcherServletInitializer。3.3 一份纯Java配置的完整Demo零XML配置的写法通常是这样写一个初始化器类替代web.xml写两个配置类替代springmvc.xml。public class MyWebAppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer { Override protected Class?[] getRootConfigClasses() { return new Class?[]{RootConfiguration.class}; } Override protected Class?[] getServletConfigClasses() { return new Class?[]{WebConfiguration.class}; } Override protected String[] getServletMappings() { return new String[]{/}; } }getServletConfigClasses返回的配置类用来构建DispatcherServlet自己的WebApplicationContextgetRootConfigClasses返回的配置类构建根容器。用一个通俗的比喻根容器放业务层和持久层Servlet容器放Web层组件。WebConfiguration替代了原来的springmvc.xmlConfiguration EnableWebMvc ComponentScan(com.example.controller) public class WebConfiguration implements WebMvcConfigurer { Bean public ViewResolver viewResolver() { InternalResourceViewResolver resolver new InternalResourceViewResolver(); resolver.setPrefix(/WEB-INF/views/); resolver.setSuffix(.jsp); return resolver; } }EnableWebMvc就是mvc:annotation-driven的Java版ComponentScan替代XML里的component-scanViewResolver用Bean手工注册。从这能看出来XML配置和Java配置本质是同一套机制的两副面孔。理解了Servlet3.0 SPI才能明白为什么项目里只要引入Spring-web依赖、写一个初始化器类连web.xml都不用建Tomcat一启动SpringMVC就自动工作了。4. 源码链路分析一次HTTP请求是怎么被处理的配置只是入口真正决定你排查问题能力的是源码里的那几条链路。下面按一次请求的生命周期走一遍。4.1 DispatcherServlet家族init方法到底在哪一步被触发DispatcherServlet不是一个光秃秃的Servlet它继承自FrameworkServletFrameworkServlet继承自HttpServletBeanHttpServletBean继承自标准的HttpServlet。HttpServlet的init()是Servlet生命周期的一部分。HttpServletBean重写了init()模板方法是initServletBean()FrameworkServlet在initServletBean()里完成了关键工作——创建并初始化WebApplicationContextDispatcherServlet又重写了FrameworkServlet在上下文刷新后回调的onRefresh(ApplicationContext context)方法在这个方法里调用initStrategies(context)完成所有组件的初始化。所以在源码层面DispatcherServlet的初始化不是一个方法从头写到尾而是父类定骨架、子类填细节的模板方法模式。你配置的contextConfigLocation、零XML配置里的WebConfiguration最终都是在FrameworkServlet.initWebApplicationContext()这一步被加载成容器的Bean定义。4.2 initStrategies九大组件的装配过程DispatcherServlet.initStrategies()的源码很短但信息量极大protected void initStrategies(ApplicationContext context) { initMultipartResolver(context); initLocaleResolver(context); initThemeResolver(context); initHandlerMappings(context); initHandlerAdapters(context); initHandlerExceptionResolvers(context); initRequestToViewNameTranslator(context); initViewResolvers(context); initFlashMapManager(context); }这九个组件就是SpringMVC的九大件。其中请求分发链路最关键的是三个initHandlerMappings初始化处理器映射器。如果容器里没有自定义HandlerMappingSpring会注册一组默认映射包括RequestMappingHandlerMapping解析RequestMapping注解、BeanNameUrlHandlerMapping解析以斜杠开头的Bean名称等。initHandlerAdapters初始化处理器适配器。关键的是RequestMappingHandlerMapping对应的RequestMappingHandlerAdapter它的afterPropertiesSet()方法会初始化参数解析器和返回值处理器。initViewResolvers初始化视图解析器。如果在配置类里注册了InternalResourceViewResolver就在这里被收集。了解了mvc:annotation-driven/EnableWebMvc为什么不能省它们会主动往容器里注册RequestMappingHandlerMapping和RequestMappingHandlerAdapter否则DispatcherServlet只能用默认策略而默认策略里虽然包含RequestMappingHandlerMapping但RequestMapping的完整支持、参数解析、JSON转换等增强能力是靠注解驱动配置加进去的。4.3 doDispatch骨架映射、拦截器、适配器最后渲染视图一次请求到达DispatcherServlet后先进入doService()方法它做了一点上下文准备随后调用核心方法doDispatch()。源码的关键流程可以压缩成下面几行// 1. 处理文件上传 processedRequest checkMultipart(request); multipartRequestParsed (processedRequest ! request); // 2. 遍历全部HandlerMapping找到能处理当前请求的HandlerExecutionChain mappedHandler getHandler(processedRequest); if (mappedHandler null) { noHandlerFound(processedRequest, response); return; } // 3. 找一个支持该Handler类型的HandlerAdapter HandlerAdapter ha getHandlerAdapter(mappedHandler.getHandler()); // 4. 执行前置拦截器 if (!mappedHandler.applyPreHandle(processedRequest, response)) { return; } // 5. 真正调用Controller里的方法得到ModelAndView mv ha.handle(processedRequest, response, mappedHandler.getHandler()); // 6. 执行后置拦截器 mappedHandler.applyPostHandle(processedRequest, response, mv); // 7. 处理异常解析视图并渲染 processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);getHandler(processedRequest)内部会遍历handlerMappings逐个调用HandlerMapping.getHandler(request)。RequestMappingHandlerMapping拿到请求URI去自己的映射注册表里查URL对应的HandlerMethod同时组装出HandlerExecutionChain把匹配的拦截器也挂上去。ha.handle()是真正调用Controller方法的入口。这里并不是简单的反射调用RequestMappingHandlerAdapter会先判断当前Controller方法能否被调用再通过一系列HandlerMethodArgumentResolver把HTTP请求里的参数绑到方法的形参上最后才真正执行反射。processDispatchResult的核心工作是两件如果有异常就交给HandlerExceptionResolver处理然后调用render()方法通过ViewResolver解析出View最终渲染响应。4.4 参数解析与返回值处理最值得抠的两段核心很多参数绑定的诡异问题都是因为不理解HandlerMethodArgumentResolver。RequestMappingHandlerAdapter在初始化时维护了一个参数解析器列表包含处理RequestParam、PathVariable、RequestBody、ModelAttribute等各种解析器。当执行Controller方法时ServletInvocableHandlerMethod会逐个遍历方法参数找第一个supportsParameter返回true的解析器来执行。以RequestParam为例RequestParamMethodArgumentResolver的职责是从request的parameterMap里按名字取值然后做类型转换。如果你声明了RequestParam(id) Long id但请求里没有带id它就会抛出MissingServletRequestParameterException最后被异常处理器变成400响应。这正是缺少必填参数返回400的底层原因。返回值处理同样是一条完整链路。没有ResponseBody时Controller返回的字符串会被当作逻辑视图名交给ViewNameMethodReturnValueHandler最终被InternalResourceViewResolver解析成/WEB-INF/views/xxx.jsp并做forward。有ResponseBody时ResponseBodyMethodProcessor会把返回值交给一组HttpMessageConverter由MappingJackson2HttpMessageConverter之类的转换器把对象序列化成JSON字节流写进响应。把这两段链路吃透很多所谓的框架黑魔法就消失了参数绑定只是找解析器取参转类型赋值返回值处理只是看有没有ResponseBody决定走视图还是走转换器。5. 手写一个mini SpringMVC从注解到DispatcherServlet理解了上面的源码链路手写一个迷你版SpringMVC其实就是按同样的思路做减法。这里我记录一下自己的实现过程设计目标很明确保留注解映射、包扫描、参数解析、视图/JSON输出这几项核心能力砍掉九大组件里不常用的部分。5.1 先定目标只做四件事完整的SpringMVC有九大组件mini版只做四件事用注解声明Controller和映射关系扫描指定包注册URL与方法的映射根据请求参数和方法形参做绑定根据是否有MyResponseBody决定输出JSON还是转发视图这四件事正好对应上一章源码分析的四段核心链路。5.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(); } Target({ElementType.TYPE, ElementType.METHOD}) Retention(RetentionPolicy.RUNTIME) public interface MyResponseBody { }包扫描是第一步。思路是把基础包名转换成路径用ClassLoader.getResources()找到对应的URL如果是目录就递归遍历逐个加载.class文件如果应用运行在war包环境里这个URL是jar:file:协议需要走JarURLConnection遍历jar条目。我最初只在本地IDE里跑直接用目录扫描就够后来部署到Tomcat才发现war场景下的jar协议必须处理。public class MyBeanFactory { private final MapString, Object beans new ConcurrentHashMap(); private final MapString, HandlerMethod handlerMapping new ConcurrentHashMap(); public void scan(String basePackage) throws Exception { String path basePackage.replace(., /); EnumerationURL resources Thread.currentThread().getContextClassLoader().getResources(path); while (resources.hasMoreElements()) { URL url resources.nextElement(); scanPackage(path, url); } } private void scanPackage(String path, URL url) throws Exception { File dir new File(url.toURI()); File[] files dir.listFiles(); if (files null) { return; } for (File file : files) { if (file.isDirectory()) { scanPackage(path / file.getName(), file.toURI().toURL()); } else if (file.getName().endsWith(.class)) { String className path.replace(/, .) . file.getName().replace(.class, ); Class? clazz Class.forName(className); if (clazz.isAnnotationPresent(MyController.class)) { Object instance clazz.getDeclaredConstructor().newInstance(); beans.put(clazz.getName(), instance); registerMapping(clazz, instance); } } } } }包扫描的细节很容易踩坑Class.forName默认使用调用方的类加载器在Web容器里最好用Thread.currentThread().getContextClassLoader()否则可能加载不到Web应用自己的类。5.3 建立映射注册表与处理器适配器扫描到MyController标注的类后要做两件事实例化并放入容器解析方法上的MyRequestMapping注册映射。类级别的路径和方法级别的路径拼接得到完整URLprivate void registerMapping(Class? clazz, Object instance) { String classPath ; if (clazz.isAnnotationPresent(MyRequestMapping.class)) { classPath clazz.getAnnotation(MyRequestMapping.class).value(); } for (Method method : clazz.getDeclaredMethods()) { if (method.isAnnotationPresent(MyRequestMapping.class)) { String methodPath method.getAnnotation(MyRequestMapping.class).value(); String url (classPath methodPath).replace(//, /); handlerMapping.put(url, new HandlerMethod(instance, method)); } } }HandlerMethod是一个简单的封装类保存目标对象、目标方法、方法参数上MyRequestParam的注解值。请求进来时根据URI查映射查到了就调用HandlerAdapter执行。参数绑定的难点在于类型转换。为了控制复杂度我支持String、Integer/int、Long/long、Double/double这几种常用类型并且规定只要方法参数标注了MyRequestParam就按注解的value从request里取参。public class MyHandlerAdapter { public Object handle(HttpServletRequest request, HttpServletResponse response, HandlerMethod handlerMethod) throws Exception { Method method handlerMethod.getMethod(); Object[] args new Object[method.getParameterCount()]; Parameter[] parameters method.getParameters(); for (int i 0; i parameters.length; i) { MyRequestParam annotation parameters[i].getAnnotation(MyRequestParam.class); String paramName annotation ! null ? annotation.value() : parameters[i].getName(); String value request.getParameter(paramName); args[i] convert(value, parameters[i].getType()); } return method.invoke(handlerMethod.getBean(), args); } private Object convert(String value, Class? targetType) { if (value null) { return null; } if (targetType String.class || targetType Object.class) { return value; } if (targetType Integer.class || targetType int.class) { return Integer.parseInt(value); } if (targetType Long.class || targetType long.class) { return Long.parseLong(value); } if (targetType Double.class || targetType double.class) { return Double.parseDouble(value); } return value; } }这里有一个容易忽略的点parameters[i].getName()在编译时如果不加-parameters参数拿到的可能是arg0这种占位名。所以实际使用中强制要求用MyRequestParam指定参数名能省掉很多麻烦。5.4 自己实现DispatcherServlet与视图输出MyDispatcherServlet继承HttpServlet核心逻辑是复刻doDispatch的骨架取URI、查映射、不存在则404、存在则调用适配器、最后决定转发还是写JSON。public class MyDispatcherServlet extends HttpServlet { private final MyBeanFactory beanFactory new MyBeanFactory(); private final MyHandlerAdapter handlerAdapter new MyHandlerAdapter(); Override public void init() throws ServletException { String basePackage getInitParameter(basePackage); if (basePackage null || basePackage.isEmpty()) { basePackage com.example; } try { beanFactory.scan(basePackage); } catch (Exception e) { throw new ServletException(MyDispatcherServlet scan package failed, e); } } Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { doDispatch(request, response); } Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { doDispatch(request, response); } private void doDispatch(HttpServletRequest request, HttpServletResponse response) throws IOException { String uri request.getRequestURI(); HandlerMethod handlerMethod beanFactory.getHandlerMethod(uri); if (handlerMethod null) { response.setStatus(404); response.getWriter().write(404 Not Found: uri); return; } try { Object result handlerAdapter.handle(request, response, handlerMethod); if (handlerMethod.isResponseBody()) { response.setContentType(application/json;charsetUTF-8); response.getWriter().write(String.valueOf(result)); } else if (result ! null) { request.getRequestDispatcher(result.toString()).forward(request, response); } } catch (Exception e) { response.setStatus(500); response.getWriter().write(500 Error: e.getMessage()); } } }这个类虽然简陋但骨架和SpringMVC的doDispatch是同一个套路找Handler、找Adapter、执行、处理结果。你甚至可以把它看成是那一段源码的注释版。5.5 以ServletContainerInitializer方式接入容器手写框架如果还要用户配web.xml就谈不上完整。我的做法是复刻SpringMVC零XML配置的套路框架jar里提供一个MySpringServletContainerInitializer使用HandlesTypes扫描所有MyWebApplicationInitializer的实现类然后逐个调用。HandlesTypes(MyWebApplicationInitializer.class) public class MySpringServletContainerInitializer implements ServletContainerInitializer { Override public void onStartup(SetClass? candidates, ServletContext servletContext) throws ServletException { if (candidates null || candidates.isEmpty()) { return; } for (Class? clazz : candidates) { if (clazz.isInterface() || !MyWebApplicationInitializer.class.isAssignableFrom(clazz)) { continue; } try { MyWebApplicationInitializer initializer (MyWebApplicationInitializer) clazz.getDeclaredConstructor().newInstance(); initializer.onStartup(servletContext); } catch (Exception e) { throw new ServletException(Failed to initialize clazz.getName(), e); } } } }配套的MyWebApplicationInitializer接口public interface MyWebApplicationInitializer { void onStartup(ServletContext servletContext); }关键一步框架jar的META-INF/services/javax.servlet.ServletContainerInitializer文件里写入一行com.example.minimvc.web.MySpringServletContainerInitializer这样Tomcat启动时会自动发现这个实现类并回调。所有依赖这个框架jar的Web项目只要写一个MyWebApplicationInitializer的实现类注册MyDispatcherServlet并指定扫描包即可完全不需要web.xml。6. 打包成jar并在新项目里接入框架写完不发布出去价值就打了一半。下面说一下从本地编译到在另一个项目里接进来的完整过程。6.1 jar的模块结构与SPI配置手写框架的jar包结构大概是com.example.minimvc ├── annotation │ ├── MyController.class │ ├── MyRequestMapping.class │ ├── MyRequestParam.class │ └── MyResponseBody.class ├── web │ ├── MyDispatcherServlet.class │ ├── MyWebApplicationInitializer.class │ └── MySpringServletContainerInitializer.class ├── handler │ ├── HandlerMethod.class │ ├── MyHandlerAdapter.class │ └── MyBeanFactory.class └── META-INF └── services └── javax.servlet.ServletContainerInitializerMETA-INF/services文件必须随jar打包进WEB-INF/libServlet容器在启动时才会扫描到。这一点很容易在打包时被忽略如果打成war后框架没生效第一件事就是检查这个文件在不在war里的WEB-INF/lib/xxx.jar/META-INF/services/路径下。6.2 mvn install 与使用方工程接入框架工程执行mvn clean install后jar会被安装到本地仓库。使用方工程在pom.xml里加依赖dependency groupIdcom.example/groupId artifactIdminimvc-spring/artifactId version1.0.0/version /dependency使用方只需要写两段代码。第一段是实现MyWebApplicationInitializer把MyDispatcherServlet注册进容器public class MyAppInitializer implements MyWebApplicationInitializer { Override public void onStartup(ServletContext servletContext) { MyDispatcherServlet servlet new MyDispatcherServlet(); ServletRegistration.Dynamic registration servletContext.addServlet(myDispatcherServlet, servlet); registration.setInitParameter(basePackage, com.example.demo.controller); registration.addMapping(/); registration.setLoadOnStartup(1); } }第二段是写一个普通ControllerMyController public class DemoController { MyRequestMapping(/hello) public String hello() { return /WEB-INF/views/hello.jsp; } MyRequestMapping(/user) MyResponseBody public String user(MyRequestParam(name) String name) { return {\name\:\ name \}; } }把使用方工程打成war放进Tomcat启动日志里能看到MySpringServletContainerInitializer.onStartup被调用访问/hello会转发到JSP访问/user?namezhang会直接返回JSON字符串。走到这一步就相当于用不到500行代码复现了SpringMVC最核心的请求分发参数绑定视图渲染链路。6.3 依赖范围与私有仓库发布发布jar时有一个细节直接影响使用方会不会踩坑servlet-api的依赖scope必须设置成provided。因为Servlet容器自带这些类如果框架jar把servlet-api一起打进去使用方项目启动时会出现类冲突。本地mvn install只能自己用。团队协作时需要配置distributionManagement通过mvn deploy把jar发布到私有仓库。结合Maven私服的仓库管理器在pom.xml里加上仓库地址选择maven-releases仓库执行deploy命令即可。章六的接入方式也可以用在这套发布流程里团队成员只需要在项目里引入坐标就能用上自己写的框架。7. 后记这些经验还能往哪里延伸写完这个mini框架之后再回头用SpringMVC感觉完全变了。以前遇到一个请求参数绑定不上只能逐行看Controller和前端代码现在第一反应是哪个HandlerMethodArgumentResolver没匹配上。遇到404会先去想是HandlerMapping没注册还是URL拼接错了。这种框架在我脑子里有一条清晰链路的感觉很难用语言形容但它确实让日常排查问题快了很多。这个mini框架本身也很值得继续扩展。我后续加过的东西包括MyPathVariable支持需要在注册映射时把URL模板里的变量位解析出来匹配时从request的URI里提取参数值加一个简单的拦截器链模仿HandlerInterceptor的前置、后置方法用Jackson替换手写的JSON字符串拼接再做一层异常处理解析器让某个异常统一返回固定JSON格式。每加一个功能就会重新审视一遍SpringMVC对应功能的源码等于以写代读把SpringMVC的关键源码又过了一遍。如果你正在学SpringMVC我的建议是不要止步于会配置、会写Controller。抽一个周末把DispatcherServlet的doDispatch从头到尾读一遍然后动手写一个极简版本哪怕只支持RequestParam一种参数绑定也值得。因为这些组件之间的关系一旦在脑子里建立起来之后无论用SpringMVC、Spring Boot还是后来出现的其他Web框架都能很快把新框架的请求处理链路对号入座。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑