从Servlet堆砌到前端控制器:手写统一调度DispatcherServlet
传统JavaWeb开发里“一个功能一个Servlet、一个URL配一个servlet-mapping”的做法前期写起来很爽一旦功能到了五六个以上各种doGet、doPost就会堆成一片重复代码配置也越加越臃肿。我当时在一套模拟项目里做到第九个接口的时候正式被引爆新增一个模块要改web.xml、新增一个类、再处理一次中文编码、再写一遍转发逻辑。后来我把这套架构推翻重来改成“所有请求先打到一个统一调度组件再由它根据URL分发到对应处理器”。这篇文章就把这个优化过程完整复盘一遍从设计思路到代码落地再到踩坑记录全部摊开讲清楚。这个方案在JavaWeb里叫前端控制器模式也就是让请求统一经过一个入口由这个入口统一调度后响应。它解决的是传统Servlet架构中类爆炸、重复逻辑分散、配置难维护的问题。适合正在学Servlet基础、准备进入框架阶段的人参考也适合被自己塞满Servlet的作业或小项目折腾过的人看。1. 从一堆Servlet到一个入口传统写法的痛点到底在哪1.1 传统Servlet架构的真实样子大多数人学Servlet时第一个项目通常是“登录注册”。当时的写法很标准写一个LoginServlet处理登录、一个RegisterServlet处理注册想在页面上展示用户列表再造一个UserListServlet。每个Servlet类里基本长这样protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); // 业务逻辑... request.getRequestDispatcher(/login_success.jsp).forward(request, response); }代码本身没有问题问题在于数量。假设你的项目有登录、注册、用户列表、用户详情、修改资料、退出登录这已经六个Servlet了。每一个都要重复写编码设置、响应类型设置、转发逻辑。等到功能堆到十几个你打开web.xml看到一片密密麻麻的servlet、servlet-mapping找一项配置要用滚轮滚三屏这个项目已经接近“不可维护”的状态。1.2 痛点背后的本质我复盘过自己的代码发现痛点不是“类多”而是三个更深层的问题第一重复代码分散在每个类里改动成本高。比如某天你要把统一的前缀加在所有转发路径前就得打开十几个Servlet逐个改。漏改一个线上就多一个404。第二类的数量与功能数量强绑定。一个功能一个类类名还得映射进web.xml。新增功能时不再只是写业务逻辑而是要同步改配置、重新部署操作链越长越容易出错。第三通用逻辑无法集中做。比如登录校验、访问日志、统一编码、异常处理这些东西每个Servlet之前都有一份但很难统一增强。想加一个“打印所有请求的耗时”必须去每个Servlet里加代码几乎是体力和毅力的双重考验。传统方式与统一调度方式的差异我自己整理了一张对比表看起来更清楚对比项传统多Servlet方式统一入口调度方式新增功能新建类 修改web.xml 到处补配置写一个处理类 加一行映射编码处理每个类里重复写调度器集中设置一次登录校验要么重复写要么靠过滤器可在执行前统一处理类数量和功能数一一对应业务类可以精简调度器独立配置复杂度配置项多而分散集中到一处后端结构可读性功能一多就乱结构清晰扩展顺滑我当时对比完这几点立刻就确定必须把入口收成一个。2. 统一调度方案设计前端控制器模式的落地思路2.1 核心设计思想统一调度说白了就是模拟一个“前台接待员”的角色。你去办事不再需要自己挨个找各个科室的窗口而是先到前台报事由由前台统一协调、分发给对应的人来处理。在这个方案里那个“前台”就是一个分发Servlet我习惯叫它DispatcherServlet和SpringMVC里的DispatcherServlet思路一致。它接收所有进入项目的请求解析出请求路径然后根据路径去查配置找到对应的处理类反射创建实例调用统一的方法再根据返回结果决定跳转页面还是重定向。类比成实际开发中的一句话就是一个Servlet负责“接单”其他的类负责“干活”谁干哪个活由配置文件说了算。2.2 路由映射选型分发Servlet拿到了路径之后怎么知道该交给谁有几种可以做映射的方式硬编码在Java代码里if (path.equals(/login)) { ... } else if ...。这比写一堆Servlet类已经好了不少但仍然是个“变大的类”每加一个功能都要改核心代码违背了开闭原则。配置文件把路径和类的对应关系写到一个.properties文件里用Properties类加载。新增功能只需要加一行配置完全不用动已有的代码。注解在类上写WebServlet(/login)通过反射扫描包来收集映射。这更优雅但需要自己写扫描逻辑对纯Servlet阶段来说稍重。我当时实现时选的是配置文件方案理由很直接它足够简单、不依赖额外库、配置和代码完全分离而且能清楚看到“一个路径对一个类”的映射关系。等这套逻辑吃透了再看注解版或框架版会轻松非常多。2.3 模块划分整个方案的模块划分其实就四个部分统一入口DispatcherServlet继承HttpServlet接管请求。处理器接口定义统一的处理方法例如execute(HttpServletRequest, HttpServletResponse)返回一个字符串表示视图结果。具体处理器登录、注册、列表等每个业务一个类实现同一个接口。映射配置properties文件维护“路径 处理器类全名”。把请求流转过程拆开看就是浏览器请求 → 容器路由给DispatcherServlet → 解析路径 → 查询配置 → 找到处理类 → 调用execute → 根据返回值转发或重定向 → 响应给浏览器。3. 核心实现手写一个调度分发器3.1 定义处理器接口与业务实现类统一调度的第一步是把所有业务类的“入口”统一出一个接口。我当时的做法是定义一个Action接口名称直接借鉴了Struts里的概念public interface Action { String execute(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException; }接口的返回值是String代表视图结果。这里我用一个约定来区分转发和重定向如果返回的字符串以redirect:开头就重定向否则就当转发路径处理。登录处理器可以这样写public class LoginAction implements Action { Override public String execute(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username request.getParameter(username); String password request.getParameter(password); UserDao dao new UserDao(); User user dao.findByUsernameAndPassword(username, password); if (user ! null) { request.getSession().setAttribute(user, user); return redirect:/user/list; } else { request.setAttribute(errorMsg, 用户名或密码错误); return /pages/login.jsp; } } }注意这个类里不再有编码设置、响应类型设置、异常的兜底处理那些统一被收走了。它只关心业务本身。结构清爽了非常多。3.2 DispatcherServlet完整代码这是整套方案的核心组件。它的任务简单但关键创建时加载映射配置请求到达时解析路径并分发。public class DispatcherServlet extends HttpServlet { private Properties actions new Properties(); Override public void init() throws ServletException { String configFile getInitParameter(configFile); String realPath getServletContext().getRealPath(configFile); if (realPath null) { throw new ServletException(找不到配置文件 configFile); } try (InputStream in new FileInputStream(realPath)) { actions.load(in); } catch (IOException e) { throw new ServletException(加载配置文件失败 configFile, e); } } Override protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); String path getRequestPath(request); String className actions.getProperty(path); if (className null) { response.sendError(HttpServletResponse.SC_NOT_FOUND, No handler found for path: path); return; } try { Class? clazz Class.forName(className); Action action (Action) clazz.getDeclaredConstructor().newInstance(); String view action.execute(request, response); handleView(request, response, view); } catch (Exception e) { throw new ServletException(处理请求失败 path, e); } } private String getRequestPath(HttpServletRequest request) { String contextPath request.getContextPath(); String requestURI request.getRequestURI(); String path requestURI.substring(contextPath.length()); // 去掉尾部多余的斜杠避免 /login/ 匹配不到 /login if (path.length() 1 path.endsWith(/)) { path path.substring(0, path.length() - 1); } return path; } private void handleView(HttpServletRequest request, HttpServletResponse response, String view) throws ServletException, IOException { if (view null) { return; } if (view.startsWith(redirect:)) { String redirectUrl view.substring(redirect:.length()); response.sendRedirect(request.getContextPath() redirectUrl); } else { request.getRequestDispatcher(view).forward(request, response); } } }这段代码有几个细节要单独拆开讲。为什么不重写doGet和doPost而是重写service因为既然要做统一调度就应该把GET、POST都收进一个入口在service里统一处理。传统写法在doGet和doPost里各写一遍逻辑本身就是重复代码的源头。统一收到service后不管前端是GET还是POST都走同一套分发逻辑。为什么用反射创建实例因为在写DispatcherServlet的时候具体要用哪个类还不知道是请求到了才从配置里查出来的。Java里动态创建对象的通用手段就是反射Class.forName()拿到类getDeclaredConstructor().newInstance()创建实例。这段代码看着有点繁琐但避开了newInstance()方法过时和强制异常处理的问题是现在比较稳的写法。为什么转发要带request.getContextPath()因为重定向是浏览器重新发一次请求必须带上项目上下文路径否则项目部署到带路径的容器下会鬼打墙式404。转发不同它发生在服务器内部用request.getRequestDispatcher()就可以了。这组差异我后面踩过不少坑。3.3 配置文件与web.xml配置映射配置文件actions.properties放在WEB-INF下结构是“路径类全名”/logincom.demo.action.LoginAction /registercom.demo.action.RegisterAction /user/listcom.demo.action.UserListAction /user/detailcom.demo.action.UserDetailAction /user/editcom.demo.action.UserEditAction /logoutcom.demo.action.LogoutAction路径不带上下文根因为getRequestPath()已经把contextPath截掉了。类名必须是全限定名这样Class.forName()才能找到。web.xml里配置DispatcherServlet接收结果映射成服务根路径servlet servlet-namedispatcher/servlet-name servlet-classcom.demo.web.DispatcherServlet/servlet-class init-param param-nameconfigFile/param-name param-valueWEB-INF/actions.properties/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping注意url-pattern//url-pattern这个配置。在Servlet规范里路径为/的映射方式是“默认Servlet”的用法表示所有没被其他更精确映射匹配的请求都会进入这里。这就是统一调度的关键原来要配一堆Servlet现在只要一个映射把整个项目的请求接管进来。这里有一个非常关键的坑如果配置的是/*而不是/JSP文件本身也会被DispatcherServlet接走导致JSP页面全部变成乱码或者直接被当普通文本处理。正确做法是用/让容器遇到JSP时仍然交给JSP处理器只有非JSP的请求才进DispatcherServlet。这一点在第5章的排错部分会再展开。3.4 参数与细节一次性把边界情况兜住核心代码写好之后我又发现了一堆边缘情况要处理。最典型的是带尾部斜杠的路径。浏览器地址栏敲/login/和/login是两个不同的URI但用户感知上应该是一样的。因此getRequestPath()里我加了去尾部斜杠的逻辑。不过要小心根路径/不能去掉尾部斜杠否则截出来变成空串所以代码里加了length() 1判断。还有一个情况是带查询字符串的请求。getRequestURI()拿到的URI是不带?后面那部分参数的所以路径解析天然安全不需要手动处理?。如果你习惯用getRequestURL()拿完整地址反而要多做一次去除参数的处理不建议。至于静态资源比如/css/style.css、/js/app.js、/images/logo.png如果部署在/这个映射下同样会进DispatcherServlet。而配置文件里没有这些路径对应的处理器就会返回404。这个问题有两种解法我写在第五部分的排错实录里。4. 完整实操从零搭建一个统一调度的JavaWeb工程4.1 准备工程结构我当时的模拟项目X使用的是传统的Eclipse/Servlet容器项目结构你也可以直接用IDEA的Web项目模板路径逻辑完全一致。最终目录结构如下src ├─ com.demo.web │ └─ DispatcherServlet.java ├─ com.demo.action │ ├─ Action.java │ ├─ LoginAction.java │ ├─ RegisterAction.java │ └─ UserListAction.java └─ com.demo.dao └─ UserDao.java web ├─ WEB-INF │ ├─ web.xml │ └─ actions.properties ├─ pages │ ├─ login.jsp │ ├─ login_success.jsp │ └─ user_list.jsp ├─ css │ └─ style.css └─ index.jsp说一下这个结构为什么这样分web包只放调度器action包放业务处理入口dao包放数据访问。业务类不和Servlet耦合也不和具体页面耦合以后要是换框架或者换前端核心的Action类大部分可以原封不动搬走。4.2 映射配置与代码编写顺序我建议的编码顺序是先从Action接口出发接着写两个具体业务类再把DispatcherServlet写出来最后才配web.xml和properties文件。为什么是这个顺序因为脚本调度器代码里用到了Action类型如果你先写DispatcherServlet里面引用的接口还不存在IDE会一路爆红虽然不影响组织代码但制造焦虑。从接口到实现再到入口依赖方向是顺的思路也更清晰。4.3 启动部署与请求调试启动容器部署工程。假设上下文路径是/demo打开浏览器访问http://localhost:8080/demo/login请求的处理过程可以按下面的顺序展开容器把请求交给DispatcherServlet因为/login匹配了/映射。DispatcherServlet先设置UTF-8编码。getRequestURI()结果是/demo/logingetContextPath()是/demo截取后path/login。actions.getProperty(/login)取到com.demo.action.LoginAction。反射创建LoginAction实例调用execute()。业务里查出用户不存在返回/pages/login.jsp。使用request.getRequestDispatcher(/pages/login.jsp).forward(...)转发到登录页表单里回显错误信息。如果登录成功返回的是redirect:/user/list。此时响应给浏览器一个302重定向浏览器再次请求/demo/user/list再由DispatcherServlet分发给UserListAction。这样做的意义是避免刷新页面时重复提交表单是Post-Redirect-Get模式的经典应用。在控制台或日志里我还建议加一个过滤链或日志记录来打印每个请求的路径和分发的类名。排查问题时一眼就能看到请求到底进了哪个Action这对调试非常有价值。4.4 细节优化日志与耗时记录我在第一次跑通后加了一个Long.parseLong计时逻辑在service方法里记录开始时间在返回前打印耗时long start System.currentTimeMillis(); // ...分发逻辑... long cost System.currentTimeMillis() - start; System.out.println([DispatcherServlet] path - className 耗时 cost ms);别小看这一行输出。当你有几十个Action之后它能帮你快速判断哪个接口变慢了是业务本身慢还是SQL慢。这是一个简单又好的“监控起点”。5. 实战避坑新架构下的常见问题与排查5.1 JSP被DispatcherServlet接管变成乱码这个坑我十次里有九次会遇到原因就是web.xml的url-pattern配错了。如果配的是/*所有请求包括JSP都进DispatcherServlet。JSP文件本身的内容会被当成普通文本输出页面上直接显示一堆Java代码或者整个页面白茫茫一片。正确做法是配/它表示“默认的兜底Servlet”容器遇到.jsp结尾的请求时会优先交给容器内置的JSP处理器而不是DispatcherServlet。排查方法很直接看请求的URI是否以.jsp结束如果以.jsp结束仍然进入了DispatcherServlet基本上映射配置就是/*没跑。5.2 静态资源404配了/之后/css/style.css会被拦截进DispatcherServlet而配置文件里没有这个路径返回404。这是一个架构方案最常见的连带问题。我的处理方案是在初始化时把静态资源后缀加入一个集合拦截前先判断// 配置静态资源后缀 String[] staticSuffix {.css, .js, .png, .jpg, .gif, .ico, .svg, .woff, .woff2}; // 请求处理前判断 for (String suffix : staticSuffix) { if (path.endsWith(suffix)) { // 让容器默认Servlet处理 request.getRequestDispatcher(path).forward(request, response); return; } }你也可以在actions.properties里显式加一行静态资源映射或者继续保持原样反正现在项目都是前后端分离要走API静态资源单独放在静态服务器上就行这里只是为了兼容JSP项目里的CSS和JS。5.3 配置文件读取不到报找不到文件这个问题在IDEA的热部署场景里容易翻车。getServletContext().getRealPath()返回的是容器部署目录下的物理路径在Eclipse部署里通常是workspace\.metadata\.plugins\org.eclipse.wst.server.core\tmp0\wtpwebapps下的对应目录。如果你把actions.properties放在src目录下它会被编译到WEB-INF/classes下路径又不一样。我最终的统一约定是属性文件只放WEB-INF下并且用getServletContext().getRealPath()来读取。这样不管你用什么IDE部署都是相对于项目发布后的物理路径定位稳定可靠。如果getRealPath()返回null说明容器没有展开目录比如嵌入式容器那就要改用getServletContext().getResourceAsStream()。5.4 路径匹配不到处理器的404排查清单把这个问题整理成一张速查表方便自己忘了的时候翻现象可能原因检查点404且响应内容里带“No handler found”映射配置中没有这个路径检查actions.properties里是否有/xxx这一行404且不是DispatcherServlet的响应请求根本没进DispatcherServlet检查web.xml的url-pattern是否漏配或路径精确不匹配路径带尾部斜杠就404getRequestPath没处理尾部斜杠看DispatcherServlet里有无去斜杠逻辑JSP页面显示源码url-pattern配成/*改为/重定向后404重定向路径少了contextPath确认handleView里重定向是否拼了request.getContextPath()中文乱码编码设置顺序不对确认setCharacterEncoding在所有getParameter之前执行排查的思路其实就一条先确认请求有没有进到DispatcherServlet再确认有没有分发到正确的Action。打日志比人肉猜快多了。5.5 实例数量膨胀处理器类如果继续按传统方式“一个功能一个类”虽然比一个Servlet一个类好些但仍然类多。我在这个阶段做了一件事在Action内部通过方法名再做一次方法级分发。比如UserAction实现了Action接口execute()方法里只做一件事public String execute(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String path request.getRequestURI(); String method path.substring(path.lastIndexOf(/) 1); switch (method) { case list: return list(request, response); case detail: return detail(request, response); case edit: return edit(request, response); default: return /pages/404.jsp; } }配置里就不再写/user/listUserListAction、/user/detailUserDetailAction而是所有/user/*都指向UserAction。这样做的好处是把相关业务聚合到同一个类里类数量回归克制坏处是execute内部会出现一个越来越大的switch。这也是后来SpringMVC用注解RequestMapping和Controller类加方法级映射要解决的核心问题先通过这个手写方式体会过度方案对后面学框架非常有帮助。6. 这套方案的本质从Servlet到框架的过渡思维6.1 与SpringMVC的关系这次优化做完之后我回头看看代码很多结构都能对应到主流框架的设计上我的方案对应框架概念DispatcherServletSpringMVC的DispatcherServletAction接口Controller方法的概念actions.properties路由层面的映射表反射创建处理类IoC容器实例化管理Action返回视图字符串视图解析前的逻辑视图名做一次这样的手写前端控制器最大的收获不是代码本身而是理解了框架解决的那些问题不是平白无故的。以后再学SpringMVC脑子里会有一个“它不过是在这个手写版本上做了托管、注解、灵活配置”的认知学习效率高很多。6.2 什么时候仍需要多个Servlet统一调度不是万能药也不是说以后就完全不写Servlet了。以下场景中多Servlet方案仍然合理项目极小两个页面三个功能硬上分发器反而绕路。需要下载文件或输出二进制流的前置静态接口单独写映射定位更直接。需要特殊过滤逻辑或独立生命周期管理的组件用独立Servlet更容易控制。具体问题具体分析。“统一调度”是为了解决“类爆炸、配置分散、重复代码多”的问题如果这些问题不存在就不需要过度设计。6.3 几个值得深思的设计点这个方案落地后有几个值得反复看的设计点我简单列一列供思考配置的key用路径而不是类名是刻意的。路径是外部可观察的API类名是内部实现细节。外部API稳定内部实现可以随时替换这就是面向接口设计的一个简单应用。Action实例每次请求都新建一个是最简单的无状态策略。如果你的Action里不小心放了实例字段就会在并发下出问题。反过来这也提示了单例和线程安全的边界在哪。返回视图字符串的约定过于“魔法字符串”了。redirect:前缀埋隐藏在代码里容易写错也没法编译期检查。一个熔炼积累方向是设计一个结果对象例如return new ViewResult(/pages/login.jsp)或者用枚举区分转发和重定向。这部分还有很大的优化空间。7. 进一步优化把统一调度扩展成基础能力平台方案跑通后我做了一次小范围的“能力外扩”把调度器做成同时能够承担通用处理的平台组件。这个思路对把这套方案应用在真实项目里很有用。7.1 集中登录校验在调用action.execute()之前先判断当前路径是否需要登录状态// 在service方法中调用Action前增加统一校验 if (!isExcludedPath(path) request.getSession().getAttribute(user) null) { response.sendRedirect(request.getContextPath() /login); return; }排除列表可以配在properties文件里/login、/register这种公开路径不拦截其余全部要求登录。这个逻辑写在DispatcherServlet里本质上就是不管你调哪个Action都要先过这道关卡。以前用过滤器也能做但把校验和调度放在一起逻辑更集中调试时也更清晰。7.2 统一异常兜底与日志记录我在catch块里做了一个兜底把异常信息记录到日志然后跳转到一个友善的错误页面而不是让容器打印一屏堆栈给用户看。对于纯Servlet阶段来说这种对用户友好、对开发友好的错误处理会让项目看起来专业很多。7.3 分页参数的统一解析项目里有用户列表、订单列表每个都需要读当前页、每页数量、总记录数。我把这些逻辑也搬进了调度器的工具方法中页面参数解析、默认值处理一次搞定。Action里只需要调用PageParam page DispatchUtils.getPageParam(request);这样做之后Action里的重复代码肉眼可见地减少。业务逻辑更加聚焦关心的是“查询什么”而不是“参数怎么来”。7.4 后续演进方向如果你还想在这个基础上继续玩我建议按以下路线走注意避开自己给自己挖的坑支持动态路径参数比如/user/detail/7从路径最后一段取ID。可以写一个正则匹配器把{id}这种参数解析出来这一步做完基本就能摸到RESTful路由的壳了。引入注解和包扫描在Action类上不再借助properties文件而是在启动时扫描指定包下面带注解的类维护一个映射Map。这一步一步做完就相当于自己搭了一个轻量的SpringMVC。引入HandlerMapping和HandlerAdapter概念把“路径查类”和“实例调用方法”拆成两个组件这就是框架的设计雏形。如果你是从零开始学JavaWeb我强烈建议把“手写一个前端控制器”这个步骤当作必经之路。它不像写几个Servlet那么简单也不像直接上框架那么省事但它是把分散的请求处理收拢为统一调度的关键转型。这个转型的思想一掌握后面学习任何Web框架都是降维打击。踩完所有坑之后再看这套方案我有一个直接的体会不要为了统一而统一。统一调度的价值来自于“所有请求都经过同一个入口”之后gateway有了做通用逻辑的机会配置才能集中管理业务代码才能不被Servlet API绑架。如果你只是把Servlet换成了Action换汤不换药那这次优化就失去了意义。我后来在做模拟项目X时又重构了三轮才把这套前端口子打磨得比较顺手。每一次重构的方向都是同一个让业务类更关注业务本身把重复环节和易变内容尽量往调度器上收拢。这个过程本身比最终的代码更值得去体会。