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表达式在内的语言特性,能提升代码的可读性和可维护性。

在这个例子中,如有限分支、离散值映射这些场景,优先考虑switchrecordenum等语言特性,而不是引入工厂、空接口之类。以下反模式:

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)
{
// 满 1000 打 9 折
return total >= 1000m ? 0.10m : 0m;
}
}

public static class BannerService
{
public static string GetBanner(decimal total)
{
// 满 1000 是 VIP
return total >= 1000m ? "VIP 用户" : "普通用户";
}
}

public static class AuditService
{
public static bool IsVip(decimal total)
{
// 满 1000 是 VIP
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查询和新增用户,上面的TFilterTProjectionDeleteAsync等都没有实际用途,提前引入这些抽象层级,只会将这些复杂度的心智负担提前承受。

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 的手段,善用语言特性,避免过度设计。