news 2026/10/14 10:02:49

C#事件与委托:低侵入式日志记录的优雅实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C#事件与委托:低侵入式日志记录的优雅实践

写这篇东西的起因是,前阵子我接手了一个老项目的维护,里面有一段业务逻辑几乎每跑一次就会在原处堆上三四行日志代码,成员说改起来很“酸爽”。我把那几处日志全部重构掉之后,发现代码立刻清爽了不少,但功能一点没少。核心思路就是标题里那四个字:事件与委托。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。事件是“已经发生的事情”,用过去时态阅读起来特别自然。这个命名细节帮我避免过很多次“到底是让我做这件事还是告诉我这件事已经做了”的混乱。

把这套思路落地到项目里其实不需要很复杂,核心就是三件事:业务类只负责定义事件、触发事件;日志类只负责订阅事件、记录信息;两者之间通过事件参数传递完整上下文。做到这三点,你的日志代码就会变得井井有条,再也不用担心业务逻辑被日志淹没。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 10:02:36

递归函数内存泄漏分析与优化实践

1. 问题现象与背景分析 那天下午&#xff0c;办公室里突然传来一阵骚动。隔壁工位的李工盯着监控大屏&#xff0c;脸色越来越难看——他负责的数据处理服务内存占用曲线像火箭一样直线上升&#xff0c;短短几分钟内就从正常的2GB飙升至64GB&#xff0c;最终触发了OOM&#xff0…

作者头像 李华
网站建设 2026/10/14 10:02:18

Unity DOTS入门:ECS+Job+Burst性能优化与5000实体Demo实战

说实话&#xff0c;我第一次认真接触Unity的DOTS&#xff0c;是在一次很普通的性能优化排查里。一个单位数量最多只有两三千的模拟场景&#xff0c;用MonoBehaviour写每帧Update&#xff0c;结果在目标配置的设备上帧率掉到二十几&#xff0c;Profiler里清清楚楚写着GC Alloc和…

作者头像 李华
网站建设 2026/10/14 10:02:05

IFRS学习路径与核心准则应用解析

1. IFRS学习路径与课程体系解析在哈萨克斯坦国立大学&#xff08;KazNU&#xff09;攻读会计学硕士期间&#xff0c;我系统学习了国际财务报告准则&#xff08;IFRS&#xff09;体系。作为全球主流的会计准则框架&#xff0c;IFRS在120多个国家被采用&#xff0c;其学习过程需要…

作者头像 李华
网站建设 2026/10/14 10:01:11

HiPhy:面向物理一致性的分层对齐视频生成框架

1. 这不是又一个“文生视频”模型&#xff1a;HiPhy解决的是物理世界建模的底层断层最近在几个AI顶会的workshop上反复听到同行提起HiPhy&#xff0c;不是因为它刷出了什么惊人的FID分数&#xff0c;而是大家在讨论“为什么我们训练出来的视频总像动画片&#xff1f;”——镜头…

作者头像 李华
网站建设 2026/10/14 10:00:23

Python单例模式5种实现与最佳实践

1. 单例模式的核心价值与应用场景 在Python开发中&#xff0c;单例模式&#xff08;Singleton Pattern&#xff09;是最常用的设计模式之一。它的核心思想是确保一个类在整个程序运行期间只有一个实例存在&#xff0c;并提供一个全局访问点。这种模式在需要控制资源访问、配置管…

作者头像 李华
网站建设 2026/10/14 10:00:09

UE5机械臂控制:骨骼绑定与运动实现详解

1. 项目概述&#xff1a;UE5机械臂控制基础在虚幻引擎5中实现机械臂运动控制是工业仿真、游戏开发等领域的基础需求。这个项目聚焦于最核心的第一步——建立控制点与机械臂骨骼之间的绑定关系。不同于简单的静态模型&#xff0c;机械臂的联动性要求我们精确处理每个关节的旋转轴…

作者头像 李华