资讯动态

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

发布时间:2026/10/14 9:59:17 来源:尧图企业网站定制
老项目要加日志最痛苦的不是写那一行输出而是不知道要在多少处地方插入这行代码。最近给模拟项目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 和 ErrorDebug 级信息太多会淹没关键日志。Timestamp 用来记录事件发生的时间而不是订阅端处理的时间两者在异步场景下差异很大等排查问题时就能体会到这个细节有多重要。Source 记录是哪个模块发出的多业务模块共享同一套事件机制时没有这个字段就很难在日志里快速定位哪条记录来自哪个模块。3.2 业务类里声明事件并触发以订单服务为例业务方法本身只做业务动作。每完成一个关键步骤调用一个受保护的方法来触发事件将这个动作广播出去。public class OrderService { // 事件声明EventHandlerLogEventArgs 是系统预定义的委托形式 public event EventHandlerLogEventArgs 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 调用会走完以下逻辑OrderService 校验入参校验失败则捕获异常并触发 Error 事件。核心业务步骤完成后触发 Info 事件携带订单号、数量、时间、来源。每个订阅方按自己的逻辑处理同一份事件数据。后续加新输出方式只要再写一个订阅方法挂上去。这套链路没有在 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 { ((EventHandlerLogEventArgs)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这样保留了事件解耦又拿到了框架的滚动存储、压缩归档等能力。每次我在新项目里用这套模式都会提醒自己事件与委托是语言层面的工具日志框架是成熟轮子两者结合使用远比二选一更合理。事件做桥梁框架做落点这是我在多个项目里验证过较顺手的组合。最后再分享一个排查日志问题时的小技巧。遇到“日志没输出”的故障先花一分钟检查订阅关系是否建立再看事件链上有没有异常中断点这两个原因占掉大部分事故。与其在日志处理代码里加各种防御不如在业务类触发事件时就把参数和调用链信息记录完整排查效率自然就上来了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