"这里我加了一层 interface,为了以后扩展。"
"你确定以后会扩展成什么样?"
"……不确定。"
这段对话在 code review 里出现的频率,比我愿意承认的高。我自己也说过前半句,不止一次。每次说出"为了以后扩展"的时候,心里其实知道后面跟的是一个猜测——基于当下的信息,揣测一个还没发生的未来。然后把这个猜测变成了代码结构,让后面所有人都要经过它。
"为了以后扩展"之所以是坏理由,不是因为有远见是不好的。是因为你现在不知道以后需要什么。你在猜。而基于猜测引入的抽象、配置、分支,大概率猜错。猜错了怎么办?已经有一堆代码依赖这个错误的结构了,改起来比没有结构更痛苦。
几种常见形态
"为了以后扩展"在代码里出现的方式,没有一百种也有几十种。但有几类是高频的,我都写过。
提前定义 interface,只有一个实现。
最经典的例子:写一个通知功能,目前只需要发邮件。需求文档上写的是"订单支付成功后发送邮件通知"。代码里出现一个 NotificationSender interface,一个 EmailSender 实现。
为什么定义 interface?因为"以后可能有短信、推送、站内信"。
三个月后扩展需求确实来了。加短信。这时候你发现 interface 的方法签名全错了——短信不需要 subject 字段,推送不需要 email 地址,站内信不需要 to 但它需要一个标题和一个正文。而你当初定义的 Send(to, subject, body string) error,三个参数对三种新渠道各有一个是多余的。
于是 interface 变得很奇怪:Send(to, subject, body, channel string),所有值都是 string,语义全靠参数名约定。或者你把参数改成 Send(notification *Notification) error,所有渠道用一个通用结构体——这下方法签名干净了,但 Notification 结构体里塞了所有渠道需要的字段,大部分字段对大部分渠道无意义。
这个 interface 没有隐藏复杂度,它把复杂度摊平了。每个调用方不需要知道它在调哪种渠道,但需要知道传哪些字段是有效的——这份知识从实现细节变成了调用方的前置条件。比没有 interface 的时候更危险。
把硬编码改成配置,因为"未来可能有不同场景"。
一个业务规则硬编码了三行 if。很简单。有人觉得不优雅——"万一以后规则变了呢?"于是抽出来变成配置文件、配置表、或者一个规则引擎。
现在要改一个规则,需要找到配置文件、理解配置语法、修改、验证配置格式、确认没有影响其他依赖同一配置的逻辑。以前只要改三行 if,读一遍上下文就确认影响范围。
"以后规则会变"成立吗?大部分情况成立。但配置化是不是正确的应对方式?不一定。如果规则变化是低频的——一年改一次——配置文件引入的理解成本和出错面,可能大于三行 if 的维护成本。如果规则变化时需要快速回滚、灰度、A/B——配置化就有价值。但"可能有不同场景"这个理由本身不够。
加 bool 参数,制造隐式分支。
func Process(order Order, skipValidation bool) ——"以后可能有些场景不需要校验"。
这个 bool 参数告诉你什么了?什么都没告诉你。你不知道"有些场景"是什么场景。你不知道 skipValidation = true 的时候这个方法还做了什么、不做什么。你甚至不知道这个方法内部有多少行为被这个 bool 控制——可能是 2 个 if,可能是 10 个。
bool 参数的本质是把两个不同的操作强行塞进同一个函数签名。它们的调用方不同、前提条件不同、失败模式不同,但它们共享了同一个名字。当你在函数体内写 if skipValidation { ... } else { ... } 的时候,你其实应该写两个函数。
建一张通用化的数据库表。
EAV(Entity-Attribute-Value)模型是"为了以后扩展"在数据库层面的终极形态。不定义具体列,而是 entity_id, attribute_name, attribute_value 三列存一切。
灵活性有了——想加什么属性加一行就行,不用改表结构。代价是什么?查询需要 pivot、约束几乎加不了(email 格式、必填、唯一性——全没了)、join 变成噩梦、数据库的优化器对你几乎没帮助。
"以后可能有各种自定义属性"——这个需求如果还没来,别提前建 EAV。如果来了,一个 JSON 列可能就够了。如果确实需要 EAV,那时候再建。
为什么猜测通常会错
因为你用来猜测的素材——当前的业务需求、当前的技术环境、当前的团队认知——和未来实际发生时你会拥有的素材,不是同一套。
你定义 NotificationSender interface 的时候,你只知道邮件通知长什么样。你基于一枚样本点拟合了一条曲线——"通知就是 to + subject + body"——然后期待未来所有的通知类型都落在这条曲线上。它大概率不会。短信通知甚至不经过同一个网关。推送通知是异步的。站内信需要已读未读状态。你用一个方法签名想覆盖四个不同的通信范式。
这不是水平问题。这是信息不足。信息不够的时候做设计,你做的不是设计——是赌。赌未来和你现在想的一样。软件工程里,这个赌局庄家的胜率比赌场高得多。
还有一个更隐蔽的错:你以为你在"为以后做准备",实际上你在为"现在的你想象的那个以后"做准备。这两个东西的差距,就是代码里多出来的那层不需要的抽象。
猜错的代价
猜错了不是回到原点重写。是更贵——已经有一堆代码依赖这个错误的结构了。
NotificationSender interface 已经被 import 了十几处。测试已经基于它写了 mock。调用方已经假设有个 .Send() 方法。现在短信来了,你发现 .Send() 不行——要么改 interface 让所有实现都跟着动,要么在外面绕一层 adapter 把新渠道伪装成旧 interface。两条路都有人选,两条路都导致代码在 interface 周围长出一个比 interface 本身更复杂的生态系统。
如果你当初不定义这个 interface——只是写一个 EmailSender 结构体,调用方直接用它——三个月后短信来的时候,你只需要加一个 SmsSender,然后在调用方决定用哪个。可能用一个简单的工厂函数,可能用策略模式,可能走消息队列。但不管选什么,决策发生在信息足够的时候。信息足够时的设计比信息不足时的猜测,正确率高得多。
Sandi Metz 说过一句话:"Duplication is far cheaper than the wrong abstraction." 重复代码是局部的——改一处不影响另一处。错误抽象是全局的——改 interface 签名,所有实现和调用方都受影响。提前支付复杂度,不仅现在多写了代码,还让未来改起来更贵。双重代价。
YAGNI 不是懒惰
YAGNI——You Aren't Gonna Need It——经常被误解成"永远不抽象"、"不要写可扩展的代码"、"代码质量不重要"。它不是这个意思。
YAGNI 是说:在有真实需求之前不要做。 真实需求的标准:至少有第二个用例出现,你清楚地知道它们的共性和差异。
第一个实现是探索。你搞清楚了这个功能在业务上到底要做什么。
第二个实现是模式。你看到了哪些是真的共性的、哪些只是碰巧相似、哪些完全不同。
第三个实现才是抽象。这时候你已经有数据了——前两例的变化方向一致,可以抽。方向不同,你就知道不该统一。
这和等到第二个用例再抽象是同一个逻辑。一句话概括:第一个用例告诉你有哪些步骤,第二个用例告诉你哪些步骤是公共的、哪些是可变的。只有一个用例的时候,你连差异在哪都看不全——你只能猜。把猜测写成代码结构,不如把代码写简单点,等证据来了再做决定。
这不叫没远见。这叫对不确定性的诚实态度。
什么时候是例外
有例外,但条件苛刻。
在同一个领域做过三次以上类似系统——你对变化方向的判断不是猜测,是模式识别。这类判断有经验支撑,不是拍脑袋。
在写基础设施或公共库——API 向后兼容的代价需要提前评估。"先发了再改"在这种场景下的成本本身就很高,提前投入设计是合理的。但大部分人写的不是公共库,是业务代码。
变化的驱动力被领域本身锁死了——比如一个财务系统的借贷记账法。复式记账的基本结构不会被业务需求改变。这种不是你在设计抽象,是领域已经替你做了设计。
对大部分日常业务代码,这三个条件不成立。你面对的是一个还在探索中的业务,一个你没完全理解的领域。在这种条件下为"以后"做设计,猜错是大概率。
几个实用的替代做法
等第二个用例。 第一个场景,一把梭。第二个出现,认真对比。第三个出现,你有数据判断了。
不把猜测写成代码,写成 ADR。 有一个想法——"以后这里可能要拆成策略模式"——别急着写代码。写在 ADR 的 "Consequences" 里:未来如果有 X、Y、Z 类型的变化,当前设计可能需要重构为策略模式。这样你保留了判断但没有提前支付复杂度。下一个接手的人读到,知道你想过这件事,也知道重构的方向——但他不需要在现在处理一个还没发生的需求。
把"可能变化的地方"隔离到薄层里,而不是让抽象层铺满全局。 别把整个通知系统架在一个通用 interface 上。只是把"发送"这个动作用一个简单的函数类型隔离:type Sender func(msg Message) error。不预设渠道类型,不预定义参数结构。未来怎么变,取决于未来的需求。
延迟决策不等于不决策。 你今天选择不抽象,是一个主动的架构决策——你判断当前信息不足以支持一个正确的抽象,你选择等待更多信息。这不是拖延,和拖延的区别是你有一个明确的触发条件:"当第二个通知渠道出现时,我们会重新评估抽象形态。"
简单不是简陋
我刚写代码那几年,觉得加 interface、加配置、加抽象层是"工程水平"的体现——代码越灵活、越可配置,越像"高级工程师写的"。
后来发现不是这么回事。
高级工程师写的东西,抽象数量往往偏少。不是因为不会抽象,是因为知道抽象的代价。知道每一个 interface、每一个配置项、每一个 bool 参数都在向阅读者收取认知税。知道"为了以后"这个短语在需求文档和 PRD 里出现的频率,远低于在代码里。
今天不写的那行代码,可能是你替未来的自己省下的最大麻烦。
不是所有远见都需要变成代码。有些远见,先写进设计记录和触发条件里,比提前写进代码结构里更有价值。
参考资料
- Sandi Metz, All the Little Things, RailsConf 2014.
- Martin Fowler, Yagni.