资讯动态

不用 Spring:手写 MVC,一个 Servlet 如何炼成 Spring MVC

发布时间:2026/9/26 7:19:00 来源:尧图企业网站定制
不用 Spring手写一个 JavaWeb 框架 ④收官手写 MVC一个 Servlet 如何炼成 Spring MVC系列连载中① 手写数据库连接池 → ② 手写 IoC 容器包扫描 三级缓存 → ③ 手写 AOPJDK 动态代理 →④ 手写 MVC本篇系列收官配套源码仓库github.com/JiaqiChen3518/video_stream系列最后一篇解决最后一块拼图HTTP 请求进来之后怎么找到该由哪个类的哪个方法处理这也是整个系列的起点——第 ② 篇开篇我埋了一个坑我的 Controller 继承HttpServlet时Tomcat 会自动创建它的实例和我的手写 IoC 容器争夺Bean 创建权导致依赖注入全部失效。这篇就来彻底回答这个坑怎么解以及 Spring MVC 给出的答案为什么也是同一招。一、问题的本质两个容器在抢地盘先厘清一个关键事实我们的应用里其实有两个容器。Servlet 容器Tomcat管网络负责接收 HTTP 请求、解析协议、管理 Servlet 的生命周期IoC 容器我们第 ② 篇手写的管对象负责创建 Bean、注入依赖、包 AOP 代理。冲突发生在只要一个类extends HttpServletTomcat 启动时就会扫描到它调用无参构造方法自己创建一个实例注册进 Servlet 容器。这个实例是 Tomcat 的私产IoC 容器对它一无所知——你在它字段上贴一百个Autowired也没人给你注入。而传统 Servlet 编程恰恰要求每个业务入口都写一个extends HttpServlet的类。几百个接口 几百个 Servlet 创建权全部旁落IoC 形同虚设。二、破局思路一个 Servlet 一张路由表解法的思想其实一句话既然 Servlet 类的创建权收不回来那就只用一个 Servlet把哪个请求由哪个方法处理做成一张表运行时查表分发。WebServlet(value/api/*,loadOnStartup1)// 只此一个 Servlet接住 /api 下所有请求publicclassDispatcherServletextendsHttpServlet{// 路由表路径 → (HTTP方法 → 处理方法)privatefinalMapString,MapString,RouteInforouteMapnewHashMap();}两个注解配合完成查表所需的元数据Target(ElementType.METHOD)Retention(RetentionPolicy.RUNTIME)publicinterfaceRequestMapping{Stringvalue();// 路径如 /video/listRequestMethod[]method();// 支持的 HTTP 方法}publicenumRequestMethod{GET,POST,PUT,DELETE}Controller 回归成普通 POJO——不继承任何 Servlet 类因此 Tomcat 不会碰它创建权完整地收归 IoC 容器ComponentpublicclassVideoController{// 注意就是一个普通类RequestMapping(value/video/list,methodRequestMethod.GET)publicvoidlist(HttpServletRequestreq,HttpServletResponseresp){// ...}}到这里第 ② 篇的悬念解开了为什么 Spring 的 Controller 是普通 POJO因为只有这样它的创建权才完全属于 IoC 容器。为什么 Spring MVC 说破天只有一个 ServletDispatcherServlet因为统一入口 路由表才能同时做到两件事接管所有请求、又不让 Servlet 容器插手对象创建。三、启动时序路由表是怎么建起来的loadOnStartup 1是关键注解——它告诉 Tomcat启动时就初始化这个 Servlet而不是等第一个请求来了才懒加载。我们需要这个保证因为路由表的构建必须在处理任何请求之前完成。init()方法按严格的顺序编排了应用的整个启动流程Overridepublicvoidinit(ServletConfigconfig)throwsServletException{super.init(config);// ① 初始化 IoC 容器扫包、建 Bean、做注入第②篇的全部内容在此生效BeanFactory.init(com.topview);// ② 业务子系统的启动钩子缓存清理任务、秒杀库存预热、WebSocket 端点注册等initSeckillSystem();initWebSocketSystem();// ③ 扫描所有 Controller构建路由表scanControllers();}第 ③ 步是本篇核心——遍历 IoC 容器里所有 Bean 的类类名以Controller结尾的就扫描其方法privatevoidscanControllers(){for(Class?clazz:BeanFactory.getAllBeanClasses()){if(!clazz.getSimpleName().endsWith(Controller))continue;for(Methodmethod:clazz.getDeclaredMethods()){RequestMappingmappingmethod.getAnnotation(RequestMapping.class);if(mappingnull)continue;for(RequestMethodhttpMethod:mapping.method()){routeMap.computeIfAbsent(mapping.value(),k-newHashMap()).put(httpMethod.name(),newRouteInfo(clazz,method));}}}}/** 路由信息哪个类的哪个方法 */privatestaticclassRouteInfo{privatefinalClass?controllerClass;privatefinalMethodmethod;// 构造器、getter 省略}注意一个设计决策Controller 实例本身不从 Servlet 容器拿而是从 IoC 容器取下一节会看到。所以RouteInfo里只存类 方法不存实例——实例的生命周期完全交给第 ② 篇的三级缓存体系去管理。这也意味着Controller 同样能享受 IoC 和 AOP 的全部能力字段注入、Log耗时日志、事务代理一样不少。四、请求分发五步走完一个 HTTP 请求service()方法承接所有/api/*请求逻辑清晰得像说明书Overrideprotectedvoidservice(HttpServletRequestreq,HttpServletResponseresp)throwsServletException,IOException{// 第 1 步解析路径/api/video/list → /video/listStringpathreq.getRequestURI().replaceFirst(^/api,);// 第 2 步查路由表先按路径再按 HTTP 方法MapString,RouteInfomethodMaprouteMap.get(path);if(methodMapnull){writeJson(resp,Result.error(404,路径不存在));return;}RouteInforoutemethodMap.get(req.getMethod());if(routenull){writeJson(resp,Result.error(405,方法不允许));return;}// 第 3 步从 IoC 容器取 Controller不是 new是 getBeanObjectcontrollerBeanFactory.getBean(route.getControllerClass());// 第 4 步统一权限校验RequirePermissionif(!checkPermission(req,route)){writeJson(resp,Result.error(403,权限不足));return;}// 第 5 步反射调用把请求交给业务方法route.getMethod().invoke(controller,req,resp);}逐点展开① 路径解析。/api前缀是 Servlet 映射和业务路径的分界线replaceFirst剥掉之后业务路径和RequestMapping里写的值严格对应。② 双层 Map 路由表。第一层按路径、第二层按 HTTP 方法天然处理了一个路径支持多种方法的场景GET /video/list查询列表、POST /video/list管理员批量操作还能精确区分 404路径不存在和 405路径在、方法不对。Spring 的HandlerMapping数据结构比这个复杂支持路径模板、通配符但查询语义是同一个。③ 从 IoC 容器取 Controller。这是全篇最有分量的一行。getBean()返回的是容器管理的实例——字段早已注入完成、需要的话外面还包着 AOP 代理第 ③ 篇的三层代理。如果走传统 Servlet 路线这一切都享受不到。④ 统一鉴权。在进入业务方法之前统一检查权限privatebooleancheckPermission(HttpServletRequestreq,RouteInforoute){RequirePermissionrequiredroute.getMethod().getAnnotation(RequirePermission.class);if(requirednull)returntrue;// 没标注解 公开接口IntegerroleUserContext.getRole();// JWT 过滤器解析后存的当前用户角色if(rolenull)returnfalse;returnBeanFactory.getBean(PermissionService.class).hasPermission(role,required.value());// RBAC角色-权限多对多}配合第 ③ 篇的方法级RequireRole形成粗筛权限点 细筛角色两道闸。所有校验都收在分发这一处Controller 方法体里看不到一行权限代码。⑤ 反射调用 异常解包。第 ③ 篇讲过的InvocationTargetException问题在这里处理——业务方法抛出的原始异常被还原后继续上抛try{route.getMethod().invoke(controller,req,resp);}catch(InvocationTargetExceptione){Throwablecausee.getTargetException();if(causeinstanceofServletException)throw(ServletException)cause;if(causeinstanceofIOException)throw(IOException)cause;if(causeinstanceofRuntimeException)throw(RuntimeException)cause;thrownewRuntimeException(cause);}为什么费劲解包再抛而不是在 Servlet 里直接 try-catch 返回错误 JSON因为异常处理应该是独立的关注点。DispatcherServlet 只负责分发异常分类、统一错误响应格式这些事交给过滤器链末尾的ExceptionFilter兜底请求 → JwtFilter解析Token身份认证 → CorsFilter跨域处理 → DispatcherServlet分发 → Controller 方法抛 BusinessException → ExceptionFilter捕获所有异常按类型转成统一 JSON 错误响应整个项目里任何一层抛出的业务异常最终都被转换成前端友好的统一格式——这也是约定优于配置的手写版实践。五、一个请求的全旅程系列总图四篇的内容最后可以用一张图串起来。以一个POST /api/video/publish请求为例否是抛出异常抛出异常Tomcat 接收 HTTP 请求过滤器链: JWT鉴权 → CORSDispatcherServlet 查路由表第④篇权限校验通过?403 统一响应BeanFactory.getBean 取 Controller第②篇已注入完毕穿过 AOP 代理层第③篇日志 → 事务Controller 方法执行Service 业务逻辑DAO 执行 SQLConnectionPool 借连接第①篇事务提交后 close 归还连接池第①篇代理的 close统一 JSON 响应ExceptionFilter 兜底Tomcat 只做了最外层的网络接入进入/api之后的一切——对象、代理、路由、连接——全部由我们手写的四个模块接管。这就是框架两个字的全貌。六、对照 Spring MVC我们复刻了什么省略了什么Spring MVC 组件职责我们的对应实现DispatcherServlet统一入口、请求分发DispatcherServlet同名同款HandlerMapping请求 → 处理器的映射双层 Map 路由表HandlerAdapter适配不同种类的处理器统一约定method.invoke(controller, req, resp)HandlerInterceptor前置/后置拦截过滤器链 分发前权限校验HandlerExceptionResolver异常 → 统一响应异常解包 ExceptionFilter数据绑定/参数解析自动装配方法参数、RequestBody未实现约定方法直接拿 req/resp 自己解析最大的省略是参数解析与数据绑定。Spring 的方法签名可以写成Result publish(RequestBody VideoDTO dto)框架自动做 JSON 反序列化、参数校验——那是另一个大工程涉及转换器体系、校验框架集成我们选择了方法自己从 req 里取参数的朴素约定。把这个差距写明白比假装等价更有价值。七、面试快问快答Q1Servlet 的生命周期是怎样的loadOnStartup起什么作用加载 →init()只调一次→ 每次请求调service()→ 应用卸载时destroy()。默认 Servlet 在第一次被请求时才初始化loadOnStartup 1让 Tomcat 启动阶段就完成初始化保证路由表等启动逻辑先于任何请求就绪。Q2为什么 Spring 的 Controller 不用继承 HttpServlet继承 HttpServlet 的类会被 Tomcat 自动实例化创建权旁落IoC 无法管理。改成 POJO 后创建权完整收归容器同时配合单一入口 DispatcherServlet 查表分发既保留 Servlet 的接入能力又让业务类享受依赖注入和 AOP。Q3404 和 405 在你的框架里怎么区分路由表第一级是路径、第二级是 HTTP 方法路径查不到返回 404路径命中但方法不匹配返回 405。Spring 的行为语义一致。Q4为什么 Controller 里的异常要解包后再抛给过滤器而不是直接在 Servlet 里 catch关注点分离。分发逻辑只管找到方法并调用异常如何分类、错误响应是什么格式是独立的横切关注点交给 ExceptionFilter 统一处理任何一层抛出的异常都被一致兜底。Q5DispatcherServlet 和普通 Servlet 的区别是什么普通 Servlet 一对一处理某个具体路径类即入口DispatcherServlet 用/*通配接管一个命名空间内部用路由表把请求二次分发给 POJO 方法。本质上它是请求的路由器这也是 Spring MVC 整个架构的地基。八、系列收官从 0 到 1 的四块拼图四篇写完回看这套手写框架每一层都解决一个真实的问题篇目解决的问题核心技术① 连接池连接的复用、上限与回收双阻塞队列、DCL 单例、动态代理改close()② IoC 容器对象的创建与组装包扫描、反射注入、三级缓存解循环依赖③ AOP日志/事务/鉴权等横切逻辑JDK 动态代理、多层代理、ThreadLocal 事务④ MVC本篇请求到方法的映射单一入口 Servlet、双层路由表、统一鉴权与异常写这个系列最大的感受是框架不是魔法是问题驱动的必然产物。连接池因为连接创建贵而存在IoC 因为对象组装乱而存在AOP 因为横切代码脏而存在MVC 因为 Servlet 模型和对象管理冲突而存在。带着问题去看 Spring 源码每一层都能看懂作者当时在面对什么——这比背十篇八股文都管用。完整项目含秒杀、WebSocket、Feed 流等业务模块在仓库里欢迎 star 交流。我们下个项目见 本篇完整源码DispatcherServlet.java 过滤器如果这篇对你有帮助欢迎点赞收藏关注

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

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

免费获取报价 →
↑