ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#事件与委托实现业务日志解耦实战

C#事件与委托实现业务日志解耦实战

老项目要加日志,最痛苦的不是写那一行输出,而是不知道要在多少处地方插入这行代码。最近给模拟项目X做改造,业务类里全是核心逻辑,没有任何日志框架,临时想记录操作痕迹。按传统做法就是在每个方法入口和出口手动加上记录语句,改完代码量涨了不少,可读性也变差了。后来我用C#的事件与委托重新组织整个日志模块,让业务类只负责发出“发生什么事”的信号,至于日志写到控制台、文件还是数据库,由订阅方自己决定。这篇文章会把为什么这样设计、委托和事件到底怎么用、实际踩过哪些坑,完整写一遍,适合正在做老项目日志改造、或者刚接触事件机制想找实战场景的朋友。

1. 为什么我用事件与委托加日志而不是直接埋点

1.1 直接埋点的问题:日志代码和业务代码纠缠在一起

最初接手那个系统时,第一反应确实是“加日志还不简单,在方法里写一行 Console.WriteLine 不就行了”。但真正动手后发现问题比想象中多。

一是插入位置太分散。用户注册、订单创建、状态流转、异常捕获,每个业务方法都要加,少加一个关键路径,后面排查问题就少一条线索。二是格式不统一。有人写时间戳,有人不写,有人把对象序列化得乱七八糟,日志文件乱成一锅粥。三是改造成本太高。当时是给模拟项目X加日志,后面需求变化时,如果只想把日志从控制台改成文件,得把所有埋点代码翻出来逐行改。

更深层的问题是职责边界。业务方法的核心任务是完成业务规则,比如校验订单参数、计算金额、更新库存。日志记录是横切关注点,它不属于任何单一业务方法。硬塞进去,业务方法既要管业务又要管记录,违背了单一职责原则,后续维护的人看着也难受。

1.2 事件与委托的方案核心:业务类只“发声”,不负责“记录”

C# 的事件机制本质是观察者模式的实现。业务类不需要知道日志怎么写、写到哪去,它只需要在关键节点触发一个事件,然后把事件参数传出去。谁关心这个事件,谁就去订阅,然后各自处理。

这个思路在日志场景里非常贴合。订单创建成功这个动作本身是个事实,业务类把它广播出去,控制台记录模块收到后写一行控制台日志,文件记录模块收到后追加到文件里,将来想加数据库记录模块,只需要再订阅同一个事件,业务类的代码一行都不用动。

用更通俗的话讲,业务类就像广播电台,只管把消息播出去,谁在听、听完做什么,电台不关心也不限制。多个收听设备可以同时收到同一段广播,这就是多播委托在日志场景下的天然优势。

对比一下直接埋点的做法:

对比维度直接写入日志事件与委托方式
代码侵入性每个业务方法都要改只需要在关键节点触发事件
日志目标切换需要改动所有埋点代码修改订阅端,业务类不动
新增日志输出端每个输出点都要加代码新增一个订阅方法即可
业务可读性被日志代码穿插影响保持相对纯净
排查日志链路需要追踪散落调用点从事件订阅关系一眼看清

1.3 为什么不用现成日志框架

市面上有成熟的日志框架,功能强大到可以直接拿去用。那为什么还用事件与委托自己搭一层?

先说结论,事件与委托方案不是要替代日志框架,而是解决“在业务系统里以什么方式接入日志”的问题。日志框架解决的是日志写得好不好、格式化实力、文件滚动策略,事件方案解决的是业务与日志之间怎么解耦。

在老系统里完整引入一套重型日志框架,往往牵涉全局改造,配置项很多,团队需要学习成本。而事件机制是语言本身提供的,不需要引入额外依赖,可以只在需要加日志的几个模块里用起来。等这些模块稳定了,再把日志接收方换成框架自带的适配器,迁移成本也很低。

我自己实践下来,事件与委托更像在业务代码和日志实现之间加了一层“消息总线”。这层总线的存在,让改造范围可以精确控制,也让业务方法可以继续专注于自己该做的事。

2. 委托与事件的关键区别:做日志前必须搞清楚的基本功

2.1 委托本质:把“怎么做”当作参数传递

很多新手朋友一看到 delegate 关键字就头疼,其实可以把委托理解成一个“方法的引用的类型”。普通变量存的是值,委托变量存的是一个方法,将来通过这个委托变量去执行方法,就像通过遥控器去控制电视一样。

举个例子:

