在维护一个演进了数年的大型系统中,业务逻辑的复杂性往往直接映射为代码的复杂性。Event Sourcing (ES) 模式因其完整的状态追溯能力和对复杂业务流程的表达力,成为我们处理核心领域模块的一个备选方案。然而,引入 ES 模式的一个直接后果就是样板代码(Boilerplate)的急剧增加。一个标准的聚合根(Aggregate Root)通常需要为每个命令(Command)定义一个处理方法,并为每个事件(Event)定义一个状态应用方法。这导致了大量的、结构相似但内容不同的代码,极易因疏忽而出错,例如方法命名与事件类型字符串不匹配。
一个典型的、手动编写的 Product
聚合根可能长这样:
// product.aggregate.js - The "Before" state
import { AggregateRoot } from './core/aggregate-root';
import { ProductCreated, StockAdded, StockRemoved } from './product.events';
import { InvariantError } from './core/errors';
export class Product extends AggregateRoot {
constructor(id) {
super(id);
this._stock = 0;
this._isAvailable = false;
this._name = null;
}
// Command Handler
createProduct(name, initialStock) {
if (this._version > -1) {
throw new InvariantError('Product already exists.');
}
if (typeof name !== 'string' || name.length < 3) {
throw new InvariantError('Product name is invalid.');
}
this.applyChange(new ProductCreated(this.id, { name, initialStock }));
}
// Command Handler
addStock(quantity) {
if (this._version === -1) {
throw new InvariantError('Product does not exist.');
}
if (quantity <= 0) {
throw new InvariantError('Quantity must be positive.');
}
this.applyChange(new StockAdded(this.id, { quantity }));
}
// Event Applier
_onProductCreated(event) {
this._name = event.payload.name;
this._stock = event.payload.initialStock;
this._isAvailable = true;
}
// Event Applier
_onStockAdded(event) {
this._stock += event.payload.quantity;
}
// ... more command handlers and event appliers
}
这段代码的问题显而易见:
- 方法与事件的隐式关联:
addStock
方法内部调用applyChange(new StockAdded(...))
,而StockAdded
事件又需要一个名为_onStockAdded
的方法来应用状态。这种基于命名约定的关联是脆弱的。 - 职责分散: 命令处理逻辑(业务规则校验)和事件应用逻辑(状态变更)被定义在不同的方法中,增加了理解代码的认知负荷。
- 样板代码: 每个命令/事件对都需要重复相似的结构。
这个痛点促使我们思考如何优化这一开发体验。
方案 A: 基于基类或高阶函数的封装
这是最直接的方案。我们可以设计一个更智能的 AggregateRoot
基类,或者使用高阶函数来减少一些重复工作。例如,我们可以通过一个映射表来注册命令和事件处理器。
// core/smart-aggregate.js
export class SmartAggregate extends AggregateRoot {
constructor(id) {
super(id);
this._commandHandlers = {};
this._eventAppliers = {};
}
registerCommandHandler(commandName, handler) {
this._commandHandlers[commandName] = handler.bind(this);
}
registerEventApplier(eventName, applier) {
this._eventAppliers[eventName] = applier.bind(this);
}
// 重写 applyChange 来自动查找 applier
applyChange(event) {
const applier = this._eventAppliers[event.constructor.name];
if (!applier) {
// 在真实项目中, 这应该是一个更具体的错误类型
throw new Error(`Missing event applier for ${event.constructor.name}`);
}
applier(event);
super.applyChange(event);
}
}
使用这个基类后,聚合根的定义会变成:
// product.aggregate.v2.js
class Product extends SmartAggregate {
constructor(id) {
super(id);
this._stock = 0;
// ...
this.registerCommandHandler('CreateProduct', this.createProduct);
this.registerCommandHandler('AddStock', this.addStock);
this.registerEventApplier('ProductCreated', this._onProductCreated);
this.registerEventApplier('StockAdded', this._onStockAdded);
}
// ... command handler and event applier implementations
}
方案A的优劣分析:
优点:
- 实现简单,不引入任何构建时依赖。
- 调试直观,代码执行逻辑依然是标准的 JavaScript。
- 显式注册使得命令/事件与处理器的关系更加明确。
缺点:
- 样板代码问题并未根除,只是从命名约定变成了构造函数中的显式注册。
- 聚合根的构造函数会变得越来越臃肿。
- 仍然存在字符串硬编码的问题(
'CreateProduct'
,'ProductCreated'
),容易出错。
这个方案本质上是一种运行时的修补,虽然有所改善,但并未从根本上解决问题。对于一个追求极致开发者体验和长期可维护性的团队来说,这还不够。
方案 B: 利用 Babel 进行代码转换构建声明式 DSL
这个方案的核心思想是,我们不应该手动编写这些连接性质的样板代码,而应该让工具在构建时自动生成它们。我们可以定义一套声明式的、对开发者更友好的语法(领域特定语言, DSL),然后利用 Babel 插件将这种语法转换为最终可执行的、符合我们框架要求的冗长代码。
我们设想的 DSL 可能长这样,使用 Decorators(装饰器)来表达意图:
// product.aggregate.dsl.js - The "After" state we want to write
import { Aggregate, CommandHandler, EventApplier } from './core/decorators';
import { ProductCreated, StockAdded, StockRemoved } from './product.events';
import { InvariantError } from './core/errors';
@Aggregate
export class Product {
constructor(id) {
this.id = id;
this._stock = 0;
this._isAvailable = false;
this._name = null;
}
@CommandHandler(ProductCreated)
createProduct(command) { // command is an instance of CreateProductCommand
if (this._version > -1) {
throw new InvariantError('Product already exists.');
}
// ... validation
yield new ProductCreated(this.id, {
name: command.payload.name,
initialStock: command.payload.initialStock
});
}
@EventApplier(ProductCreated)
_onProductCreated(event) {
this._name = event.payload.name;
this._stock = event.payload.initialStock;
this._isAvailable = true;
}
@CommandHandler(StockAdded)
addStock(command) {
if (this._version === -1) {
throw new InvariantError('Product does not exist.');
}
// ... validation
yield new StockAdded(this.id, { quantity: command.payload.quantity });
}
@EventApplier(StockAdded)
_onStockAdded(event) {
this._stock += event.payload.quantity;
}
}
这里的 @Aggregate
、@CommandHandler
、@EventApplier
都是我们自定义的装饰器。@CommandHandler
甚至可以改成一个 generator 函数,用 yield
来发出事件,这使得命令处理的意图更加清晰。
方案B的优劣分析:
优点:
- 极致的声明性: 开发者只需关注业务逻辑,装饰器清晰地表达了每个方法的作用。
- 消除样板代码: 所有注册和关联逻辑都将被自动生成。
- 强类型关联:
@CommandHandler(ProductCreated)
直接关联事件类,而不是字符串,避免了拼写错误,IDE 也能提供重构支持。 - 关注点分离: 业务开发者编写 DSL,框架维护者负责 Babel 插件,实现了技术细节与业务逻辑的隔离。
缺点:
- 引入构建时依赖: 项目必须集成 Babel 和我们的自定义插件,增加了构建过程的复杂性。
- “魔法”代码: 代码的实际运行行为与源码不完全一致,对新成员或调试来说可能存在一个学习曲线。
- 实现成本高: 开发和维护一个健壮的 Babel 插件需要对 AST (抽象语法树) 有深入的理解。
最终选择与理由
尽管方案B存在技术门槛,但从长远来看,其带来的维护性、代码清晰度和开发效率的提升是巨大的。在一个大型项目中,通过工具链保证代码质量和一致性,远比依赖开发者个人的细心和规范要可靠。因此,我们决定投入资源开发一个内部的 Babel 插件来实现这个 DSL。
核心实现概览
整个实现分为两部分:ES/CQRS 运行时核心库,以及实现 DSL 的 Babel 插件。
1. 运行时核心
这部分是标准的 ES 框架组件,Babel 插件生成的目标代码将依赖于它。
// core/aggregate-root.js
export class AggregateRoot {
constructor(id) {
this.id = id;
this._version = -1;
this._uncommittedEvents = [];
}
get version() {
return this._version;
}
get uncommittedEvents() {
return this._uncommittedEvents;
}
// ... loadFromHistory, markChangesAsCommitted 等方法
// 这个方法是给生成的代码调用的
_apply(event) {
const handlerName = `_on${event.constructor.name}`;
if (typeof this[handlerName] === 'function') {
this[handlerName](event);
} else {
// 在生产环境中,这应该是一个更健壮的日志或错误处理
console.warn(`Missing event applier method: ${handlerName} for event ${event.constructor.name}`);
}
this._version++;
}
applyChange(event) {
this._apply(event);
this._uncommittedEvents.push(event);
}
}
注意这里的 _apply
方法,它回到了方案A之前的命名约定,但这没关系,因为这个约定是由我们的 Babel 插件来强制保证的,而不是由人来遵守。
2. Babel 插件的实现
Babel 插件的核心是操作 AST。它是一个接受 babel
对象作为参数,并返回一个包含 visitor
对象的函数。visitor
对象定义了如何处理 AST 中的特定类型的节点。
目标转换:
我们将把上面的 DSL 代码转换为类似这样的标准 JavaScript 类:
// product.aggregate.dsl.js (Transpiled Output)
import { AggregateRoot } from './core/aggregate-root';
import { ProductCreated, StockAdded } from './product.events';
// ...
export class Product extends AggregateRoot {
constructor(id) {
super(id); //
this._stock = 0;
this._isAvailable = false;
this._name = null;
// --- Injected by Babel Plugin ---
this._commandHandlers = {
'CreateProductCommand': this.__auto_createProduct.bind(this),
'AddStockCommand': this.__auto_addStock.bind(this)
};
this._eventAppliers = {
'ProductCreated': this._onProductCreated.bind(this),
'StockAdded': this._onStockAdded.bind(this)
};
// --- End of Injection ---
}
// The original method is renamed and wrapped
__auto_createProduct(command) {
// Generator function logic is transformed here
const generator = this.createProduct(command);
let result = generator.next();
while (!result.done) {
this.applyChange(result.value);
result = generator.next();
}
}
// Original Command Handler - kept for source map debugging
createProduct(command) {
// ... original user code
}
_onProductCreated(event) {
// ... original user code
}
// ... other handlers and appliers
}
Babel 插件核心逻辑
// babel-plugin-es-dsl.js
const BIND_THIS_HELPER = `
function _bind(self, methods) {
const bound = {};
for (const key in methods) {
bound[key] = methods[key].bind(self);
}
return bound;
}
`;
export default function({ types: t }) {
return {
name: 'babel-plugin-es-dsl',
visitor: {
Class(path) {
// 1. 检查类是否有 @Aggregate 装饰器
if (!path.node.decorators || !path.node.decorators.some(d => d.expression.name === 'Aggregate')) {
return;
}
// 移除 @Aggregate 装饰器
path.node.decorators = path.node.decorators.filter(d => d.expression.name !== 'Aggregate');
// 2. 确保类继承自 AggregateRoot
// (为简化,此处省略。实际项目中需要注入 import 和 extends)
const commandHandlers = {};
const eventAppliers = {};
// 3. 遍历所有类方法
path.traverse({
ClassMethod(methodPath) {
const decorators = methodPath.node.decorators;
if (!decorators) return;
// 处理 @CommandHandler
const cmdHandlerDecorator = decorators.find(d => d.expression.callee && d.expression.callee.name === 'CommandHandler');
if (cmdHandlerDecorator) {
const eventTypeNode = cmdHandlerDecorator.expression.arguments[0];
const eventTypeName = eventTypeNode.name;
const commandTypeName = eventTypeName.replace(/Created|Updated|Removed|Added$/, 'Command'); // Simple convention
const originalMethodName = methodPath.node.key.name;
const newMethodName = `__auto_${originalMethodName}`;
// 记录映射
commandHandlers[commandTypeName] = t.identifier(newMethodName);
// 如果是 generator,包装它
if (methodPath.node.generator) {
const wrapper = t.classMethod(
'method',
t.identifier(newMethodName),
[t.identifier('command')],
t.blockStatement([
t.variableDeclaration('const', [
t.variableDeclarator(t.identifier('generator'), t.callExpression(
t.memberExpression(t.thisExpression(), t.identifier(originalMethodName)),
[t.identifier('command')]
))
]),
t.variableDeclaration('let', [t.variableDeclarator(t.identifier('result'), t.callExpression(
t.memberExpression(t.identifier('generator'), t.identifier('next')), []
))]),
t.whileStatement(
t.unaryExpression('!', t.memberExpression(t.identifier('result'), t.identifier('done'))),
t.blockStatement([
t.expressionStatement(t.callExpression(
t.memberExpression(t.thisExpression(), t.identifier('applyChange')),
[t.memberExpression(t.identifier('result'), t.identifier('value'))]
)),
t.expressionStatement(t.assignmentExpression('=', t.identifier('result'), t.callExpression(
t.memberExpression(t.identifier('generator'), t.identifier('next')), []
)))
])
)
])
);
// 在当前方法后插入包装器方法
methodPath.insertAfter(wrapper);
}
// 移除装饰器
methodPath.node.decorators = methodPath.node.decorators.filter(d => d !== cmdHandlerDecorator);
}
// 处理 @EventApplier
const eventApplierDecorator = decorators.find(d => d.expression.callee && d.expression.callee.name === 'EventApplier');
if (eventApplierDecorator) {
const eventTypeNode = eventApplierDecorator.expression.arguments[0];
eventAppliers[eventTypeNode.name] = t.memberExpression(t.thisExpression(), methodPath.node.key);
// 移除装饰器
methodPath.node.decorators = methodPath.node.decorators.filter(d => d !== eventApplierDecorator);
}
}
});
// 4. 找到构造函数,注入处理器映射
const constructor = path.get('body').get('body').find(p => p.isClassMethod({ kind: 'constructor' }));
if (constructor) {
const commandHandlersObject = t.objectExpression(
Object.entries(commandHandlers).map(([key, value]) =>
t.objectProperty(t.stringLiteral(key), value)
)
);
const eventAppliersObject = t.objectExpression(
Object.entries(eventAppliers).map(([key, value]) =>
t.objectProperty(t.stringLiteral(key), value)
)
);
const commandHandlersAssignment = t.expressionStatement(
t.assignmentExpression(
'=',
t.memberExpression(t.thisExpression(), t.identifier('_commandHandlers')),
t.callExpression(
t.identifier('_bind'), // This needs a helper function injected
[t.thisExpression(), commandHandlersObject]
)
)
);
const eventAppliersAssignment = t.expressionStatement(
t.assignmentExpression(
'=',
t.memberExpression(t.thisExpression(), t.identifier('_eventAppliers')),
t.callExpression(
t.identifier('_bind'),
[t.thisExpression(), eventAppliersObject]
)
)
);
// 确保super()调用之后注入
const superCallIndex = constructor.node.body.body.findIndex(
node => t.isExpressionStatement(node) && t.isCallExpression(node.expression) && t.isSuper(node.expression.callee)
);
if(superCallIndex !== -1) {
constructor.node.body.body.splice(superCallIndex + 1, 0, commandHandlersAssignment, eventAppliersAssignment);
} else {
// 如果没有显式super(), 加到最前面
constructor.node.body.body.unshift(commandHandlersAssignment, eventAppliersAssignment);
}
// 注入 _bind helper 函数到文件顶部
const program = path.findParent((p) => p.isProgram());
program.unshiftContainer('body', t.template.ast(BIND_THIS_HELPER));
}
}
}
};
}
这是一个简化的实现,但展示了核心思路:遍历、识别、转换和注入。一个生产级的插件还需要处理更多的边缘情况,比如没有构造函数、复杂的继承关系、注入 import
语句等。
3. 测试策略
- 插件测试: 使用
@babel/helper-plugin-test-runner
或jest
配合快照测试。为每一种 DSL 写法创建一个输入文件,然后断言 Babel 转换后的输出与预期的快照一致。这是保证插件本身质量的关键。 - 聚合根单元测试: 对转换后的代码进行测试。测试用例依然遵循 Event Sourcing 的 BDD(行为驱动开发)风格:
Given
(历史事件),When
(执行命令),Then
(期望产生的新事件)。这验证了业务逻辑的正确性。
// product.aggregate.test.js
describe('Product Aggregate', () => {
it('should create a product when CreateProductCommand is issued', () => {
// Given
const product = new Product('prod-123');
const command = new CreateProductCommand({ name: 'Laptop', initialStock: 10 });
// When
// 在真实框架中,命令会通过一个 CommandBus 分发
// 这里简化为直接调用
const handler = product._commandHandlers['CreateProductCommand'];
handler(command);
const newEvents = product.uncommittedEvents;
// Then
expect(newEvents).toHaveLength(1);
expect(newEvents[0]).toBeInstanceOf(ProductCreated);
expect(newEvents[0].payload.name).toBe('Laptop');
});
});
架构的扩展性与局限性
这种基于代码生成的 DSL 架构有很好的扩展性。例如,我们可以轻松地增加 @Snapshot
装饰器,当聚合根的版本达到一定阈值时,自动生成创建快照的逻辑。或者增加 @Saga
装饰器来定义流程管理器。
然而,其局限性也同样明显。
首先,它深度绑定了项目的构建工具链。如果未来决定从 Babel 迁移到 SWC 或 esbuild,这个插件就需要重写,这是一个不小的成本。
其次,它创建了一个抽象层。当出现问题时,开发者需要判断是业务逻辑错误,还是 Babel 插件转换过程中的 bug,这无疑增加了调试的复杂度。对于不熟悉 AST 操作的团队成员,维护这个插件本身也是一个挑战。
最后,这种方法不适用于所有场景。对于业务逻辑简单、变化不频繁的小型项目,引入这套复杂的构建时方案是过度设计。它的价值在于能够管理大规模、高复杂度、长生命周期的核心业务系统。