为什么 async/await 杀不死 Generator?

最近在做代码 Review 时,被一段处理 Policy(政策)批处理的逻辑勾起了很多思考。

1. 缘起:一次“失败”的逻辑抽取

业务场景其实很常见:分页拉取数据、做 24h 业务过滤、统计成功/跳过/报错的数量,最后每 10 个一组聚合发送。

起初,大家习惯性地想用 async/await 来写。但当你试图把“分页 + 过滤”这部分通用的数据获取逻辑抽出来复用时,你会发现代码变得极其扭曲。最常见的尝试是写一个带有 Callback(回调) 的工具函数:

JavaScript

// 这种模式下,控制权在 walkPolicies 手里 await walkPolicies\(async \(data\) => \{ stats\.success\+\+; buffer\.push\(data\); if \(buffer\.length === 10\) await sendBatch\(buffer\.splice\(0, 10\)\); \}, \(\) => stats\.skip\+\+\);

很多人说 Generator 的好处是“关注点分离”,但其实回调函数也能做到。真正的痛点在于“非侵入性”。 在回调模式下,如果你想改动执行节奏(比如发完一组歇一会儿),或者想中途停止,你必须去修改工具函数的内部实现

而 Generator 的价值在于:它允许你在不触碰执行函数内部逻辑的前提下,由外部决定如何、何时推进流程。


2. 控制权的反转:不改源码的解耦

换成 async generator 后,生产者和消费者的界限变得极其清晰。

生产者(只管业务流,不关心频率):

1
2
3
4
5
6
7
8
9
10
11
12
13
async function* policyProvider() {
let page = 1;
while (true) {
const batch = await fetchPage(page++);
for (const policy of batch) {
if (await wasSeenIn24Hours(policy.holderId)) {
yield { type: 'SKIP' };
} else {
yield { type: 'DATA', payload: policy };
}
}
}
}

消费者(掌控一切):

1
2
3
4
5
6
7
8
for await (const result of policyProvider()) {
// 所有的节奏控制、统计、批处理逻辑都在这里
// 我不需要去改 policyProvider 的代码,就能实现各种复杂的调度
if (result.type === 'DATA') {
buffer.push(result.payload);
if (buffer.length === 10) await sendBatch(buffer.splice(0, 10));
}
}

这种 控制反转(IoC) 最大的价值是:生产者只负责产出状态,而外部可以根据状态控制生成者。这种“非侵入式”的调度,是 async/await 很难优雅实现的。


3. 底层硬核:为什么我们会转入原理?

当我们发现 async/await 无法优雅地处理这种逻辑抽取时,直觉会把我们带向底层:Generator 和普通的函数到底差在哪?

这涉及到 JS 引擎对内存的魔术。普通函数高度依赖物理调用栈(Stack),执行完即销毁。但 Generator 彻底打破了栈的物理限制:

  • 暂停(Yield):引擎会把当前的执行上下文(变量、IP 指针、this 等)直接从物理栈里弹出来,**写回到堆(Heap)**里的对象中封存。

  • 恢复(Next):引擎从堆里把这个快照捡回来,重新压回栈顶接着跑。

而且,Normal Generator 的暂停和恢复是同步的。它不需要排队,它就是在同一个执行序列里完成了“现场切换”。

所以,闭包让“作用域”活了下来,而 Generator 让整个“执行过程”活了下来。


4. 写在最后:为什么它成了“少数派”的武器?

既然 Generator 这么强大,为什么现在很少见到了?

第一,它的“双向通信”反而是个负担。

Generator 允许通过 next\(val\) 往函数内部传值,这在理论上很美,但在业务代码中极其少见。这种复杂的交互让代码的可读性直线下降,如果只是为了拿数据,async/await 配合简单的循环显然更自然。

第二,它处于一种“不上不下”的尴尬位置。

在 90% 的简单场景下,我们只需要“等一个结果”,async/await 的心智负担最低。在极致复杂的响应式场景下,大家又去用 RxJS 了。

