View on GitHub

rotcare.github.io

《代码防腐实用技术》

如何不 Review 每一行代码,同时保持代码不被写乱

本文的读者

本文的目标是以尽可能浓缩的篇幅提供可模仿的步骤来达成“如何不 Review 每一行代码,同时保持代码不被写乱”的目标。总共三步

第一步:不要拆分代码仓库,不要拆微服务

拆分微服务以及代码仓库的缺点

拆分微服务和代码仓库相比单体架构,最重要的目标是减少分支冲突,控制发布变更的风险。但是拆分微服务和代码仓库并不是最佳的解决方案。Monorepo + Feature Toggle 是更好的解决方案。

使用 Monorepo + Feature Toggle 可以提供所有拆分微服务达成的目标,同时克服以上微服务拆分带来的缺点

经常听说的一个说法是最终是要拆分成微服务,多仓库的。单体应用单仓库只是一个过度形态。这会导致我们认为为啥不一步到位呢。但事实并非如此,微服务和多仓库并不一定适合所有人。你可以用 Monorepo + Feature Toggle 用一辈子。

具体如何实践 Monorepo + Feature Toggle 按照 https://www.branchbyabstraction.com/ 和 https://trunkbaseddevelopment.com/ 的指导去做就可以了。

第二步:管控集成类需求的代码审查

当我们把代码都放一个代码仓库里之后,立即要面临的问题是代码不会写乱么?你怎么控制什么代码写在哪里?每一行代码写之前都来问你,每一行代码写完了都需要你来 Review 么?

所以,我们需要一种强制检查代码写在了正确的位置的自动化机制。这个机制就叫“依赖管理”。对应常见的编程语言

当我们把代码拆分成多个包(或者叫模块),并使得这些包(模块)形成特定的依赖关系,就可以通过编译器检查控制什么代码必须写在什么地方,从而不需要靠人去检查。这个依赖关系如下图所示

motherboard

这样做的好处是可以减少 Review 的负担。不需要盯着每一行代码了,只需要重点盯着主板的修改就可以了。实现的步骤是

比如说我们决定有一个团购插件,有一张表 GroupPurchaseCampaign 记录了团购活动的参与商品和规则。那么要展示团购活动列表的时候,就会自然有限选择在团购插件里来写,因为这个插件里可以访问这张表。这里说的“访问”是指可以 import GroupPurchaseCampaign 这个类型的意思。插件不能 import 另外另外一个插件定义的类型,但是不意味着运行时不能访问别的插件的数据。运行时的数据都是通的。限制的是编译期,谁可以 import 谁。

当需要主板进来实现”集成类需求”的时候,应该如何做。分为以下三类

主板起到的作用和 C 编程里的“头文件”的作用是一样的,就是给模块之间相互调用提供声明。主板的代码要尽可能的少,绝对不要在主板里提供 CRUD 的裸数据接口,主板里定义的是界面的槽,流程的槽,而不是直接把数据库的原始数据暴露出去。

技术上如何实现:在一个包里提供声明,在另外一个包里写实现。这个有两类做法:

无论是哪种具体实现技术,都不要实现成如下图所示这样

orchestration

在插件之上不应该有一个额外的包(模块)包含业务逻辑了。插件对主板的插入应该是一个 AutoWire,纯机械不含业务的过程。业务编排这样的概念一定不要出现在依赖关系的最顶层。我们已经在最底下的主板实现了所谓的“业务编排”了。

SaaS 可以把自己的功能拆解到多个插件来实现。但是经常有“按需”组装,或者付费购买的需求。我们并不需要动态来组装代码来获得“按需”组装的产品效果。代码可以是一份,只是通过运行时的开关来控制某些插件是否启用。这些开关可以是配置文件,也可以是数据库表来控制。在没有启用的时候,界面上完全隐藏相关的组件(就是 if/else 判断),用户也察觉不到这个功能的存在。付费购买其实就是付费买这个开关,也不需要像 Apple Store 那样真的去做什么代码下载和安装。当然给外包公司做二次开发就是完全另外一个话题了,与本主题无关。是否打开某个插件可以是全局性的(给每个商家或者租户启用),也可以是“订单”级别。一个所谓的订单履约流程,需要组合多个插件的功能。对于每个订单来说,都有一堆 bit 开关来决定某个插件是否启用了,以及对应的业务数据是什么。比如 GroupPurchase + OrderSelfPickup + Order 可以组合成团购自提的订单。订单在此处只是一个例子,不同类型的业务有自己的领域概念。

第三步:管控规范型需求的代码审查