public delegate void LogDelegate(string message); // 定义两个输出方法 public void WriteToConsole(string msg) { Console.WriteLine($"[控制台] {msg}"); } public void WriteToFile(string msg) { File.AppendAllText("app.log", msg); } // 委托变量可以指向任意符合签名的方法 LogDelegate logger = WriteToConsole; logger("订单创建成功"); // 输出到控制台 logger = WriteToFile; logger("订单创建成功"); // 输出到文件

这就是委托最基础的特征:方法可以被当作数据一样赋值、传递。在日志场景中,如果只是需要一个可替换的日志输出方法,委托就足够了。真正让整个模式产生质变的,是它支持把多个方法组合成一条调用链。

2.2 多播委托:一次调用广播给多个接收方

委托有一个特性叫多播,用加号运算符可以挂接多个方法,用减号可以移除。调用委托时,链上的方法会按顺序依次执行。

LogDelegate logger = WriteToConsole; logger += WriteToFile; logger += (msg) => Console.WriteLine($"[数据库模拟] {msg}"); // 一句话同时触发三个输出 logger("订单创建成功");

做日志时这个特性实在方便。业务端不知道也不关心挂在链上的到底有谁,反正自己后面执行一句触发就完成任务;所有输出端各自处理收到的消息,互不干扰。

2.3 事件不是公开的委托字段

很多人会把 event 后边的字段和普通的委托字段搞混,因为声明时看起来很像:

// 方式一:普通委托字段 public LogDelegate LogHandler; // 方式二:事件 public event LogDelegate LogGenerated;

关键区别在于访问权限。普通委托字段在类外面可以被随意重新赋值,别人可以执行logHandler = null把你挂上去的所有方法清掉,甚至可以像调用方法一样直接在外部触发。事件则不同,事件对类外部的调用者只开放两个操作:注册订阅和使用-=取消订阅。不能直接给事件整体重新赋值,也不能在类外部触发事件。

在日志场景里,这种限制非常有价值。业务的日志事件,只有业务类自己在合适的时机触发,不能让外部的某个方法随便触一发,否则日志时序全乱了。同样的道理,也不希望外部把事件上已有的订阅覆盖掉,否则会出现“明明订阅了却不输出日志”的诡异故障。

所以我在线上项目里遇到类似需求,第一反应永远是优先考虑事件,而不是直接暴露委托字段。这不是技术上的偏执,是防止后续人乱用接口的有效防御。

3. 落地实现:一套带事件日志记录的完整示例

3.1 设计日志事件参数:LogEventArgs

事件在触发时要携带信息,就像广播要有一份稿件。C# 惯例是继承 EventArgs 类,把需要传递的内容封装成一个事件参数类。

public enum LogLevel { Debug, Info, Warning, Error } public class LogEventArgs : EventArgs { public LogLevel Level { get; } public string Message { get; } public DateTime Timestamp { get; } public string Source { get; } public LogEventArgs(LogLevel level, string message, DateTime timestamp, string source) { Level = level; Message = message; Timestamp = timestamp; Source = source; } }

这里步骤看起来很基础,却值得多说两句。

Level 用于区分日志等级,方便订阅端决定哪些消息要输出、哪些直接丢弃。比如线上环境可能只关心 Warning 和 Error,Debug 级信息太多会淹没关键日志。Timestamp 用来记录事件发生的时间,而不是订阅端处理的时间,两者在异步场景下差异很大,等排查问题时就能体会到这个细节有多重要。Source 记录是哪个模块发出的,多业务模块共享同一套事件机制时,没有这个字段就很难在日志里快速定位哪条记录来自哪个模块。

3.2 业务类里声明事件并触发

以订单服务为例,业务方法本身只做业务动作。每完成一个关键步骤,调用一个受保护的方法来触发事件,将这个动作广播出去。

