14.7 应用控制器
把跨页面导航和应用流程集中成可测试的状态与事件决策,再把具体动作交给命令对象。
学习目标
- 能解释应用控制器如何集中屏幕导航与应用流程,并说清它与页面控制器、领域规则的边界
- 能修改一个 TypeScript 流程控制器,让状态、事件、命令和视图选择可以被单元测试
- 能回答:一次非法跳转出现时,哪条状态证据应被拒绝,什么时候应该撤回应用控制器
先建立直觉:把散落的去向收回一张流程卡
想象订单后台有三个窗口:选商品、填地址、确认支付。用户每做完一步,前台都要判断下一步去哪儿;如果每个窗口各自猜一次,窗口之间很快会出现互相矛盾的去向。
这章解决的是“谁决定下一屏,以及这次决定依据什么”的问题。没有一个共同的流程记录时,返回按钮、刷新页面和失败重试都可能把用户送到不该到的地方;有了它,流程决定可以集中记录、测试和审查。
代价也很具体:多了一层流程协调代码。若所有页面都只是一次独立请求,这层代码反而会增加绕路;只有当多个页面共享一条有条件的流程时,集中决策才值得承担。
14.7 应用控制器的边界
↡集中决定屏幕导航和应用流程,并按当前上下文返回下一条命令与要显示的视图的协调对象。 位于输入页面和具体业务动作之间。它回答的是“当前上下文收到这个事件后,下一步允许做什么”,而不是“订单领域是否允许某个状态转换”。这正是它与前端控制器的差别:前端控制器集中所有请求的共同入口,应用控制器集中一条应用流程的去向。
它最适合向导式交互,也适合“前一步输入决定后续屏幕”的流程。页面控制器只需要报告用户事件并请求下一步;应用控制器依据上下文挑选命令和视图;命令或应用服务再执行具体工作。这样,页面数量增长时,导航规则不必复制到每个页面处理函数里。
在运行链上可以这样定位它:输入页面 → 应用控制器 → 命令/应用服务 → 结果 → 应用控制器 → 下一视图。控制器拥有的是流程上下文和导航决定,不是数据库连接、订单折扣或领域对象的全部生命周期。
用状态和事件写出流程证据
↡描述用户当前处在哪个应用步骤以及该步骤附带输入的、可被测试的流程上下文。 是流程的当前位置,例如 address 或 confirm;它不等于订单领域状态,也不应该偷偷复制领域状态机。↡让流程从当前状态向下一状态推进的外部事实,例如提交地址或确认订单。 是用户动作或应用结果,例如 submitAddress、paymentAccepted。
把状态和事件分开,测试就能直接回答:在 address 收到 submitAddress 会产生什么?在 done 再收到同一事件应该拒绝什么?如果答案只能从几个页面处理函数的副作用里推测,流程边界还没有被写出来。
命令执行,视图只负责呈现
↡封装一次流程动作的可调用对象,例如保存地址或创建支付请求,并把结果交回协调层。 承接一次具体动作。↡根据流程状态和命令结果选择下一屏,而不直接渲染页面内容的决定。 只返回 AddressView、ConfirmView 或 DoneView 这样的结果,让真正的页面组件负责展示。
与之相邻的是 ↡为一个具体页面或动作接收请求并编排该页面响应的控制器。。它仍然可以接收 HTTP 请求,但不应在每个页面里重复整条向导。页面控制器报告事件,应用控制器决定下一步,命令对象执行业务动作;三者的职责拆开后,非法跳转才有明确的拒绝点。
专属设计案例:订单后台多视图
本章用一个三步订单流程承载代码练习:cart 选择商品,address 填写配送信息,confirm 确认支付,成功后进入 done。我们只把页面导航放进应用控制器;订单金额、库存和支付授权仍由应用服务或领域模型负责。
先写一张可以被测试的流程表。每一行只回答“当前状态遇到事件后,返回哪个命令和视图”;不要在这张表里查询数据库,也不要把支付规则塞进导航条件。
type FlowState = "cart" | "address" | "confirm" | "done";
type FlowEvent =
| { type: "chooseItems" }
| { type: "submitAddress" }
| { type: "paymentAccepted" }
| { type: "back" };
type FlowCommand = "SaveAddress" | "CreatePayment" | "Noop";
type FlowView = "CartView" | "AddressView" | "ConfirmView" | "DoneView";
type Decision = {
state: FlowState;
command: FlowCommand;
view: FlowView;
};这些类型把可观察的输出固定下来:测试不需要启动浏览器,就能断言某个事件是否产生了正确的命令和下一屏。Noop 不是成功的业务动作,而是一个明确的“保持或拒绝”信号,实际项目可以把它替换成带原因的 Rejected 结果。
让决策保持纯粹
实现时,把流程决策写成纯函数或无副作用的方法。它可以读取当前状态和事件,但不应在选择视图的同时修改订单、写数据库或发送支付请求。
export function decide(state: FlowState, event: FlowEvent): Decision {
if (state === "cart" && event.type === "chooseItems") {
return { state: "address", command: "Noop", view: "AddressView" };
}
if (state === "address" && event.type === "submitAddress") {
return { state: "confirm", command: "SaveAddress", view: "ConfirmView" };
}
if (state === "confirm" && event.type === "paymentAccepted") {
return { state: "done", command: "CreatePayment", view: "DoneView" };
}
if (event.type === "back" && state !== "cart") {
return { state: "cart", command: "Noop", view: "CartView" };
}
return { state, command: "Noop", view: "CartView" };
}这里故意保留一个可改进点:非法事件目前回到 CartView,真实代码应返回 Rejected 并保留原因,避免把错误伪装成首页。这个缺口会在下方主图的故障开关里显现出来。
把副作用留给命令边界
应用控制器可以调用一个命令执行器,但它只负责按 Decision.command 选择执行者。执行器返回成功或失败事件,再交回应用控制器进行下一次决策;这样网络失败、支付拒绝和用户返回都能成为可测试的输入,而不是散落在页面回调中的特殊分支。
export function handle(
state: FlowState,
event: FlowEvent,
run: (command: FlowCommand) => FlowEvent | null,
): Decision {
const decision = decide(state, event);
if (decision.command === "Noop") return decision;
const result = run(decision.command);
return result ? decide(decision.state, result) : decision;
}这段代码的关键不在函数名,而在依赖方向:输入页面只发送事件,应用控制器只作流程决定,命令执行器才拥有副作用。若 handle 开始读取订单表或拼接 HTML,就应该把新增工作移回应用服务或视图层。
四步交互实验:观察一次导航和一次非法跳转
猜一猜:把当前应用状态从 address 改成 confirm,但不发送 submitAddress,应用控制器应该允许直接显示确认页吗?先在心里写下“允许”或“拒绝”,再用主图逐步播放检查理由。
第 1 / 4 步 · 页面只发送事件,不预先决定下一屏
逐步检查页面、流程协调层和领域动作的责任边界。
主图支持播放、暂停、单步和拖动进度。点“注入非法跳转”后,红色路径会让页面控制器绕过应用控制器直接进入确认页;这不是更快的导航,而是丢失了状态证据。点“重置图示”后,再回到正常流程重新观察。
1. 页面只报告事件
购物页或地址页只报告用户动作;它不预先决定下一屏,也不直接调用支付命令。
代码实践:把非法跳转变成测试
一个最小单元测试不需要渲染四个页面,只需给决策函数不同的状态和事件。合法路径应产生预期的下一状态;非法路径应产生可识别的拒绝结果,不能偷偷回到某个默认页面。
const address = decide("address", { type: "submitAddress" });
expect(address.state).toBe("confirm");
expect(address.command).toBe("SaveAddress");
const skipped = decide("address", { type: "paymentAccepted" });
expect(skipped.command).toBe("Rejected");
expect(skipped.reason).toBe("payment requires confirmation");练习中的 Rejected 需要你把 Decision 扩展为联合类型。重要的是测试表达了流程契约:应用控制器允许哪些边,拒绝哪些边;页面组件只消费通过检查的视图选择。
选择与拒绝矩阵
| 评审问题 | 采用应用控制器的证据 | 应拒绝或撤回的信号 |
|---|---|---|
| 流程 | 多个屏幕共享有条件的导航,状态与事件可列举 | 每个页面都是一次独立请求,没有共享流程 |
| 责任 | 控制器只决定命令和视图,领域规则在应用服务 | 控制器直接改订单、算金额或维护领域状态机 |
| 失败 | 非法边返回可断言的拒绝结果,失败可再次决策 | 所有错误都被改写成默认首页,原因消失 |
| 替代 | 页面控制器仍保留简单页面的局部编排 | 团队只是因为框架提供全局对象就强行集中 |
常见误区
本章小结
- 应用控制器集中跨页面导航与应用流程的决定。
- 应用状态记录流程位置,事件描述请求推进的事实。
- 命令对象承接副作用,视图选择只表达下一屏。
- 非法跳转应返回可测试的拒绝,而不是默认页面。
- 只有共享流程足够复杂时,新增的协调层才值得保留。
练习
练习
问题 1:改 Demo 代码。 把 decide 扩展为 Decision 联合类型,使非法事件返回 { state, command: "Rejected", reason },并让主图中的“非法跳转”对应这条结果。需要改哪些类型和分支?
问题 2:写一条边界测试。 给出一个测试,证明 address + paymentAccepted 不能直接进入 done,同时给出一个合法的 address + submitAddress 断言。
问题 3:做一次方案选择。 一个页面只有一次查询和一次响应,没有后续屏幕,也没有跨请求条件。你会保留应用控制器吗?如果不保留,应把编排放在哪里?
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 应用控制器
一张专门管理“下一步去哪儿”的流程卡;它选择命令和页面,但不替业务对象做所有决定。
- 应用状态
用户当前处在流程哪一步以及该步输入的记录,例如正在填地址还是准备确认。
- 事件
让流程发生变化的事实,例如用户提交地址或支付服务返回成功。
- 命令对象
代表一次具体动作的对象,例如保存地址;它可以调用应用服务,但不负责决定下一屏。
- 视图选择
一个说明“接下来展示哪一屏”的结果,不是页面 HTML,也不应该包含业务计算。
- 页面控制器
负责接收某个页面请求并编排该页面响应的对象;简单页面可以只靠它,不必升级成全局流程协调层。
前后导航
来源与改写范围
- Martin Fowler:Application Controller 模式摘要:核对集中处理屏幕导航、应用流程,以及输入控制器向其请求命令和视图的事实边界。
- Martin Fowler:Patterns of Enterprise Application Architecture:核对第14章 Web Presentation Patterns 的主题范围。
- Martin Fowler:模式目录:核对 14.7 应用控制器在模式族中的位置。
- Pearson:Patterns of Enterprise Application Architecture:交叉核对出版信息。