在C#项目里,模块之间如果互相直接引用并调用方法,代码就会迅速变成一张错综复杂的网。发布订阅模式通过事件总线(EventBus)把消息的发送者和接收者拆开,发送方不需要知道谁在处理消息,接收方也不需要知道消息是谁发出的。这种方式特别适合权限、日志、缓存刷新等横切场景。

一、发布订阅与事件总线的基础原理
发布订阅模式包含两个核心角色:发布者(Publisher)和订阅者(Subscriber)。发布者产生一条事件数据,订阅者提前声明自己关心某类事件。传统做法是用C#自带的event关键字,但event绑定在具体的类实例上,跨模块时就得层层传递对象引用。
事件总线相当于一个全局的调度中心。它内部维护一个字典,键是事件类型,值是处理该事件的委托列表。任何模块都能向总线发布事件,也能从总线订阅事件,彼此不需要持有对方引用。这样既保留了C#委托的类型安全,又实现了彻底的解耦。
二、实现一个简单的泛型EventBus
下面用一个轻量的泛型事件总线演示核心机制。我们用Dictionary<Type, List<Delegate>>保存订阅关系,通过泛型方法限制事件数据类型。
using System;
using System.Collections.Generic;
using System.Linq;
// 事件基类,所有自定义事件都继承它
public abstract class EventBase
{
public DateTime OccurredOn { get; } = DateTime.Now;
}
// 订单创建事件
public class OrderCreatedEvent : EventBase
{
public int OrderId { get; set; }
public decimal Amount { get; set; }
}
// 简单的事件总线
public class EventBus
{
private readonly Dictionary<Type, List<Delegate>> _handlers = new Dictionary<Type, List<Delegate>>();
private readonly object _lock = new object();
// 订阅事件
public void Subscribe<T>(Action<T> handler) where T : EventBase
{
lock (_lock)
{
var type = typeof(T);
if (!_handlers.ContainsKey(type))
{
_handlers[type] = new List<Delegate>();
}
_handlers[type].Add(handler);
}
}
// 发布事件
public void Publish<T>(T eventData) where T : EventBase
{
List<Delegate> handlersCopy;
lock (_lock)
{
if (!_handlers.TryGetValue(typeof(T), out var list))
{
return;
}
handlersCopy = list.ToList();
}
foreach (var handler in handlersCopy)
{
((Action<T>)handler).Invoke(eventData);
}
}
}
上面的代码里,Subscribe方法用lock保证多线程下字典操作安全。Publish先复制委托列表再逐个执行,避免处理过程中订阅变化导致异常。你可以这样使用:
var bus = new EventBus();
// 库存模块订阅订单创建事件
bus.Subscribe<OrderCreatedEvent>(e =>
{
Console.WriteLine($"库存模块收到订单{e.OrderId},金额{e.Amount}");
});
// 订单模块只管发布,不引用库存模块
bus.Publish(new OrderCreatedEvent { OrderId = 1, Amount = 99.9m });
这种写法的优点是结构清晰,没有第三方依赖。缺点是事件总线是单例或全局对象时,要注意内存泄漏:如果订阅者被回收但委托还留在总线里,就会引发悬挂引用。
三、与直接接口调用的对比
如果不使用事件总线,订单模块通常会注入一个IInventoryService接口并直接调用。下表列出两种方式的差异:
| 对比维度 | 接口直接调用 | EventBus发布订阅 |
|---|---|---|
| 模块依赖 | 编译期依赖接口 | 零引用依赖 |
| 新增消费者 | 修改调用方代码 | 仅新增订阅,不改发布方 |
| 调试难度 | 调用链直观 | 需查订阅注册位置 |
| 事务控制 | 易用本地事务 | 需额外补偿机制 |
从表里能看出,事件总线在扩展性上优势明显,但在需要强一致性的场景要谨慎。例如扣减库存和创建订单必须在同一个数据库事务里,这时硬拆成事件反而增加复杂度。
实际架构中常见折中方案:核心链路用接口保证事务,辅助链路(如发邮件、写日志)走事件总线。这样既有性能和解耦,又不丢失数据安全。
四、在真实项目中的生命周期管理
ASP.NET应用里可以把EventBus注册为单例服务,在Startup或Program里配置订阅。WinForm或WPF要注意窗体关闭时取消订阅,否则总线会持有窗体引用导致无法回收。
public class EventBusWithUnsubscribe : EventBus
{
// 返回取消订阅的令牌
public IDisposable SubscribeToken<T>(Action<T> handler) where T : EventBase
{
Subscribe(handler);
return new UnsubscribeToken(() => Unsubscribe(handler));
}
public void Unsubscribe<T>(Action<T> handler) where T : EventBase
{
var type = typeof(T);
if (_handlers.ContainsKey(type))
{
_handlers[type].Remove(handler);
}
}
private class UnsubscribeToken : IDisposable
{
private readonly Action _action;
public UnsubscribeToken(Action action) => _action = action;
public void Dispose() => _action();
}
}
通过返回IDisposable,订阅方在using块或窗体Dispose中释放即可自动撤掉订阅。这个模式把内存安全交给语言自身的资源管理机制,比手动调Unsubscribe更不容易遗漏。
另外,如果事件处理可能耗时,建议在Publish里用Task.Run把委托抛到线程池,防止阻塞发布线程。但要注意线程安全和执行顺序,必要时引入Channel或消息队列做异步解耦。
五、总结与落地建议
用C#实现发布订阅并不复杂,核心就是委托加字典。事件总线把这种机制提升为架构级工具,让模块通信从“你调用我”变成“我通知大家”。小型项目可用本文的轻量实现,大型系统可接入Prism的EventAggregator或第三方RabbitMQ桥接。
落地时先识别哪些流程属于通知类而非业务核心,把这些抽成事件;再约定统一的事件命名和版本规则;最后在测试环境验证订阅丢失和异常隔离,确保某个订阅者报错不会拖垮整个发布过程。