public class OrderService { // 事件声明,EventHandler<LogEventArgs> 是系统预定义的委托形式 public event EventHandler<LogEventArgs> LogGenerated; public void CreateOrder(string orderId, int quantity) { try { if (string.IsNullOrEmpty(orderId)) { throw new ArgumentException("订单号不能为空", nameof(orderId)); } if (quantity <= 0) { throw new ArgumentOutOfRangeException(nameof(quantity), "商品数量必须大于0"); } // 模拟核心业务处理 OnLogGenerated(LogLevel.Info, $"创建订单成功,订单号:{orderId},数量:{quantity}", nameof(OrderService)); // 继续其他业务逻辑,比如写库存流水等 } catch (Exception ex) { OnLogGenerated(LogLevel.Error, $"创建订单失败:{ex.Message}", nameof(OrderService)); throw; } } // 统一触发方法,用 protected virtual 方便子类扩展 protected virtual void OnLogGenerated(LogLevel level, string message, string source) { LogGenerated?.Invoke(this, new LogEventArgs(level, message, DateTime.Now, source)); } }

这里出现了几个关键设计,逐个说明。

之所以提取一个 OnLogGenerated 方法,而不是在业务方法里直接写LogGenerated?.Invoke(...),是为了集中所有触发细节。子类如果希望额外做点处理,比如在触发事件前把消息格式化一遍,直接重写这个方法就行,不需要修改业务逻辑方法。

事件触发代码里用了空值条件运算符?.,这是为了应对“没有订阅者”的情况。一个事件如果没有订阅方法,直接调用 Invoke 会抛空引用异常。?.Invoke的意思是只有事件不为空时才触发,安全且写法简洁。

业务方法里捕获异常时触发 Error 级事件后throw,确保问题不会因为加了日志而被吞掉。这是做日志时最容易犯的错误,以为记录了错误就万事大吉,代码继续往下走,最终导致业务状态错乱。日志只负责记录事实,不能改变业务流程。

3.3 订阅端:控制台、文件同时接入

事件定义在业务类里,日志输出则完全是另一侧的事情,这一侧也就是订阅方。

var service = new OrderService(); // 订阅一:控制台输出 service.LogGenerated += (sender, e) => { Console.WriteLine($"[{e.Timestamp:yyyy-MM-dd HH:mm:ss}] [{e.Level}] [{e.Source}] {e.Message}"); }; // 订阅二:文件追加 service.LogGenerated += (sender, e) => { var line = $"[{e.Timestamp:yyyy-MM-dd HH:mm:ss}] [{e.Level}] [{e.Source}] {e.Message}{Environment.NewLine}"; File.AppendAllText("app.log", line); }; // 订阅三:按等级过滤 service.LogGenerated += (sender, e) => { if (e.Level >= LogLevel.Warning) { // 这里可以接入报警系统,比如发送消息通知 } }; // 执行业务操作 service.CreateOrder("SO-20240601-001", 2);

运行这段代码,控制台输出日志,app.log 文件收到同样的日志,第三路订阅则根据等级决定是否触发告警。三路输出各自独立,互不干扰,这正是多播委托最让人舒服的使用体验。

有人会问,订阅方法写成 Lambda 表达式和普通方法有什么区别。其实没有本质区别,Lambda 表达式只是匿名方法的简化写法。订阅程序启动时需要把整个订阅关系建立好,至于订阅方法是在同一个类里还是单独的日志处理类里,完全取决于项目结构。我个人倾向于把复杂的订阅逻辑放进专门的日志模块,启动时通过一个静态方法完成注册,这样订阅关系集中在一处,方便排查。

3.4 完整链路串起来看效果

把上面代码串起来,一次 CreateOrder 调用会走完以下逻辑:

