SOLID 在 C# 中 第1部分:单一职责
Source: Dev.to
这是我关于 C# 中 SOLID 原则系列的第 1 部分。每篇文章都会通过真实代码逐步讲解一个原则,这类代码正是你在生产代码库中会看到的。
什么是单一职责原则?
简易版: 一个类应该只有一个修改的理由。
不是一个方法。不是一行代码。一个 修改的理由 —— 这意味着一个职责,一个责任范围。
当团队成员说“我需要更新邮件发送方式”时,这个更改应该只影响一个类,而不是一个同时处理订单计算和数据库查询的类。
问题:一个做所有事情的类
我在几乎所有参与过的代码库中都见过这种模式。某个时刻,有人创建了一个 OrderService,然后不断往里添加功能:
public class OrderService {
private readonly string connectionString;
public OrderService(string connectionString) {
this.connectionString = connectionString;
}
public void PlaceOrder(string customerEmail, string product, int quantity, decimal price) {
// 1. Calculate the total
decimal total = quantity * price;
if (quantity > 10)
total *= 0.9m; // 10% bulk discount
// 2. Save to database
using var connection = new SqlConnection(connectionString);
connection.Open();
using var command = new SqlCommand(
"INSERT INTO Orders (Email, Product, Qty, Total) VALUES (@e, @p, @q, @t)",
connection);
command.Parameters.Add("@e", SqlDbType.NVarChar).Value = customerEmail;
command.Parameters.Add("@p", SqlDbType.NVarChar).Value = product;
command.Parameters.Add("@q", SqlDbType.Int).Value = quantity;
command.Parameters.Add("@t", SqlDbType.Decimal).Value = total;
command.ExecuteNonQuery();
// 3. Send confirmation email
var message = new MimeMessage();
message.From.Add(new MailboxAddress("Orders", "orders@company.com"));
message.To.Add(new MailboxAddress("", customerEmail));
message.Subject = "Order Confirmed";
message.Body = new TextPart("plain") {
Text = $"Thanks for ordering {quantity}x {product}. Total: ${total}"
};
using var smtp = new SmtpClient();
smtp.Connect("smtp.company.com", 587);
smtp.Send(message);
smtp.Disconnect(true);
// 4. Log it
File.AppendAllText("orders.log",
$"{DateTime.Now}: Order placed - {product} x{quantity} = ${total}\n");
}
}
为什么这是个问题
这个类有 四个修改动因:
| 动因 | 需要修改的内容 |
|---|---|
| 折扣规则 | 修改计算逻辑 |
| 数据库结构 | 修改 SQL 语句 |
| 邮件格式 | 修改消息构造方式 |
| 日志方式 | 修改文件写入代码 |
团队中可能有四个人需要同时修改这个文件,而几乎不可能在隔离的情况下对其中任意一种行为进行测试。这通常被称为 “上帝类” —— 它了解太多、做的事太多。
Source: …
修复方案:每个类只承担一个职责
让我们把它拆分开来。每个类只负责一个单一的原因,并且我们将面向接口编程,以便这些组件可以被单独测试。
合约(接口)
// Each interface represents one responsibility
public interface IOrderCalculator {
decimal CalculateTotal(int quantity, decimal unitPrice);
}
public interface IOrderRepository {
void Save(Order order);
}
public interface IOrderNotifier {
void SendConfirmation(string customerEmail, string product, int quantity, decimal total);
}
public interface IOrderLogger {
void Log(string product, int quantity, decimal total);
}
实现
订单计算器 – 仅负责定价规则
public class OrderCalculator : IOrderCalculator {
public decimal CalculateTotal(int quantity, decimal unitPrice) {
decimal total = quantity * unitPrice;
if (quantity > 10)
total *= 0.9m; // bulk discount
return total;
}
}
订单仓储 – 仅负责持久化
public class OrderRepository : IOrderRepository {
private readonly string connectionString;
public OrderRepository(string connectionString) {
this.connectionString = connectionString;
}
public void Save(Order order) {
using var connection = new SqlConnection(connectionString);
connection.Open();
using var command = new SqlCommand(
"INSERT INTO Orders (Email, Product, Qty, Total) VALUES (@e, @p, @q, @t)",
connection);
command.Parameters.Add("@e", SqlDbType.NVarChar).Value = order.CustomerEmail;
command.Parameters.Add("@p", SqlDbType.NVarChar).Value = order.Product;
command.Parameters.Add("@q", SqlDbType.Int).Value = order.Quantity;
command.Parameters.Add("@t", SqlDbType.Decimal).Value = order.Total;
command.ExecuteNonQuery();
}
}
邮件订单通知器 – 仅负责通知
public class EmailOrderNotifier : IOrderNotifier {
public void SendConfirmation(string customerEmail, string product, int quantity, decimal total) {
// Using MailKit (the modern replacement for System.Net.Mail.SmtpClient,
// which has been deprecated since .NET 6)
var message = new MimeMessage();
message.From.Add(new MailboxAddress("Orders", "orders@company.com"));
message.To.Add(new MailboxAddress("", customerEmail));
message.Subject = "Order Confirmed";
message.Body = new TextPart("plain") {
Text = $"Thanks for ordering {quantity}x {product}. Total: ${total}"
};
using var smtp = new SmtpClient();
smtp.Connect("smtp.company.com", 587);
smtp.Send(message);
smtp.Disconnect(true);
}
}
订单日志记录器 – 仅负责日志
public class FileOrderLogger : IOrderLogger {
public void Log(string product, int quantity, decimal total) {
File.AppendAllText("orders.log",
$"{DateTime.Now}: Order placed - {product} x{quantity} = ${total}\n");
}
}
编排流程
public class OrderProcessor {
private readonly IOrderCalculator calculator;
private readonly IOrderRepository repository;
private readonly IOrderNotifier notifier;
private readonly IOrderLogger logger;
public OrderProcessor(IOrderCalculator calculator,
IOrderRepository repository,
IOrderNotifier notifier,
IOrderLogger logger) {
this.calculator = calculator;
this.repository = repository;
this.notifier = notifier;
this.logger = logger;
}
public void PlaceOrder(string customerEmail, string product, int quantity, decimal unitPrice) {
var total = calculator.CalculateTotal(quantity, unitPrice);
var order = new Order {
CustomerEmail = customerEmail,
Product = product,
Quantity = quantity,
Total = total
};
repository.Save(order);
notifier.SendConfirmation(customerEmail, product, quantity, total);
logger.Log(product, quantity, total);
}
}
现在每个类只有 一个变化的理由,这使得代码库更易于理解、测试和维护。
我们到底获得了什么?
你可以对每个组件单独进行测试。 想验证批量折扣吗?直接测试 OrderCalculator。无需数据库、邮件服务器或文件系统:
[Test]
public void CalculateTotal_BulkOrder_AppliesTenPercentDiscount() {
var calculator = new OrderCalculator();
var total = calculator.CalculateTotal(quantity: 15, unitPrice: 10m);
Assert.That(total, Is.EqualTo(135m)); // 150 * 0.9
}
尝试在原来的上帝类上编写同样的测试。仅仅为了测试算术,你就需要数据库连接、SMTP 服务器以及可写的文件系统。
更改是有范围的。 当你的老板说“我们要从 SMTP 换到 SendGrid”时,你只需编写一个新的 IOrderNotifier 实现。计算器、仓库、日志记录器:它们都不受影响,也不会意外被破坏。
新开发者能更快找到东西。 “定价逻辑在哪里?” → OrderCalculator.cs。不再埋在 200 行方法的第 47 行里。
如何发现 SRP 违规
在代码审查时我会留意的红旗:
- 类名中包含 “And”(或应该包含):
OrderAndEmailService、UserValidationAndStorage。 - 描述时使用 “also” 这个词: “它计算总额 also 发送邮件 also 保存到数据库。”
- 依赖来自完全不同的领域:一个类同时使用
SmtpClient和SqlConnection表明它承担了太多职责。 - 方法长度超过约 30 行:这不是硬性规定,但长方法往往是多个职责被塞在一起的表现。
- 团队中多个人因无关原因频繁编辑同一个文件。
最常见的反对意见
“但是现在我有太多文件了!”
是的。五个小而专注的文件比一个庞大的文件更容易理解、测试和修改。你的 IDE 有 Ctrl+T。好好利用它。
增加文件的成本几乎为零。神类(God Class)的成本是没人愿意触碰的代码以及跨不相关功能的连锁 bug。
快速规则
- 问:“我能用一句话描述这个类的职责吗,且不使用 and 吗?”
- 如果一个类有超过 3‑4 个依赖,它可能做得太多了。
- 协调者是可以的。
OrderService上面协调工作,但它本身并不 执行 工作。 - 不要走极端。你不需要为每个方法都创建一个类。将因同一原因而改变的内容归为一组。
接下来: 第 2 部分 – 开闭原则,“扩展,而非修改”。 我们将看看如何在不触碰已有类的情况下添加新行为。
这是一系列文章的一部分,内容涵盖 SOLID → 单元测试 → 清洁架构 → 集成测试。 关注我 以获取下一篇。