14.3 前端控制器
以一个入口处理站点全部请求,集中认证、路由、错误处理和公共策略,再分派到具体动作。
学习目标
- 能解释前端控制器如何把共同请求工作集中到一个入口,并指出它与具体业务动作的边界
- 能修改一个 TypeScript 路由入口,让认证、日志和分发顺序可测试且不会把业务规则塞进入口
- 能回答:当一个请求绕过统一入口时,哪一条安全或审计证据会消失,应该拒绝还是继续扩张中央控制器
先从一条请求开始
想象订单后台只有一个服务台:每位访客先在这里登记身份、说明要办的事,服务台再把人带到查看、编辑或删除窗口。这样,所有窗口都遵守同一套进门规则;如果访客可以从后门直达某个窗口,登记和记录就会出现缺口。
本章解决的就是这个“先过同一扇门,再去办具体事情”的问题。没有它时,每个页面都要重复检查身份、记录日志和判断语言,规则会逐渐不一致;有了它,也必须警惕这扇门变成包办一切的巨大房间。
先建立可验证的边界
把目标写成一条可追踪的链:HTTP 请求 → 统一入口 → 共同策略 → 具体动作 → HTTP 响应。每一段都要有一个可回答的问题:谁解释输入?谁记录安全事件?谁读取订单?谁决定返回哪种页面?如果同一段业务规则在入口和动作对象各写一遍,边界已经开始泄漏。
什么是前端控制器
↡接收站点全部请求、先执行共同请求处理,再把请求交给具体动作的单一入口对象。是站点请求处理的共同门槛。它不等于“所有代码都放在一个类里”,而是让每条请求先经过同一个 handler,再由该 handler 选择请求专属的命令对象。Martin Fowler 的公开模式摘要也把核心描述为:单一 handler 统一处理请求,再分派给命令对象完成特定行为。
它通常负责解析 method 与 path、建立请求上下文、执行共同策略、查找路由并转交;它不应该直接写“订单能否取消”这类领域判断。这样做的收益是横切规则只有一个入口,运行时可用装饰器追加行为;代价是入口会增加间接层,路由表和错误处理也需要测试。
三个职责层次
共同策略只做一次
↡横跨多个请求、但不属于某个具体业务动作的共同规则,例如认证、日志和国际化。包括认证、授权、日志、国际化和统一错误映射。它们放在入口附近,是为了让查看订单和编辑订单不会各自发明一套登录检查。共同并不意味着随便放:入口可以验证“谁在请求”,但不能替订单决定“这个状态是否允许转换”。后者应留给领域服务或命令对象。
变化留在路由表
↡把请求方法和路径映射到处理函数或对象的明确数据结构。把
GET /orders/42 和 POST /orders/42/cancel
映射到不同处理者。把映射显式化有两个好处:新增页面时只改一处,测试可以直接验证“请求去了哪里”;也有一个边界:路由表只能做选择,不能在每个分支中偷偷执行完整业务流程。
具体动作交给命令
↡封装一次请求专属动作的对象,接收已经过共同策略检查的上下文并返回响应结果。承接查看、编辑、删除等具体动作。它可以调用应用服务和领域模型,完成输入校验、状态变更与视图选择;前端控制器只关心把请求交给谁。若命令对象之间还重复认证和日志,说明共同策略没有真正集中,应该先修入口契约。
订单后台的请求流
先预测:如果把“记录审计事件”复制到三个页面处理者里,新增第四个页面时最可能漏掉什么?打开下方主图,按步骤看共同工作如何只出现一次;再点“注入绕过故障”,观察后门调用为何破坏证据链。
第 1 / 4 步 · 请求先到同一个入口,而不是散落到每个页面
按步骤观察共同工作和具体动作的边界;再注入故障,检查是否有人绕过入口。
三个静态快照
主图适合连续观察;下面的三张快照把同一条链拆开,方便逐步核对每个边界。每一步都保留该阶段的图示,不用记忆纯文字描述。
1. 请求先到统一入口
浏览器只提交请求,入口先接住它;此时还没有选择具体订单动作。
代码实践:让入口可测试
下面是一段最小 TypeScript 草图。它把共同策略、路由选择和具体动作分成可替换的函数,便于在测试中分别验证“未登录会被拒绝”和“已登录请求到达正确命令”。这不是某个 Web 框架的配置,而是模式边界的可执行表达。
type Request = { method: string; path: string; userId?: string };
type Response = { status: number; body: string };
type Context = { request: Request; audit: (event: string) => void };
type Handler = (context: Context) => Response;
const routes: Record<string, Handler> = {
"GET /orders/42": ({ audit }) => {
audit("order.view");
return { status: 200, body: "order detail" };
},
"POST /orders/42/cancel": ({ audit }) => {
audit("order.cancel");
return { status: 202, body: "cancel requested" };
},
};
export function frontController(request: Request): Response {
if (!request.userId) return { status: 401, body: "sign in required" };
const handler = routes[`${request.method} ${request.path}`];
if (!handler) return { status: 404, body: "route not found" };
return handler({ request, audit: (event) => console.info(event) });
}这里的入口只完成三件事:先守住身份边界,再查表,再转交。真实项目还应把 audit 和命令注册表注入,而不是在入口里直接创建数据库连接或写复杂领域规则。一个有价值的单元测试可以替换 audit,断言一次合法请求只记录一次事件;另一个测试可以传入不存在的路径,确认不会调用任何命令。
什么时候应该停下来拒绝
单一入口不是越集中越好。可以采用它的信号是:多个请求确实共享认证、日志或语言策略,且这些规则需要一致变更。应重新评估的信号是:入口开始读取订单表、拼装每个页面的全部数据、处理每个状态转换,或者一个小页面改动会触及中央控制器的许多业务分支。
此时可把策略拆成中间件、把领域规则移入应用服务、把页面差异留给命令对象。若团队只是因为框架默认提供一个全局 controller 就把所有逻辑塞进去,那不是模式证据;评审应要求一条请求流和一组能证明入口边界的测试。
常见误区
本章小结
- 前端控制器是所有请求共同经过的单一入口。
- 横切关注点在入口集中一次,避免页面处理者各自复制。
- 路由表负责选择,命令对象负责具体业务动作。
- 入口应守住边界,不应演变成业务总管。
- 绕过入口会让安全与审计失去共同证据。
练习
练习
问题 1:补全入口边界。 在代码草图中加入一个 audit 断言:合法的 GET /orders/42 只能通过 frontController 到达 ViewOrderCommand,不能由测试直接调用命令来代替入口测试。
问题 2:诊断故障。 删除页绕过统一入口,自己从 cookie 读取用户并写审计日志。指出两个风险,并说明应把代码移到哪里。
问题 3:改 Demo 代码。 给主图增加一个“导出报表”命令。你需要修改哪一层?如何证明增加它没有让入口重新承担报表业务?
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 前端控制器
所有请求先到达的统一入口;它负责共同的请求工作,然后把具体动作交给别的对象。
- 横切关注点
许多请求都会需要、但不属于某一个页面业务的规则,例如认证和日志。
- 路由表
把请求方法和路径对应到处理者的一张映射表。
- 命令对象
代表一次具体请求动作的对象,例如查看订单或取消订单。
前后导航
参考资料
- Martin Fowler:Front Controller 模式摘要:核对模式定义、单一入口与命令分发的事实边界。
- Martin Fowler:Patterns of Enterprise Application Architecture:核对图书主题、章节范围和模式目录关系。
- Pearson:Patterns of Enterprise Application Architecture:交叉核对出版信息与 Web Presentation Patterns 所在章节。