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 换成数据库实体,哪一项输入契约会被破坏?按顺序操作下面三步,并在每一步写下“看到的状态—责任所有者—可推翻证据”。

Page Controller:页面边界负责编排,不拥有业务规则HTTP 请求GET /orders/42匹配路由OrderPageControllervalidate(input)choose(response)只编排,不计算业务规则调用用例OrderApplicationServicedetail(id) / update(id, input)产出OrderDetailViewModel只携带页面需要的字段选择视图order-detail.html模板只负责渲染与转义HTTP 200 / 303 / 400HTTP 响应结果可测试、可定位边界规则控制器传递已验证输入;应用服务拥有用例;视图只接收视图模型重复认证、日志和错误映射可上移到前端控制器,但页面契约仍留在页面控制器。请求进入页面边界 → 调用应用服务 → 选择视图模型与响应

当前:请求进入页面边界

第 1 / 3 步 · 请求进入页面边界

按步骤检查责任归属;最后可重置并重放同一请求。

页面控制器把请求契约、应用服务和视图选择串成一条可测试的责任链; 共享的是用例,不是页面的输入与导航边界。
分步1 / 3

1. 锁定页面边界

观察 URL、请求字段和页面控制器的输入契约。确认 /orders/:id 的缺失参数在控制器内被拒绝,而不是让模板或数据库猜测。

Page Controller:页面边界负责编排,不拥有业务规则HTTP 请求GET /orders/42匹配路由OrderPageControllervalidate(input)choose(response)只编排,不计算业务规则调用用例OrderApplicationServicedetail(id) / update(id, input)产出OrderDetailViewModel只携带页面需要的字段选择视图order-detail.html模板只负责渲染与转义HTTP 200 / 303 / 400HTTP 响应结果可测试、可定位边界规则控制器传递已验证输入;应用服务拥有用例;视图只接收视图模型重复认证、日志和错误映射可上移到前端控制器,但页面契约仍留在页面控制器。请求进入页面边界 → 调用应用服务 → 选择视图模型与响应
页面控制器把请求契约、应用服务和视图选择串成一条可测试的责任链; 共享的是用例,不是页面的输入与导航边界。

选择、拒绝与迁移信号

评审问题保留页面控制器的证据应拒绝或迁移的信号
输入每个页面拥有清晰的请求契约,坏输入在入口处结束多个页面被迫接受同一个含糊参数对象
业务控制器调用应用服务,不直接作领域决定控制器包含价格、库存或事务规则
输出视图只接收视图模型,导航结果可测试模板重新读取请求、数据库或权限上下文
横切公共认证与日志可由前端控制器统一中央入口开始分派每个页面的业务分支

如果两个页面只有少量重复的认证和日志,可以先抽出中间件或前端控制器的横切层;如果它们的请求字段、视图模型和失败导航不同,则应保留两个页面控制器。迁移的触发器不是“类变多”,而是同一横切策略无法一致执行,或入口分派已经成为不可测试的业务树。

练习、答案与节点验证

练习

问题 1:边界推理。 订单编辑页提交非法数量时,为什么应由页面控制器返回字段错误,而不是让模板或领域对象读取原始表单?

问题 2:改 Demo 代码。 修改上面的 post 方法,使 updateQuantity 失败时回到编辑页并保留错误,而不是把异常直接变成 500 响应;同时保持控制器不包含库存规则。

问题 3:场景选型。 详情页、编辑页和删除页都重复认证、日志和错误映射,但输入与视图仍各不相同;此时应如何选择页面控制器与前端控制器的组合?

术语复核与本章小结

名词解释

本章出现的专业名词,用大白话再讲一遍。

页面控制器

面向一个页面或动作的请求入口,负责把输入交给用例,再选择返回什么。

请求契约

一份可检查的输入约定,例如路径中必须有订单编号、数量必须是正整数。

应用服务

协调一个用例的入口;它可以调用领域对象和事务,但不负责 HTML 排版。

视图模型

为某个页面准备的数据形状,只带渲染所需字段,不把数据库实体原样暴露给模板。

导航状态

成功、校验失败或重定向后页面要去哪里,以及用户能看到什么结果。

前端控制器

一个站点级入口,适合集中稳定的认证、日志和错误映射,再分派到页面或动作。

掌握 14.2 页面控制器,意味着你能从请求契约出发画出责任链,能用代码让两个页面共享应用服务而不共享输入边界,也能在重复逻辑出现时区分“抽出横切策略”和“制造巨型入口”。观察到模板查库或控制器拥有领域规则时,应先拒绝该实现,再用可重放的错误响应证明修法有效。

阅读导航

资料与写作方式声明

本章以Martin Fowler,《Patterns of Enterprise Application Architecture》权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…