☰
C#核心进阶:OOP、AOP与泛型约束的工程实践
2026/10/13 4:52:24 网站建设 项目流程

1. 这两类概念,其实是同一份"基本功"

先说个挺有意思的现象:我见过不少写了两三年 C# 的开发,对 LINQ、async/await 用得很熟,但被问一句"where 关键字在泛型里有什么用"就卡壳;反过来,能把 OOP 三大特性背得滚瓜烂熟的朋友,一聊到 AOP 又觉得那是 Java 的专利。其实这些概念在 C# 里是环环相扣的。标题里把 OOP、AOP、封装/继承/多态、where 泛型约束放在一起,并不是硬凑,它们在真实工程里经常会同时出现在一份代码里。比如你写上位机去连 PLC,底层设备通信类要封装,不同型号设备要继承,统一操作接口要多态,给 Modbus、OPC、TCP 各自写适配器时又会碰到泛型约束,而上位机的操作日志、超时重试又天然适合用 AOP 去切。

这几种特性,本质上都在回答同一类问题:代码怎么写才能少改、好扩展、不容易出错。封装告诉你把变化关在门里,继承和多态告诉你门怎么开才统一,where 约束告诉你泛型这根管子能接哪些管子,AOP 则告诉你横切逻辑怎么抽出来,别跟业务代码搅在一起。这篇文章就把这些点全部串起来,用 C# 的实际代码逐一拆开讲,顺便给出我在上位机和后端项目里踩过的一些坑。适合正在学 C# 的初学者,也适合写了几年代码但没系统梳理过这些机制的开发者。

2. OOP 三件套:封装、继承、多态在 C# 里的落地姿势

2.1 封装不是"私有字段加属性"这么简单

封装在 C# 里最基础的体现是访问修饰符和属性。但很多人把它理解成"字段private,外面用属性访问",这其实只完成了 20%。封装的本质是隐藏实现细节,同时把"对数据的操作规则"也收进类内部。