  1. OrderService 校验入参,校验失败则捕获异常并触发 Error 事件。
  2. 核心业务步骤完成后,触发 Info 事件,携带订单号、数量、时间、来源。
  3. 每个订阅方按自己的逻辑处理同一份事件数据。
  4. 后续加新输出方式,只要再写一个订阅方法挂上去。

这套链路没有在 OrderService 里写一行与日志实现相关的代码,OrderService 完全不知道日志最终被写到哪里,也不知道有几个订阅方在听。这就是事件驱动解耦的直观体验。

4. 实际操作中常见问题与排查技巧

4.1 忘记订阅导致空引用

新手最常遇到的是空引用异常。事件没有订阅者,直接用LogGenerated(this, args)调用就会抛 NullReferenceException。原因很简单,事件没有订阅时就等于 null。

解决方案是用?.Invoke,这也是我建议统一封装 OnXxx 方法的原因之一。所有触发动作都走同一个方法,只要这个方法里用了?.Invoke,任何业务方法里都不会出现空引用问题。

排查这类问题时,可以先确认业务类是否真的初始化了事件。事件不用手动初始化,但要在 Program 启动流程里确认订阅动作执行过。订阅只是个赋值操作,如果某条路径绕过了订阅代码,事件自然为空。

4.2 重复订阅导致日志重复输出

这是事件方案里最容易出隐蔽故障的点。日志方法被挂到了同一个事件上多次,每次业务操作都触发两遍甚至更多遍日志输出,排查问题时会被重复日志搅乱视线。

什么情况会重复订阅?比较常见的是在页面回发、服务重启后订阅逻辑反复执行。比如一个初始化方法被多次调用,每次都在事件上加一个方法,却没有对应的退订,订阅方法就堆积在链上。

// 错误示例:多次执行就会重复订阅 public void Init(OrderService service) { service.LogGenerated += HandleLog; }

解决方法是在添加前先退订,保证同一方法只有一个订阅:

// 重复订阅的副作用是可以避免的 service.LogGenerated -= HandleLog; service.LogGenerated += HandleLog;

这里减号操作不管当前有没有挂过,都不会报错,所以这种写法可以安全地放在初始化方法里。关键是要理解,事件链上的退订是按方法引用匹配的,同一个 Lambda 表达式每次生成都是不同的匿名方法,无法通过减号移除。如果先用了 Lambda 订阅,又想在某处退订,最干净的方案是把 Lambda 提取成一个具名方法。

4.3 订阅者异常导致链路中断

委托链上多个订阅方法按顺序执行,一个订阅方法抛异常后,后续的订阅方法不会继续执行。在日志场景里这等于,文件订阅方法因为磁盘路径权限问题抛异常,控制台订阅方法连带不执行,整个日志全部丢失。

处理这个问题的手段有两种。一是每个订阅方法内部尽量 catch 所有异常,确保日志逻辑不影响其他订阅方。二是业务类触发事件时,遍历调用列表并对每个订阅方法做异常隔离。后者更稳健,但也要根据项目情况取舍,因为捕获异常本身也是一种开销。

下面展示一个遍历调用的例子:

protected virtual void OnLogGenerated(LogLevel level, string message, string source) { var handler = LogGenerated; if (handler == null) return; var args = new LogEventArgs(level, message, DateTime.Now, source); foreach (var subscriber in handler.GetInvocationList()) { try { ((EventHandler<LogEventArgs>)subscriber).Invoke(this, args); } catch (Exception ex) { // 这里可以根据策略决定是忽略还是记录异常 } } }

GetInvocationList 拿到委托链上的每个方法,逐个执行并捕获异常。这样即使文件订阅炸了,控制台订阅照常工作。

4.4 订阅关系没有清理引发内存泄漏

事件订阅在某种意义上是“引用持有”。事件发布者通过事件字段引用订阅者,如果发布者生命周期很长,订阅者生命周期相对短,那么订阅者因为被事件引用而无法被垃圾回收,造成内存泄漏。

例如窗口类里订阅了全局服务的事件,窗口关闭后事件链上仍然挂着窗口的日志处理方法,窗口对象就无法被回收。这个故障在日志场景里很典型,因为很多人觉得日志订阅是一次性任务,不做退订处理。

基本原则是:发布者比订阅者活得长时,订阅者结束生命周期前要退订。对话框、窗体这类短生命周期对象,在关闭事件里显式退订相关日志事件。服务实例这种常驻对象之间互相订阅,相对安全一些,但一旦涉及临时创建的消费者对象,就要特别小心。

4.5 线程安全问题

事件触发时,订阅方法在哪个线程执行是关键问题。如果 OrderService 在后台线程中处理订单,触发 LogGenerated 后,订阅方法也在这个后台线程中执行。此时如果在订阅方法里更新了 UI 控件,就需要处理跨线程问题。

处理思路有几种。一是日志订阅方法只做数据收集,收集后投递到 UI 线程或专门日志线程处理。二是在订阅方法里用同步上下文方式切回主线程。三是如果只是写文件,非 UI 线程反而是好事,不阻塞界面。

线程问题没有一劳永逸的答案,需要结合项目的并发模型来判断。建议在事件参数里把必要上下文信息带完整,订阅方根据实际需求决定如何跨线程处理。

5. 几个直接可用的扩展思路

有了基本的事件日志机制,还能往几个方向顺手扩展。第一个是增加异步订阅,用 async void Lambda 订阅事件时要小心异常处理,async void 的异常不能直接被 catch,建议在 Lambda 内部自己处理全部异常。第二个是给日志事件参数增加调用链追踪字段,比如请求编号、用户标识,这样多模块日志串起来能还原一次完整操作轨迹。第三个是可以把事件日志和现有日志框架整合,订阅方内部调用日志框架的 API,这样保留了事件解耦,又拿到了框架的滚动存储、压缩归档等能力。

每次我在新项目里用这套模式,都会提醒自己:事件与委托是语言层面的工具,日志框架是成熟轮子,两者结合使用远比二选一更合理。事件做桥梁,框架做落点,这是我在多个项目里验证过较顺手的组合。

最后再分享一个排查日志问题时的小技巧。遇到“日志没输出”的故障,先花一分钟检查订阅关系是否建立,再看事件链上有没有异常中断点,这两个原因占掉大部分事故。与其在日志处理代码里加各种防御,不如在业务类触发事件时就把参数和调用链信息记录完整,排查效率自然就上来了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进