e2e 引擎这东西,第一次做的人十有八九会栽在同一个地方:上来就写执行逻辑,写了两千行之后发现驱动层换不掉、会话收不干净、并发一开就串数据。我在几个不同规模的团队里都参与过这类执行引擎的搭建和重构,最后复盘下来,真正决定成败的不是执行器写得多花哨,而是最开始那份Engine Contract定得够不够准,以及Session这一层有没有被当成正经的架构单元来对待。这篇就按我实际做过的顺序展开:先讲契约怎么定、为什么这么定,再讲会话模型怎么设计,然后给出一套可以照着敲的骨架代码,最后把能力协商、超时重试、可观测性和排查经验一次说透。适合正在做或准备做自动化执行引擎的开发者,也适合已经有一版能用但总觉得"哪里不对劲"的人对照着看。
1. 先把 Engine Contract 这件事想明白
1.1 为什么必须先定契约,而不是先写实现
很多人对"契约"的理解停留在"定义一个 interface",这其实只对了一半。契约的本质是多方并行开发的边界协议:写执行器的人、写驱动适配的人、写报告聚合的人、写调度平台的人,四拨人如果不在同一个接口上达成一致,后面每接一个模块都要改一次核心代码。我见过最典型的反例是,引擎入口直接暴露了底层的页面句柄,上层为了拿个截图,直接穿透三层去调底层方法,结果驱动一换,二十几个调用点全部报废。
先定契约还有一个很现实的好处:它可以被 mock。契约稳定之后,上层业务逻辑可以在引擎完全没实现的情况下先跑通,用假实现返回固定的 StepResult,联调时间能压缩一大半。这一点在多人协作里价值极高,因为大家不必互相等。
我个人的经验判断是:契约设计投入的时间占整个项目 10% 左右是合理的,低于 5% 基本都会返工。返工的成本不是改接口那几行代码,而是所有已经写好的调用方。
1.2 Engine Contract 的最小完备集合
不要贪多。一个 e2e 执行引擎的对外契约,真正必需的就那么几组能力。我通常按下面的表格来盘点,多出来的都要问一句"不加会死吗"。
| 能力分组 | 方法/属性 | 是否必需 | 说明 |
|---|---|---|---|
| 元信息 | name、version | 必需 | 用于日志归属与兼容性判断 |
| 能力声明 | capabilities() | 必需 | 返回支持的步骤类型、平台、限制 |
| 会话创建 | createSession(options) | 必需 | 唯一的生产会话入口 |
| 引擎销毁 | dispose() | 必需 | 释放全局资源,需幂等 |
| 健康检查 | health() | 建议 | 长驻进程场景必备 |
| 事件订阅 | on(event, handler) | 建议 | 可观测性的接入点 |
| 会话枚举 | listSessions() | 可选 | 仅调试与运维场景需要 |
这里有个取舍值得单独说:不要把 execute 放在 Engine 上。初学者常写成engine.execute(step),图省事,但这就等于引擎内部藏了一个隐式全局会话,所有调用共享同一份上下文,并发一来直接串味。执行一定是 Session 的职责,引擎只负责造会话和收会话。
契约里还有一个隐式约定要写进文档:哪些方法是幂等的。dispose()必须幂等,重复调用不能抛错;createSession()不幂等,每次调用都必须产出独立会话;close()必须幂等。这三条不写清楚,上层就没法安全地做重试。
1.3 契约的版本演进策略
契约一旦对外,就不能随便改。我的做法是给契约打语义化版本,并且在能力声明里带上contractVersion字段。演进遵循三条线:
- 加方法:小版本号递增,老实现可以返回
NOT_SUPPORTED,上层通过能力探测决定是否调用。 - 改签名:大版本号递增,同时在引擎侧提供一个适配层,把老签名转成新签名,给调用方一个过渡期。
- 删方法:只在超大版本做,而且提前至少两个迭代周期在能力声明里标记
deprecated。
注意:能力探测一定要走
capabilities()的一次性快照,不要在每次执行时临时判断。运行期反复探测会让代码里到处是 if,最后没人敢动。
2. Session 模型:引擎里最容易被低估的一层
2.1 Session 到底承载了什么
在很多人脑子里,Session 就是个 id 加一个底层句柄。实际做下来,一个正经的会话至少要背负五件事:
- 上下文容器:cookie、本地存储、缓存、认证态,这些东西的生命周期必须严格绑定在会话上,不能漏到进程级。
- 隔离单元:会话之间不能有任何共享可变状态,哪怕是一个全局计数器都要谨慎。
- 资源账本:这个会话开了几个句柄、占了多少内存、跑了多久,出问题时这些数字是第一手证据。
- 状态机载体:会话有明确的生命周期状态,非法转换必须被拦住。
- 追踪锚点:所有日志、指标、快照都挂同一个 traceId,事后能拼出一条完整时间线。
我踩过最疼的一次坑,就是把认证态存在了引擎实例上:单会话测试全绿,一开四并发,四个会话互相顶掉登录态,报错信息还特别有迷惑性,说是"元素找不到"。查了两天才定位到是作用域写错了。
2.2 会话状态机怎么设计
状态机不要设计得太花。我的经验是六个状态足够覆盖 95% 的场景:
| 状态 | 含义 | 允许的下一步 |
|---|---|---|
| created | 对象已构造,尚未初始化 | initializing、closed |
| initializing | 正在建立底层连接 | ready、failed |
| ready | 空闲,可接受执行 | busy、closing、failed |
| busy | 正在执行步骤 | ready、failed、closing |
| closing | 正在回收资源 | closed、failed |
| closed | 终态 | 无 |
关键点在于终态不可回退,以及busy 状态下拒绝新的 execute。后者很多人不做,结果是两个调用同时进来,步骤交错执行,日志时间线完全错乱。最简单的做法是在 execute 入口做一次 CAS 式的状态切换,切不成功就直接返回一个明确的SESSION_BUSY错误,而不是排队等待——排队会把超时逻辑搞得极其复杂。
type SessionState = | 'created' | 'initializing' | 'ready' | 'busy' | 'closing' | 'closed' | 'failed'; const LEGAL_TRANSITIONS: Record<SessionState, SessionState[]> = { created: ['initializing', 'closed', 'failed'], initializing: ['ready', 'failed'], ready: ['busy', 'closing', 'failed'], busy: ['ready', 'closing', 'failed'], closing: ['closed', 'failed'], closed: [], failed: ['closing', 'closed'], };2.3 隔离级别怎么选
隔离级别直接决定了并发能力,也直接决定了排查难度。我一般给团队三个档位,按测试用例的性质来选。
| 隔离级别 | 实现方式 | 单会话内存开销(经验值) | 启动耗时 | 适用场景 |
|---|---|---|---|---|
| 进程级 | 独立进程 | 300MB ~ 600MB | 1.5s ~ 4s | 强隔离、易崩的用例 |
| 上下文级 | 独立浏览器上下文 | 80MB ~ 200MB | 200ms ~ 800ms | 大多数常规 e2e |
| 页面级 | 共享上下文、独立标签 | 30MB ~ 80MB | 50ms ~ 200ms | 无状态页面快速校验 |
上面这些数字是常见实践下的量级参考,具体值跟你跑的页面复杂度关系极大,别当真理用,自己测一轮更靠谱。选型的核心逻辑是:上下文污染的风险 × 用例数量对比启动开销 × 用例数量。如果用例之间会写同一个账号的数据,那就别省这个钱,上上下文级隔离。
提示:无论选哪一档,会话关闭时都要显式清理,不能依赖进程退出。我见过依赖自动回收的引擎,跑八小时之后内存从 2G 涨到 14G。
3. 实操:从零搭一个可跑的 Engine 骨架
3.1 目录结构与依赖取舍
结构上我倾向按"契约 / 实现 / 适配"三层切,别按功能切。这样契约层的改动影响面一眼可见。
engine/ contract/ engine.ts # EngineContract 与 Session 接口 types.ts # StepResult、SessionOptions、错误码 events.ts # 事件类型定义 core/ engine-impl.ts # 引擎实现 session-impl.ts # 会话实现 state.ts # 状态机与守卫 errors.ts # 错误分类 adapters/ registry.ts # 适配器注册表 base.ts # 适配器抽象 infra/ clock.ts # 可注入时钟,方便测试超时 logger.ts metrics.ts依赖上我只坚持两条:一是必须能注入时钟,否则超时逻辑没法写单测;二是必须能注入日志器,引擎内部不要直接调 console。其他依赖能少则少,引擎这种基础设施层,每多一个第三方包就多一个版本冲突源。
TypeScript 配置里,我建议把strict全开,尤其strictNullChecks和noImplicitAny。引擎里空值处理写错一个地方,就是线上随机崩。
3.2 契约层的类型定义
先写类型,再写实现,这个顺序不要反。下面是我常用的版本,去掉了一些业务相关的字段,保留骨架。
export type ErrorClass = 'transient' | 'fatal' | 'assertion' | 'timeout'; export interface EngineError { code: string; message: string; class: ErrorClass; retryable: boolean; cause?: unknown; } export interface StepResult { stepId: string; ok: boolean; durationMs: number; error?: EngineError; artifacts?: string[]; } export interface SessionOptions { traceId: string; isolation?: 'process' | 'context' | 'page'; deadlineMs?: number; tags?: Record<string, string>; } export interface CapabilitySet { contractVersion: string; isolationLevels: string[]; stepTypes: string[]; maxConcurrentSessions: number; supportsSnapshot: boolean; } export interface SessionSnapshot { sessionId: string; state: string; uptimeMs: number; stepsExecuted: number; lastError?: EngineError; resources: Record<string, number>; } export interface ActionStep { id: string; type: string; params: Record<string, unknown>; timeoutMs?: number; } export interface Session { readonly id: string; readonly traceId: string; readonly state: string; execute(step: ActionStep): Promise<StepResult>; snapshot(): Promise<SessionSnapshot>; close(reason?: string): Promise<void>; } export interface EngineContract { readonly name: string; readonly version: string; capabilities(): CapabilitySet; createSession(options: SessionOptions): Promise<Session>; listSessions(): string[]; health(): { ok: boolean; detail?: string }; dispose(): Promise<void>; }注意StepResult里我用了ok而不是抛异常。这个选择有争议,但执行引擎里我倾向于把失败当成正常返回值,因为失败是高频事件,用异常表达会导致调用方到处写 try/catch,而且异常在并发场景下容易吞掉上下文。只有"引擎自身不可用"这类真异常才 throw。
3.3 Session 实现的完整骨架
这部分是重头戏。核心是三点:状态守卫、截止时间传递、资源记账。
export class SessionImpl implements Session { private _state: SessionState = 'created'; private stepsExecuted = 0; private readonly startedAt: number; private readonly deadlineAt: number | null; private lastError?: EngineError; private adapter?: BaseAdapter; constructor( readonly id: string, readonly traceId: string, private readonly opts: SessionOptions, private readonly clock: Clock, private readonly log: Logger, ) { this.startedAt = clock.now(); this.deadlineAt = opts.deadlineMs ? this.startedAt + opts.deadlineMs : null; } get state(): SessionState { return this._state; } private transition(to: SessionState): void { const legal = LEGAL_TRANSITIONS[this._state]; if (!legal.includes(to)) { throw new Error(`illegal transition ${this._state} -> ${to}`); } this._state = to; } async init(): Promise<void> { this.transition('initializing'); try { this.adapter = await createAdapter(this.opts.isolation ?? 'context'); this.adapter.onCrash((err) => { this.lastError = mapError(err); this._state = 'failed'; }); this.transition('ready'); } catch (e) { this.lastError = mapError(e); this.transition('failed'); throw e; } } async execute(step: ActionStep): Promise<StepResult> { if (this._state !== 'ready') { return { stepId: step.id, ok: false, durationMs: 0, error: { code: 'SESSION_NOT_READY', message: `session state is ${this._state}`, class: 'fatal', retryable: false, }, }; } const budget = this.remainingBudget(step.timeoutMs); if (budget <= 0) { return { stepId: step.id, ok: false, durationMs: 0, error: { code: 'DEADLINE_EXCEEDED', message: 'session deadline already exceeded', class: 'timeout', retryable: false, }, }; } this.transition('busy'); const t0 = this.clock.now(); try { const result = await withTimeout( this.adapter!.run(step), budget, step.id, ); this.stepsExecuted++; return { stepId: step.id, ok: true, durationMs: this.clock.now() - t0, artifacts: result.artifacts }; } catch (e) { const err = mapError(e); this.lastError = err; return { stepId: step.id, ok: false, durationMs: this.clock.now() - t0, error: err }; } finally { if (this._state === 'busy') this.transition('ready'); } } private remainingBudget(stepTimeout?: number): number { const sessionLeft = this.deadlineAt ? this.deadlineAt - this.clock.now() : Number.POSITIVE_INFINITY; const stepLeft = stepTimeout ?? Number.POSITIVE_INFINITY; return Math.min(sessionLeft, stepLeft); } async close(reason = 'normal'): Promise<void> { if (this._state === 'closed' || this._state === 'closing') return; this.transition('closing'); try { await this.adapter?.dispose(reason); this.transition('closed'); } catch (e) { this.lastError = mapError(e); this._state = 'closed'; // 关闭失败也要进终态,避免泄漏 this.log.warn('session close failed', { id: this.id, reason }); } finally { registry.release(this.id); } } }几个细节值得单独点出来。第一,execute里busy的恢复放在finally,但加了状态判断——如果执行过程中适配器崩了把状态推到failed,这里不能硬拉回ready。第二,close里关闭失败也强制进终态,宁可标记成异常关闭也不能让会话挂在closing上占着资源。第三,所有时间都走注入的 clock,测试超时逻辑时可以直接推进虚拟时间,不用真等。
3.4 引擎入口与生命周期管理
引擎实现本身很薄,主要是会话注册表、并发闸门和销毁顺序。
export class EngineImpl implements EngineContract { readonly name = 'e2e-engine'; readonly version = '1.4.0'; private sessions = new Map<string, SessionImpl>(); private disposed = false; constructor( private readonly cfg: EngineConfig, private readonly clock: Clock, private readonly log: Logger, ) {} capabilities(): CapabilitySet { return { contractVersion: '1.4', isolationLevels: ['process', 'context', 'page'], stepTypes: this.cfg.enabledStepTypes, maxConcurrentSessions: this.cfg.maxConcurrent, supportsSnapshot: true, }; } async createSession(opts: SessionOptions): Promise<Session> { if (this.disposed) throw new Error('engine disposed'); const active = [...this.sessions.values()] .filter(s => s.state !== 'closed' && s.state !== 'failed').length; if (active >= this.cfg.maxConcurrent) { throw new Error(`session limit reached: ${active}`); } const id = `s-${this.clock.now().toString(36)}-${rand(6)}`; const s = new SessionImpl(id, opts.traceId, opts, this.clock, this.log); this.sessions.set(id, s); await s.init(); return s; } async dispose(): Promise<void> { if (this.disposed) return; this.disposed = true; const all = [...this.sessions.values()]; await Promise.allSettled(all.map(s => s.close('engine-dispose'))); this.sessions.clear(); registry.reset(); } health() { const open = [...this.sessions.values()] .filter(s => s.state !== 'closed').length; return { ok: !this.disposed, detail: `open sessions: ${open}` }; } }dispose用allSettled而不是all,这点很关键:一个会话关闭失败不能阻断其他会话回收。销毁一个引擎最怕的就是卡在半路,进程退不掉。
4. 能力协商与驱动适配
4.1 为什么要把能力声明做成一次性快照
能力协商的意义在于把运行期的判断前移。上层调度系统在派发任务之前调一次capabilities(),拿到引擎支持的步骤类型和隔离级别,不支持的用例直接路由到别的引擎或者标记跳过。这比"跑起来发现不支持再抛错"优雅太多,也省机器。
实现上要注意两件事。一是这个返回值要做成不可变快照,深拷贝或者冻结,避免上层不小心改了它,导致后续判断失真。二是它要包含限额信息,比如maxConcurrentSessions,调度器靠这个决定往这个引擎实例上派多少任务,比硬编码一个数字靠谱得多。
注意:能力声明里不要写"理论上支持"的东西。写了
supportsSnapshot: true就必须真能返回快照,否则排查问题时上层会一直等到超时。
4.2 适配器注册表怎么写
适配器是引擎与具体执行环境之间的桥。注册表的设计目标只有一个:引擎核心不认识任何具体驱动。
interface AdapterFactory { name: string; supports(isolation: string): boolean; create(opts: SessionOptions): Promise<BaseAdapter>; } class Registry { private factories = new Map<string, AdapterFactory>(); register(f: AdapterFactory) { if (this.factories.has(f.name)) { throw new Error(`adapter already registered: ${f.name}`); } this.factories.set(f.name, f); } async createFor(isolation: string, opts: SessionOptions) { for (const f of this.factories.values()) { if (f.supports(isolation)) return f.create(opts); } throw new Error(`no adapter for isolation=${isolation}`); } reset() { this.factories.clear(); } }register里做重名检查是必要的,热加载场景下重复注册会导致工厂链越滚越长。createFor的遍历顺序就是优先级,注册时先注册的更优先,这个语义要写在文档里。
4.3 并发度到底怎么算
这是最容易被拍脑袋决定的参数。我一般用三条约束取最小值:
并发上限 = min( 物理核数 × 单核超配系数, // CPU 约束 可用内存 × 0.75 ÷ 单会话峰值内存, // 内存约束 授权席位 / 外部服务限流阈值 // 外部约束 )举个具体例子:一台 16 核、64GB 的机器,单会话峰值内存实测 350MB,外部服务限流 20 QPS,每个会话平均 0.8 QPS。那么:
- CPU 约束:16 × 1.5 = 24
- 内存约束:64 × 0.75 ÷ 0.35 ≈ 137
- 外部约束:20 ÷ 0.8 = 25
三者取最小是 24,但还要再乘一个 0.7 的安全系数,最终落在 16 左右。安全系数不是迷信,是给突发留余量——真跑满 24 的时候,任何一个用例内存翻倍就会触发 OOM。
提示:单会话峰值内存不要用平均值。取 95 分位数,因为 OOM 是被最胖的那个会话触发的,不是被平均的那个。
5. 错误处理、超时与重试
5.1 错误分类是所有容错逻辑的地基
引擎里的错误不分级,重试策略就无从谈起。我用的分类只有四档,够用:
| 错误类别 | 典型场景 | 是否重试 | 处理方式 |
|---|---|---|---|
| transient | 网络抖动、元素暂时未就绪 | 是 | 指数退避,最多 2 次 |
| timeout | 步骤超时、会话超时 | 视情况 | 会话超时不重试,步骤超时可 1 次 |
| assertion | 断言失败 | 否 | 直接判定用例失败并留证 |
| fatal | 适配器崩溃、会话非法状态 | 否 | 关闭会话,重建后再跑 |
分类的关键是每个类别要有明确的判据,不能靠字符串匹配错误消息。我的做法是让适配器在抛错时带上结构化的code,引擎按 code 的前缀映射到类别,比如NET_*归 transient,ASSERT_*归 assertion。字符串匹配的错误分类在半年后一定会失效,因为错误消息会被改。
5.2 超时预算要分层,不能只有一个数
一套能用的超时体系至少三层:全局任务超时 → 会话超时 → 单步超时。三者的关系必须是包含关系,而且下层只能用上层的剩余预算。
我见过最糟糕的实现是三层各配各的超时,互相不知道对方。结果是单步配了 30 秒,会话配了 20 秒,跑到第 19 秒时单步还在等,会话被杀,日志里只留一个语焉不详的"会话已关闭"。用剩余预算的方式传递,就不会出现这种自相矛盾。
function remainingBudget( deadlineAt: number | null, now: number, stepTimeout?: number, ): number { const sessionLeft = deadlineAt ? deadlineAt - now : Infinity; const stepLeft = stepTimeout ?? Infinity; return Math.min(sessionLeft, stepLeft); }超时的实现要走AbortController或等价的取消机制,不能只是"超时了就返回"。否则底层操作还在跑,资源还在占,下一轮并发直接爆。取消信号必须一路传到适配器最里层。
5.3 重试的边界在哪
重试不是免费午餐。三个约束必须同时成立才能重试:
- 幂等性成立:这一步重复执行的副作用可接受。填表单重试一次可能就是两条数据。
- 重试预算充足:重试占用的时间要从会话预算里扣。预算不够时重试等于加速失败。
- 错误类别允许:只有 transient 和部分 timeout 应该重试。
退避策略我用抖动指数退避:第 n 次重试等min(base × 2^n, cap) × random(0.5, 1.0)。加抖动是为了避免一批会话同时失败后同时重试,把下游打穿。这个场景在并发 16 以上时几乎必然发生,别省这一行。
6. 可观测性:让问题自己冒出来
6.1 事件总线怎么接
引擎内部所有关键节点都发事件,外部订阅与否是外部的事。事件列表我一般固定这几个:session.created、session.ready、step.started、step.finished、session.failed、session.closed、engine.disposed。
每个事件强制带traceId和sessionId。没有这两个字段的事件等于没有。日志器那边用 traceId 做上下文注入,一次会话的所有日志能一键捞出来,排查效率差十倍不止。
一个容易忽略的点是事件监听器要及时解绑。会话关闭时如果不断开监听,长驻进程跑一天之后 EventEmitter 上挂了几万个 handler,内存泄漏加性能雪崩,而且极难定位。
6.2 快照要留什么
snapshot()的返回内容要能回答三个问题:这个会话现在在干什么、它跑了多久、它占了多少资源。我通常塞这些字段:状态、已执行步数、运行时长、最后一次错误、以及资源计数(打开的句柄数、缓存条目数等)。
快照的触发时机有两个:主动查询(排查时用)和自动落盘(会话异常关闭时)。后者更有价值,因为很多问题在会话关闭后就再也复现不了了。落盘也不用搞得很复杂,一个 JSON 文件加时间戳就够,重要的是在失败那一刻自动写,而不是等运维来捞。
提示:快照落盘要注意脱敏。会话上下文里可能有登录凭证,直接序列化到磁盘是隐患,序列化前过一遍白名单。
7. 常见问题与排查速查表
7.1 高频问题速查
| 现象 | 大概率原因 | 排查方向 | 修复方式 |
|---|---|---|---|
| 并发下用例互相串数据 | 上下文没有按会话隔离 | 检查是否有模块级可变状态 | 移到会话实例内 |
| 会话关闭后进程不退出 | 有未释放的定时器或句柄 | 关闭时打印活动句柄 | 显式清理并置空 |
| 内存随时间线性上涨 | 事件监听器或缓存未清理 | 对比快照的资源计数 | 关闭时解绑并清空 |
| 超时后仍有网络请求 | 取消信号没传到最里层 | 在适配器最底层打日志 | 全链路传递取消信号 |
| 状态机报非法转换 | 并发重入或崩溃回调抢跑 | 记录状态变更序列 | 加状态守卫,收敛回调 |
| 重试引发流量尖峰 | 无抖动或抖动范围太小 | 看重试时间分布 | 加大抖动窗口 |
7.2 我踩过的几个坑
第一个坑:把 close 写成只做一半。早期的实现里,close先释放适配器,再更新状态。结果释放过程中抛了异常,状态永远停在closing,会话既不能被复用也不能被回收,跑两小时注册表里堆了几百个僵尸。后来改成"无论释放成功与否,最终一定进终态,同时打警告日志",问题才断根。
第二个坑:以为并发就是多开几个实例。第一版直接按任务数开进程,机器上 32 个实例同时启动,启动瞬间 CPU 打满,所有实例互相拖慢,整体耗时比串行还长。后来加了并发闸门和启动排队,前 8 个立刻起,后面的按 200ms 间隔错峰,稳定性和总耗时都好了很多。
第三个坑:用错误消息做分类。有次上游改了一句错误文案,从"连接超时"改成"连接已超时",重试逻辑瞬间全部失效,所有网络抖动都变成了致命错误,成功率掉了 15 个点。改成结构化错误码之后,再没出现过这类问题。
第四个坑:忽略了时钟。前几版直接用Date.now(),超时逻辑完全没法写单测,只能真等 30 秒。后来把 clock 抽成依赖,测试里推进虚拟时间,超时用例从"跑一次半分钟"变成"跑一次几毫秒",覆盖率也跟着上去了。
8. 这套骨架后续怎么长
骨架跑通之后,一般会往两个方向长。往调度侧长,加上任务队列、优先级、失败重投、按标签路由;往能力侧长,加上视频录制、网络拦截、性能指标采集。这两个方向都要守住一条:新增能力必须通过契约暴露,而不是在实现类里加个 public 方法让上层直接调。契约一旦被绕过,前面所有的设计投入就白费了。
还有个我个人的偏好:把契约层单独发一个包,版本号独立管理,引擎实现依赖契约,上层业务也依赖契约。这样契约的每次变更都有明确的版本记录,谁在什么时候因为什么改了契约,一查就知道。多包管理会带来一点工程复杂度,但比起契约悄悄变形带来的维护成本,这点复杂度非常划算。
真要说这套东西里最值得花时间的部分,还是最开始那两个小时——把 Engine Contract 的每个方法、每个返回值、每条幂等性约定写清楚,把 Session 的状态机画在一张纸上。这两件事做扎实了,后面的代码写起来其实很快,而且改起来不慌。