SOLID 设计评审
引导 Agent 使用具体的 .NET 视角逐一审查 C# 类或模块中的每个 SOLID 原则,明确指出代码异味、违反的原则以及最小重构方案——优先处理那些实际导致耦合或代码变更的问题,而非教科书式的吹毛求疵。
何时使用
- 一个类变得臃肿不堪——在同一个文件中涵盖了 IO、业务逻辑、映射以及副作用。
- 一个 PR 引入了新的类型变体,并且需要修改现有的
switch或if-else链来适配它。 - 构造函数通过
new来创建自己的依赖,或者直接引用了DateTime.Now/static辅助方法。 - 一个接口积累了多个成员以满足多个不相关的调用方,导致不得不进行空实现。
流程
- 确定待审查的类/模块。 说出其声明的职责。如果其名称需要用“与”或“或”来描述,这本身就是一个信号。
- 使用下面的 C# 透镜,按顺序逐一审视每个 SOLID 原则。 将每个原则视为一次独立的审视;不要在找到第一个问题后就停止。
- 明确指出代码异味和违反的原则。 “创建自己
HttpClient和SqlConnection的胖构造函数” → DIP;“抛出NotSupportedException的基类重写” → LSP。要精确。 - 利用 C# 机制提出最小重构方案。 指出类型、模式或 DI 注册的变更——而不仅仅是说“应用该原则”。泛泛的“遵循 SOLID”建议不算一个发现。
- 根据实际影响进行优先级排序。 一个混用 EF 持久化和邮件发送的上帝服务在每个迭代中都会引起变更;一个包含 12 个成员但没有任何调用方完全使用的
IOrderService是真正的 ISP 违规。一个恰好满足两个松散相关用例的sealed类,如果它只有一个变更原因,则不算违反 SRP。要区分那些造成伤害的违规和那些仅仅是不够纯粹的违规。
.NET / Azure 检查
SRP — 单一职责原则
- 上帝服务/上帝类。 一个同时涉及 EF Core(
_context.SaveChangesAsync)、发送邮件(_emailSender.SendAsync)、验证业务规则以及映射为 DTO 的单个类,有多个变更原因。将IOrderValidator、IOrderRepository(或直接在专用持久化类中使用 DbContext)和IOrderNotifier提取为独立的协作对象。 - 胖控制器。 一个包含
if/验证逻辑、直接调用数据库并格式化响应负载的 ASP.NET Core 控制器,承担了三项工作。控制器应该负责编排:调用服务、映射结果、返回IActionResult。业务规则属于应用层/领域层。 - 类型名称或职责中包含“与”。
OrderValidationAndPersistenceService是一种命名异味,标志着 SRP 违规。如果你只能用“与”来描述这个类,那就拆分开。 - 多个变更原因。 问一问:“如果更换邮件提供商,这个类需要修改吗?如果更改数据库模式呢?如果业务规则发生变化呢?” 有多个“是”意味着有多个职责。
OCP — 开闭原则
- 在类型鉴别器上增长的
switch/if-else。 在服务内部针对每个新的OrderType枚举值都必须重新打开的switch(order.Type)块违反了 OCP。用策略模式替换:定义IOrderHandler(或IOrderProcessor),为每个类型注册一个实现;通过注入到协调器中的IEnumerable<IOrderHandler>进行解析,调度逻辑为handler.Handles(order.Type)。 - 硬编码的行为扩展。 如果添加一个新的折扣规则、通知渠道或导出格式需要修改现有类,而不是直接放入一个新的
IDiscountRule或IExporter,那么这个类没有对修改关闭。在第二个 case 出现之前就引入抽象。 - 类型鉴别器反模式。 在处理类内部的
if (order is PremiumOrder)或if (typeof(T) == typeof(FooOrder))与上面的switch是等价的代码异味。多态或策略注册表可以消除这些。
LSP — 里氏替换原则
- 抛出
throw new NotSupportedException()/NotImplementedException()的重写。 一个无法履行基类契约的子类型(例如,ReadOnlyCollection<T>在Add时抛出异常)违反了 LSP。如果重写无法有意义地实现,则层次结构是错误的——优先选择组合或独立的接口。 - 削弱后置条件的子类型。 如果基类保证
GetOrders()永远不会返回null,那么返回null的重写就违反了调用方所依赖的契约。记录不变量(通过 XML 文档或契约)并在重写中强制执行。 - 增强前置条件的子类型。 一个添加了基类中没有的 null 检查,或者拒绝了基类接受的合法参数范围的重写,会破坏传递了有效基类契约输入的代码。子类型必须接受基类所接受的一切。
- 调用代码中普遍存在的
is/as向下转型。 调用方中的if (handler is SpecialHandler sh) sh.SpecialMethod()意味着抽象被破坏了——要么该方法应该属于基接口,要么这些类型不应该共享一个层次结构。频繁的向下转型是 LSP/抽象失败的信号。
ISP — 接口隔离原则
- 包含不相关关注的胖接口(10 个以上成员)。 一个暴露了
CreateOrder、CancelOrder、GetInvoice、SendConfirmation、ExportToCsv、CalculateTax、ValidateAddress和GetShippingRates的IOrderService,迫使每个实现者和测试替身去 stubbing 它不用的方法。拆分为角色接口:IOrderWriter、IOrderReader、IOrderNotifier、IOrderExporter。 - 空实现 /
NotImplementedException桩。 如果一个 mock 或测试实现中一半的接口成员都返回throw new NotImplementedException(),那么这些成员属于不同的角色接口。空实现的存在是 ISP 违规的直接证据。 - 客户端引入了它们从未调用的方法。 如果
PaymentService依赖IOrderService但只调用GetOrder,那么它应该依赖IOrderReader。缩小依赖范围使耦合显式化,并降低测试成本。
DIP — 依赖倒置原则
- 在类内部通过
new创建具体依赖。 在服务内部构造的new HttpClient()创建了隐藏的依赖,绕过了 DI 容器,导致 socket 泄漏(没有连接池),并且使得类在不使用反射技巧的情况下无法测试。注入IHttpClientFactory(类型化客户端)或通过构造函数注入命名客户端。new SqlConnection(connectionString)同理——注入IDbConnectionFactory或者让 EF Core / Dapper 通过注册的DbContext来管理连接。 - 依赖具体类而非抽象。 一个构造函数类型为
SqlOrderRepository而非IOrderRepository的类将自身耦合到了存储技术。任何应该可复用或可独立测试的类必须仅依赖接口或抽象类型。 - 与
DateTime.Now/ 静态状态的隐藏耦合。 业务方法内部的var now = DateTime.Now使得类在测试中是非确定性的,并且如果没有时间旅行技巧就无法测试。引入ISystemClock(或使用 .NET 8+ 中的TimeProvider)并注入它。Environment.MachineName、Random.Shared或任何其他环境相关的静态成员也是如此。 - 静态可变单例。 服务类中的
static Dictionary<string, Order> _cache是隐藏的共享状态,绕过了 DI 生命周期管理,并且不是并发安全的。替换为通过 DI 注入的IMemoryCache/IDistributedCache,它支持正确的作用域和驱逐策略。
判断说明: 并非所有的
new都是 DIP 违规。new OrderDto(order)(一个普通的数据容器)或new List<T>()(一个标准集合)是没问题的。DIP 关注的是构造的对象具有行为、外部 IO 或配置的情况——即当用测试替身或替代实现来替换它会产生价值的时候。
红旗标志
| 信号 | 为什么重要 |
|---|---|
在服务方法或构造函数内部构造 new HttpClient() | 绕过了 IHttpClientFactory 连接池(负载下 socket 耗尽),隐藏了依赖,并使类在没有生产 IO 的情况下无法测试。 |
在协调类中使用 switch(order.Type) 或 if (x is ConcreteType) | 每个新变体都必须重新打开;调用方对类型层次结构了解过多。替换为从 DI 解析的 IEnumerable<IHandler>。 |
在非抽象重写中抛出 throw new NotImplementedException() 或 throw new NotSupportedException() | 子类型无法履行基类契约——LSP 违规。要么正确实现该方法,要么重构层次结构。 |
| 一个拥有 10+ 成员但调用方只使用其中 2-3 个的接口 | 强制创建大量的测试替身;表明多个角色接口被合并为一个。 |
通过 new AppDbContext(options) 自行构造 DbContext 的类 | 绕过了 DI 生命周期管理(Scoped vs Transient vs Singleton),阻止了连接池,并破坏了可测试性。注入 AppDbContext 或 IDbContextFactory<AppDbContext>。 |
在业务方法内直接调用 DateTime.Now | 在测试中非确定性;与系统时钟的耦合隐藏了逻辑依赖。注入 TimeProvider(.NET 8+)或 ISystemClock。 |
控制器操作包含 if/switch 业务逻辑或 EF 查询 | 控制器承担了本属于应用层/领域层的职责;对业务规则的单一更改就需要触及控制器。 |
用作进程内缓存的 static 可变字段 | 没有显式锁定时线程不安全;绕过了 DI 作用域;无法被驱逐或观察。使用 IMemoryCache。 |