14.2 页面控制器
为特定页面或动作设置控制器,由它接收请求、调用模型并选择响应。
学习目标
- 能沿“HTTP 请求 → 页面控制器 → 应用服务 → 视图 → HTTP 响应”标出每个责任边界。
- 能编写一个只编排输入校验、应用调用和响应选择的页面控制器,并让两个页面复用同一领域服务。
- 能注入重复流程和越界访问两个反例,用日志、测试与响应状态判断何时应改用前端控制器。
为什么页面控制器值得单独学习
页面控制器解决的是一个很具体的维护问题:订单详情页和订单编辑页都要接收请求,但它们的输入契约、导航结果和视图模型并不相同。把每个页面或动作的入口责任放到一个小控制器里,可以让变化停留在页面边界;控制器只负责编排,不把业务规则藏进模板,也不让路由处理器直接改数据库。
这里先建立一个可验证的判断标准。一个页面控制器方案只有在“请求能被解释、业务能被调用、响应能被选择、失败能被定位”四件事都可观察时才算成立。猜一猜:如果把订单校验搬进模板,第一次失败会出现在哪一层?如果两个页面开始复制同一段认证、日志和路由分派,又该由谁统一它们?
三个必须先排除的误区
术语与订单后台案例
在订单后台多视图中,↡针对单个页面或动作接收请求、调用应用逻辑并选择响应的控制器 是每个页面的编排点;它不等于一个无边界的业务服务。请求进入后,↡约束请求字段、身份和返回形状的可验证约定 先把输入说清楚,↡为一个用例协调领域对象和事务边界的可调用入口 再完成用例编排。
控制器不应把领域对象原样交给模板,而应生成 ↡专门为一个页面准备、只携带渲染所需字段的数据形状。当保存成功要跳转列表页、校验失败要回到编辑页时,↡由成功、失败和重定向共同构成的页面去向与状态 必须被显式返回。若认证、日志和错误映射在许多页面重复,才考虑引入 ↡为站点全部请求提供统一入口并分派到具体动作的控制器,而不是提前牺牲页面边界。
案例的单元键是 poeaa24-pattern-26-page-controller。本章固定五个观察点:HTTP 请求、控制器、应用服务、视图和 HTTP 响应。读者要记录路由、输入校验、导航、模板转义、视图复用五项证据;决定不是“用了某框架所以正确”,而是能否解释每一次变化由谁吸收、每一次失败在哪里停止。
从请求到响应的责任链
一次 GET /orders/42 应先由页面控制器解析路径参数,再调用查询用例,最后选择 order-detail 视图。一次 POST /orders/42 则需要验证表单、调用保存用例,并在成功与失败之间选择不同的导航状态。两个控制器可以共享 OrderApplicationService,但各自保留自己的输入类型和输出选择。
这条链有三个可复核的不变量:控制器不直接写持久化细节;应用服务不依赖某一张 HTML 模板;视图不重新解释原始请求。动手试:在代码切片中故意删除输入校验,再观察错误是否仍然能在响应生成前被定位;随后把错误恢复为字段级反馈,确认控制器仍只承担编排。
专属代码切片:两个页面共享应用服务
type HttpRequest = {
path: Record<string, string | undefined>;
form: Record<string, string | undefined>;
};
type Response =
| { status: 200; view: "order-detail"; model: ViewModel }
| { status: 303; location: string }
| { status: 400 | 404; view: "order-edit" | "not-found"; errors?: string[] };
class OrderPageController {
constructor(private readonly app: OrderApplicationService) {}
async get(request: HttpRequest): Promise<Response> {
const id = request.path.id;
if (!id)
return { status: 400, view: "order-edit", errors: ["缺少订单编号"] };
const model = await this.app.detail(id);
return model
? { status: 200, view: "order-detail", model }
: { status: 404, view: "not-found" };
}
async post(request: HttpRequest): Promise<Response> {
const id = request.path.id;
const quantity = Number(request.form.quantity);
if (!id || !Number.isInteger(quantity) || quantity < 1) {
return { status: 400, view: "order-edit", errors: ["数量必须是正整数"] };
}
await this.app.updateQuantity(id, quantity);
return { status: 303, location: `/orders/${id}` };
}
}这里的 OrderPageController 只做三件事:把请求翻译成用例参数、选择结果类型、决定页面去向。OrderApplicationService 可以被详情页和编辑页共同调用;如果控制器开始直接执行 SQL、计算折扣或读取模板内部状态,就已经越过了页面边界。
三步交互实验:沿着一条请求逐层检查
先预测:点击“下一步”时,哪一个对象会首先获得新的责任?如果把 ViewModel 换成数据库实体,哪一项输入契约会被破坏?按顺序操作下面三步,并在每一步写下“看到的状态—责任所有者—可推翻证据”。
当前:请求进入页面边界
1. 锁定页面边界
观察 URL、请求字段和页面控制器的输入契约。确认 /orders/:id 的缺失参数在控制器内被拒绝,而不是让模板或数据库猜测。
选择、拒绝与迁移信号
| 评审问题 | 保留页面控制器的证据 | 应拒绝或迁移的信号 |
|---|---|---|
| 输入 | 每个页面拥有清晰的请求契约,坏输入在入口处结束 | 多个页面被迫接受同一个含糊参数对象 |
| 业务 | 控制器调用应用服务,不直接作领域决定 | 控制器包含价格、库存或事务规则 |
| 输出 | 视图只接收视图模型,导航结果可测试 | 模板重新读取请求、数据库或权限上下文 |
| 横切 | 公共认证与日志可由前端控制器统一 | 中央入口开始分派每个页面的业务分支 |
如果两个页面只有少量重复的认证和日志,可以先抽出中间件或前端控制器的横切层;如果它们的请求字段、视图模型和失败导航不同,则应保留两个页面控制器。迁移的触发器不是“类变多”,而是同一横切策略无法一致执行,或入口分派已经成为不可测试的业务树。
练习、答案与节点验证
练习
问题 1:边界推理。 订单编辑页提交非法数量时,为什么应由页面控制器返回字段错误,而不是让模板或领域对象读取原始表单?
问题 2:改 Demo 代码。 修改上面的 post 方法,使 updateQuantity 失败时回到编辑页并保留错误,而不是把异常直接变成 500 响应;同时保持控制器不包含库存规则。
问题 3:场景选型。 详情页、编辑页和删除页都重复认证、日志和错误映射,但输入与视图仍各不相同;此时应如何选择页面控制器与前端控制器的组合?
术语复核与本章小结
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 页面控制器
面向一个页面或动作的请求入口,负责把输入交给用例,再选择返回什么。
- 请求契约
一份可检查的输入约定,例如路径中必须有订单编号、数量必须是正整数。
- 应用服务
协调一个用例的入口;它可以调用领域对象和事务,但不负责 HTML 排版。
- 视图模型
为某个页面准备的数据形状,只带渲染所需字段,不把数据库实体原样暴露给模板。
- 导航状态
成功、校验失败或重定向后页面要去哪里,以及用户能看到什么结果。
- 前端控制器
一个站点级入口,适合集中稳定的认证、日志和错误映射,再分派到页面或动作。
掌握 14.2 页面控制器,意味着你能从请求契约出发画出责任链,能用代码让两个页面共享应用服务而不共享输入边界,也能在重复逻辑出现时区分“抽出横切策略”和“制造巨型入口”。观察到模板查库或控制器拥有领域规则时,应先拒绝该实现,再用可重放的错误响应证明修法有效。