但这并不代表它过时了。当你发现 async/await 让你不得不通过修改函数内部来实现外部控制时,说明你需要的不再是一个简单的异步结果,而是一个**“状态保存在堆里的执行环境”**。


架构笔记:Service 逻辑解耦与 Domain Interface 落地

1. 问题起因:Service 承担了太多转换逻辑

目前后端代码比较突出的问题是 Service 层里 Transform (Mapper) 偏多。Service 本应聚焦业务步骤,但现在需要处理各种类型转换:

  • Adapter 接口定义不清:比如 Service 在调用第三方 Client 拼 SOAP 报文时,新增一个 Adapter,它的入参应该用什么类型?是用第三方的格式,还是用我们自己定义的 Domain Model?

  • Domain Model 缺失

    • Entity 绑定 DB 结构,不适合充当 Domain Model。

    • DTO/Types 混合了前端请求和临时定义,也不适合充当 Domain Model。

  • 现状:缺少统一的业务模型,导致 Service 成了类型转换的汇集点。

2. DDD 的思路:以业务模型为中心

按照 DDD 的思路,基础设施层应适配业务模型(Domain Model):

  • 第三方 Adapter:应依赖 Domain Model,负责将我们的标准数据转换为第三方接口要求的格式。

  • Repository:应接收 Domain Model。Service 不需要负责构建 Entity,存取逻辑交由 Repo 内部处理。

  • Controller:在入口处将 DTO 转换为 Domain Model。

3. DDD 落地难题:技术与现实的双重阻力

A. 技术层面的“手动挡”痛点 (LB4 vs. Java)

  • 级联与脏检查缺失:Java 的 Hibernate 支持 Dirty Checking,开发者修改 Model 属性后,框架会自动同步到多张表。LB4 下需要手动处理 patch

  • 加载策略差异:Java 支持 Lazy Loading(延迟加载),可以定义大而全的聚合根且不影响性能;LB4 需要显式 include,否则就得在 Service 里写零散的查询。

  • 持久化细节溢出:LB4 强制一表一 Repo,导致更新聚合根时的事务编排、Diff 对比都堆积在 Service 里。

B. 现实层面的“深层阻力”(核心痛点)

  • 认知惯性与成本:团队习惯了 CRUD 的开发方式。引入 Domain Interface 意味着多写一层转换(Mapper),在交付压力大的时候,容易被看作“浪费时间”。

  • “改不动”的恐惧:老代码里 DTO、Entity 和业务逻辑深度耦合。要把这些混杂的 types 拆分出来,往往牵一发动全身,很难保证不出问题。

  • 收益隐形化:解耦带来的收益(易维护、测试友好)是长期的,而重构的成本(工时、回归测试)是眼前的。在“业务优先”的环境下,这类改动不容易争取到资源支持。

4. 最小改动方案:渐进式落地 (Touch-and-Go)

由于阻力较大,暂时不做大范围的架构升级,先采取以下务实步骤:

  • 第一步:确立主权 (Domain Interface)
    从现有 types 中提炼出标准的 Domain Interface。老逻辑暂不改动,先给新功能设定规范。

  • 第二步:建立防腐层 (Adapter & Mapper)
    针对痛点较明显的第三方服务(如 SOAP),强制其 Adapter 依赖 Domain Interface。把字段映射这类枯燥的工作从 Service 中移出去。

  • 第三步:持久化下沉 (Smart Repository)
    在 Repository 层封装多表操作。Service 只需调用 save\(domainInterface\),内部处理具体细节,减少 Service 的视觉干扰。

  • 第四步:增量渗透
    遵循 Touch-and-Go 原则:改到哪,修到哪。不为了重构而重构,而是在业务变动的间隙,顺带完成解耦。

5. 最终总结:架构主权的“防腐”与务实演进

1. 隔离变化:建立三道防火墙
我们的核心目标不是消除转换,而是通过物理隔离,把“体力活”从业务逻辑中抽离出来:
接口协议变了(DTO) → 只改 Mapper
数据库表结构变了(Entity) → 只改 Repository
核心业务规则变了(Logic) → 只改 Domain Service/Logic