public class TemperatureSensor { private double _rawValue; private readonly double _offset; // 对外只暴露校准后的温度,原始值和偏移量外部一概不知 public double Temperature { get { return _rawValue * 0.1 - _offset; } } public TemperatureSensor(double offset) { _offset = offset; } public void UpdateRawValue(double raw) { // 防御性校验放在这里,而不是散落在调用方 if (raw < -5000 || raw > 5000) throw new ArgumentOutOfRangeException(nameof(raw)); _rawValue = raw; } }

注意这里的Temperature属性只有 get,没有 set。外部可以读温度,但不能直接写温度,要改数据只能通过UpdateRawValue。这样做的意义在于:你将来可以把设备协议从模拟量改成 Modbus,只需要改这个类内部,所有调用方完全不受影响。这就是封装最大的价值——变化被限制在最小范围内。

实际项目中我在上位机里经常把"通信协议解析"和"业务数据"分离。传感器类内部管着原始报文的拆分校验,外部拿到的永远是干净的物理量。假如哪天协议版本升级,报文格式变了,我只动类内部,整个上位机界面一行都不用改。这才是封装真正的工程意义,而不是加个属性那么简单。

再说访问修饰符,很多人不知道 C# 还有个internal,它是"程序集内可见"。在解决方案里拆多个项目时,internal配合InternalsVisibleTo可以在不对外开放内部实现的前提下,给单元测试项目开个后门。我见过不少团队把不该公开的类全部打成public,最后 API 面越来越大,想收都收不回来。

还有一个细节:属性的init访问器(C# 9 以后),它比 set 更严格,只能在对象初始化时赋值,之后不可变。

public class DeviceConfig { public string DeviceName { get; init; } public int RetryCount { get; init; } }

这比写一堆构造函数重载要清晰得多,对象一创建就不能被篡改,对配置类来说相当省心。

2.2 继承的正确姿势:基类管套路,派生类管差异

继承是 OOP 里最容易被滥用的一环。很多人喜欢为了"复用代码"去继承,结果基类越堆越厚,子类互相牵连。在 C# 里,继承的正确用法是:基类把不变的处理流程写好,派生类只负责重写那些真正有差异的部分。

举个例子,工业上位机里读取不同仪表的流程通常是一样的:连接、发送请求、等待响应、解析校验、返回结果。这些步骤里只有"发送请求"和"解析校验"是随设备变化的。

public abstract class DeviceHandler { // 模板方法:定义了整体流程,子类不许重写 public async Task<ReadResult> ReadAsync(ReadRequest req, CancellationToken ct) { await ConnectAsync(ct); byte[] raw = await SendAndReceiveAsync(req, ct); ReadResult result = await ParseAsync(raw, ct); return result; } protected abstract Task<byte[]> SendAndReceiveAsync(ReadRequest req, CancellationToken ct); protected abstract Task<ReadResult> ParseAsync(byte[] raw, CancellationToken ct); // 连接逻辑大多数设备共通,但允许子类覆盖 protected virtual Task ConnectAsync(CancellationToken ct) { // 默认实现 return Task.CompletedTask; } } public class ModbusTcpHandler : DeviceHandler { protected override Task<byte[]> SendAndReceiveAsync(ReadRequest req, CancellationToken ct) { // 组 Modbus 报文,通过 TCP 发送 } protected override Task<ReadResult> ParseAsync(byte[] raw, CancellationToken ct) { // 解析 Modbus 响应 } }

这个模式叫模板方法模式,是继承最值得用的场景之一。基类把"读一个数据"的骨架锁死,新设备接入时你只需要写两个方法,流程绝对错不了。如果不这么做,每个设备类里都重新写一遍"连接-发送-等待-解析",一百种设备就有一百份重复代码,后期维护简直噩梦。

继承里virtual和abstract的区别经常被面试问,实际使用也容易混。简单说:abstract方法是"子类必须实现",virtual方法是"子类可选覆盖"。基类里的ConnectAsync用 virtual,是因为大多数设备直接 TCP 连接就够用,个别设备需要额外握手才覆盖它。这样设计的好处是,默认行为由基类兜底,特殊行为由子类按需定制。

2.3 多态:接口和基类让代码不再"看人下菜"

多态在 C# 里分两种:编译时多态(方法重载)和运行时多态(重写、接口实现)。工程上我们更关心运行时多态,因为它解决的核心痛点是:上层代码不需要知道对象的具体类型,只要知道它满足某个契约就够了。

public interface IDevice : IDisposable { Task<bool> OpenAsync(); Task CloseAsync(); Task<ReadResult> ReadAsync(string address); } public class PlcDevice : IDevice { ... } public class SerialDevice : IDevice { ... } public class VirtualDevice : IDevice { ... } // 上层只管接口 public class DataCollector { private readonly IDevice _device; public DataCollector(IDevice device) { _device = device; } public async Task CollectAsync() { await _device.OpenAsync(); // 具体是 PLC 还是串口设备,DataCollector 不关心 ReadResult data = await _device.ReadAsync("1# 温度"); // ... } }

这套东西看似简单,但它背后是依赖倒置原则:上层模块不依赖具体实现,只依赖抽象接口。这样后续设备换型号、换协议,只需要新增实现类,DataCollector一行不用改。界面层可以拿VirtualDevice来模拟数据做开发调试,等真实设备到位了再替换实现,这在上位机项目里非常实用。

多态还有一层更隐蔽的价值:它逼你把接口设计得足够"小"。接口方法越少,实现类越容易写,扩展点就越多。我见过有人把IDevice一口气定义了 30 个方法,结果每个实现类都有一堆NotSupportedException,这种接口还不如不抽象。

2.4 封装的边界:别把所有字段都私有

我在这里提供一个反直觉的建议:私有字段是对的,但字段一旦多到某个量级,就该考虑把相关字段封装成独立的类,而不是在一个类里塞几十个私有字段。比如设备状态相关的布尔量、错误码、时间戳,归拢成一个DeviceState类,外层再包一层只读属性。这样类内部结构清爽,测试时也能直接构造状态对象,不用走一遍完整初始化流程。

另外,继承和密封的平衡要注意。C# 默认类是"可继承"的,但这不代表每个类都应该被继承。没有想清楚扩展点的类,建议直接sealed掉。这样做的理由很直白:一旦类被继承,它的内部实现就成了隐式公共契约,子类的行为会和基类耦合在一起,后续基类改一行代码,所有子类行为都可能变。我在实践里吃过亏,一个sealed的配置类被去掉 sealed 后,子类一不小心覆盖了某个方法,线上数据格式就乱了。所以,默认 sealed,想清楚了再打开继承。

3. where 泛型约束:泛型不是"啥都能装"

3.1 为什么光写一个 T 远远不够

很多人第一次看到泛型时觉得这就是个"类型占位符",T 想传什么传什么。这种理解在只写泛型集合的时候没毛病,但一旦你想在泛型方法里做点正经事,就会发现光有 T 远远不够。比如这个方法:

public T GetValue<T>(string key) { object raw = GetFromStorage(key); // 如果 T 是任意类型,这里就不能随便转 return (T)raw; // 万一 raw 是 null,值类型直接崩 }

如果你打算把配置项、缓存数据封装成一个泛型读取接口,你就需要告诉编译器:T 必须是引用类型、必须有无参构造函数、必须实现某个接口。这就是 where 泛型约束存在的意义——它把泛型从"完全放飞"拉回到"有边界的通用"。

3.2 常用约束逐个拆解

C# 里的泛型约束有几种基础形态,我直接用代码展示,然后逐个说适用场景。

// 约束 T 是引用类型(class 或接口、委托等) public TResponse GetCached<TResponse>(string key) where TResponse : class // 约束 T 是值类型(int、struct、enum 等) public Nullable<T> TryParseTo<T>(string text) where T : struct // 约束 T 必须有无参构造函数 public T CreateInstance<T>() where T : new() { return new T(); } // 约束 T 必须是某个基类或实现某个接口 public void Send<T>(T message) where T : IProtocolMessage // 约束 T 必须可以跟某个类型互相转换 public TOutput ConvertTo<TOutput, TInput>(TInput input) where TOutput : class where TInput : IConvertible

where T : class和where T : new()是出现频率最高的两个约束。class 约束能让泛型参数安全地判空,值类型永远不会是 null,如果没这个约束,你写if (value == null)编译器会警告,因为值类型永远不满足判断;new()约束则允许在泛型方法里直接new T(),没有这个约束编译器直接禁止你这么写。

接口约束是我最常用的。比如上位机里多个设备都实现了ISensor接口,我想写一个通用的"批量轮询"方法,就可以写上where T : ISensor,这样方法内部就能直接调用ISensor暴露的属性和方法,而不是对着一个裸 T 只能做装箱和反射。

3.3 组合约束和泛型类的约束声明

约束不是只能写一条,可以多条组合,比如:

public class Repository<T> where T : class, IEntity, new() { public T CreateNew() { return new T(); } }

这里 T 的三条约束分别解决三个问题:class 保证引用语义,IEntity 保证有 Id 之类的公共属性,new() 保证能无参构造。组合约束在写泛型仓储的时候几乎是标配。但注意约束的书写顺序有讲究:class/struct 要写在最前面,然后是基类或接口约束,最后才轮到 new()。写反了编译器直接报错,我见过不少新手卡在这一步。

还有个关联细节:泛型方法的重载决议也会考虑约束。两个泛型方法,一个约束where T : struct,另一个约束where T : class,调用时编译器会依据泛型参数类型选择合适的重载。这在某些 API 设计里用来区分值类型和引用类型的处理路径,比如序列化时对值类型走高效拷贝,对引用类型走反射。虽然这种写法不常见,但理解后调试别人代码时不容易懵。

3.4 泛型约束的实际案例:缓存工具

为了方便理解,我写一个简单但完整的缓存封装。它把泛型约束都用到了实处。

public class CacheService { private readonly IMemoryCache _cache; public CacheService(IMemoryCache cache) { _cache = cache; } // TItem 必须是引用类型,且能被 new 出来 public async Task<TItem> GetOrCreateAsync<TItem>(string cacheKey, Func<Task<TItem>> factory) where TItem : class, new() { if (_cache.TryGetValue(cacheKey, out object cached)) { return (TItem)cached; } TItem result = await factory(); if (result != null) { _cache.Set(cacheKey, result, TimeSpan.FromMinutes(5)); } return result; } }

注意where TItem : class, new()的意义:class 约束保证从缓存里取出来的 object 能安全强转为 TItem,不会出现装箱拆箱的隐性问题;new() 约束则允许在缓存穿透时直接构造缺省实例兜底。如果你去掉这两个约束,代码虽然也能编译通过,但很多细节没法写,只能靠反射绕来绕去,健壮性反而差。

实际项目里,这个缓存工具被我用在设备配置的读取上。配置是内存里的强类型对象,支持刷新和失效。将来配置源从数据库换到 Redis,CacheService 内部逻辑完全不用动,这又是泛型抽象带来的好处。

4. AOP 在 C# 里的实现:不只是切面编程的概念

4.1 AOP 原理:代理模式的延伸

AOP(面向切面编程)的核心思想,就是把日志、鉴权、异常处理、性能统计这类横切逻辑从业务代码中抽出来,统一用一种"旁挂"的方式执行。它依赖的底层机制,本质上还是 OOP 里的多态和代理。

拿 Java 那套来类比:JDK 动态代理基于接口,CGLIB 动态代理基于继承。C# 里实现 AOP 也有类似的两条路线。如果你用了依赖注入框架,最常见的手法是注册代理;如果不希望引入大框架,可以用内置的DispatchProxy或者 Castle DynamicProxy。

代理的本质一句话:调用方拿到的是代理对象,代理对象包着一个真实对象,方法调用被代理拦一道,先做额外处理,再调用真实方法。

public interface IService { Task<int> CalculateAsync(int a, int b); }

这个接口对应一个实现类,我们希望在不改动实现类的前提下,给方法加上日志和异常捕获。

4.2 用 DispatchProxy 实现一个日志切面

C# 内置的DispatchProxy是.NET Standard 2.0 之后提供的动态代理基类,用它写一个通用 AOP 封装并不难。我直接展示一个完整的示例,代码不多,但功能完整。

public class LoggingProxy<TInterface> : DispatchProxy where TInterface : class { private TInterface _target; public static TInterface Create(TInterface target) { LoggingProxy<TInterface> proxy = DispatchProxy.Create<TInterface, LoggingProxy<TInterface>>(); proxy._target = target; return proxy as TInterface; } protected override object Invoke(MethodInfo targetMethod, object[] args) { Console.WriteLine($"[调用前] {targetMethod.Name} 参数:{string.Join(",", args ?? Array.Empty<object>())}"); try { object result = targetMethod.Invoke(_target, args); Console.WriteLine($"[调用后] {targetMethod.Name} 返回:{result}"); return result; } catch (Exception ex) { Console.WriteLine($"[异常] {targetMethod.Name} 抛出 {ex.Message}"); throw; // 保留原始异常栈 } } }

用法如下:

IService realService = new RealService(); IService proxy = LoggingProxy<IService>.Create(realService); int result = await proxy.CalculateAsync(3, 5);

调用方完全感觉不到代理的存在,但每个方法都被日志环绕。这套做法的限制是,目标类型必须是接口或抽象类,因为 DispatchProxy 通过生成接口/类的子类代理来实现拦截。

那么问题来了:既然有了 DispatchProxy,是不是所有 AOP 需求都能用它实现?不一定。它只能拦截通过接口或虚方法进行的调用,如果一个方法是非虚的,且调用方持有的是真实类型引用,代理就不起作用。这就是我在后文"常见坑"里要讲的重点。

4.3 用特性标注实现声明式 AOP

只靠 DispatchProxy,方法级别的"切点"还不够细。我们往往希望能给某个方法单独加缓存、单独做权限校验,这时候可以结合 .NET 的特性(Attribute)机制,把 AOP 做成声明式的。

思路是:定义好切面逻辑,然后通过特性去标注哪些方法需要被代理增强。这种"特性标注 + 动态代理"的组合,才是 .NET 世界里比较收敛的 AOP 姿势。

[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] public class RetryOnExceptionAttribute : Attribute { public int MaxRetryCount { get; } public RetryOnExceptionAttribute(int maxRetryCount = 3) { MaxRetryCount = maxRetryCount; } } public class RetryProxy<TInterface> : DispatchProxy where TInterface : class { private TInterface _target; public static TInterface Create(TInterface target) { RetryProxy<TInterface> proxy = DispatchProxy.Create<TInterface, RetryProxy<TInterface>>(); proxy._target = target; return proxy as TInterface; } protected override object Invoke(MethodInfo targetMethod, object[] args) { var attr = targetMethod.GetCustomAttribute<RetryOnExceptionAttribute>(); if (attr == null) { return targetMethod.Invoke(_target, args); } int retry = 0; while (true) { try { return targetMethod.Invoke(_target, args); } catch (Exception) when (retry++ < attr.MaxRetryCount) { Thread.Sleep(200 * retry); // 逐渐加长等待时间 } } } }

这个模式在设备通信里极其受用。PLC 通信偶尔会超时,给特定的读方法标注[RetryOnException(3)],就能自动重试,业务层完全无感知。类似地,也可以做一个[Logging]特性、[Authorize]特性,把日志、鉴权这些横切逻辑全部收敛到代理里。代码的整洁度和可读性会有质的提升。

4.4 AOP 的实际适用场景与不适用场景

AOP 不是银弹。根据我自己的经验,适合用 AOP 的场景有:

  • 日志:方法入参、出参、耗时,用代理拦一道比到处写日志代码干净得多。
  • 异常处理:统一捕获、统一兜底,尤其在服务边界处。
  • 事务控制:数据库仓储层,在方法入口开事务,正常提交、异常回滚。
  • 重试与超时:设备通信、网络请求这种天然高失败率的操作。
  • 缓存:读方法先查缓存,再执行真实逻辑,结果写缓存。
  • 权限校验:方法级别鉴权。

不适合用 AOP 的场景:

  • 对象状态管理:一个对象内部的私有状态流转,你用 AOP 去拦反而把逻辑搞复杂了。
  • 性能极度敏感的短方法:每次调用都走一次反射和代理分发,性能损耗虽然不是数量级,但高频调用时也确实要花点时间。

性能方面,DispatchProxy 走的反射调用比直接调用慢一些。实测下来,每秒几万次调用的场景无压力,但百万级就不建议了。如果你的项目确实需要极致性能,可以换用 Castle DynamicProxy(内部做了缓存优化),或者干脆把切面逻辑编进代码生成器生成的类型里。

5. 把这些串起来:一个上位机数据采集的小框架

前面几节分别讲了 OOP、泛型约束、AOP,但很多读者更关心的是:这些怎么在同一个项目里配合?我带你们看一个我实际搭过的上位机采集框架雏形,规模不大,但五个概念全部上场。

5.1 设备抽象与泛型约束

先定义设备接口,这是多态的基础:

public interface IDevice { string DeviceId { get; } Task ConnectAsync(CancellationToken ct); Task DisconnectAsync(CancellationToken ct); Task<DeviceData> ReadAsync(string point, CancellationToken ct); }

再定义一个泛型客户端,用于管理不同协议的设备:

public class DeviceClient<T> where T : IDevice, new() { private readonly T _device; public DeviceClient() { _device = new T(); } public async Task<DeviceData> SafeReadAsync(string point, CancellationToken ct) { if (!await CanConnectAsync(ct)) { await _device.ConnectAsync(ct); } return await _device.ReadAsync(point, ct); } private async Task<bool> CanConnectAsync(CancellationToken ct) { // 检查连接状态,避免每次都重连 // 这里只是一个示意,真正的实现要看具体设备协议 return true; } }

where T : IDevice, new()在这里的意义非常直观:你调用new DeviceClient<ModbusTcpDevice>()的时候,编译器就保证了 ModbusTcpDevice 一定实现了 IDevice 接口,并且可以被无参构造。如果某个设备类没实现接口,编译直接失败,而不是运行时踩雷。

5.2 用泛型仓储管理设备配置

紧接着配置读取部分可以用泛型约束做得很舒服:

public class DeviceConfigRepository<T> where T : class, IDeviceConfig, new() { private readonly Dictionary<string, T> _cache = new Dictionary<string, T>(); public T GetOrCreate(string deviceId) { if (_cache.TryGetValue(deviceId, out T config)) { return config; } config = new T(); _cache[deviceId] = config; return config; } }

这里的IDeviceConfig只是要求配置类有DeviceId属性。这种泛型仓储写法,避免为每种设备写一套重复的读取代码。新设备接入时只增加新的配置类和接口实现,框架本身完全闭合。

5.3 用 AOP 包一层设备调用

原始的设备调用直接写在DeviceClient<T>里也可以,但我想让设备调用的日志和重试不污染业务流程,于是用前文提到的 RetryProxy 把设备客户端包了一层:

IDeviceClient client = new DeviceClient<ModbusTcpDevice>(); IDeviceClient guardedClient = RetryProxy<IDeviceClient>.Create(client); // 之后所有代码都用 guardedClient,而 RetryProxy 负责自动重试和异常日志

加上前面写的[RetryOnException]特性,我可以只给某些重要的读取方法标注重试策略,其他方法保持原样。这样代理的粒度从"类级别"细化到"方法级别",更实用。比如温度采集需要重试,状态读取不需要,逻辑非常清晰。

public interface IDeviceClient { [RetryOnException(3)] Task<DeviceData> ReadTemperatureAsync(string point, CancellationToken ct); Task<DeviceData> ReadStatusAsync(CancellationToken ct); }

注意:特性放在接口方法上,代理获取targetMethod时拿到的是接口的方法定义,所以特性要标在接口方法上才有效。如果标在实现类的方法上,代理默认是拿不到的(除非做额外处理)。这是一个很容易踩的细节。

5.4 委托和事件在框架里的角色

热词里还出现了"委托",因为 AOP 和泛型这俩概念很容易让人忽略 C# 的另外两个基础武器:委托(delegate)和事件(event)。在我的框架里,设备状态变化、采集结果更新,都是通过事件对外通知的。

public class DataCollector { public event EventHandler<DeviceDataReceivedEventArgs> DataReceived; protected virtual void OnDataReceived(DeviceData data) { DataReceived?.Invoke(this, new DeviceDataReceivedEventArgs(data)); } }

外部订阅:

_collector.DataReceived += (sender, e) => { // 更新界面曲线图 };

这里委托的价值在于,UI 层和采集层彻底解耦。采集层只负责产生数据并发布事件,至于界面是显示曲线还是写入数据库,采集层一概不管。这和 AOP 的"横切"思想是一脉相承的:都是把跟业务主链路无关的逻辑从主代码中剥离出去,让每个模块只关心自己那部分。

如果你写的采集层代码里到处是BeginInvoke、Thread.Sleep、if (uiThread != null)之类的东西,说明事件和委托的抽象已经失控了,得回头收拾收拾。

6. 常见问题与排查技巧实录

这个概念密集的知识体系,实操中问题也是一环扣一环。我把这些年遇到的高频问题整理成速查表,每种都给出了排查思路。

问题表现根因解决方法
泛型方法调用时报"缺少约束"方法内部使用了 new T(),但没加 where T : new()补上对应约束,或改用 Activator.CreateInstance
代理对象方法没有被拦截调用的是非虚方法,或调用方持有具体类型而非接口确保通过接口调用,把方法声明为 virtual/abstract
特性标注在实现类上但代理不生效DispatchProxy 拿到的 MethodInfo 来自接口定义把特性标在接口方法上,或在代理里额外查找实现类特性
泛型仓储返回相同实例,修改了配置影响所有使用者忘记写深拷贝,缓存直接返回了原始对象缓存里存深拷贝,或返回只读封装视图
事件订阅后内存泄漏订阅方未退订,发布方比订阅方活得更久在 Dispose 里退订,或用弱事件模式
AOP 代理调用性能偏低每次调用都走反射 Invoke高频路径不要用代理,或用编译时代码生成方案

6.1 泛型约束缺失引发的连锁错误

有一次同事写了个公共方法:

public TDto MapTo<TDto>(object source) where TDto : class { // 内部通过 new TDto() 创建目标对象 }

编译报错,原因就是没加new()约束。对于这种问题,我习惯在写任何泛型方法时,先问自己三个问题:

  • 这个方法会不会给 T 赋值或判空?会的话考虑 class 或 struct 约束。
  • 这个方法会不会 new T()?会的话加 new() 约束。
  • 这个方法会不会调用 T 的成员?会的话就用接口约束,或者基类约束。

一个经验法则:泛型约束越少越好,但一旦缺了会导致无法编译或运行时异常,那就必须加。我自己在传参时遇到过把where T : class忘掉,方法里一句if (obj == null)直接让编译器报警告,看起来能跑但语义是错的。这种细节不处理好,后期代码审计时就是隐患。

6.2 代理不生效的经典场景

我在一个自动重试需求里,原本是把方法写在具体类上的,结果调用方直接用具体类实例,代理怎么都不触发。排查才发现问题不在代理代码,而在类型设计:你只有通过接口或者抽象类的引用去调用,才能走到代理那一步。后来我把所有对外方法收敛到一个接口里,调用方一律面向接口编程,问题就消失了。

如果你在排查没有日志输出的问题,可以先快速验证一条:直接调用真实对象,看看逻辑是否正常。如果真实对象正常,代理对象没动静,基本可以断定是"代理没有被创建成功"或者"调用方持有的不是代理引用"。

6.3 开始设计时就规避的坑:约束和继承混用

泛型约束和继承还有一个容易犯的错误:泛型参数类型自身继承了一个基类,但你还想让它同时实现某个接口,于是约束写成where T : MyBase, IMyInterface, new()。语法上没问题,但你要注意约束的顺序和含义。一旦把MyBase换成某个带泛型的基类,问题就复杂了,比如:

public class Repo<T> where T : BaseRepo<T>, new()

这种"递归泛型约束"在领域设计中偶尔用到,但对新手来说太绕,调试起来也困难。我的建议是:在约束层面保持简单,宁可多写几个类,也不要在泛型约束上炫技。泛型是用来减少重复的,如果约束本身把类型关系纠缠得比重复代码还难懂,那就得不偿失了。

6.4 当 AOP 遇见 async/await:小心返回值陷阱

在 DispatchProxy 里处理异步方法有个挺隐蔽的坑:如果拦截的接口方法返回Task<T>,你在 Invoke 里直接调targetMethod.Invoke(...),拿到的对象是Task<T>。如果你在代理层想做"调用完成后打日志",需要先把返回值转成 Task,再 await,否则日志会在方法真正执行完成之前就打出去。我在实际代码里写过这样一个代理版本:

protected override object Invoke(MethodInfo methodInfo, object[] args) { object result = methodInfo.Invoke(_target, args); if (result is Task task) { return AwaitTaskAsync(task, methodInfo.Name); } return result; } private static async Task AwaitTaskAsync(Task task, string methodName) { try { await task; Console.WriteLine($"{methodName} 完成"); } catch (Exception ex) { Console.WriteLine($"{methodName} 异常 {ex.Message}"); throw; } }

同样地,如果接口返回的是Task<T>,Await 时还要保留返回的 T,让调用方拿到真实结果。这类"返回值和真实执行时机"的问题,是异步代码套 AOP 最常见的坑,没有之一。写这套代理封装时,务必用真实的异步方法做自动化测试,别只拿同步方法验证。

7. 最后分享一点我个人的习惯

这些概念不管面试被问多少遍,落到代码里才是真正的分水岭。我的习惯是:每写一个新模块,先花十分钟把"哪些方法对外、哪些内部实现、哪些可以被替换、哪些横切逻辑需要剥出来"想清楚,再动手写。等代码能跑通之后,回过头来检查一遍有没有为了炫技而过度设计。封装、继承、多态、泛型约束、AOP,这些工具本身没有高下之分,放在合适的位置才是关键。

另外还有个小技巧,写泛型方法时不急着加约束,先用最朴素的 T 把逻辑写出来,编译报什么错,你再决定加哪条约束。这种方式虽然看起来笨,但往往比一开始就拍脑袋加一堆约束要准确得多。毕竟约束的本质是"告诉编译器你的意图",意图想明白了,约束自然就写对了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询