RailsEventStore中CQRS事件溯源应用的事件Schema优雅校验方案问询
你完全没忽略关键要点——这种想把领域事件核心数据和RailsEventStore的事件信封分离的思路,完全契合Greg Young提出的事件信封理念,而且确实能避免重复冗余的代码。下面分享几种优雅的实现方式:
1. 封装通用基类,结合Dry::Struct定义领域事件
这种方式把Dry::Struct作为领域事件的Schema载体,用一个通用的DomainEvent基类来封装校验和属性访问逻辑,具体事件类只需要关联对应的Struct即可:
# 通用事件基类,封装Dry::Struct校验与属性委托 class DomainEvent < RailsEventStore::Event # 关联对应的Dry::Struct类 def self.struct_class(struct_class) @struct_class = struct_class end def initialize(event_id: SecureRandom.uuid, metadata: nil, data: {}) # 用Dry::Struct校验并转换数据,失败会抛出Dry::Struct::Error validated_data = self.class.instance_variable_get(:@struct_class).new(data).to_h super(event_id: event_id, metadata: metadata, data: validated_data) end # 委托属性访问到data哈希,直接用event.user_id替代event.data[:user_id] def method_missing(name, *args, &block) data.key?(name) ? data[name] : super end def respond_to_missing?(name, include_private = false) data.key?(name) || super end end # 定义纯领域事件的Schema(Dry::Struct) class UserRegisteredStruct < Dry::Struct attribute :user_id, Dry::Types::String end # 定义RailsEventStore所需的事件信封类,只需关联Struct class UserRegistered < DomainEvent struct_class UserRegisteredStruct end
使用时,你只需要专注于维护UserRegisteredStruct的Schema,事件信封类的代码极简,而且可以直接通过event.user_id访问属性,体验和操作纯Ruby对象一致。
2. 全局中间件校验,无需修改事件类
如果不想给事件类加基类,可以用RailsEventStore的中间件机制,全局拦截事件进行Schema校验:
# 维护事件类与Dry::Struct的映射关系 EVENT_STRUCT_MAPPING = { UserRegistered => UserRegisteredStruct, OrderPlaced => OrderPlacedStruct # 其他事件... }.freeze # 自定义校验中间件 class EventSchemaValidator def initialize(app) @app = app end def call(event) # 找到对应事件的Struct类,执行校验 if (struct_class = EVENT_STRUCT_MAPPING[event.class]) struct_class.new(event.data) end @app.call(event) end end # 在初始化RailsEventStore客户端时注入中间件 Rails.application.config.to_prepare do RailsEventStore::Client.new( middleware: [EventSchemaValidator.new] ) end
这种方式的优势是无需修改现有事件类,校验逻辑集中管理。如果想优化属性访问体验,可以给RailsEventStore::Event扩展一个模块,添加method_missing逻辑,让所有事件都能直接访问data中的属性。
3. 轻量方案:用Dry::Types直接定义Schema
如果觉得Dry::Struct有点重,也可以直接用Dry::Types在事件类中定义Schema,通过模块注入校验逻辑:
# 校验模块,注入到事件类中 module EventDataValidation def self.included(base) base.extend(ClassMethods) end module ClassMethods # 定义data的Schema def data_schema(schema) @data_schema = schema end end def initialize(event_id: SecureRandom.uuid, metadata: nil, data: {}) # 执行Schema校验 validated_data = self.class.instance_variable_get(:@data_schema).call(data) super(event_id: event_id, metadata: metadata, data: validated_data) end # 属性访问优化 def method_missing(name, *args, &block) data.key?(name) ? data[name] : super end def respond_to_missing?(name, include_private = false) data.key?(name) || super end end # 具体事件类,直接定义Schema class UserRegistered < RailsEventStore::Event include EventDataValidation data_schema Dry::Types::Hash.schema( user_id: Dry::Types::String, email: Dry::Types::String.constrained(format: URI::MailTo::EMAIL_REGEXP) ) end
这种方式无需额外定义Struct类,Schema直接写在事件类中,代码更紧凑。
总结
你的思路完全正确:把领域事件的核心数据和RailsEventStore的信封(包含event_id、metadata等框架相关信息)分离,是符合事件溯源最佳实践的。上面的几种方式都能帮你避免冗余的初始化代码,同时优雅地实现Schema校验,还能优化属性访问的体验。
内容的提问来源于stack exchange,提问作者aef

