14.7 应用控制器

把跨页面导航和应用流程集中成可测试的状态与事件决策,再把具体动作交给命令对象。

学习目标

  • 能解释应用控制器如何集中屏幕导航与应用流程,并说清它与页面控制器、领域规则的边界
  • 能修改一个 TypeScript 流程控制器,让状态、事件、命令和视图选择可以被单元测试
  • 能回答:一次非法跳转出现时,哪条状态证据应被拒绝,什么时候应该撤回应用控制器

先建立直觉:把散落的去向收回一张流程卡

想象订单后台有三个窗口:选商品、填地址、确认支付。用户每做完一步,前台都要判断下一步去哪儿;如果每个窗口各自猜一次,窗口之间很快会出现互相矛盾的去向。

这章解决的是“谁决定下一屏,以及这次决定依据什么”的问题。没有一个共同的流程记录时,返回按钮、刷新页面和失败重试都可能把用户送到不该到的地方;有了它,流程决定可以集中记录、测试和审查。

代价也很具体:多了一层流程协调代码。若所有页面都只是一次独立请求,这层代码反而会增加绕路;只有当多个页面共享一条有条件的流程时,集中决策才值得承担。

14.7 应用控制器的边界

位于输入页面和具体业务动作之间。它回答的是“当前上下文收到这个事件后,下一步允许做什么”,而不是“订单领域是否允许某个状态转换”。这正是它与前端控制器的差别:前端控制器集中所有请求的共同入口,应用控制器集中一条应用流程的去向。

它最适合向导式交互,也适合“前一步输入决定后续屏幕”的流程。页面控制器只需要报告用户事件并请求下一步;应用控制器依据上下文挑选命令和视图;命令或应用服务再执行具体工作。这样,页面数量增长时,导航规则不必复制到每个页面处理函数里。

在运行链上可以这样定位它:输入页面 → 应用控制器 → 命令/应用服务 → 结果 → 应用控制器 → 下一视图。控制器拥有的是流程上下文和导航决定,不是数据库连接、订单折扣或领域对象的全部生命周期。

用状态和事件写出流程证据

是流程的当前位置,例如 addressconfirm;它不等于订单领域状态,也不应该偷偷复制领域状态机。 是用户动作或应用结果,例如 submitAddresspaymentAccepted

把状态和事件分开,测试就能直接回答:在 address 收到 submitAddress 会产生什么?在 done 再收到同一事件应该拒绝什么?如果答案只能从几个页面处理函数的副作用里推测,流程边界还没有被写出来。

命令执行,视图只负责呈现

承接一次具体动作。 只返回 AddressViewConfirmViewDoneView 这样的结果,让真正的页面组件负责展示。

与之相邻的是 。它仍然可以接收 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,应用控制器应该允许直接显示确认页吗?先在心里写下“允许”或“拒绝”,再用主图逐步播放检查理由。

专属流程图 · 事件进入
Application Controller:事件 → 状态决策 → 下一屏页面报告事实,流程层选择命令和视图,业务服务保留领域规则页面控制器当前页面:AddressViewsubmitAddress只报告用户事件不决定下一屏不执行支付命令Application Controller读取应用状态 + 事件state: addressevent: submitAddress输出:command + next view不查询订单表 · 不计算支付金额非法事件 → Rejected(reason)流程证据集中在这里决策结果commandSaveAddressnext viewConfirmView命令执行副作用视图只负责呈现发送事件只在允许的边上推进执行动作 / 返回下一屏绕过 Application Controller:没有合法状态边当前步骤:事件进入 · 地址页只报告 submitAddress;它不应该直接打开确认页。状态 + 事件 → 命令 / 视图

第 1 / 4 步 · 页面只发送事件,不预先决定下一屏

逐步检查页面、流程协调层和领域动作的责任边界。

应用控制器把跨页面流程写成可测试的状态与事件决策;故障开关展示页面绕过这条边界时为什么必须拒绝。

主图支持播放、暂停、单步和拖动进度。点“注入非法跳转”后,红色路径会让页面控制器绕过应用控制器直接进入确认页;这不是更快的导航,而是丢失了状态证据。点“重置图示”后,再回到正常流程重新观察。

分步1 / 4

1. 页面只报告事件

购物页或地址页只报告用户动作;它不预先决定下一屏,也不直接调用支付命令。

专属流程图 · 事件进入
Application Controller:事件 → 状态决策 → 下一屏页面报告事实,流程层选择命令和视图,业务服务保留领域规则页面控制器当前页面:AddressViewsubmitAddress只报告用户事件不决定下一屏不执行支付命令Application Controller读取应用状态 + 事件state: addressevent: submitAddress输出:command + next view不查询订单表 · 不计算支付金额非法事件 → Rejected(reason)流程证据集中在这里决策结果commandSaveAddressnext viewConfirmView命令执行副作用视图只负责呈现发送事件只在允许的边上推进执行动作 / 返回下一屏绕过 Application Controller:没有合法状态边当前步骤:事件进入 · 地址页只报告 submitAddress;它不应该直接打开确认页。状态 + 事件 → 命令 / 视图
应用控制器把跨页面流程写成可测试的状态与事件决策;故障开关展示页面绕过这条边界时为什么必须拒绝。

代码实践:把非法跳转变成测试

一个最小单元测试不需要渲染四个页面,只需给决策函数不同的状态和事件。合法路径应产生预期的下一状态;非法路径应产生可识别的拒绝结果,不能偷偷回到某个默认页面。

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,也不应该包含业务计算。

页面控制器

负责接收某个页面请求并编排该页面响应的对象;简单页面可以只靠它,不必升级成全局流程协调层。

前后导航

来源与改写范围

资料与写作方式声明

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

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

讨论

评论区加载中…