设计模式修炼之路 ㉚ 前端控制器模式——所有请求,先过我这道门
横切关注点写一次全站无死角、抄漏一处校验就是一个洞、门神必须薄、路由分散声明集中执行、与 MVC 的 C 对账
目录
J2EE 模式第五站。
㉙ 的 DAO 把"怎么存"收走了,业务层终于干净。可 Web 应用的麻烦不止在存储——请求进门的第一个问题是:谁接的活?
Servlet 时代的默认答案:一个 URL 一个 Servlet,各自为政。于是"进门三件套"——设置编码、登录校验、访问日志——只能靠复制粘贴在每个 Servlet 里传播:老员工抄得齐,新来的不知道有模板,抄漏一处校验,就是一个安全洞。想要"全局加个限流"?挨个文件改一遍,改漏一个就白干。
用一个控制器收口所有请求:进门的事集中办,业务的事分发办——前端控制器模式,J2EE 八式里的门神。
一、前端控制器模式是什么
J2EE 蓝皮书的定义大意:前端控制器(Front Controller)为 Web 应用提供一个集中的请求处理入口,所有请求先到这里,统一的认证、授权、编码、日志在此完成,再由它分发给具体的处理器。
四个角色:
| 角色 | 干什么 | 本文对应 |
|---|---|---|
| Front Controller(前端控制器) | 全应用唯一入口:横切关注点 + 分发 | FrontController |
| Dispatcher(分发器) | 维护 URL 到处理器的映射,负责路由 | 本文并入 FrontController 的 routes |
| Handler / Controller(业务控制器) | 一个 URL 一家,只管业务 | OrderController / UserController |
| View(视图) | 渲染响应 | 本文 println 代劳 |
一句话理解分工:前端控制器说"所有请求先过我这道门",业务控制器说"这个请求的业务我管"。
它同样不是 GoF 二十三式,是 J2EE 蓝皮书成员——诞生动机就是上面那个复制粘贴惨案:横切关注点(所有请求都要做、且做法一样的事)散落在 N 个 Servlet 里,纪律靠自觉,漏洞靠运气。
二、优点与缺点
优点:
- 横切关注点写一次,全站无死角:编码、认证、日志、限流——写在门神身上,任何 URL 都逃不掉,新同事想漏都漏不了。
- 集中分发,路由一目了然:URL 到哪个处理器,一张路由表看全——"这个接口谁在管"从考古问题变成查表问题。
- 统一错误处理:404、401、500 在一个地方兜底,不会有的页面报错白屏、有的页面报错误码。
- 安全审计有唯一卡口:想拦截所有请求做审计、限流、灰度——只需在门口加,不用满楼跑。
缺点:
- 唯一入口 = 唯一故障点:门神挂了全站挂——所以现实里它必须薄(只做进门三件事加分发),重活不许干。
- 所有请求串一遍相同检查:静态资源请求也走登录校验,部分检查对部分路径是浪费——解药是 ㉛ 按路径配过滤器。
- 路由表会膨胀:大应用几百条路由堆在一个类里——现实框架用注解把路由拆回各个控制器(第五节见)。
三、什么时候用
判断标准:有多条入口 URL + 存在对所有请求一视同仁的横切关注点——就用前端控制器,这几乎是 Web 应用的出厂设置。
- 典型场景:Web 应用、HTTP API——认证、日志、编码、限流,哪个应用躲得开?
- 微服务 API 网关:跨服务版前端控制器——认证、限流、路由,在集群门口统一办
- 反面对照:桌面应用、单文件脚本——没有"请求进门"这回事,硬套是给自己立门神;纯内部工具只有一个入口时,前端控制器退化成一个 main 方法,模式已经在了,只是不值得命名
四、实现方式:谁能看个人资料
场景:一个迷你 Web 应用,两个页面——订单列表 /order/list、个人资料 /user/profile。进门三件套:编码、日志、登录校验。看两套世界。
4.1 反面教材:每个 Servlet 各自为政
老员工写的 OrderServlet,三件套抄得整整齐齐:
public class BadOrderServlet {
public void service(Request request) {
System.out.println(" [OrderServlet] 设置编码 UTF-8");
System.out.println(" [OrderServlet] 检查登录:" + request.getUserId() + " 已登录");
System.out.println(" [OrderServlet] 记访问日志:" + request.getUserId() + " -> " + request.getPath());
System.out.println(" [OrderServlet] 业务:拉取订单列表");
}
}
新同事写的 UserServlet——不知道有模板,只写了业务,编译照样过,功能照样跑:
public class BadUserServlet {
public void service(Request request) {
System.out.println(" [UserServlet] 业务:拉取个人资料");
}
}
运行输出:
========== 反面教材:每个 Servlet 各自为政 ==========
-- 请求 /order/list(u001 已登录,OrderServlet 接活) --
[OrderServlet] 设置编码 UTF-8
[OrderServlet] 检查登录:u001 已登录
[OrderServlet] 记访问日志:u001 -> /order/list
[OrderServlet] 业务:拉取订单列表
-- 请求 /user/profile(u001 已登录,UserServlet 接活) --
[UserServlet] 业务:拉取个人资料
UserServlet 少了三件套——三件套是靠复制粘贴传播的,抄没抄全靠自觉
-- 请求 /user/profile(匿名用户,UserServlet 接活) --
[UserServlet] 业务:拉取个人资料
安全事故:未登录直接拉到个人资料——抄漏一处校验,就是一个洞
三个病灶全齐:纪律靠自觉(三件套的正确性不靠结构保证,靠每个写代码的人记得抄);抄漏一处就是一个洞(UserServlet 没拦住匿名请求——复制粘贴传播的校验,传播不到的地方就是裸奔);全局改动 N 处(哪天校验规则要改、要加限流,挨个 Servlet 翻,改漏一个就前功尽弃)。注意 OrderServlet 本身没写错——反面教材从来不是谁的错,是结构把正确的希望寄托在自觉上。
4.2 正解:前端控制器
业务控制器干干净净,只有业务:
public class UserController implements Controller {
@Override
public void handle(Request request) {
System.out.println(" [UserController] 业务:拉取个人资料");
}
}
门神收口一切——三件套写一次,路由一张表:
public class FrontController {
private final Map<String, Controller> routes = new HashMap<>();
public FrontController() {
routes.put("/order/list", new OrderController());
routes.put("/user/profile", new UserController());
}
/** 所有请求都从这里进 */
public void handle(Request request) {
System.out.println(" [前端控制器] 统一设置编码 UTF-8");
System.out.println(" [前端控制器] 统一访问日志:" + request.displayUser() + " -> " + request.getPath());
if (!request.isLoggedIn()) {
System.out.println(" [前端控制器] 统一登录检查:未登录,拒绝:401 请先登录");
return;
}
System.out.println(" [前端控制器] 统一登录检查:" + request.getUserId() + " 已登录");
Controller controller = routes.get(request.getPath());
if (controller == null) {
System.out.println(" [前端控制器] 404:没有这个页面 " + request.getPath());
return;
}
controller.handle(request);
}
}
运行输出:
========== 正解:前端控制器统一入口 ==========
-- 请求 /order/list(u001 已登录) --
[前端控制器] 统一设置编码 UTF-8
[前端控制器] 统一访问日志:u001 -> /order/list
[前端控制器] 统一登录检查:u001 已登录
[OrderController] 业务:拉取订单列表
-- 请求 /user/profile(u001 已登录) --
[前端控制器] 统一设置编码 UTF-8
[前端控制器] 统一访问日志:u001 -> /user/profile
[前端控制器] 统一登录检查:u001 已登录
[UserController] 业务:拉取个人资料
-- 请求 /user/profile(匿名用户再试) --
[前端控制器] 统一设置编码 UTF-8
[前端控制器] 统一访问日志:匿名 -> /user/profile
[前端控制器] 统一登录检查:未登录,拒绝:401 请先登录
-- 请求 /no/such/page(乱敲的 URL) --
[前端控制器] 统一设置编码 UTF-8
[前端控制器] 统一访问日志:u001 -> /no/such/page
[前端控制器] 统一登录检查:u001 已登录
[前端控制器] 404:没有这个页面 /no/such/page
三处对照读出含金量:UserController 里一行校验都没有,但匿名请求进不来——正确性从"每个人记得抄"变成"结构保证";被拒绝的请求也先记了日志(匿名 -> /user/profile)——审计无死角,攻击尝试留痕;乱敲的 URL 统一 404——错误处理有了兜底的地方。反面教材那个安全洞,在这里结构上就不可能存在。
4.3 三处细节
细节一:门神必须薄。 前端控制器只干两件事:进门三件套 + 分发。业务逻辑一旦写进门神,它就从"全站唯一入口"堕落成"全站唯一上帝类"——缺点一的故障点风险直接翻倍。判断标准:门神里的代码应该对所有请求成立,"只对订单成立"的代码立刻请去 OrderController。
细节二:分发的形态会进化。 本文路由表是构造器里硬编码的 Map.put——够用,但加页面要改门神。下一步进化是命令模式(⑱):请求参数直接映射处理器类;再进一步是注解扫描:Spring 的 @RequestMapping("/user/profile") 把路由声明放回各个控制器,框架启动时自动汇总成路由表——路由分散声明、集中执行,膨胀问题就这么解的。
细节三:三件套会长大成三十件套。 编码、认证、日志只是开胃菜:限流、审计、灰度、租户识别、链路追踪……全往 handle() 方法里塞,门神眼看撑爆。而且它们各有各的讲究:编码必须最前、权限校验得在认证之后、限流只想挂部分路径、顺序还得可配置。横切关注点"可插拔、可排序"的需求,就是 ㉛ 拦截过滤器模式的出生证明——下一篇见分晓。
五、在框架里找它
-
Spring MVC 的 DispatcherServlet:名字都写在脸上了——Front Dispatcher Servlet。所有请求先进它,HandlerMapping 是路由表,@Controller 是业务控制器,@RequestMapping 是路由声明。Interceptor(拦截器)则是它给横切关注点留的插槽。本文 FrontController 的工业化完整版。
-
Struts 的 ActionServlet:老一代答案——ActionServlet 收口一切,按配置把请求分发给各个 Action。前端控制器模式教会了整整一代 Java 框架"怎么当门神"。
-
Django / Flask / FastAPI:Python 世界同款思想——WSGI/ASGI 统一入口 + URL 路由表分发到视图函数;中间件(middleware)承担三件套职责。框架换了语言,门神的岗位职责全球统一。
-
API 网关(Kong、Spring Cloud Gateway):微服务时代的前端控制器——几十个服务前面立一扇大门,认证、限流、灰度、路由到下游服务,全都门口办。从"一个应用的门神"放大到"一个集群的门神",模式没变,编制升了。
六、两个"控制器"对账——㉖ MVC 的 C vs ㉚ 前端控制器
㉖ 讲 MVC 时留过一个尾巴:MVC 的 Controller 和前端控制器是什么关系?摊开对账:
| MVC 的 Controller(㉖) | Front Controller(㉚) | |
|---|---|---|
| 数量 | 一个用例一个:LoginController、OrderController | 全应用唯一 |
| 管什么 | 这个请求的**业务编排**:调模型、选视图 | 所有请求的**共同前奏**:认证、日志、编码,加分发 |
| 谁写 | 你写 | 框架写(DispatcherServlet、ActionServlet) |
| 视角 | 业务视角 | 基建视角 |
| 一句话 | 接到活以后怎么干 | 活怎么统一地接到 |
Spring MVC 里两者同框:DispatcherServlet 是前端控制器,@Controller 是 MVC 的 C——你的每个 @Controller 方法干的是 ㉖ 的活,而把它们串起来的那扇门是 ㉚。所以别再问"MVC 的 C 是不是前端控制器"——狭义 MVC 只说"控制器接收输入、调用模型";落到 Web 上,这活被拆成两半:门口的统一前奏(㉚,框架的)和屋里的一单一办(㉖,你的)。
写在最后
- 前端控制器 = 全应用唯一入口:横切关注点写一次,分发路由一张表
- 疗效:校验无死角、审计有卡口、错误有兜底——正确性从靠自觉变成靠结构
- 铁律:门神必须薄——对所有请求成立的代码才配进门神
- 分发的进化:硬编码路由表 → 命令映射 → 注解扫描,路由分散声明、集中执行
- 当代转世:DispatcherServlet、Django 中间件、API 网关——门神从应用门口站到集群门口
- 对账:㉖ 的 C 是"接到活以后怎么干",㉚ 是"活怎么统一地接到"——Spring MVC 里两者同框
前端控制器把进门三件套写死在 handle() 里,顺序固定、全体适用。可现实很快翻脸:编码必须在最前、权限校验得排在认证后、限流只想挂 API 路径、静态资源什么检查都不想要——横切关注点越长越多,还各有各的顺序和地盘,门神的 handle() 方法眼看撑爆。谁让这些横切逻辑"可插拔、可排序、可挑路径"?——拦截过滤器模式,下一篇见。