利用 Babel 插件为 Event Sourcing 聚合根构建声明式 DSL


在维护一个演进了数年的大型系统中,业务逻辑的复杂性往往直接映射为代码的复杂性。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
}

这段代码的问题显而易见:

  1. 方法与事件的隐式关联: addStock 方法内部调用 applyChange(new StockAdded(...)),而 StockAdded 事件又需要一个名为 _onStockAdded 的方法来应用状态。这种基于命名约定的关联是脆弱的。
  2. 职责分散: 命令处理逻辑(业务规则校验)和事件应用逻辑(状态变更)被定义在不同的方法中,增加了理解代码的认知负荷。
  3. 样板代码: 每个命令/事件对都需要重复相似的结构。

这个痛点促使我们思考如何优化这一开发体验。

方案 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-runnerjest 配合快照测试。为每一种 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 操作的团队成员,维护这个插件本身也是一个挑战。
最后,这种方法不适用于所有场景。对于业务逻辑简单、变化不频繁的小型项目,引入这套复杂的构建时方案是过度设计。它的价值在于能够管理大规模、高复杂度、长生命周期的核心业务系统。


  目录