EventBus + Redis发布订阅模式
使用Redis配合事件总线去实现下具体的业务。
-
需求
一个简单的电商,有几个重要的需求点
商品下单后TODO
- 存储订单信息
- 锁定商品库存
- 消息推送商家端
订单支付后TODO
- 存储订单支付信息
- 商品库存减少
- 消息推送商家端
- 会员积分调整
技术思路
这里用控制台实现上面的业务功能外,自行编写一个基于C#反射特性的事件总线,方便具体业务事件的后续扩展,比如订单支付后后续还要加会员消息推送啥的。使用Redis的发布订阅模式对事件处理进行异步化,提升执行性能。
所以最终技术架构就是 事件总线+Redis发布订阅。
完成事件总线
这里先不急着将上面的订单、支付、会员 等进行建模。先将事件总线的架子搭好。首先需要理解事件总线在业务系统的目的是什么。
事件总线存在目的最重要的就是解耦 。我们需要实现的效果就是针对指定事件源对象触发事件后,但凡注册了该事件参数的事件处理类则开始执行相关代码。
下图可以看出我们的事件处理类均需要引用事件参数,所有事件处理类都是基于对事件参数处理的需求上来的。
但是!并不是意味创建了事件处理类就一定会去执行!能否执行除了取决于事件源的触发外就是必须有一层注册(也可称映射)。
在WinForm程序里处处可见事件的绑定,如 this.button1.OnClick+=button1OnClick;
那么在这里我将绑定事件放置到一个字典里。C#的字典Dictionary是个key value的键值对数据集合,键和值都可以是任意数据类型。
我们可以将事件处理类EventHandle和事件参数EventData作为键和值存储到字典里。在事件源触发时根据EventData反向找出所有的EventHandle
思路就是这样,开始编码了。
定义事件参数接口,后续具体业务的事件参数接口均要继承它。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
/// <summary> /// 事件参数接口 /// </summary> public interface IEventData { /// <summary> /// 事件源对象 /// </summary> object Source { get ; set ; } ///// <summary> ///// 事件发生的数据 ///// </summary> //TDataModel Data { get; set; } /// <summary> /// 事件发生时间 /// </summary> DateTime Time { get ; set ; } } |
需要一个事件处理接口,后续具体业务的事件处理接口均需要继承它
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
/// <summary> /// 事件实现接口 /// </summary> public interface IEventHandle<T> where T : IEventData { /// <summary> /// 处理等级 /// 方便事件总线触发时候可以有序的执行相应 /// </summary> /// <returns></returns> int ExecuteLevel { get ; } /// <summary> /// 事件执行 /// </summary> /// <param name="eventData">事件参数</param> void Execute(T eventData); } |
现在已经将事件参数和事件处理都抽象出来了,接下来是要实现上面说的注册容器的实现了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
/// <summary> /// 事件仓库 /// </summary> public interface IEventStore { /// <summary> /// 事件注册 /// </summary> /// <param name="handle">事件实现对象</param> /// <param name="data">事件参数</param> void EventRegister(Type handle, Type data); /// <summary> /// 事件取消注册 /// </summary> /// <param name="handle">事件实现对象</param> void EventUnRegister(Type handle); /// <summary> /// 获取事件处理对象 /// </summary> /// <param name="data"></param> /// <returns></returns> Type GetEventHandle(Type data); /// <summary> /// 根据事件参数获取事件处理集合 /// </summary> /// <typeparam name="TEventData">事件参数类型</typeparam> /// <param name="data">事件参数</param> /// <returns></returns> IEnumerable<Type> GetEventHandleList<TEventData>(TEventData data); } |
实现上面的接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
|
/// <summary> /// 基于反射实现的事件仓储 /// 存储事件处理对象和事件参数 /// </summary> public class ReflectEventStore : IEventStore { private static Dictionary<Type, Type> StoreLst; public ReflectEventStore() { StoreLst = new Dictionary<Type, Type>(); } public void EventRegister(Type handle, Type data) { if (handle == null || data == null ) throw new NullReferenceException(); if (StoreLst.Keys.Contains(handle)) throw new ArgumentException($ "事件总线已注册类型为{nameof(handle)} !" ); if (!StoreLst.TryAdd(handle, data)) throw new Exception($ "注册{nameof(handle)}类型到事件总线失败!" ); } public void EventUnRegister(Type handle) { if (handle == null ) throw new NullReferenceException(); StoreLst.Remove(handle); } public Type GetEventHandle(Type data) { if (data == null ) throw new NullReferenceException(); Type handle = StoreLst.FirstOrDefault(p => p.Value == data).Key; return handle; } public IEnumerable<Type> GetEventHandleList<TEventData>(TEventData data) { if (data == null ) throw new NullReferenceException(); var items = StoreLst.Where(p => p.Value == data.GetType()) .Select(k => k.Key); return items; } } |
根据上面代码可以看出来,我们存储到Dictionary内的是Type类型,GetEventHandleList方法最终获取的是一个List<Type>的集合。
我们需要在下面创建的EventBus类里循环List<Type>并且执行这个事件处理类的Execute方法。
实现EventBus
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
/// <summary> /// 事件总线服务 /// </summary> public class EventBus : ReflectEventStore { public void Trigger<TEventData>(TEventData data, SortType sort = SortType.Asc) where TEventData : IEventData { // 这里如需保证顺序执行则必须循环两次 - -.... var items = GetEventHandleList(data).ToList(); Dictionary< object , Tuple<Type, int >> ds = new Dictionary< object , Tuple<Type, int >>(); foreach ( var item in items) { var instance = Activator.CreateInstance(item); MethodInfo method = item.GetMethod( "get_ExecuteLevel" ); int value = ( int )method.Invoke(instance, null ); ds.Add(instance, new Tuple<Type, int >(item, value)); } var lst = sort == SortType.Asc ? ds.OrderBy(p => p.Value.Item2).ToList() : ds.OrderByDescending(p => p.Value.Item2).ToList(); foreach ( var k in lst) { MethodInfo method = k.Value.Item1.GetMethod( "Execute" ); method.Invoke(k.Key, new object [] { data }); } } } |
上面可以看到,我们的事件总线是支持对绑定的事件处理对象进行有序处理,需要依赖下面这个枚举
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
/// <summary> /// 排序类型 /// </summary> public enum SortType { /// <summary> /// 升序 /// </summary> Asc = 1, /// <summary> /// 降序 /// </summary> Desc = 2 } |
好了,至此,我们的简易版的事件总线就出来了~ 接下来就是去建模、实现相应的事件参数和事件处理类了。
创建订单模型:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
/// <summary> /// 订单模型 /// </summary> public class OrderModel { /// <summary> /// 订单ID /// </summary> public Guid Id { get ; set ; } /// <summary> /// 用户ID /// </summary> public Guid UserId { get ; set ; } /// <summary> /// 订单创建时间 /// </summary> public DateTime CreateTime { get ; set ; } /// <summary> /// 商品名称 /// </summary> public string ProductName { get ; set ; } /// <summary> /// 购买数量 /// </summary> public int Number { get ; set ; } /// <summary> /// 订单金额 /// </summary> public decimal Money { get ; set ; } } |
创建订单下单事件参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
public interface IOrderCreateEventData : IEventData { /// <summary> /// 订单信息 /// </summary> OrderModel Order { get ; set ; } } /// <summary> /// 订单创建事件参数 /// </summary> public class OrderCreateEventData : IOrderCreateEventData { public OrderModel Order { get ; set ; } public object Source { get ; set ; } public DateTime Time { get ; set ; } } |
OK~接下来就是实现我们上面需求上的那些功能了。
- 存储订单信息
- 锁定商品库存
- 消息推送商家端
这里我不实现存储订单信息的事件处理对象,我默认此业务必须同步处理,至于后面两个则可以采取异步处理。通过下面代码创建相应的事件处理类。
订单创建事件之消息推送商家端处理类。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
/// <summary> /// 订单创建事件之消息处理类 /// </summary> public class OrderCreateEventNotifyHandle : IEventHandle<IOrderCreateEventData> { public int ExecuteLevel { get ; private set ; } public OrderCreateEventNotifyHandle() { Console.WriteLine($ "创建OrderCreateEventNotifyHandle对象" ); this .ExecuteLevel = 2; } public void Execute(IOrderCreateEventData eventData) { Thread.Sleep(1000); Console.WriteLine($ "执行订单创建事件之消息推送!订单ID:{eventData.Order.Id.ToString()},商品名称:{eventData.Order.ProductName}" ); } } |
订单创建消息之锁定库存处理类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
/// <summary> /// 订单创建事件 锁定库存 处理类 /// </summary> public class OrderCreateEventStockLockHandle : IEventHandle<IOrderCreateEventData> { public int ExecuteLevel { get ; private set ; } public OrderCreateEventStockLockHandle() { Console.WriteLine($ "创建OrderCreateEventStockLockHandle对象" ); this .ExecuteLevel = 1; } public void Execute(IOrderCreateEventData eventData) { Thread.Sleep(1000); Console.WriteLine($ "执行订单创建事件之库存锁定!订单ID:{eventData.Order.Id.ToString()},商品名称:{eventData.Order.ProductName}" ); } } |
OK~ 到main方法下开始执行订单创建相关代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
static void Main( string [] args) { Guid userId = Guid.NewGuid(); Stopwatch stopwatch = new Stopwatch(); stopwatch.Start(); EventBus eventBus = new EventBus(); eventBus.EventRegister( typeof (OrderCreateEventNotifyHandle), typeof (OrderCreateEventData)); eventBus.EventRegister( typeof (OrderCreateEventStockLockHandle), typeof (OrderCreateEventData)); var order = new Order.OrderModel() { CreateTime = DateTime.Now, Id = Guid.NewGuid(), Money = ( decimal )300.00, Number = 1, ProductName = "鲜花一束" , UserId = userId }; Console.WriteLine($ "模拟存储订单" ); Thread.Sleep(1000); eventBus.Trigger( new OrderCreateEventData() { Order = order }); stopwatch.Stop(); Console.WriteLine($ "下单总耗时:{stopwatch.ElapsedMilliseconds}毫秒" ); Console.ReadLine(); } |
至此,我们采取事件总线的方式成功将需求实现了,执行后结果如下:
可以看到我们的下单总耗时是3038毫秒,如您所见,我们解决了代码的耦合性但是没有解决代码的执行效率。
Redis基础命令
Redis有两种方式可支持我们实现MQ功能,1、使用列表(List)相关命令特性;2、使用publish、subscribe命令特性;
这里我是采取列表相关命令实现。
使用列表(List)相关命令的特性实现
- 压入数据(发布消息)
使用列表(List)的LPUSH
RPUSH
命令可以从列表左边和右边压入数据;
LPUSH
将一个或多个值插入到列表头部(此处可以将列表想象成一个从左到右的链表数据结构,LPUSH就是将指定的值插入最左侧!)
如下命令,将多个元素压入list1的头部(最左侧)
1
|
LPUSH list1 测试1 测试2 |
执行结果如下:
上面是写入多个元素,我们也可以写入单个元素
1
|
LPUSH list1 测试3 |
需要留意,每次执行完LPUSH后,Redis会返回当前列表的长度。
RPUSH
在指定列表的尾部(相当于一个链表的最右侧)添加单个或多个元素
如下命令,还是在list1上添加多个元素,并查看执行后的list1元素信息
1
|
RPUSH list 测试4 测试5 |
同理,RPUSH也可直接写入单个元素,和LPUSH一样。
- 拉取数据(消费数据)
这里的拉取数据不单单是读取List内的元素,而是将元素从列表中取出来
BLPOP
移出并获取列表的第一个元素(从左至右), 如果列表没有元素会阻塞当前线程,直到等待超时或发现可弹出元素为止。
如下命令,从list1这个列表获取从左至右第一个元素,在100秒内如果获取则结束阻塞,否则阻塞到100秒之后。
1
|
BLPOP list1 100 |
执行结果如下:
需要留意的是BLPOP命令如果拉取到数据则会返回两行数据,1行为列表的key名称,1行为获取到的元素值。如果直到阻塞结束都没有获取到元素值则直接返回命令执行超时。如下图:
BRPOP
移出并获取列表的最后一个元素(从左至右), 如果列表没有元素会阻塞当前线程直到等待超时或发现可弹出元素为止。该命令与BLPOP除了获取的元素位置不同,其他特性全部一致。
LPOP
移出并获取列表的第一个元素(从左至右),如获取到元素则返回元素信息,没有元素则立即返回null。
如下命令:
1
|
LPOP list1 |
RPOP
移出并获取列表的最后一个元素(从左至右),如获取到元素则返回元素信息,没有元素则立即返回null。该命令与LPOP除了获取的元素位置不同其他特性全部一致;
RPOPLPUSH
移除列表的最后一个元素(最右侧的元素),并将该元素添加到另一个列表并返回。该命令如获取到元素则返回元素信息,否则返回错误信息。
可以通过RPOPLPUSH这个命令的特性对MQ内一致性要求较高的业务进行处理,在从列表获取元素成功后将该元素添加到一个备份列表,在业务处理完毕后再从备份列表将该元素删除。
执行下面命令测试下:
1
|
RPOPLPUSH list1 listback |
BRPOPLPUSH
从列表中弹出一个值,将弹出的元素插入到另外一个列表中并返回它; 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。
该命令其实就是在BRPOP的基础上将LPUSH的功能加上了,依旧也保留了指定超时时间内未获取到元素则阻塞线程。
执行下面命令测试下:
1
|
BRPOPLPUSH list1 listback 10 |
执行结果如下:
完善代码
基于上面Redis的相关命令,我们再完善下上篇博客的代码。这里我们需要新增一个控制台启动项,将它作为消费服务,原来的控制台即订单保存的控制台作为消息发布的服务。
下单代码更改为下面的样子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
/// <summary> /// 异步方式触发订单相关事件 /// </summary> public static void AsynEventHandle() { Guid userId = Guid.NewGuid(); Stopwatch stopwatch = new Stopwatch(); stopwatch.Start(); var order = new OrderModel() { CreateTime = DateTime.Now, Id = Guid.NewGuid(), Money = ( decimal )300.00, Number = 1, ProductName = "鲜花一束" , UserId = userId }; Console.WriteLine($ "模拟存储订单【采取Redis做消息队列的异步方式】" ); Thread.Sleep(1000); FullRedis fullRedis = new FullRedis( "127.0.0.1:6379" , "" , 1); //这里尝试过使用redis 的订阅发布模式,在执行发布命令时候发现值但凡出现空格或者"符号则会异常... fullRedis.LPUSH( "orders" , new OrderModel[] { order }); stopwatch.Stop(); Console.WriteLine($ "下单总耗时:{stopwatch.ElapsedMilliseconds}毫秒" ); Console.ReadLine(); } |
可以看到,我们已经将事件总线相关代码给移除了,上面代码除了向Redis的队列(List)里写入元素外就只是对订单进行了持久化动作,所以看代码就知道执行效率的提升了。
接下来,看消费服务的代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
static void Main( string [] args) { XTrace.UseConsole(); Console.WriteLine( "进入Redis消息订阅者模式订单消息推送订阅者客户端!" ); EventBus eventBus = new EventBus(); eventBus.EventRegister( typeof (OrderCreateEventNotifyHandle), typeof (OrderCreateEventData)); eventBus.EventRegister( typeof (OrderCreateEventStockLockHandle), typeof (OrderCreateEventData)); FullRedis fullRedis = new FullRedis( "127.0.0.1:6379" , "" , 1); fullRedis.Log = XTrace.Log; fullRedis.Timeout = 30000; OrderModel order = null ; while (order == null ) { order = fullRedis.BLPOP<OrderModel>( "orders" , 20); if (order != null ) { Console.WriteLine($ "得到订单信息:{JsonConvert.SerializeObject(order)}" ); //执行相关事件 eventBus.Trigger( new OrderCreateEventData() { Order = order, }); //再次设置为null方便循环读取 order = null ; } } Console.ReadLine(); } |
消费服务首先从Redis里通过BLPOP从orders列表中获取元素,再触发事件总线,执行订单保存相关业务处理。
最终看下执行效率如何?
消息发布的执行效率(订单保存)
消息消费
可以看到目前消息发布的执行效率下单总耗时间为1170毫秒,我们再改为同步的测试下结果:
可以看到,同步执行的结果是3035毫秒。
小结
两种方式相差了将近2000毫秒~ 而且后续如果再继续扩展订单存储相关处理的话同步执行的响应时间会更加拉长,而采取Redis MQ的方式配合事件总线我们可以将整个业务拆分为独立的应用,采取分布式的方式提高响应效率,同时事件总线的加入方便我们后续业务的扩展。
消息发布端将订单信息写入到列表后如果消息消费者在拉取到数据后业务执行过程中代码出现异常导致无法满足业务的完整性如何处理
答:可以使用上述Redis命令中的RPOPLPUSH或BRPOPLPUSH在拉取元素后写入到一个备份的列表中,在我们的逻辑代码执行完毕后在将备份列表中的该元素值移除。
上述代码已发布到Github,有需要的自行下载。