写这篇东西的起因是,前阵子我接手了一个老项目的维护,里面有一段业务逻辑几乎每跑一次就会在原处堆上三四行日志代码,成员说改起来很“酸爽”。我把那几处日志全部重构掉之后,发现代码立刻清爽了不少,但功能一点没少。核心思路就是标题里那四个字:事件与委托。C# 的事件和委托经常被初学者当成“难啃的骨头”,其实它们就是我们给代码装上的“通知线”和“回调钩子”。而把它们和日志记录结合起来,可以做到在不侵入核心业务的前提下,精准记录每一次关键的调用和状态变化。这篇文章我会从实际场景出发,把这套玩法的原理、实现、坑点和取舍都讲透,适合刚接触委托事件的初级开发者,也适合想给现有系统做低侵入式日志改造的从业者。
1. 先说结论:事件与委托“用来添加日志”到底解决了什么问题
你可能会想:加日志而已,直接在方法里写log.Info("...")不就行了?确实可以。但大部分项目里,日志代码会逐渐失控:业务方法里夹杂着十几行格式化字符串、判断日志级别、拼接上下文信息,真正的业务逻辑反而被淹没。更麻烦的是,如果有一天想统一给所有关键操作追加操作人、耗时、耗时阈值告警,你得一个个方法去找、去改,改不干净还容易漏。
用事件和委托来解决日志问题的本质,是把“业务发生了什么”和“业务之后要做什么”解耦。业务模块只需要在合适的时机触发一个事件或调用一个委托,日志模块作为外部订阅者/包装者来决定怎么记录、记录多少。这样业务代码不用知道日志是什么、怎么写,日志代码也不用钻进业务内部。
我在实际项目里做的第一版改造其实特别朴素:找出业务中那些“开始处理”和“处理结束”的边界,把它们改造成事件触发点,然后专门写一个日志订阅者,把原来散落在各处logger.Info("begin...")之类的调用全部收拢到一个地方。运行结果没变,但可维护性提升了一个量级。
2. 前置:委托与事件的最小模型,搞懂这里才知道怎么下手
2.1 委托就是“可以传来传去的方法引用”
委托这个概念,你可以把它理解成“函数指针”的类型安全版本。比如我定义一个委托:
public delegate void LogHandler(string message);这个LogHandler能引用的方法必须满足“一个 string 参数、没有返回值”的签名。然后:
LogHandler logHandler = Console.WriteLine; logHandler("你好");委托真正的威力在于它可以被作为参数传递。比如我希望在业务代码里留下一个“钩子”,让外部决定要不要记录什么:
public void DoSomething(LogHandler optionalLogger) { // 核心业务 optionalLogger?.Invoke("DoSomething 执行完成"); }调用方可以传一个不记录任何东西的空操作,也可以传一个写文件、写数据库的实现,业务代码本身完全不需要关心。
2.2 事件:加了访问控制的多播委托
事件可以理解为被类“受控”的委托。外部可以订阅它(+=)或取消订阅(-=),但不能直接赋值覆盖已有的订阅者,更不能在类外部随意触发它。这一点很重要,它就是日志记录能够安全接入的关键:
public class Order { public event EventHandler<OrderStatusChangedEventArgs>? StatusChanged; }EventHandler<T>是 C# 内置的委托类型,它的签名是void(object? sender, T e)。之所以推荐使用内置的EventHandler而不是自己发明委托类型,是因为整个生态都遵循这个约定,后来的维护者一看就懂。
2.3 委托字段和事件的差别,一张表说清楚
我在重构时发现很多人分不清这两种写法该怎么选,所以顺手整理了一张对照表:
| 对比点 | 委托字段 | 事件 |
|---|---|---|
| 外部赋值 | 可以直接obj.Callback = x覆盖 | 只能用+=/-=,不能覆盖 |
| 外部触发 | 可以直接obj.Callback()调用 | 不允许,只能在类内部触发 |
| 订阅管理 | 容易误覆盖,容易被外部清空 | 相对安全,适合作为对外通知的入口 |
| 适用场景 | 回调、模板方法、策略注入 | 属性变化通知、状态变更广播 |
一句话:如果只是想让外部传入一个“方法”让你调用,用委托字段就够了;如果想对外广播“我这里有事情发生了,谁来关心都可以”,请使用事件。
3. 第一个玩法:用委托包装器给方法执行加上日志
3.1 一个可复用的日志执行器
我们先不看事件,只看委托。我经常在项目里写一个小工具,作用是“把一个方法包起来,自动记录开始和结束”:
public static class LogExecutor { public static T Execute<T>(string stepName, Func<T> action, Action<string> logger) { logger($"开始:{stepName}"); var stopwatch = System.Diagnostics.Stopwatch.StartNew(); try { T result = action(); stopwatch.Stop(); logger($"完成:{stepName},耗时 {stopwatch.ElapsedMilliseconds} ms"); return result; } catch (Exception ex) { stopwatch.Stop(); logger($"失败:{stepName},耗时 {stopwatch.ElapsedMilliseconds} ms,异常:{ex}"); throw; } } }注意这里的几个巧思:
- 泛型
T让这个包装器既能处理void方法(封装成Action再返回bool或直接单独写一个重载),也能处理有返回值的方法。 - 用
Stopwatch计时,这是排查性能问题的利器,我后来几乎每个关键步骤都会包一层。 catch之后必须throw,不能把异常吞掉。日志模块只负责记录,不改变业务流程。- 日志委托由调用方传入,这样测试时就传一个往控制台写的函数,生产环境就传真正的日志组件,可替换性很强。
3.2 一个完整调用示例
比如原来有这样一段业务代码:
public Order CreateOrder(OrderRequest request) { // 各种订单校验、库存扣减、落库 // ...核心逻辑... return order; }改造后就是:
public Order CreateOrder(OrderRequest request) { return LogExecutor.Execute("创建订单", () => { // 各种订单校验、库存扣减、落库 // ...核心逻辑... return order; }, _logger.LogInfo); }业务方法内部没有出现任何日志框架的调用,核心逻辑保持纯净。这个模式其实就是简易版的“面向切面编程”,虽然比不上专门的 AOP 框架,但对于绝大多数中小项目来说,成本低、理解成本低,已经非常够用。
3.3 补充上下文信息的技巧
实际用的时候你会发现,只记录“开始”“结束”还不够,排查问题需要知道是哪个订单、哪个用户。我建议通过委托签名把上下文传进去,比如:
public static void ExecuteWithContext( string stepName, Func<IDictionary<string, object>, object?> action, ...)或者更简洁的方式是使用传入的 lambda 特性,让外部捕获上下文变量。这一点在日志里尤其重要:日志的价值不在“有”,而在于“能不能帮你还原现场”。
4. 第二个玩法:用事件记录对象状态变化
如果你的需求不是“某个方法执行了”,而是“某个对象状态发生了改变”,那委托包装器就不太合适,这时事件才是正道。
4.1 自定义事件参数,把变化细节带上
标准的事件参数一般是EventArgs子类,里面携带“从什么状态变到什么状态”的信息。我写了个例子,模拟订单状态流转:
public enum OrderStatus { Pending, Paid, Shipped, Completed } public class OrderStatusChangedEventArgs : EventArgs { public OrderStatus OldStatus { get; } public OrderStatus NewStatus { get; } public DateTime ChangedTime { get; } public OrderStatusChangedEventArgs(OrderStatus oldStatus, OrderStatus newStatus) { OldStatus = oldStatus; NewStatus = newStatus; ChangedTime = DateTime.UtcNow; } } public class Order { private OrderStatus _status; public event EventHandler<OrderStatusChangedEventArgs>? StatusChanged; public void UpdateStatus(OrderStatus newStatus) { if (_status == newStatus) return; var oldStatus = _status; _status = newStatus; OnStatusChanged(oldStatus, newStatus); } protected virtual void OnStatusChanged(OrderStatus oldStatus, OrderStatus newStatus) { StatusChanged?.Invoke(this, new OrderStatusChangedEventArgs(oldStatus, newStatus)); } }注意几个细节:
UpdateStatus里先判断状态是否真的变化了,避免无意义的日志和事件广播。OnStatusChanged使用?.Invoke,这是 C# 6 以后的空安全调用方式。事件没有任何订阅者时不会抛异常。- 事件参数里带上了变化时间。我强烈建议把业务上关心的上下文都塞进事件参数里,日志订阅者不需要去访问业务对象内部,尽量做到“事件一经触发,信息自包含”。
4.2 日志订阅者登场
记录日志的代码就变得非常干净:
public class OrderLogger { public void Subscribe(Order order) { order.StatusChanged += OnOrderStatusChanged; } private void OnOrderStatusChanged(object? sender, OrderStatusChangedEventArgs e) { _logger.LogInfo($"订单状态变化:{e.OldStatus} -> {e.NewStatus},时间:{e.ChangedTime}"); } }这段代码没有任何业务逻辑,就是纯粹的记录。如果未来要增加“状态变更后发送通知”或“推送到监控大屏”,你只需要再写一个订阅者,用+=挂上去,业务代码一行都不用改。这就是事件解耦的实际价值。
4.3 完整的实体变更日志实践
我在某个模拟项目里实际做过一个更完整的版本:一个EntityChangeTracker,订阅多个实体的变化事件,统一收集变化记录,最终一次性写入日志表。这个模式非常适用于需要审计日志(谁在什么时间把什么字段从 A 改成了 B)的系统。
实体属性变更事件 ——> 跟踪器收集变更对 ——> 组装审计日志 ——> 落库不过这里我不画流程图,直接说实现思路。首先是给实体定义一个通用的属性变更事件:
public class EntityPropertyChangedEventArgs : EventArgs { public string EntityId { get; } public string PropertyName { get; } public object? OldValue { get; } public object? NewValue { get; } public EntityPropertyChangedEventArgs(string entityId, string propertyName, object? oldValue, object? newValue) { EntityId = entityId; PropertyName = propertyName; OldValue = oldValue; NewValue = newValue; } }然后实体类的属性 setter 里触发事件:
public string Name { get => _name; set { if (_name == value) return; var old = _name; _name = value; PropertyChanged?.Invoke(this, new EntityPropertyChangedEventArgs(Id, nameof(Name), old, value)); } }最后日志跟踪器订阅这个事件,统一记录。这样做的好处是实体不依赖任何日志框架,你可以随时把日志实现替换成其他方案,甚至可以让同一事件被多个监听者使用。
4.4 多订阅者对日志结果的影响
事件是支持多播的,也就是说一个事件可以挂多个订阅者。这意味着日志订阅者可以和安全通知订阅者并存,互不干扰。但要注意顺序问题:+=的订阅顺序通常就是触发顺序,如果你想保证日志一定先执行,那就要在订阅顺序上多留个心眼,或者在事件参数里设计好阶段标记。我在实际中见过为了“先干 A 再干 B”而用委托字段手动维护调用链的代码,结果一多起来就很难维护,后面还是换成了事件订阅者并显式控制注册顺序。
5. 异常处理和异步日志:最容易出事的两块
5.1 事件处理程序里抛异常会直接传导给触发者
这一点很多人会忽略。事件处理程序本质上是普通方法调用,如果一个订阅者内部抛了异常,异常会顺着调用栈直接抛给事件的触发方(也就是业务代码)。这就非常尴尬了:日志系统出问题,业务也跟着崩。
为了避免这种情况,我建议在业务侧触发事件时做一次防护,比如遍历调用列表逐个 Invoke,并用 try-catch 包住:
protected virtual void OnStatusChanged(OrderStatus oldStatus, OrderStatus newStatus) { var args = new OrderStatusChangedEventArgs(oldStatus, newStatus); var handlers = StatusChanged?.GetInvocationList(); if (handlers == null) return; foreach (EventHandler<OrderStatusChangedEventArgs> handler in handlers) { try { handler(this, args); } catch (Exception ex) { // 这里记录异常,但不能再把它抛出去影响业务 _fallbackLogger.LogError(ex, "事件处理程序发生异常"); } } }GetInvocationList()在这里的价值是:即使第一个订阅者炸了,后面的订阅者依然可以执行。日志订阅者通常是最不希望拖垮业务的,所以这种“挨个调用、互不连坐”的写法非常适合事件总线类场景。
5.2 异步事件记录的正确姿势
事件里搞异步是个深坑。最常见的问题是很多人直接在事件处理程序里写async void:
order.StatusChanged += async (s, e) => { await _logger.LogAsync(e); // 这样写很危险 };async void的异常无法被捕获,一旦LogAsync内部出错,整个进程都可能被砸掉。更稳妥的做法是给事件设计一个异步版本的委托,或者把日志写入放入一个可靠的后台队列:
public delegate Task AsyncEventHandler<TEventArgs>(object? sender, TEventArgs e);然后在触发时用一个统一的方法 await 所有处理器。不过这需要业务代码本身就支持异步。如果业务代码是同步的,一个折中方案是使用Task.Run把日志任务丢到线程池,但要注意这样日志就变成“尽力而为”,进程退出时有可能会丢日志。更好的方案是内置一个线程安全的队列,用后台消费者统一写,这样既不影响性能,也不容易丢。
5.3 UI 线程场景下的日志输出
如果是 WinForms / WPF 这类 UI 程序,事件触发往往发生在 UI 线程。此时日志订阅者里如果直接用Console.WriteLine或者同步写文件,可能会造成界面卡顿。更合理的方式是只把日志内容放入一个轻量集合,由专门的日志后台线程负责实际写入;或者至少把耗时操作(文件写入、数据库写入)放到异步方法里。
6. 我用这套模式时踩过的坑,整理成一份排查手册
6.1 空值判断:总有人忘了判空
事件没有订阅者时,StatusChanged?.Invoke没有问题,但委托字段并不一定用了?. 我见过朋友写的代码:_callback.Invoke(),一旦_callback是 null,直接NullReferenceException。请记住:任何委托字段在调用之前必须判空,或者用?.Invoke。这看起来基础,但就是这些细节决定了一次上线会不会变成生产事故。
6.2 委托是不可变的,订阅/退订实际是“替换整个调用链”
很多人没意识到:对事件执行+=和-=实际上是在创建一个新的多播委托,然后把事件字段替换成这个新委托。这不是“往链子上加一个环”那么简单,而是“整条链子重新打造了一条”。
这个特性带来一个经典并发坑:在你var e = StatusChanged复制引用并准备调用它的时候,另一个线程可能已经对StatusChanged做了+=或-=,但你现在手里拿着的是旧引用。所以你想要调用最新订阅者,就必须在调用点重新读取字段;反过来,如果你希望本次调用只针对某一时刻的订阅者集合,那局部变量快照反而是正确行为。日志场景下,我通常会让订阅者尽早注册、永不退订,减小这个坑的出现概率。
6.3 事件的内存泄漏:订阅了却忘了退订
如果长生命周期对象订阅了短生命周期对象的事件,短生命周期对象可能永远无法被垃圾回收。比如窗体Form订阅了一个全局EventBus的事件,窗体关闭后没有退订,EventBus仍然持有Form的引用,整个窗体内存就一直悬着。
实践建议:短生命周期对象涉及事件订阅时,要么实现IDisposable并在Dispose里统一-=,要么在构造函数里订阅、析构/关闭时退订。另外可以考虑使用弱事件模式,不过弱事件复杂度更高,非必要不建议新手直接上。
6.4 在事件处理程序里修改引发事件的对象:递归地狱
我在写实体变更日志时踩过一次:日志订阅者里记录了变更,又顺手调用了对象的其他 setter,结果再次触发同样的属性变更事件,形成了递归调用。还好当时只是测试环境,日志文件瞬间暴涨。解决办法是:在实体内部加入一个“是否正在触发事件”的防重入标志,或者日志订阅者只读取参数,绝不回调业务对象。
6.5 用日志包装器包住整个方法时,注意返回值路径
LogExecutor.Execute如果忘记在成功路径上返回result,整个方法就会变成default,运行结果悄悄出错。所以包装器内部逻辑我强烈建议配上单元测试,尤其是“成功时返回正常值”“抛异常时能原样抛出”“耗时统计准确”这三条,基本能覆盖 90% 的使用场景。
7. 我的取舍心得:什么时候该用,什么时候别硬上
这套“事件与委托 + 日志”的组合拳,说到底是一种通知与解耦手段。我在实际项目中的体会是:如果业务逻辑是线性的、固定的,直接调用日志方法反而是最直白、最容易维护的;一旦业务模块变多、日志观察者不固定、可能需要以后扩展多种监听者,那事件和委托就是更合适的选择。
我个人的选择依据大致是这样:
- 只有一个日志输出端,且业务代码已经很清晰:直接写日志,不折腾。
- 日志需要同时支持控制台、文件、数据库、远程监控等多个输出端:用事件或委托封装,各输出端作为独立订阅者。
- 核心业务对象的状态变化需要被多个模块感知:用事件,日志只是其中一个订阅者。
- 有严格的性能要求,日志不能拖慢主流程:用事件也可能有开销,更建议采用后台队列异步写。
- 需要审计日志,即“谁在什么时候改了哪个字段”:事件参数的自包含设计能帮大忙。
最后再分享一个我最近养成的习惯:所有事件名称都用过去时态,比如OrderStatusChanged、PropertyChanged,而不是ChangeOrderStatus、ChangeProperty。事件是“已经发生的事情”,用过去时态阅读起来特别自然。这个命名细节帮我避免过很多次“到底是让我做这件事还是告诉我这件事已经做了”的混乱。
把这套思路落地到项目里其实不需要很复杂,核心就是三件事:业务类只负责定义事件、触发事件;日志类只负责订阅事件、记录信息;两者之间通过事件参数传递完整上下文。做到这三点,你的日志代码就会变得井井有条,再也不用担心业务逻辑被日志淹没。