2. 先看一个具体的痛点:条件判断是工作流里最容易“烂尾”的部分
如果说工作流持久化关心的是状态机怎么推进,那么条件工作流关心的就是状态之间怎么选择路径。
很多团队在最初几周里把工作流的节点、状态、事件都设计得清清楚楚,到了条件分支就开始放飞自我:
if ("approved".equals(task.getResult()) && task.getAmount() > 10000) { workflow.moveTo("financeReview"); } else if ("rejected".equals(task.getResult())) { workflow.moveTo("end"); }这段代码在业务量小的时候完全没问题。但随着规则增多,你会看到:
- 条件散落在各 Service 里,没有一个统一入口;
- 条件写成了字符串常量,甚至直接写死
"approved"; - 改成枚举后,发现有一处
if没改,equals永远返回 false,线上流程静默走到了错误分支; - 测试的时候只能靠人肉构造各种状态组合,漏掉一个分支就出事故。
这就是典型的无类型条件写法——条件判断的逻辑是对的,但条件本身没有被类型系统约束起来。字符串、数字、布尔值散落在代码各处,编译器无法帮你检查,重构的时候全靠肉眼。
而今天我们讨论的“条件工作流的有类型写法”,就是把条件本身建模成类型。它解决的问题,不是“条件表达式怎么写更短”,而是:
- 让编译器帮你检查分支是否完备;
- 让条件从“散落的 if 字符串”变成“可以集中定义、复用、测试的数据结构”;
- 让业务规则的变更落到有限几个类型上,而不是满代码库找字符串。
这篇文章会从一个订单审批流程的实际例子出发,分别用 TypeScript 和 Java 展示两种有类型写法,并给出完整代码、运行演示和工程建议。读完你可以直接把这些思路落到自己的项目里,不需要引入任何重量级框架。
3. 无类型条件写法到底差在哪里
在进入有类型写法之前,先把无类型写法的三个主要问题讲透。很多团队不是不想改,而是没意识到这些问题有多隐蔽。
3.1 问题一:条件逻辑散落,不可复用
假设同一个业务里有三种流程:订单审核、退款审核、工单派发。它们都依赖相同的判断条件:当前金额是否超过某阈值、当前角色是否有审批权。
无类型写法的典型结果是这样:
// 订单审核服务 if (order.amount > 10000 && currentUser.hasRole("FINANCE")) { ... } // 退款审核服务 if (refund.amount > 10000 && currentUser.hasRole("FINANCE")) { ... } // 工单派发服务 if (ticket.priority >= HIGH && currentUser.hasRole("OPS")) { ... }金额阈值、角色名、比较逻辑全部散落在不同 Service 中。当你把阈值从 10000 改成 5000 的时候,得全局搜索10000,找出所有相关位置,然后祈祷没有遗漏。
3.2 问题二:字符串比较导致编译期失明
无类型写法的核心问题在于:条件所依赖的判断标准是字符串或魔法数字,而这些值在编译期没有任何检查。
"approved"拼成"approve",编译器不会报错;"FINANCE"改成"FINANCE_DEPT",所有写死的旧字符串不会自动更新;if (task.status == 3)里的3是什么状态,半年后没人记得。
这些错误只能在运行时暴露,甚至在特定组合下才触发,排错成本非常高。
3.3 问题三:分支不可穷尽
工作流里最容易出事的不是主路径,而是“未穷尽”的分支。一个if-else if-else结构,开发者很可能只处理了正常业务能遇到的情况,却漏掉了某些边界组合。
无类型写法下,分支穷尽性完全靠人的自觉。有人觉得反正会有默认 else,不写也行——结果线上流程走到某个从未设想的组合时,默认分支把数据推到了错误状态,最后要靠数据库工单来补。
这三个问题叠加起来,就是很多人说的“工作流代码跑着跑着就成了一团乱麻”。它和技术水平关系不大,而是模型选错了——你用字符串和 if 去表达本来应该用类型系统去表达的东西。
4. 有类型写法的核心思想
有类型写法并不是一种特定框架,而是一套设计思路。它的本质是:把“条件”当作一等公民,用类型系统来约束条件的定义、组合和判断。
这里先建立几个基础概念,后面才能在代码里看清楚。
4.1 什么是“条件即数据”
无类型写法里,条件和业务逻辑混在一起,判断的过程就是执行代码的过程。
有类型写法的第一步,是把一个条件表示成一份可结构化的数据。例如:
type Condition = | { kind: "amount"; operator: "gt" | "lt"; value: number } | { kind: "role"; role: "FINANCE" | "OPS" | "ADMIN" } | { kind: "and"; left: Condition; right: Condition };这里Condition就是一个类型。它描述了一个条件“长什么样”,而不是直接去判断真假。真正判断的时候,可以写一个解释器来执行它:
function evaluate(cond: Condition, ctx: Context): boolean { switch (cond.kind) { case "amount": return cond.operator === "gt" ? ctx.amount > cond.value : ctx.amount < cond.value; case "role": return ctx.roles.includes(cond.role); case "and": return evaluate(cond.left, ctx) && evaluate(cond.right, ctx); } }这样的好处是:
- 条件可以被序列化成 JSON,存入数据库或配置文件;
- 条件可以被集中管理、测试、日志输出;
- 条件的结构在编译期就是确定的,不容易拼错。
4.2 什么是“分支即类型”
比“条件即数据”更进一步,是用**判别联合(Discriminated Union)或密封接口(Sealed Interface)**来表达一条流程的所有可能分支。
无类型写法里,流程走向是一个隐式概念,靠 if 的走向体现。有类型写法里,流程走向是一个显式类型:
type OrderFlowStep = | { kind: "start" } | { kind: "autoApprove" } | { kind: "needManagerReview"; reason: string } | { kind: "needFinanceReview"; amount: number } | { kind: "done"; result: "approved" | "rejected" };拿到这样的类型后:
- 编译器可以帮你检查:
switch分支是否覆盖了所有可能的流程节点; - 你不能再写一个没有定义的流程节点;
- 每个节点自带数据,不再靠外部
Map<String, Object>传参。
4.3 有类型写法的三个层次
| 层次 | 做法 | 解决的问题 |
|---|---|---|
| 第一层 | 用枚举替代字符串 | 拼写错误、散落常量 |
| 第二层 | 用代数数据类型(联合类型/密封接口)建模条件和分支 | 分支不可穷尽、状态无结构 |
| 第三层 | 用解释器模式统一执行,用类型守卫收敛判断逻辑 | 条件不可测试、逻辑散落 |
大部分团队能做好第一层就够了,但遇到复杂流程,第二层和第三层带来的收益会非常明显。
4.4 与规则引擎的关系
有些读者会问:这不就是规则引擎吗?确实,Condition建模成数据之后,和轻量规则引擎的思路非常接近。区别在于:
- 规则引擎(如 Drools)一般是一套完整的 DSL 和配套运行时,适合团队有专职规则维护者;
- 有类型写法不需要额外工具链,直接利用语言自带的类型系统,适合大部分业务代码场景。
如果你只是因为几个if太乱而想引入规则引擎,其实有点大材小用。先尝试把条件建模成类型,往往就够了。
5. 环境准备与前置条件
后面的示例代码可以在您本机的常规开发环境里直接运行。为了不限制版本,这里只说明我演示时用的基础环境,实际版本以您的项目为准。
| 环境项 | 说明 |
|---|---|
| TypeScript 示例 | Node.js + TypeScript 编译器,不需要额外框架 |
| Java 示例 | JDK 17 及以上(因为用到 sealed interface),不需要 Spring 或其他框架 |
| 操作系统 | Windows / macOS / Linux 均可 |
| 运行方式 | 命令行直接执行,或通过 IDE 运行 main 方法 |
TypeScript 的安装和初始化:
npm install -g typescript mkdir typed-workflow cd typed-workflow npm init -y npm install typescript ts-node --save-dev npx tsc --init --target es2020 --strictJava 示例不需要额外的依赖管理,直接写一个.java文件用java命令运行即可。
6. 核心流程拆解
这部分是整篇文章的主干。我们以一个通用场景为例:订单审批流程。
流程逻辑如下:
- 订单金额小于 1000:自动通过,走
autoApprove; - 金额在 1000 到 10000 之间:需要部门经理审批,走
managerReview; - 金额大于 10000:需要部门经理和财务双重审批,走
financeReview; - 审批结果为拒绝:直接结束,走
done。
这个流程足够简单,但又能体现条件分支、多步流转、类型建模三个要素。
6.1 第一步:定义流程节点类型
流程节点是整个工作流的核心数据类型。在 TypeScript 里用判别联合定义:
export type OrderStatus = | { kind: "CREATED" } | { kind: "AUTO_APPROVED" } | { kind: "MANAGER_REVIEW"; manager: string } | { kind: "FINANCE_REVIEW"; manager: string; amount: number } | { kind: "DONE"; result: "APPROVED" | "REJECTED" };每一个节点带了自己的数据。比如FINANCE_REVIEW节点记录了审批经理是谁、金额是多少,这让每一步流转都能自解释。
6.2 第二步:定义条件和判断函数
条件本身也是一个类型。这里用Condition来表达“金额大于某值”和“角色匹配”:
export type Condition = | { kind: "amountGt"; value: number } | { kind: "amountLt"; value: number } | { kind: "hasRole"; role: string } | { kind: "and"; left: Condition; right: Condition } | { kind: "or"; left: Condition; right: Condition }; export function evaluateCondition(cond: Condition, ctx: OrderContext): boolean { switch (cond.kind) { case "amountGt": return ctx.amount > cond.value; case "amountLt": return ctx.amount < cond.value; case "hasRole": return ctx.userRoles.includes(cond.role); case "and": return evaluateCondition(cond.left, ctx) && evaluateCondition(cond.right, ctx); case "or": return evaluateCondition(cond.left, ctx) || evaluateCondition(cond.right, ctx); default: // 使用了穷尽性检查 const _exhaustive: never = cond; throw new Error(`Unknown condition kind: ${JSON.stringify(_exhaustive)}`); } }注意最后的never分支。TypeScript 会在这里做穷尽性检查:如果Condition将来新增了一种类型,而switch没有处理,编译阶段就会报错,而不是运行到线上才出问题。
6.3 第三步:定义流转函数
流转函数接收当前状态和上下文,返回下一个状态。这里就能体现“类型驱动流转”的价值:
export interface OrderContext { orderId: string; amount: number; userRoles: string[]; } export function transit(status: OrderStatus, ctx: OrderContext): OrderStatus { switch (status.kind) { case "CREATED": if (ctx.amount < 1000) { return { kind: "AUTO_APPROVED" }; } if (ctx.amount >= 1000 && ctx.amount <= 10000) { if (ctx.userRoles.includes("MANAGER")) { return { kind: "MANAGER_REVIEW", manager: "managerA" }; } return { kind: "DONE", result: "REJECTED" }; } if (ctx.amount > 10000) { if (ctx.userRoles.includes("MANAGER") && ctx.userRoles.includes("FINANCE")) { return { kind: "FINANCE_REVIEW", manager: "managerA", amount: ctx.amount }; } return { kind: "DONE", result: "REJECTED" }; } return { kind: "DONE", result: "REJECTED" }; case "MANAGER_REVIEW": return { kind: "DONE", result: "APPROVED" }; case "FINANCE_REVIEW": return { kind: "DONE", result: "APPROVED" }; case "AUTO_APPROVED": case "DONE": return status; } }这里返回的永远是OrderStatus类型里定义的节点,不允许返回一个不存在的状态。
6.4 第四步:驱动整个流程
有了transit函数,就可以循环直到流程结束:
function runWorkflow(start: OrderStatus, ctx: OrderContext): OrderStatus { let current = start; let guard = 0; while (current.kind !== "DONE" && guard < 10) { current = transit(current, ctx); guard++; } return current; }guard防止死循环,这在演示代码里是必要的保护。生产环境建议用有向图或者最大步数限制,后面最佳实践会展开讲。
到此为止,一个最简的“有类型条件工作流”已经完整了。它没有引入任何框架,只靠 TypeScript 的类型系统就完成了:
- 流程节点类型化;
- 条件类型化;
- 分支穷尽性编译期检查;
- 流转逻辑集中收敛。
7. 完整示例与代码实现
上面的拆解是分步骤的,这一节直接给出一个可以复制运行的完整示例。先给 TypeScript 版本,再给 Java 版本。
7.1 TypeScript 完整示例
创建一个src/workflow.ts:
// 文件路径:src/workflow.ts export type OrderStatus = | { kind: "CREATED" } | { kind: "AUTO_APPROVED" } | { kind: "MANAGER_REVIEW"; manager: string } | { kind: "FINANCE_REVIEW"; manager: string; amount: number } | { kind: "DONE"; result: "APPROVED" | "REJECTED" }; export type Condition = | { kind: "amountGt"; value: number } | { kind: "amountLt"; value: number } | { kind: "hasRole"; role: string } | { kind: "and"; left: Condition; right: Condition } | { kind: "or"; left: Condition; right: Condition }; export interface OrderContext { orderId: string; amount: number; userRoles: string[]; } export function evaluateCondition(cond: Condition, ctx: OrderContext): boolean { switch (cond.kind) { case "amountGt": return ctx.amount > cond.value; case "amountLt": return ctx.amount < cond.value; case "hasRole": return ctx.userRoles.includes(cond.role); case "and": return evaluateCondition(cond.left, ctx) && evaluateCondition(cond.right, ctx); case "or": return evaluateCondition(cond.left, ctx) || evaluateCondition(cond.right, ctx); default: { const _exhaustive: never = cond; throw new Error(`Unhandled condition kind: ${JSON.stringify(_exhaustive)}`); } } } export function decideNextStatus(status: OrderStatus, ctx: OrderContext): OrderStatus { switch (status.kind) { case "CREATED": { if (ctx.amount < 1000) { return { kind: "AUTO_APPROVED" }; } if (ctx.amount <= 10000) { if (ctx.userRoles.includes("MANAGER")) { return { kind: "MANAGER_REVIEW", manager: "managerA" }; } return { kind: "DONE", result: "REJECTED" }; } if (ctx.userRoles.includes("MANAGER") && ctx.userRoles.includes("FINANCE")) { return { kind: "FINANCE_REVIEW", manager: "managerA", amount: ctx.amount }; } return { kind: "DONE", result: "REJECTED" }; } case "MANAGER_REVIEW": return { kind: "DONE", result: "APPROVED" }; case "FINANCE_REVIEW": return { kind: "DONE", result: "APPROVED" }; case "AUTO_APPROVED": case "DONE": return status; } } export function runWorkflow(start: OrderStatus, ctx: OrderContext): OrderStatus { let current = start; let guard = 0; while (current.kind !== "DONE" && guard < 10) { console.log(`step=${guard + 1}, current=${current.kind}`); current = decideNextStatus(current, ctx); guard++; } return current; }创建src/main.ts:
// 文件路径:src/main.ts import { OrderStatus, OrderContext, runWorkflow } from "./workflow"; const cases: Array<{ name: string; ctx: OrderContext }> = [ { name: "小额订单,自动通过", ctx: { orderId: "A001", amount: 500, userRoles: ["USER"] }, }, { name: "普通金额订单,有经理审批权", ctx: { orderId: "A002", amount: 5000, userRoles: ["USER", "MANAGER"] }, }, { name: "大额订单,缺少财务审批权,拒绝", ctx: { orderId: "A003", amount: 50000, userRoles: ["USER", "MANAGER"] }, }, { name: "大额订单,有经理和财务审批权,进入财务审批", ctx: { orderId: "A004", amount: 50000, userRoles: ["USER", "MANAGER", "FINANCE"] }, }, ]; const start: OrderStatus = { kind: "CREATED" }; for (const c of cases) { console.log(`\n=== ${c.name} ===`); const result = runWorkflow(start, c.ctx); console.log("final status:", JSON.stringify(result)); }运行:
npx ts-node src/main.ts预期输出:
=== 小额订单,自动通过 === step=1, current=CREATED final status: {"kind":"AUTO_APPROVED"} === 普通金额订单,有经理审批权 === step=1, current=CREATED final status: {"kind":"MANAGER_REVIEW","manager":"managerA"} === 大额订单,缺少财务审批权,拒绝 === step=1, current=CREATED final status: {"kind":"DONE","result":"REJECTED"} === 大额订单,有经理和财务审批权,进入财务审批 === step=1, current=CREATED final status: {"kind":"FINANCE_REVIEW","manager":"managerA","amount":50000}你会发现这个演示没有处理MANAGER_REVIEW和FINANCE_REVIEW到DONE的完整过程。这是因为runWorkflow默认从CREATED开始连续流转;如果您需要模拟审批人点击“通过”按钮,只需要调用decideNextStatus并传入当前状态即可:
const status1: OrderStatus = { kind: "MANAGER_REVIEW", manager: "managerA" }; const ctx: OrderContext = { orderId: "B001", amount: 5000, userRoles: ["USER", "MANAGER"] }; const next = decideNextStatus(status1, ctx); console.log(JSON.stringify(next)); // {"kind":"DONE","result":"APPROVED"}7.2 Java 完整示例(基于 sealed interface)
Java 17 引入了密封接口(sealed interface),它非常适合表达“节点类型有限”的工作流模型。下面的代码不需要任何框架,直接编译运行。
创建一个Workflow.java:
// 文件路径:Workflow.java import java.util.Set; public class Workflow { // 1. 定义上下文 public record OrderContext(String orderId, double amount, Set<String> userRoles) {} // 2. 定义节点类型:sealed interface 限定实现类范围 public sealed interface OrderStatus permits Created, AutoApproved, ManagerReview, FinanceReview, Done {} public record Created() implements OrderStatus {} public record AutoApproved() implements OrderStatus {} public record ManagerReview(String manager) implements OrderStatus {} public record FinanceReview(String manager, double amount) implements OrderStatus {} public record Done(String result) implements OrderStatus {} // 3. 定义条件类型和评估器 public sealed interface Condition permits AmountGt, AmountLt, HasRole, And, Or {} public record AmountGt(double value) implements Condition {} public record AmountLt(double value) implements Condition {} public record HasRole(String role) implements Condition {} public record And(Condition left, Condition right) implements Condition {} public record Or(Condition left, Condition right) implements Condition {} public static boolean evaluate(Condition cond, OrderContext ctx) { return switch (cond) { case AmountGt c -> ctx.amount() > c.value(); case AmountLt c -> ctx.amount() < c.value(); case HasRole c -> ctx.userRoles().contains(c.role()); case And c -> evaluate(c.left(), ctx) && evaluate(c.right(), ctx); case Or c -> evaluate(c.left(), ctx) || evaluate(c.right(), ctx); }; } // 4. 流转函数 public static OrderStatus transit(OrderStatus status, OrderContext ctx) { return switch (status) { case Created ignored -> decideFromCreated(ctx); case ManagerReview ignored -> new Done("APPROVED"); case FinanceReview ignored -> new Done("APPROVED"); case AutoApproved ignored -> status; case Done ignored -> status; }; } private static OrderStatus decideFromCreated(OrderContext ctx) { if (ctx.amount() < 1000) { return new AutoApproved(); } if (ctx.amount() <= 10000) { if (ctx.userRoles().contains("MANAGER")) { return new ManagerReview("managerA"); } return new Done("REJECTED"); } if (ctx.userRoles().contains("MANAGER") && ctx.userRoles().contains("FINANCE")) { return new FinanceReview("managerA", ctx.amount()); } return new Done("REJECTED"); } // 5. 驱动流程 public static OrderStatus run(OrderStatus start, OrderContext ctx) { OrderStatus current = start; int guard = 0; while (!(current instanceof Done) && guard < 10) { System.out.println("step=" + (guard + 1) + ", current=" + current); current = transit(current, ctx); guard++; } return current; } public static void main(String[] args) { var ctx = new OrderContext("A004", 50000, Set.of("USER", "MANAGER", "FINANCE")); var start = new Created(); OrderStatus finalStatus = run(start, ctx); System.out.println("final status=" + finalStatus); } }编译运行:
javac Workflow.java java Workflow预期输出:
step=1, current=Created[] final status=FinanceReview[manager=managerA, amount=50000.0]这个示例里,sealed interface OrderStatus明确了订单流程只有五种节点,编译器会强制switch分支覆盖所有类型。如果后续新增一种Canceled节点,transit方法必须同步更新,否则编译失败。这就是“有类型写法”在 Java 里的体现。
8. 有类型写法与传统工作流引擎方案的对比
很多团队在遇到工作流需求时,第一反应是引入 Flowable、Camunda 这类重型引擎,再配一套 BPMN 图形界面。这套方案有它的适用场景,但和“有类型写法”解决的问题并不完全重合。
| 维度 | 有类型写法 | 传统 BPMN 引擎 |
|---|---|---|
| 学习成本 | 低,只需要掌握语言类型系统 | 高,需要学习 BPMN 规范、部署模型 |
| 启动成本 | 零依赖,直接写代码 | 需要部署引擎、建表、配数据源 |
| 条件建模 | 用类型和代码描述,编译期检查 | 用表达式字符串(如 SpringEL、JUEL),运行时解析 |
| 流程可视化 | 无,需要靠代码阅读或额外开发 | 自带图形化设计器 |
| 动态变更流程 | 不擅长,修改需要发版 | 擅长,可通过模型部署动态更新 |
| 适合场景 | 流程相对固定、规则复杂但可枚举 | 流程经常调整、需要业务人员配置 |
一个重要判断:如果你们的业务流程半年才变一次,规则又比较清晰,有类型写法是更划算的选择。只有当你面临“业务人员需要频繁调整流程节点”时,BPMN 引擎的图形化配置能力才值得那套运维成本。
9. 运行结果与效果验证
上面两个示例都能直接运行,但运行成功不代表代码模型是对的。这里给出更完整的验证思路。
9.1 验证编译期的穷尽性
这是有类型写法最核心的价值。我们可以做一个测试:把 TypeScript 里OrderStatus的AUTO_APPROVED分支注释掉。
case "AUTO_APPROVED": case "DONE": return status;改成:
case "DONE": return status;TypeScript 编译器会报错,提示AUTO_APPROVED没有被处理。Java 同理,如果sealed interface的switch漏掉某个实现类,编译也会失败。这个验证每一名读者都可以自己动手做,能够直观感受到“编译器帮忙兜底”的效果。
9.2 验证业务逻辑
业务逻辑的验证还是要靠测试。给decideNextStatus写单元测试是最直接的方式。这里给出 TypeScript 版的极简测试思路:
// 文件路径:src/workflow.test.ts import { decideNextStatus, OrderStatus, OrderContext } from "./workflow"; function assertEqual(actual: OrderStatus, expected: OrderStatus) { const a = JSON.stringify(actual); const e = JSON.stringify(expected); if (a !== e) { console.error(`FAIL: expected ${e}, got ${a}`); process.exit(1); } console.log(`PASS: ${a}`); } const ctx: OrderContext = { orderId: "T001", amount: 5000, userRoles: ["USER", "MANAGER"] }; const start: OrderStatus = { kind: "CREATED" }; const next = decideNextStatus(start, ctx); assertEqual(next, { kind: "MANAGER_REVIEW", manager: "managerA" });运行:
npx ts-node src/workflow.test.ts在真实项目中,您可以用 Jest 或 Vitest 替代这套手写断言。重点不是测试框架,而是条件判断本身已经被集中到一个函数里,测试成本很低。
9.3 验证失败时先看哪里
如果出现异常,按这个顺序排查:
- 编译期是否报错?如果是,说明新增的节点或条件类型没有在
switch里处理,这是类型系统在提醒你补分支。 - 运行结果不符合预期?先打印当前状态和上下文,确认
ctx数据是否正确。 - 流程卡在循环里?检查
guard计数是否足够,生产环境建议用有向图判断无环。
10. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| TypeScript 编译报“not all code paths return a value” | switch某个 case 没有返回值,或条件分支不完整 | 检查transit和evaluateCondition的所有 case | 用穷尽性检查never类型,或补全所有分支 |
| Java 编译报“switch expression does not cover all possible input values” | sealed interface新增实现类,但switch没同步更新 | 查看报错位置,对照密封接口的实现类列表 | 在switch中补上新增类型的分支 |
| 流程走到了错误的节点 | 业务条件判断本身写错 | 打印ctx和每一步的current状态 | 为decideNextStatus补充测试用例,覆盖边界金额 |
| 多个业务方各自实现一套条件逻辑,代码重复 | 没有统一建抽象层 | 全局搜索条件判断的散落位置 | 按本文方法把条件收敛为统一类型和解释器 |
| 想给条件增加新类型 | 需要同步修改的类型较多 | 从Condition类型定义开始,逐层看switch | 先改类型定义,再依赖编译器列出所有受影响位置 |
| 流程出现死循环 | 流转函数里存在环,例如 A -> B -> A | 检查每一步的跳转逻辑,查看是否遗漏终止条件 | 增加步数上限,或提前做有向图无环校验 |
11. 有类型写法的局限性
必须说清楚,有类型写法不是银弹。它的适用边界要明确。
11.1 不擅长动态流程变更
如果你需要业务人员在管理后台拖拽流程节点、实时发布新流程,有类型写法会很别扭。因为节点和条件都是编译进代码里的,动态变更就需要引入脚本引擎或规则引擎,复杂度反而上去了。
11.2 调试 JSON 序列化后的条件时体验一般
把Condition序列化成 JSON 存储没问题,但反序列化回来时,需要自己处理kind字段和实际类型的映射。这比直接调用代码多了一层转换,出错时要多排查一步。
11.3 跨语言协作时需要额外约定
如果部分服务用 Java,部分用 Go,Condition这个类型在不同语言里各写一遍,就失去了“单一来源”的优势。这种情况需要考虑是否引入 schema 定义(如 JSON Schema)来约束。
11.4 类型系统不是业务流程文档
类型能约束数据结构,但不能替代流程图。团队里如果同时需要给非技术人员讲解流程,建议额外维护一份简单的状态图文档,代码负责“做对”,文档负责“看懂”。
12. 最佳实践与工程建议
写到这里,把实际落地时会用到的经验整理成清单。
12.1 从最小模型起步,不要一开始就建通用引擎
“条件工作流”范围很广。有的人需要串行条件分支,有的人需要并行汇聚,有的人需要子流程。
建议第一个版本只支持最简单的顺序流转和条件分支,不要加入并行、回退、超时提醒这些高级特性。等跑通一两个真实流程后,再根据瓶颈决定是否扩展。过早抽象是这类代码最常犯的错误。
12.2 用穷尽性检查锁住分支完整性
TypeScript 用never,Java 用sealed interface加完整switch。这两个技巧是“有类型写法”中最值得推广的实践。成本几乎为零,收益却很大——每次新增节点都会迫使你思考所有流转路径。
12.3 条件判断收敛到单一解释器
不要让业务代码里到处写if (amount > threshold)这样的判断。把所有条件抽象成Condition,然后只在evaluateCondition这一个地方处理。这样条件规则变更时,只需要改类型定义和解释器,测试也只需要覆盖这一个函数。
12.4 上下文对象保持不可变
流转函数要尽量避免直接修改传入的上下文。每次调用transit返回新状态,而不是在原来对象上改字段。这不仅能避免并发问题,也让每一步流转变得可追溯、可回放。
12.5 给流程加上最大步数保护和环检测
生产环境的工作流必须有防死循环机制。最简单的做法是限制最大流转步数;如果流程更复杂,可以在启动前对状态图做一次 DFS,检测是否存在从起点可以到达的环。
12.6 事件日志记录每一步流转
无论什么类型的工作流,都要记录完整的流转历史。建议至少记录:当前状态、目标状态、发生时间、触发人/触发系统、上下文关键字段快照。这些日志既是排查问题的依据,也是后续做流程分析的基础数据。
12.7 条件可以序列化到配置中心,但入口要收敛
如果业务上确实需要动态调整阈值,可以把Condition序列化成 JSON 放到配置中心。但注意:反序列化和校验逻辑必须收敛在同一个模块里,不要散落在各个服务中各写各的。
12.8 版本兼容策略
流程代码一旦发布,历史数据可能还停留在旧的状态节点。新增节点类型时,要考虑老数据的兼容:旧状态如何映射到新类型?是否需要做数据迁移?建议状态类型中的kind字段不要轻易改名,新增节点时尽量走“新增分支”而不是“修改旧分支”的路径。
13. 总结与后续学习方向
这篇文章围绕“条件工作流的有类型写法”做了三件事:
第一,讲清楚了无类型写法的问题——字符串散落、编译期不可检查、分支不可穷尽。这些问题在小型项目里不明显,但随着流程增多会迅速放大。
第二,用 TypeScript 和 Java 给出了完整的可运行示例。核心思想是把“条件”建模成类型,把“节点”建模为判别联合或密封接口,然后用解释器统一执行。两个示例都没有引入任何框架,可以直接落进现有工程。
第三,给了落地建议和局限性说明。有类型写法不是要替代 Flowable 这类 BPMN 引擎,而是给“流程相对固定、规则可枚举”的业务提供一种更轻、更安全的建模方式。
如果你准备在自己的项目里实践,建议按这个顺序推进:
- 先把你现有代码里所有
if (xxx.equals("yyy"))这样的条件整理出来,看能不能抽成一个枚举或联合类型; - 挑一个最典型的工作流节点,用本文的
Condition结构重新建模; - 跑通后,用穷尽性检查体验一次“编译器帮你找出漏掉的分支”;
- 再决定是否要把所有流程都迁移到这种写法。
后续可以继续深入的方向包括:工作流持久化方案(状态与事件怎么存储)、条件表达式与脚本语言的取舍、并行分支与汇聚节点建模、以及工作流监控与可视化。每一个方向都可以基于本文的模型继续扩展。