最开始,心中理想且完美的架构设计让我们能构建出清晰的 MVVM (Model-View-ViewModel) 架构、现代的 API 设计。
但随着项目不断迭代,功能越来越多,算上现在 Coding Agent 几乎全方位参与到代码编写中,我们不得不需要采取一些设计原则和架构来保证代码的可维护性、可扩展性和可读性。
SOLID 原则是架构设计的基本之一,相信现在主流的 AI 都能理解并应用这些原则。尽管如此,驾驭项目的架构依然是开发者的责任。
发生了什么
SOLID 代表了五个设计原则,每个都是架构设计的原子。作为方法论,它们保证了所写的代码为你所想的,尽可能避免未来的瑕疵。
虽然 SOLID 不能显著地在项目层面减少功能重构的次数,但是它的目的是让代码跑在正确的轨道上,减少因架构纰漏导致未来难以推进新功能 / Bug 修复的情况。
僵化
当项目的耦合度过高时,任何改动都有可能是牵一发而动全身的。
比如,一个收藏夹功能,起初只是个网址列表,后来打算加入分类功能,如果之前的存储结构过于扁平简单,可能就需要重构整个存储结构来支持分类功能。
在技术层面,出现问题顶多翻翻 Git 记录,然后重构一下代码就继续。但是在业务层,每个大小功能的实现时间变得越来越不可预测,让项目逐渐失去迭代的动力。
脆弱
还有第二关。打破僵化后,每一次更改可能让代码变得越来越脆弱,甚至可能引入新的 Bug。
对一个统一表格样式的修改,导致少数页面在魔改部分样式继承的表格内呈现的控件 Margin 失效,盖住部分按钮甚至完全消失。
失去代码控制权带来的不安感也会从技术上升到业务部署分发,难以担保版本稳定性。
不可移植
公司的项目和业务线越来越多,开发者们也会发现部分项目的有些关键逻辑是很像的,但由于之前没有抽象出公共组件,导致每个项目都在重复造轮子。
比如,多个项目都需要一个用户权限管理模块,但每个项目都独立开发了一个,之后的安全维护和功能都需要同步给每个项目,增加了维护成本。
粘度
实现通常伴随着取舍,过度设计会让实现变得复杂,过于简单又可能导致未来的扩展困难。找到平衡点是关键。
开发环境的效率也决定开发者是否愿意大动干戈:缺少自动化测试、缺乏 CI/CD 流程、没有 Dev / Staging 环境,都可能让开发者在面对架构调整时望而却步,宁愿在现有的基础上继续堆积功能。
以上四个问题源自需求的变化,我们可以借助 SOLID 原则来应对这些。
设计原则
单一职责原则
一段代码因什么而改变?
当我们从变更的理由出发,发现一个类能够承载多个动机,那么不由得让人怀疑这个类是否承担了过多的职责。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| public class OrderService { public void Place(Order order) { if (order.Items.Count == 0) throw new InvalidOperationException();
using var db = new AppDbContext(); db.Orders.Add(order); db.SaveChanges();
var smtp = new SmtpClient("mail.local"); smtp.Send("[email protected]", "[email protected]", "New Order", $"Order {order.Id}"); } }
|
显然,这是一个看起来单纯的下单服务类,但它承担了三个职责:业务校验、持久化和通知。任何一个职责的变更都可能导致这个类的修改。
我们可以这么做:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| public interface IOrderValidator { void Validate(Order order); } public interface IOrderRepository { void Save(Order order); } public interface IOrderNotifier { void Notify(Order order); }
public class OrderService { private readonly IOrderValidator _validator; private readonly IOrderRepository _repository; private readonly IOrderNotifier _notifier;
public OrderService( IOrderValidator validator, IOrderRepository repository, IOrderNotifier notifier) { _validator = validator; _repository = repository; _notifier = notifier; }
public void Place(Order order) { _validator.Validate(order); _repository.Save(order); _notifier.Notify(order); } }
|
可以借助接口来明确职责间的边界,在之后的迭代中,任何一个职责的设计与实现变更都不会影响到其他职责。
当然,接口还可以让我们在测试时更容易地 Mock 依赖,从而让更容易地检验每个职责的正确性。
所以,单一职责原则 (Single Responsibility Principle, SRP) 的核心是,把因同样原因变化的东西放在一起,把因不同原因变化的东西分开。一个类只能做一件事属于 SRP 的一个常见误解。
开闭原则
添加新功能时,我们大多需要关注新增部分本身即可;但当我们需要修改现有系统时,事情就可能变得复杂一点了。
1 2 3 4 5 6 7 8 9 10 11 12 13
| public class DiscountCalculator { public decimal Calculate(Customer customer, decimal price) { return customer.Type switch { CustomerType.Regular => price, CustomerType.Vip => price * 0.9m, CustomerType.Employee => price * 0.8m, _ => throw new NotSupportedException() }; } }
|
这段代码很简单,根据不同的客户类型计算折扣,但如果我们需要新增一个客户类型,比如 CustomerType.Partner,就需要修改 DiscountCalculator 类。
我们可以这么做:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| public interface IDiscountPolicy { bool CanHandle(Customer customer); decimal Calculate(decimal price); }
public class VipDiscountPolicy : IDiscountPolicy { public bool CanHandle(Customer customer) => customer.Type == CustomerType.Vip; public decimal Calculate(decimal price) => price * 0.9m; }
public class DiscountCalculator { private readonly IEnumerable<IDiscountPolicy> _policies;
public DiscountCalculator(IEnumerable<IDiscountPolicy> policies) { _policies = policies; }
public decimal Calculate(Customer customer, decimal price) => _policies.First(p => p.CanHandle(customer)).Calculate(price); }
|
这样就实现了动态多态性,我们可以在不修改 DiscountCalculator 的情况下,新增一个 PartnerDiscountPolicy 来处理新的客户类型。
开闭原则的体现倒也没有看起来这么架构,也可以体现在语言特性之中,比如:
1 2 3 4 5 6 7 8 9
| interface ICallable { void Call(); }
class Phone : ICallable { public void Call() => Console.WriteLine("Calling via Phone"); }
|
接口的实现就是对开闭原则静态多态性的体现,新增一个 Skype 类实现 ICallable 接口,就可以在不修改现有代码的情况下,新增一个调用方式。
不是说开闭原则就是要避免修改旧代码,也不是为每个变化做抽象,保持代码结构简洁往往比过度设计更重要。开闭原则面向的层面往往更高,这里只是用类举例子。
那什么时候好使呢?Robert C. Martin 后来指出,插件架构是开闭原则的最终体现。
它的关键在于确保插件内部的所有依赖都指向系统,而系统没有依赖插件。插件可以随时被添加或移除,而系统不需要修改,也不知道插件的存在。
里氏替换原则
面向对象的继承关系是一个很容易被误解的地方,里氏替换原则 (Liskov Substitution Principle, LSP) 的核心是,如果你有一个父类和一个子类,那么这个基类和子类应该可以互换 (interchangeably) 使用,而不会产生错误的结果。
听起来挺奇怪的,举个例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| public class Rectangle { public virtual int Width { get; set; } public virtual int Height { get; set; } }
public class Square : Rectangle { public override int Width { get => base.Width; set { base.Width = value; base.Height = value; } }
public override int Height { get => base.Height; set { base.Width = value; base.Height = value; } } }
|
如果采取该设计:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| public interface IShape { int Area { get; } }
public sealed class Rectangle : IShape { public Rectangle(int width, int height) { Width = width; Height = height; }
public int Width { get; } public int Height { get; } public int Area => Width * Height; }
public sealed class Square : IShape { public Square(int side) => Side = side; public int Side { get; } public int Area => Side * Side; }
|
前一个设计的问题在于,如果一个针对 Rectangle 的单元测试断言:
1 2 3
| rect.Width = 5; rect.Height = 10; Assert.AreEqual(50, rect.Width * rect.Height);
|
那么当传入 Square 时,这个测试将彻底失败。这就破坏了行为兼容性。
编译通过了,但调用的契约变化了,导致调用方的行为不再符合预期。
引入IShape 接口后,调用方只关心面积的计算,而不关心具体的形状类型,这样就不存在隐性的契约破坏了。
里氏替换原则 (Liskov Substitution Principle, LSP) 的原本形式化表述是:如果某个性质对类型T的对象成立,那么对其子类型S的对象也应成立。它是一个语义上的约束,而不仅仅是语法约束。
需要关心的是,子类是否偷偷加强了前置条件(调用方必须满足的条件),或者削弱了后置条件(调用方可以期望的结果),又或是引入了意外的副作用。
接口隔离原则
继承/接口有可能带来不必要的依赖,然而,并不是所有的客户端都需要依赖同一套特性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| public interface IWorker { void Code(); void Test(); void Deploy(); }
public class Developer : IWorker { public void Code() { } public void Test() { } public void Deploy() { } }
public class Tester : IWorker { public void Code() => throw new NotSupportedException(); public void Test() { } public void Deploy() => throw new NotSupportedException(); }
|
这里就很容易看出来问题,Tester 类不需要 Code 和 Deploy 方法,但它仍然被迫实现了这些方法,这违反了接口隔离原则 (Interface Segregation Principle, ISP)。
其实,调用方依赖什么角色,就只看到什么契约。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| public interface ICoder { void Code(); } public interface ITester { void Test(); } public interface IDeployer { void Deploy(); }
public class Developer : ICoder, ITester { public void Code() { } public void Test() { } }
public class Tester : ITester { public void Test() { } }
|
这个原则的设计动机并不是为了让接口更小,一个接口一个接口去数数,而是为了让接口更契合调用方的需求。一个接口应该只包含调用方所需要的方法,而不是所有可能的方法。
依赖反转原则
我觉得这是在我代码中最常用(或是最显然)的原则了。简单来说:
- 高层模块不应该依赖低层模块,二者都应该依赖抽象。
- 抽象不应该依赖细节,细节应该依赖抽象。
1 2 3 4 5 6 7 8 9 10
| public class ReportService { private readonly SqlReportRepository _repository = new();
public Report Generate(int id) { var data = _repository.Load(id); return new Report(data); } }
|
我们可以改成:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| public interface IReportRepository { ReportData Load(int id); }
public class ReportService { private readonly IReportRepository _repository;
public ReportService(IReportRepository repository) { _repository = repository; }
public Report Generate(int id) { var data = _repository.Load(id); return new Report(data); } }
|
这样,ReportService依赖的是自己真正需要的能力,而不是具体的实现。和第一个原则一样,依赖反转原则也让我们更容易地 Mock 依赖,从而让测试变得更容易。
同时,该原则也体现了“配置”与“使用”的分离。ReportService 不需要知道 IReportRepository 的具体实现,它只关心它的接口契约。这样,我们可以在不同的环境中提供不同的实现,比如在测试环境中使用一个内存中的存储,而在生产环境中使用 SQL 数据库。
我们可以借助 DI 容器库来管理依赖的注入,但并不意味着用了就觉着项目被 DI 原则约束住了。DI 容器只是一个工具,它帮助我们更容易地遵循依赖反转原则,但真正的关键在于设计时的抽象和模块化。
结尾
所以,为什么代码越来越难改?每次改动都可能引入新的 Bug?新功能的迭代越来越慢?
任何设计原则,无论 SOLID 还是之后会提及的 KISS、DRY、YAGNI,都是作为方法论来帮助我们在项目迭代变化中保持边界。
实际上,这些东西用起来并不能让我更快的实现功能、修复问题,这方面也起不到多少作用;但就跟装修一样,决定承重墙的位置和房间的布局,决定了未来的扩展和改造的难易程度。