2. 务实主义:承认限制,划出净土
不盲目遵循 DDD 教条(比如非要实现 Java 那种透明异步加载或脏检查),而是针对 LB4 的现状做折中:
手动挡的封装:既然框架没有“全自动”功能,就把多表 Diff 和事务编排这些脏活限制在 Smart Repository 内部,不溢出到 Service。
接口即主权:利用 TypeScript 的 Interface 特性,在不改动底层老旧 Entity 的前提下,定义出一套相对纯净、统一的业务语言。

3. 落地策略:Touch-and-Go(增量渗透)
真正的阻力来自“改不动”的恐惧和交付压力,所以暂时不做大开大合的调整:
改到哪,修到哪:不为了重构而重构。只在业务变动触及相关逻辑时,顺手把旧的映射逻辑移到 Mapper,把第三方报文拼接放进 Adapter。
从局部清爽开始:通过一个个“干净”的 Service 示范,让团队逐渐感受到解耦后维护成本的降低,慢慢从“搬运工”模式转向“指挥官”模式。

《Designing Data Intensive Applications》读书笔记 - 数据库复制

这一章讲数据库复制(Replication),目标很简单就是保存数据副本在多个机器上,但是实现却没那么容易。首先需要数据复制的几个原因:

  1. 数据中心地理上更靠近用户
  2. 增强可用性,即便部分服务器节点失败,整个系统依然可用
  3. 提高读取的吞吐量
阅读更多

《The DynamoDB Book》读书笔记

同事推荐的一本书,只有英文电子版。作者是Alex DeBrie,之前介绍过,是单表设计的推崇者。

这本书前面部分几个章节介绍 DynamoDB 的基本概念,后面部分是一些实际的设计案例。

阅读更多

DynamoDB 单表设计的优势与考量

大多数开发都有关系数据库设计经验,在初次使用 DynamoDB 设计数据模型的时候,很容易陷入关系数据库的思维陷阱, 不自觉的遵守关系数据库设计的范式, 尝试将数据模型规范化,每个实体或实体关系都有对应的单独的表,通常称之为多表设计。
与之对应的是,将所有实体和实体关系都存储在同一张表中,毕竟 DynamoDB 是 Schemaless 的数据库,称之为单表设计。
这儿要强调的是,这两种设计只是极端的两点。可能也不是一个合适的命名,因为在实际应用中,单表设计并不意味着只能有一张表。
在两个极端之间,单表设计更倾向于将相关实体存入在同一张表中,多表设计则倾向将不同实体类型存入不同的表中。

官方文档中,单表和多表设计比较时也较为推荐单表设计。本文就来根据实际经验,讨论下实际实践中单表设计的优势。
我们自己的项目采用的是单表设计,很大程度上受 《The DynamoDB Book》影响,作者 Alex DeBrie 是单表设计的推崇者。当然,我们项目中已经有十几张表,尽管我们已经尽量将相关实体存入同一张表中。

阅读更多

AWS Connect 转接最近通话的客服

需求

最近接到一个需求,需要将客户来电转接到最近与客户通话的客服。这个需求很容易理解,
客户可能因为各种各样的原因中断通话,再次来电很可能是因为同一个诉求,比如保险索赔,可能需要多次来回沟通。
将通话转给同一个客服,客服可以接着继续处理而不用熟悉客户场景,这样做能够提高处理效率。
尽管这个需求看起来很基础,但是并没有一个开箱可用的方案。我们的呼叫中心是 Amazon Connect,不过并没有启用 Profile,一些方案也不能采用。

阅读更多

AWS client getaddrinfo EMFILE issue

最近,在我们系统中引入了 AWS Cloud Map 作为我们的服务发现系统。部署几周后没有问题,今天突然抛出错误,日志显示错误 getaddrinfo EMFILE events.ap-southeast-2.amazonaws.com
当然,并非所有请求都触发了此错误,只是在高流量时段才出现了这个错误。

阅读更多