从Servlet堆砌到前端控制器:手写统一调度DispatcherServlet

发布时间:2026/10/23 19:29:36
从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;charset=UTF-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 模块划分

整个方案的模块划分其实就四个部分:

  1. 统一入口:DispatcherServlet,继承HttpServlet,接管请求。
  2. 处理器接口:定义统一的处理方法,例如execute(HttpServletRequest, HttpServletResponse),返回一个字符串表示视图结果。
  3. 具体处理器:登录、注册、列表等每个业务一个类,实现同一个接口。
  4. 映射配置: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;charset=UTF-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下,结构是“路径=类全名”:

/login=com.demo.action.LoginAction /register=com.demo.action.RegisterAction /user/list=com.demo.action.UserListAction /user/detail=com.demo.action.UserDetailAction /user/edit=com.demo.action.UserEditAction /logout=com.demo.action.LogoutAction

路径不带上下文根,因为getRequestPath()已经把contextPath截掉了。类名必须是全限定名,这样Class.forName()才能找到。

web.xml里配置DispatcherServlet,接收结果映射成服务根路径:

<servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>com.demo.web.DispatcherServlet</servlet-class> <init-param> <param-name>configFile</param-name> <param-value>WEB-INF/actions.properties</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>

注意<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

请求的处理过程可以按下面的顺序展开:

  1. 容器把请求交给DispatcherServlet,因为/login匹配了/映射。
  2. DispatcherServlet先设置UTF-8编码。
  3. getRequestURI()结果是/demo/login,getContextPath()是/demo,截取后path=/login。
  4. actions.getProperty("/login")取到com.demo.action.LoginAction。
  5. 反射创建LoginAction实例,调用execute()。
  6. 业务里查出用户不存在,返回/pages/login.jsp。
  7. 使用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/list=UserListAction、/user/detail=UserDetailAction,而是所有/user/*都指向UserAction。这样做的好处是把相关业务聚合到同一个类里,类数量回归克制,坏处是execute内部会出现一个越来越大的switch。这也是后来SpringMVC用注解@RequestMapping和Controller类加方法级映射要解决的核心问题,先通过这个手写方式体会过度方案,对后面学框架非常有帮助。

6. 这套方案的本质:从Servlet到框架的过渡思维

6.1 与SpringMVC的关系

这次优化做完之后,我回头看看代码,很多结构都能对应到主流框架的设计上:

我的方案对应框架概念
DispatcherServletSpringMVC的DispatcherServlet
Action接口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 后续演进方向

如果你还想在这个基础上继续玩,我建议按以下路线走,注意避开自己给自己挖的坑:

  1. 支持动态路径参数:比如/user/detail/7,从路径最后一段取ID。可以写一个正则匹配器把{id}这种参数解析出来,这一步做完,基本就能摸到RESTful路由的壳了。
  2. 引入注解和包扫描:在Action类上,不再借助properties文件,而是在启动时扫描指定包下面带注解的类,维护一个映射Map。这一步一步做完,就相当于自己搭了一个轻量的SpringMVC。
  3. 引入HandlerMapping和HandlerAdapter概念:把“路径查类”和“实例调用方法”拆成两个组件,这就是框架的设计雏形。

如果你是从零开始学JavaWeb,我强烈建议把“手写一个前端控制器”这个步骤当作必经之路。它不像写几个Servlet那么简单,也不像直接上框架那么省事,但它是把分散的请求处理收拢为统一调度的关键转型。这个转型的思想一掌握,后面学习任何Web框架都是降维打击。

踩完所有坑之后再看这套方案,我有一个直接的体会:不要为了统一而统一。统一调度的价值来自于“所有请求都经过同一个入口”之后,gateway有了做通用逻辑的机会,配置才能集中管理,业务代码才能不被Servlet API绑架。如果你只是把Servlet换成了Action,换汤不换药,那这次优化就失去了意义。我后来在做模拟项目X时,又重构了三轮,才把这套前端口子打磨得比较顺手。每一次重构的方向都是同一个:让业务类更关注业务本身,把重复环节和易变内容尽量往调度器上收拢。这个过程本身,比最终的代码更值得去体会。