有了主板加插件,Monorepo 已经切分出了多个子目录了。每个开发者也基本上能够知道什么需求写在什么目录下,哪些目录是自己经常修改的。接下来的问题是,如果每个开发者都各写各的,那他们之间有重复实现怎么办?谁来避免同一个东西,被不同产品经理提出多遍,再由不同的开发者用不同的姿势实现多变,导致浪费和返工?这个也是一个 Code Review 的问题。不能指望有一个人来 Review 每一行代码。

解决办法就是我们希望有一个人来“收口”,然后由这个人来保证收口之后的代码没有重复的实现,建立合理的抽象。如下图所示

monopoly

所谓“收口”,就是要阻止上图中这样的绕过“这层抽象”,去访问“底层API”的行为。比如说,所有的编程语言都提供了 Http 调用的能力。但是我们希望封装一个 Http Restful API 的调用 SDK。在这个 SDK 里我们统一实现重试,统一实现熔断摘除故障节点这样的一些功能。避免每个调用 Restful 接口的地方都重复地 try catch,重复地写不一致地重试逻辑。那就需要有一个人来封装这样的库,同时强制所有“应该使用这个库的地方”都使用了这个库。

实现方案要比管控集成类需求要稍微麻烦一些。集成类需求可以用包之间的依赖关系来约束什么代码写在哪里,规范型需求的问题是假设一个业务包,比如团购。它依赖了 Http Restful SDK,而 Http Restful SDK 又依赖了 Http 的库。那么就意味着团购这个包通过依赖的传递性,也依赖了 Http 的库。在现有的编程语言里,都无法禁止团购的包通过传递性依赖获得的调用 Http 库的权利。这个时候我们就需要通过自制 lint 工具的方式,在编译期额外做更严格的依赖关系检查。通过 lint 检查,强迫所有访问 Http 库的代码都“收口”到某个目录里。然后我们就可以通过 Review 这个目录的改动,确保重试逻辑只写了一份,而不是散落到各个地方。

这样的 lint 规则可以检查以下类型的访问

再举一个例子。通过 lint 检查,我们可以确保所有包含样式的前端组件都写在某个目录下,比如说 RegularUi 和 SpecialUi。其他目录中的组件,只能是通过组装 RegularUi 和 SpecialUi 目录中的组件来完成自己的设计稿还原。当然这样就是一种“收口”。我们可以通过 Review 对 RegularUi 和 SpecialUi 这两个目录下文件的修改,来发现是不是有两个开发者在尝试实现极度类似的页面组件,也可以促成两个产品经理互相交流一下,是不是把两个组件合并成一样的行为,避免不必要的实现成本。

“收口”的代价是不可避免会出现很多一次性的需求,个性化的需求。比如优惠券的界面就是要和其他界面不一样。因为对样式做了收口,所以就不能直接写在优惠券这个包里面。于是就有了 SpecialUi 这个目录,用于写被收口了,但是并不可复用的东西。SpecialUi 里的组件数量的多寡,体现了 Ui 不一致性的严重程度。如果每个页面都不一样,都非常有艺术感。那说明这样的产品并不适合对样式进行收口,就应该各写各的,每个页面都纯手工打造。

“收口”的 lint 检查的关键是要去掉对人的主观判断的依赖。我们不需要判断这里来是不是一定能复用 RegularUi。我们宁愿过度收口,导致 SpecialUi 的出现,也要避免人为主观判断的介入。这种“过度的”收口,是规范型需求能实现自动化检查的关键。一旦我们允许酌情出现一些例外的情况下,那么又变成了需要 Review 每一行代码了。

“收口”之后的一个风险是强行抽象。明明不适合复用同一个组件的场合,仍然复用了同一个组件。导致组件变得更复杂,导致组件经常被修改。一个对策是控制组件或者函数的参数个数,参数应该尽可能地少。如果某个函数在 Monorepo 中有10处调用,但是其名为 IsVipUserPriviliged 参数仅仅在 1 处调用有传值。那么这个 IsVipUserPriviliged 参数大概率是不应该被添加进来的,是强行抽象的产物。对于 IsVipUserPriviliged 的处理,更适合直接写在调用的地方,而不是被写到可复用的目录里。

收益

在这三步都完成之后,你获得了一个“机器人”。它帮你在每个开发者提交代码的时候检查代码是不是写到了正确的位置。在通过了这个机器人检查的基础上,你只需要关注重点的一些目录就可以了,对其他的修改仅仅需要抽查。这个机器人能够像拆分了微服务一样,确保代码不写乱。同时不像微服务那样,拆分之后就很难调整了。因为代码仍然一个仓库里,只是分了目录,随时都可以再调整。

lark