跳至主要内容
可维护性,看的是改动落在几处

可维护性,看的是改动落在几处

上周给一个跑了四年的项目加支付渠道。业务逻辑不到一百行,剩下的时间全花在找地方上:渠道枚举加一项,网关白名单加一项,后台下拉列表加一项,前端图标映射加一项,三个测试 fixture 各加一行,最后还有一份要给运营的表格。真正拖慢我的不是难度,是我得先确认「到底有几处」——而且确认完还不放心,靠 grep 搜字符串,总会漏。

那天我把这件事记了下来,当作自己对可维护性的定义:改一个需求,改动落在几个地方,以及我要花多久才能知道是哪几个地方。跟代码写得整不整洁,关系其实不大。

改动落在几处

散弹式修改这个词早就有,但它的成本容易被低估,因为单看每一处都不多:加一行而已。问题在「完整性」——漏一处就是线上问题,而漏没漏取决于我的记忆力和 grep 的运气。

所以我现在判断一段代码好不好维护,先看它能不能让编译器或者类型检查器帮我找全落点。同样是传一个渠道标识,两种写法的维护成本差很远:

PYTHON2 行
1
2
def pay(order: dict, channel: str) -> dict:
...
PYTHON7 行
1
2
3
4
5
6
7
@dataclass(frozen=True)
class Channel:
code: str
display_name: str

def pay(order: Order, channel: Channel) -> Receipt:
...

第一种写法里 channel 是裸字符串,任何地方都能塞一个 "alipay" 进去,哪天渠道改名,没有任何工具会提醒我。第二种至少让「有哪些渠道」变成一个可以 import、可以遍历的对象,加一个渠道时,枚举会逼着我去处理没写分支的地方——Python 没有穷尽性检查,但 match 配一个 assert_never 能顶一部分。

我不是说所有东西都该类型化。函数内部临时用的 dict 挺好,改起来快,也不值得为它建个类。我的界线大致是「谁会改它」:只有我在一个函数里用完就扔,我记得住;一旦会被别的模块消费,它就该有个名字。

先找得到,才谈改得快

有时候落点并不多,但我找不到,这更费时间。我见过一个项目,所有 handler 靠命名约定注册:文件名必须叫 xxx_handler.py,放在任意目录,框架启动时扫描。写的时候确实省事,不用维护一张注册表。但我接手后想找「用户注销的逻辑在哪」,只能全局搜 handler,然后一个个点开看文件名猜。省下的是当初写注册代码的十分钟,付出的是每个后来者的一次导航。

我现在倾向把入口收在一处:路由、定时任务、消息消费者各一个目录,注册表显式写出来。不在这几个目录里的代码,一律是被调用的,不主动接收外部事件。这样新人(包括半年后的我)从入口往下读,能先建起一张地图,再决定深挖哪一支。显式注册表还有个副作用:新增一条路由会出现在 diff 里,而不是藏在一个文件名中。

这条也有代价。显式注册意味着加东西时要多改一个文件,偶尔会忘。我接受这个代价,因为忘在注册表上的错误是启动就能发现的,忘在扫描约定里的错误要等到线上才冒出来。

报错和删除,都算维护成本

代码能跑的时候没人看报错,出事的时候它是唯一线索。我见过太多这种:

PYTHON1 行
1
raise KeyError("status")

半个月后看到这行日志的人,需要知道是哪个订单、哪份配置、缺的是哪一段。我现在的标准是报错尽量回答三件事:哪个输入、期待什么、去哪里改。做不到三条,至少要留一个能 grep 的唯一标识,比如错误码或者配置文件名。写的时候多花三十秒,排查时能省半小时。

另一件我更看重的事是可删除性。加功能时没人拦你,删功能时才看得出结构好不好:如果下线一个功能要动十几个文件、还有两张表不敢 drop,说明它跟别的东西缠在一起了。

我有个土办法:每隔一段时间,挑一段我认为没用的代码试着删掉,跑测试。删不掉的,就把「为什么删不掉」写在它上面的注释里——是哪个定时任务还在调它,还是哪张报表依赖这个字段。半年后有人想删,至少知道自己会撞上什么,而不是重新考古一遍。

现在我 review 自己的 PR 会多问一句:这个功能如果半年后要下线,需要动几个文件?超过五个,我就先停下来,把入口和配置收一收,再写功能。上周那个支付渠道,最后把七处收成了两处:一个渠道枚举,一份 yaml。活没变少,但下次加渠道,我知道该开哪两个文件。

你手上正在改的那个需求,落点在几个文件里?

关于本文

由 Lu's Blog 撰写,采用 CC BY-NC 4.0 许可协议。

#代码质量 #可维护性 #工程实践

评论