Orleans之Event Sourcing自定义存储
假设你已经看过上一篇Orleans基础知识,否则本篇看起来可能会稍微有些吃力,如果没看过,请点击Orleans基础知识以及使用示例 阅读
Orleans实现Event Sourcing(事件溯源)机制主要依赖于2个元素:
- Journaled Grains
- Log Consistency Provider
这2个元素相辅相成,协调处理Gray对象的
Journaled Grains(日记颗粒)
Journaled Grain职责:
管理对象的状态,包括从存储中读取以及将新事件应用到对象,同时它还需要管理新事件的持久化存储。
JournaledGrain<TGrainState,TEventBase>,具有以下类型参数:
- 表示 grain 状态的TGrainState,它必须是具有公共默认构造函数的类。
- TEventBase是可为此 grain 引发的所有事件的公用超类型,可以是任何类或接口。
正如它的职责一样,它需要2个参数,一个是状态,一个是事件。
RaiseEvent(引发事件)
在Journaled Grain派生类中调用RaiseEvent(@event)会触发Event Sourcing的日志一致性程序进行事件提交和存储。
RaiseEvent(new DepositTransaction() { DepositAmount = amount, Description = description }); await ConfirmEvents();
Apply(更新状态以响应事件)
GrainState 类可以在StateType上实现一个或多个Apply方法,方法里的代码用来指定如何更新状态以响应事件。通常,用户会创建多个重载,并为事件的运行时类型选择最接近的匹配项:
class GrainState { Apply(E1 @event) { // code that updates the state } Apply(E2 @event) { // code that updates the state } }
其实还可以通过重写Grain 类的
TransitionState
函数,达到相同的效果,但使用此函数需要在内部编写大量swich case才能实现分类处理,不如Apply那样优雅和易于维护。Log Consistency Provider(日志一致性提供程序)
Log Consistency Provider职责:
主要用于协调记录领域对象事件的日志以及(受事件影响的)相关领域模型不断演化的状态。
尽管Orleans官方提供了3种Log Consistency Providers,但让人意外的是,这3种都不适合直接用在实际的生产环境。其中StateStorage.LogConsistencyProvider根本就不作用于对象事件,它只记录对象在应用了事件之后的最终状态,属于一种快照式的实现。而LogStorage.LogConsistencyProvider刚好相反,它只存储对象的所有事件,但不存储对象的状态,属于一种事件流式的实现。唯一可能用于实际生产环境的是第三种CustomStorage.LogConsistencyProvider,这个自定义存储日志一致性提供程序的接口ICustomStorageInterface<StateType, EventType>需要实现2个方法-ReadStateFromStorage和ApplyUpdatesToStorage,看上去似乎并不复杂,但你可能已经猜到了,魔鬼都藏在细节中...
ReadStateFromStorage(从存储中读取状态)
官方文档关于ReadStateFromStorage的说明是:
ReadStateFromStorage 预期会返回读取的版本和状态。 如果尚未存储任何内容,则它应为版本返回零,并且匹配的状态对应于 StateType 的默认构造函数。
考虑到实际生产环境中,我们会同时持久化存储事件流和状态快照,这意味着这个方法必须执行以下几个步骤:
- 创建状态对象(对应version为0)
- 读取状态存储中最新的状态快照(如果有),并更新状态对象的version和快照内容
- 根据version将之后发生的新事件应用(重播一遍)到当前version状态快照
- 对比version,将最新的状态快照重新写入状态存储
- 返回最终的version和状态
这个方法返回的是一个KeyValuePair<int, StateType>,其中int是version(版本号,每应用一个事件版本号+1),StateType是状态对象。
/// <summary> /// Reads the current state and version from storage /// (note that the state object may be mutated by the provider, so it must not be shared). /// </summary> /// <returns>the version number and a state object.</returns> Task<KeyValuePair<int,TState>> ReadStateFromStorage();
ApplyUpdatesToStorage(应用更新到存储)
官方文档关于ApplyUpdatesToStorage的说明是:
如果预期的版本与实际版本不匹配,则 ApplyUpdatesToStorage 必须返回 false(这类似于 e-tag 检查)。
是不是有些看不懂?预期版本是个啥,实际版本指的又是什么,没关系,我找到了源码中更深层次的说明,如下:
如果存储中的版本与预期版本匹配,将给定的增量数组应用于存储,并返回true。否则,不做任何操作b并返回false。如果应用成功,实际存储的版本必须增加增量的数量。
这里的预期版本,指的是ApplyUpdatesToStorage本身的参数,它有2个参数,一个是增量事件数组,一个是预期版本。
/// <summary> /// Applies the given array of deltas to storage, and returns true, if the version in storage matches the expected version. /// Otherwise, does nothing and returns false. If successful, the version of storage must be increased by the number of deltas. /// </summary> /// <returns>true if the deltas were applied, false otherwise</returns> Task<bool> ApplyUpdatesToStorage(IReadOnlyList<TDelta> updates, int expectedversion);
由于ApplyUpdatesToStorage和ReadStateFromStorage一样都是供Orleans的日志一致性提供程序内部运转时调用,因此这2个参数也不需要人为干预,是由日志一致性提供程序内部调用时会自动赋值这两个参数。
IReadOnlyList<DomainEventBase> updates——增量事件列表
int expectedversion——预期版本
预期版本参数表示日志一致性提供程序知道的存储中已经存在的最新事件,Orleans加入这个参数的意义推测是为了,以防万一,集群中一些其他的Silo也运行着同样的JournaledGrain(而且是同样的主键ID),并且已经把一些新的事件写入了存储而当前的Silo却还不知情,这样可能会导致重复写入,所以需要拿系统预期的版本与实际存储的版本做一个对比。
要实现以上所述,这个方法必须执行以下几个步骤:
- 获取存储中最新事件的版本
- 如果获取到的版本与预期的版本不一致,则什么都不做,并返回false
- 如果获取到的版本为0,则新写入事件和状态快照
- 对于updates中的增量事件列表,循环将事件写入存储,并逐步增量更新版本
- 退出并返回true
Event Sourcing自定义存储解决方案示例剖析
项目环境:.Net Core+Orleans Event Sourcing+MySql
示例程序源码地址:https://github.com/MV10/OrleansEventStreamLog
由于本示例是由国外一名40年code经验的大叔根据自身金融从业经验基于Orleans的Event Sourcing编写的简单范例——金融客户管理系统。功能较为完善,代码量较大,因此这里仅针对项目中的关键点作出讲解。
数据库初始化
此示例解决方案采用的是自定义存储,所以相关数据库表结构需要自行设计,主要用到2张表,一张
[dbo].[CustomerEventStream]用来存储事件流,一张[dbo].[CustomerSnapshot]用来存储状态快照。
由于原示例采用的是SqlServer存储,这里我们需要把它换成Mysql,相关Mysql数据表创建Sql脚本如下:
/* Navicat Premium Data Transfer Source Server : 本地Mysql Source Server Type : MySQL Source Server Version : 80029 Source Host : localhost:3306 Source Schema : OrleansESL Target Server Type : MySQL Target Server Version : 80029 File Encoding : 65001 Date: 21/08/2022 17:03:25 */ SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- ---------------------------- -- Table structure for CustomerEventStream -- ---------------------------- DROP TABLE IF EXISTS `CustomerEventStream`; CREATE TABLE `CustomerEventStream` ( `CustomerId` varchar(20) NOT NULL, `ETag` int NOT NULL, `Timestamp` char(33) NOT NULL, `EventType` varchar(10000) CHARACTER SET utf8mb3 COLLATE utf8_general_ci NOT NULL, `Payload` varchar(10000) CHARACTER SET utf8mb3 COLLATE utf8_general_ci NOT NULL, PRIMARY KEY (`CustomerId`,`ETag`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; SET FOREIGN_KEY_CHECKS = 1;
/* Navicat Premium Data Transfer Source Server : 本地Mysql Source Server Type : MySQL Source Server Version : 80029 Source Host : localhost:3306 Source Schema : OrleansESL Target Server Type : MySQL Target Server Version : 80029 File Encoding : 65001 Date: 21/08/2022 17:03:33 */ SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- ---------------------------- -- Table structure for CustomerSnapshot -- ---------------------------- DROP TABLE IF EXISTS `CustomerSnapshot`; CREATE TABLE `CustomerSnapshot` ( `CustomerId` varchar(20) NOT NULL, `ETag` int NOT NULL, `Snapshot` varchar(10000) CHARACTER SET utf8mb3 COLLATE utf8_general_ci NOT NULL, PRIMARY KEY (`CustomerId`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; SET FOREIGN_KEY_CHECKS = 1;
DemoHost项目
作为Orleans服务端程序,单独启动,依赖Microsoft.Orleans.Server包,Program.cs中主要代码配置如下:
var host = Host.CreateDefaultBuilder(args); host.UseOrleans(builder => { builder //silo使用localhost机群 .UseLocalhostClustering() // cluster and service IDs default to "dev" //在没有配置cluster和service的ID情况下,默认cluster和service的ID为"dev" .Configure<EndpointOptions>(options => options.AdvertisedIPAddress = IPAddress.Loopback) //表示使用自定义存储作为默认的日志一致性提供程序 .AddCustomStorageBasedLogConsistencyProviderAsDefault() //依赖注入Grain类和自定义的日志一致性提供程序 .ConfigureApplicationParts(parts => { parts.AddApplicationPart(typeof(CustomerCommands).Assembly).WithReferences(); parts.AddApplicationPart(typeof(CustomerQueries).Assembly).WithReferences(); parts.AddApplicationPart(typeof(CustomerManager).Assembly).WithReferences(); }); });
DomainModel项目
作为领域模型实体类库,定义各种业务实体类、事件类和事件基类,供其他项目使用。
事件基类:
namespace DomainModel.DomainEvents { public class DomainEventBase { public static readonly int NEW_ETAG = -1; public DateTimeOffset Timestamp = DateTimeOffset.Now;//时间戳 public int ETag { get; set; } = NEW_ETAG;//版本号,同Version } }
事件类:(其一举例)
namespace DomainModel.DomainEvents { public class AccountAdded : DomainEventBase { public Account Account; } }
业务实体类:(其一举例)
namespace DomainModel { public class Account { public bool IsPrimaryAccount; public string AccountType; public string AccountNumber; public decimal Balance; } }
ServiceCustomerManager项目
作为Event Sourcing自定义存储机制实现类库,依赖Microsoft.Orleans.Core.Abstractions、Microsoft.Orleans.CodeGenerator.MSBuild和Microsoft.Orleans.EventSourcing包,定义了接口封装,实现了日志一致性存储及应用事件更新到状态。
同时,由于要把项目数据源从SqlServer切换到Mysql,因此还需要引入依赖Mysql.Data包。
ICustomerManager(接口封装):
namespace ServiceCustomerManager { public interface ICustomerManager : IGrainWithStringKey , IEventSourcedGrain<Customer, DomainEventBase> //日志一致性自定义存储接口 , ICustomStorageInterface<CustomerState, DomainEventBase> { } }
CustomerManager(日志一致性自定义存储接口实现类):(涉及Mysql存储调整了部分代码)
namespace ServiceCustomerManager { public class CustomerManager : EventSourcedGrain<Customer, CustomerState, DomainEventBase> , ICustomerManager//封装好的接口 { //数据库连接字符串切换为Mysql //private static string ConnectionString = @"Server=(localdb)\MSSQLLocalDB;Integrated Security=true;Database=OrleansESL"; private static string ConnectionString = @"Server=localhost;DataBase=OrleansESL;uid=root;pwd=12345678;pooling=true;port=3306;CharSet=utf8;sslMode=None;"; private readonly ILogger<CustomerManager> Log; public CustomerManager(ILogger<CustomerManager> log) { Log = log; } //实现ICustomStorageInterface接口方法,从存储中读取状态 public async Task<KeyValuePair<int, CustomerState>> ReadStateFromStorage() { //开启数据库连接 Log.LogInformation("ReadStateFromStorage: start"); using var connection = new MySqlConnection(ConnectionString); await connection.OpenAsync(); //读取数据库存储中最新的状态快照和版本号 var (etag, state) = await ReadSnapshot(connection); Log.LogInformation($"ReadStateFromStorage: ReadSnapshot loaded etag {etag}"); //根据数据库当前版本号,将更高版本的事件逐个应用到当前状态快照 var newETag = await ApplyNewerEvents(connection, etag, state); //将应用后的最新状态和版本号写入数据库存储中 if (newETag != etag) await WriteNewSnapshot(connection, newETag, state); etag = newETag; //关闭数据库连接 await connection.CloseAsync(); Log.LogInformation($"ReadStateFromStorage: returning etag {etag}"); return new KeyValuePair<int, CustomerState>(etag, state); } //实现ICustomStorageInterface接口方法,应用更新到存储 public async Task<bool> ApplyUpdatesToStorage(IReadOnlyList<DomainEventBase> updates, int expectedversion) { //开启数据库连接 Log.LogInformation($"ApplyUpdatesToStorage: start, expected etag {expectedversion}, update count {updates.Count}"); using var connection = new MySqlConnection(ConnectionString); await connection.OpenAsync(); //获取数据库存储中的事件流最新版本号 Log.LogInformation("ApplyUpdatesToStorage: checking persisted stream version"); int ver = await GetEventStreamVersion(connection); Log.LogInformation($"ApplyUpdatesToStorage: persisted version {ver} is expected? {(ver == expectedversion)}"); //如果预期版本与实际存储版本不一致,则直接返回false if (ver != expectedversion) return false; //如果存储的版本号为0 if (ver == 0) { //则新写入一个事件流 Log.LogInformation("ApplyUpdatesToStorage: etag 0 special-case write Initialized event"); await WriteEvent(connection, new Initialized { ETag = 0, CustomerId = GrainPrimaryKey }); //并新写入一个状态快照 Log.LogInformation("ApplyUpdatesToStorage: etag 0 special-case write snapshot"); await WriteNewSnapshot(connection, 0, State); } //循环此次增量事件列表 foreach (var e in updates) { ver++; Log.LogInformation($"ApplyUpdatesToStorage: update ver {ver} event {e.GetType().Name} has etag {e.ETag}"); if (e.ETag == DomainEventBase.NEW_ETAG) { //将事件逐个写入数据库存储 e.ETag = ver; await WriteEvent(connection, e); } } //关闭数据库连接 await connection.CloseAsync(); Log.LogInformation("ApplyUpdatesToStorage: exit"); return true; } //获取JournaledGrain主键 private string GrainPrimaryKey { get => ((Grain)this).GetPrimaryKeyString(); } //读取数据库存储最新的状态快照 private async Task<(int etag, CustomerState state)> ReadSnapshot(MySqlConnection connection) { Log.LogInformation("ReadSnapshot: start"); int etag = 0; var state = new CustomerState(); state.CustomerId = GrainPrimaryKey; using var cmd = new MySqlCommand($"SELECT ETag, Snapshot FROM CustomerSnapshot WHERE CustomerId=@customerId;"); cmd.Connection = connection; cmd.Parameters.AddWithValue("@customerId", GrainPrimaryKey); using var reader = await cmd.ExecuteReaderAsync(); if(reader.HasRows) { Log.LogInformation("ReadSnapshot: found snapshot to load"); await reader.ReadAsync(); etag = reader.GetInt32(0); var snapshot = reader.GetString(1); state = JsonConvert.DeserializeObject<CustomerState>(snapshot, JsonSettings); } await reader.CloseAsync(); Log.LogInformation($"ReadSnapshot: exit returning etag {etag}"); return (etag, state); } //将更高版本的事件逐个应用到当前状态快照 private async Task<int> ApplyNewerEvents(MySqlConnection connection, int snapshotETag, CustomerState state) { //获取大于当前版本号的事件流内容列表 Log.LogInformation($"ApplyNewerEvents: start for etags newer than {snapshotETag}"); using var cmd = new MySqlCommand($"SELECT ETag, Payload FROM CustomerEventStream WHERE CustomerId = @customerId AND ETag > @etag ORDER BY ETag ASC;"); cmd.Connection = connection; cmd.Parameters.AddWithValue("@customerId", GrainPrimaryKey); cmd.Parameters.AddWithValue("@etag", snapshotETag); using var reader = await cmd.ExecuteReaderAsync(); int etag = snapshotETag; if (reader.HasRows) { //循环读取事件流内容 while (await reader.ReadAsync()) { etag = reader.GetInt32(0); var payload = reader.GetString(1); //将事件流Json内容反序列化为事件对象 var eventbase = JsonConvert.DeserializeObject(payload, JsonSettings) as DomainEventBase; Log.LogInformation($"ApplyNewerEvents: applying event {eventbase.GetType()} for etag {etag}"); //调用Grain状态类的Apply方法将事件应用到状态 MethodInfo apply = typeof(CustomerState).GetMethod("Apply", new Type[] { eventbase.GetType() }); apply.Invoke(state, new object[] { eventbase }); } } await reader.CloseAsync(); Log.LogInformation($"ApplyNewerEvents: exit returning etag {etag}"); return etag; } //写入/更新状态快照 private async Task WriteNewSnapshot(MySqlConnection connection, int etag, CustomerState state) { Log.LogInformation($"WriteNewSnapshot: start write for etag {etag}"); var snapshot = JsonConvert.SerializeObject(state, JsonSettings); using var cmd = new MySqlCommand(); cmd.Connection = connection; cmd.CommandText = (etag == 0) ? $"INSERT INTO CustomerSnapshot (CustomerId, ETag, Snapshot) VALUES (@customerId, @etag, @snapshot);" : $"UPDATE CustomerSnapshot SET ETag = @etag, Snapshot = @snapshot WHERE CustomerId = @customerId;"; cmd.Parameters.AddWithValue("@customerId", GrainPrimaryKey); cmd.Parameters.AddWithValue("@etag", etag); cmd.Parameters.AddWithValue("@snapshot", snapshot); await cmd.ExecuteNonQueryAsync(); Log.LogInformation("WriteNewSnapshot: exit"); } //获取数据库存储中最大的版本号 private async Task<int> GetEventStreamVersion(MySqlConnection connection) { // The MAX aggregate returns NULL for no rows, allowing ISNULL to substitute the 0 value, otherwise // ExecuteScalarAsync would return null for an empty recordset //这里需要注意将SQL语句切换为Mysql语法 using var cmd = new MySqlCommand("SELECT ETag FROM CustomerEventStream WHERE CustomerId = @customerId ORDER BY ETag DESC Limit 1;"); cmd.Connection = connection; cmd.Parameters.AddWithValue("@customerId", GrainPrimaryKey); //int etag = (int)await cmd.ExecuteScalarAsync(); //兼容Mysql object etagobj = await cmd.ExecuteScalarAsync(); int etag = 0; if (etagobj != null) { etag = (int)etagobj; } return etag; } //写入事件 private async Task WriteEvent(MySqlConnection connection, DomainEventBase e) { Log.LogInformation($"WriteEvent: start for {e.GetType().Name}"); var payload = JsonConvert.SerializeObject(e, JsonSettings); using var cmd = new MySqlCommand($"INSERT INTO CustomerEventStream (CustomerId, ETag, Timestamp, EventType, Payload) VALUES (@customerId, @etag, @timestamp, @typeName, @payload);"); cmd.Connection = connection; cmd.Parameters.AddWithValue("@customerId", GrainPrimaryKey); cmd.Parameters.AddWithValue("@etag", e.ETag); cmd.Parameters.AddWithValue("@timestamp", e.Timestamp.ToString("o")); cmd.Parameters.AddWithValue("@typeName", e.GetType().Name); cmd.Parameters.AddWithValue("@payload", payload); await cmd.ExecuteNonQueryAsync(); Log.LogInformation("WriteEvent: exit"); } private JsonSerializerSettings JsonSettings { get; } = new JsonSerializerSettings() { TypeNameHandling = TypeNameHandling.All }; } }
这个类,是实现日志一致性提供程序自定义存储的核心,一定要能够看懂并掌握。
CustomerState(状态类):
public class CustomerState : Customer { public void Apply(Initialized e) { // Do nothing, this represents the default constructor } public void Apply(AccountAdded e) { var acct = Accounts.Find(a => a.AccountNumber.Equals(e.Account.AccountNumber)); if(acct == null) Accounts.Add(e.Account); } public void Apply(AccountRemoved e) { Accounts.RemoveAll(a => a.AccountNumber.Equals(e.AccountNumber)); } public void Apply(CustomerCreated e) { PrimaryAccountHolder = e.PrimaryAccountHolder; MailingAddress = e.MailingAddress; } public void Apply(MailingAddressChanged e) { MailingAddress = e.Address; } public void Apply(ResidencePrimaryChanged e) { PrimaryAccountHolder.Residence = e.Address; } public void Apply(ResidenceSpouseChanged e) { Spouse.Residence = e.Address; } public void Apply(SpouseChanged e) { Spouse = e.Spouse; } public void Apply(SpouseRemoved e) { Spouse = null; } public void Apply(TransactionPosted e) { var acct = Accounts.Find(a => a.AccountNumber.Equals(e.AccountNumber)); if (acct != null) acct.Balance = e.NewBalance; } }
主要是指定了,各种事件发生时,如何更新处理对应状态的响应逻辑。
ServiceCustomerAPI项目
作为Grain的操作类库,主要用来供客户端调用,实际的业务场景和触发逻辑都在这里面。
操作类:(其一举例)
public CustomerCommands(IClusterClient clusterClient, ILogger<CustomerCommands> log) { OrleansClient = clusterClient; Log = log; } public async Task<APIResult<Customer>> AddAccount(string customerId, Account account) { try { var mgr = OrleansClient.GetGrain<ICustomerManager>(customerId); //调用RaiseEvent引发事件 await mgr.RaiseEvent(new AccountAdded { Account = account }); //等待事件确认 await mgr.ConfirmEvents(); return new APIResult<Customer>(await mgr.GetManagedState()); } catch (Exception ex) { return new APIResult<Customer>(ex); } }
DemoClient项目
作为Orleans项目的客户端程序,单独启动,负责调用Orleans的Silo服务端,依赖Microsoft.Orleans.Client包。主要相关配置代码如下:
static async Task<IClusterClient> GetOrleansClusterClient() { var client = new ClientBuilder() .ConfigureLogging(logging => { logging .AddFilter("Microsoft", LogLevel.Warning) .AddFilter("Orleans", LogLevel.Warning) .AddFilter("Runtime", LogLevel.Warning) .AddConsole(); }) //和silo配置保持一致 .UseLocalhostClustering() // cluster and service IDs default to "dev" .ConfigureApplicationParts(parts => { parts.AddApplicationPart(typeof(CustomerCommands).Assembly).WithReferences(); parts.AddApplicationPart(typeof(CustomerQueries).Assembly).WithReferences(); parts.AddApplicationPart(typeof(CustomerManager).Assembly).WithReferences(); }) .Build(); await client.Connect(); return client; }
主要调用逻辑相关代码如下:
public static async Task Main(string[] args) { Console.WriteLine("Connecting client."); var clusterClient = await GetOrleansClusterClient(); Console.WriteLine("Retrieving CQRS grains."); var cmd = clusterClient.GetGrain<ICustomerCommands>(0); var query = clusterClient.GetGrain<ICustomerQueries>(0); string id = "12345678"; var exists = await query.CustomerExists(id); Console.WriteLine($"Customer exists? {exists.Output}"); APIResult<Customer> snapshot; if(!exists.Output) { var residence = new Address { Street = "10 Main St.", City = "Anytown", StateOrProvince = "TX", PostalCode = "90210", Country = "USA" }; var person = new Person { FullName = "John Doe", FirstName = "John", LastName = "Doe", Residence = residence, TaxId = "555-55-1234", DateOfBirth = DateTimeOffset.Parse("05/01/1960") }; Console.WriteLine("Creating new customer."); snapshot = await cmd.NewCustomer(id, person, residence); } else { Console.WriteLine("Retrieving customer."); snapshot = await query.FindCustomer(id); } if(snapshot.Success) { Console.WriteLine($"Customer name: {snapshot.Output.PrimaryAccountHolder.FullName}"); } else { Console.WriteLine($"Exception:\n{snapshot.Message}"); } await clusterClient.Close(); clusterClient.Dispose(); if(!Debugger.IsAttached) { Console.WriteLine("\n\nPress any key to exit..."); Console.ReadKey(true); } }
运行
先后运行DemoHost和DemoClient项目,即可在数据库看到Event Sourcing自定义存储效果:
本文为作者月井石原创,转载请注明出处~