SOLID 等设计原则能让代码组织得更好,但其带来的复杂性或是过度设计,可能会让代码变得难以理解和维护。
并不是在任何时候,我们都要引入抽象层或是抽出重复代码,一些看起来很专业的设计是需要被审视的。
背景
KISS (Keep It Simple, Stupid) 体现了极简主义理念,类似于 奥卡姆剃刀 或是 少即是多 的思想。
如果一个系统越复杂,那么理解、维护和修复的成本就越高,所以不必要的复杂度应该被避免。
DRY (Don’t Repeat Yourself) 被定义为:系统中的每一条知识都必须有一个单一的、明确的、权威的表示。
Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.
它关注于 知识来源,不只是代码的重复,无论数据库模式、测试规则、构建脚本、文档和配置等等都在其范畴内。
YAGNI (You Aren’t Gonna Need It) 简单来说就是,在真正需要之前,不要去实现某个功能。
Always implement things when you actually need them, never when you just foresee that you [will] need them.
在错误的时间点引入设计或实现,可能会导致过度设计。
冲突与权衡
单独说每个设计原则看起来挺完美的,实际上,它们之间可能会产生冲突。
比如,看到两段很像的代码:
- DRY:尝试抽出公共方法、基类
- YAGNI:如果只是为了避免重复而抽象,可能会引入不必要的复杂性
我们可以引入 Rule of Three,即看到三次重复时,才考虑抽象。
实际应用中,它们大致可以形成这种关系:
YAGNI: 实现新需求或是打算重构的时候,判断这是当前要交付的真实需求吗?
KISS:如果要做,现有的设计是否已经难以理解?
DRY:重复的代码是否稳定多次出现?如果没有,那写重了暂时无所谓;否则,就需要考虑抽象。
语境
KISS
在 C# 中,包括模式匹配和switch表达式在内的语言特性,能提升代码的可读性和可维护性。
在这个例子中,如有限分支、离散值映射这些场景,优先考虑switch、record、enum等语言特性,而不是引入工厂、空接口之类。以下反模式:
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 28 29 30 31 32 33 34 35 36 37
| public interface IOrderStatusPresenter { string Present(); }
public sealed class DraftPresenter : IOrderStatusPresenter { public string Present() => "待提交"; }
public sealed class PaidPresenter : IOrderStatusPresenter { public string Present() => "已支付"; }
public sealed class CancelledPresenter : IOrderStatusPresenter { public string Present() => "已取消"; }
public static class OrderStatusPresenterFactory { public static IOrderStatusPresenter Create(OrderStatus status) => status switch { OrderStatus.Draft => new DraftPresenter(), OrderStatus.Paid => new PaidPresenter(), OrderStatus.Cancelled => new CancelledPresenter(), _ => throw new ArgumentOutOfRangeException(nameof(status)) }; }
public enum OrderStatus { Draft, Paid, Cancelled }
|
只是为了三个状态引入接口 + 工厂 + 三个实现类,显然是过度设计。抽象层级远远超过了变化的真实规模,这里没有运行时动态替换、没有多租户差异、也没插件,所以可以直接使用switch表达式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| public enum OrderStatus { Draft, Paid, Cancelled }
public static class OrderStatusText { public static string ToText(OrderStatus status) => status switch { OrderStatus.Draft => "待提交", OrderStatus.Paid => "已支付", OrderStatus.Cancelled => "已取消", _ => throw new ArgumentOutOfRangeException(nameof(status)) }; }
|
借助switch表达式把控制流压缩到最直接的样子,同时保留所有可能值的检查和非法值处理,只要变化规模没那么高,这样的设计就足够了。
DRY
不只是提一个工具类那么简单,而是把分散的规则、配置收缩在一起。
比如,Microsoft 的 Configuration 文档中提到,IConfiguration提供来自多个配置源的统一访问方式,避免了在代码中硬编码配置路径、键名等信息。
反模式示例:
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 static class CheckoutService { public static decimal GetDiscountRate(decimal total) { return total >= 1000m ? 0.10m : 0m; } }
public static class BannerService { public static string GetBanner(decimal total) { return total >= 1000m ? "VIP 用户" : "普通用户"; } }
public static class AuditService { public static bool IsVip(decimal total) { return total >= 1000m; } }
|
从 DRY 的角度,VIP 这个业务知识被重复了,一旦未来 VIP 的定义(比如门槛)发生变化:从1000改为2000,就需要在三个地方修改,容易遗漏。所以,可以这么改:
1 2 3 4 5 6 7 8 9 10
| public static class LoyaltyPolicy { public const decimal VipThreshold = 1000m;
public static bool IsVip(decimal total) => total >= VipThreshold;
public static decimal GetDiscountRate(decimal total) => IsVip(total) ? 0.10m : 0m;
public static string GetBanner(decimal total) => IsVip(total) ? "VIP 用户" : "普通用户"; }
|
这样,VIP 变成了一个单一的知识来源,未来只需要在LoyaltyPolicy中修改门槛值即可。
在实际应用中,这个VipThreshold可以通过Options 模式从配置文件中读取,避免硬编码。
YAGNI
很多所谓未来要考虑扩展的点,其实只是想象中的需求,而不是实际交付的一部分。
提前布局泛型仓储、插件模型、没第二个实现却被抽象的接口、复杂的策略模式等等,都是过度设计的表现。
1 2 3 4 5 6 7
| public interface IRepository<TEntity, TKey, TFilter, TProjection> { Task<TEntity?> FindAsync(TKey id, CancellationToken cancellationToken = default); Task<IReadOnlyList<TProjection>> QueryAsync(TFilter filter, CancellationToken cancellationToken = default); Task AddAsync(TEntity entity, CancellationToken cancellationToken = default); Task DeleteAsync(TKey id, CancellationToken cancellationToken = default); }
|
如果当前系统只有一个很小的用户模块,只需要按id查询和新增用户,上面的TFilter、TProjection、DeleteAsync等都没有实际用途,提前引入这些抽象层级,只会将这些复杂度的心智负担提前承受。
1 2 3 4 5 6 7 8 9 10
| public sealed record User(Guid Id, string Name);
public sealed class UserRepository { private readonly List<User> _users = new();
public User? FindById(Guid id) => _users.FirstOrDefault(x => x.Id == id);
public void Add(User user) => _users.Add(user); }
|
未来如果真出现分页查询、复杂筛选之类的需求,再重构也不迟。保持代码可变更、容易变更比提前引入复杂抽象更重要。
结尾
想大干一场重构之前,让 KISS、DRY、YAGNI 给自己浇冷水。
依赖注入很有用,但接口不是百亿补贴,不是每个类都要领一个。
C# 的语法糖越来越多,它们本就是达到 KISS 的手段,善用语言特性,避免过度设计。