这篇我打算用两段代码开头,都是我自己写的,相隔四年。
2019 年的我
需求:给用户发通知,支持站内信和邮件。
我当时写的东西是这样的结构:一个 NotificationChannel 接口,两个实现类,一个 ChannelFactory,一个 NotificationTemplate 抽象类和三个模板子类,一个 NotificationContext 承载参数,一个 ChannelSelector 策略接口和两个策略实现。
加上配置和测试,一共 14 个文件,大约 900 行。
我当时非常得意,觉得这个设计「以后加短信、加推送都不用改代码」。
结果是:那个项目活了两年半,一共加过一次新渠道。加的时候我发现新渠道需要异步发送和回调确认,而我的接口是同步的、无返回的。我不得不改接口,然后连带改了所有实现类和工厂。
那 900 行为一次扩展付出的代价,比直接写 if-else 高得多。
2023 年的我
同样的需求,在另一个项目上。我写了一个文件,大概 70 行:
def notify(user, kind, data):
text = render(kind, data)
if user.prefers_email:
send_email(user.email, text)
else:
save_inbox(user.id, text)就这样。没有接口,没有工厂,没有策略。
一年半之后要加推送,我加了三行 if 分支。又过了半年要加异步,我把 send_email 改成投递到队列,改了两个地方。
总代码量到今天大概 140 行。
我从中学到的三件事
一、抽象是在总结经验,不是在预测未来
2019 年的我,是在预测未来会有很多渠道。预测错了,而且错的方向不是数量,是维度——我以为变的是渠道种类,实际变的是同步/异步这个我完全没考虑的维度。
好的抽象来自于你已经写了三遍类似的代码,你看到了它们真实的共同点。坏的抽象来自于你想象它们将来会有什么共同点。
这就是那个老规矩「三次法则」的道理:写第一遍,写第二遍时忍住,写第三遍时再抽象。前两遍的重复是你买抽象的入场券。
二、抽象的成本是「间接层」,而间接层的代价是理解
我后来有一次接手别人的代码,追一个 bug。调用链是这样的:Controller 调 Service 接口,Service 由工厂生成,工厂读配置决定实现类,实现类调 Handler 接口,Handler 又有一层装饰器包装。
我从入口跳到真正干活的那 20 行代码,中间跳了 7 次,用了 25 分钟。而那 20 行本身,我三分钟就看懂了。
每一层抽象都是一次「你得跳一下才知道真正发生了什么」。抽象的成本不在写的时候,在读的时候,而代码被读的次数是被写的次数的几十倍。
三、判断标准:这个抽象有没有减少我需要理解的东西
这是我现在唯一的判断标准,比任何设计原则都好使。
好的抽象让我可以不去想某件事。比如一个连接池,我用它就不需要想连接的创建和回收了。这是真正的减负。
坏的抽象只是把东西挪了个位置,我该想的还得想,而且还多了一层要理解。比如上面那个 ChannelSelector,我看了它还是得去看具体策略,什么都没省。
什么时候我会坚决抽象
说了这么多,我不是反对抽象。有三种情况我会毫不犹豫地抽:
- 边界处。凡是跟外部系统打交道的地方,我一定包一层。因为外部是我控制不了的,包一层的价值是隔离变化,而且这个变化真实存在——我经历过三次上游接口改造。
- 重复出现三次以上的、真正相同的东西。注意是「真正相同」,不是「看起来像」。判断方法是:如果需求变了,这三处会不会一起变?会,就抽。不会,就是巧合。
- 危险操作。比如所有涉及金额的计算,我一定收敛到一个地方。不是为了复用,是为了让审查有唯一的位置。
一个更细的观察
我发现自己过度抽象的时候,往往伴随着一种特定的心理状态:我对需求不确定,于是用架构的复杂度来对冲焦虑。
「我不知道以后会怎么变,那我把所有地方都做成可扩展的吧。」这句话听起来负责任,实际上是把决策成本转嫁给了未来的读者。
而真正负责任的做法是承认不确定,写最简单的版本,并且让它容易删除。
好代码的标准不是容易扩展,是容易删除。
这句话我最近越想越对。2019 年那 900 行,我到今天也没删掉,因为牵扯太多。2023 年那 70 行,我随时可以整个扔掉重写,成本一个下午。
哪个更「灵活」,其实一目了然。