这篇文章的读者苦恼的是,代码总是被同事搞得乱七八糟,但你又不能审查每一行代码。你需要开发一个SaaS来实现各种复杂功能的组合,但是你不能像互联网公司那样堆一堆人来开发微服务。你模仿了主流的微服务、DDD等做法,但没有达到预期的效果。你不介意尝试一些新的非主流方法。达到“如何不审查每一行代码,同时又不让代码乱七八糟”的目标。一共三步第1步:代码仓库不拆分,微服务不拆分。Monorepo就是你所需要的,FeatureToggle就是你所需要的。第二步:管控一体化需求代码审查:主板加插件。第三步:控制规范需求的代码审查:排他性关闭。第一步:不拆分码仓,不拆分微服务拆分微服务和码仓的劣势利用组织边界来强化代码划分边界,对以后的调整会有很大的阻力。随着新需求的出现,我们对代码组织方式的看法会不断调整。不要轻易使用“组织结构”这样的核武器来实现小目标。代码仓库拆分后,不利于编译时的集成和集成后的整体验证。即使拥有运行时集成的所有好处,也不必失去编译时集成的选项。跨代码库的代码阅读、开发过程中的协助和检查将变得困难。微服务控制变更风险的灰色边界是固定的,即微服务的大小。切割得越精细,每次更改的东西就越少,风险就越小。这不够灵活。微服务的弹性边界是固定的。如果某个视频剪辑需要很大的内存,我们想独立伸缩,就得把这部分代码切割成一个独立的微服务。与单体架构相比,拆分微服务和代码仓库最重要的目标是减少分支冲突,控制发布变更的风险。但是拆分微服务和代码存储库并不是最好的解决方案。Monorepo+FeatureToggle是一个更好的解决方案。Monorepo:所有代码都在一个存储库中。这样就不存在不同模块不同版本仓库的问题了。每个人都是一个统一的版本。升级线上系统的过程分为两个步骤:部署+发布。部署时,将整个Monorepo的代码部署到目标机器上,但不代表发布。FeatureToggle:功能开关,精确控制释放哪些逻辑分支。通过这种方式,部署与发布是解耦的。您可以精确控制您想要的灰度比例。一个特性开关的两个版本的逻辑分支的共存也是可能的。使用Monorepo+FeatureToggle可以提供拆分微服务实现的所有目标,同时克服上述微服务拆分的缺点。代码所有权通过目录结构进行控制。您可以要求此目录中的代码必须通过您的代码审查。调整目录结构比调整代码仓库容易得多,调整组织结构也容易得多。您可以在编译时集成此选项。在开发过程中更容易实施辅助和检查工具,并且更容易阅读跨模块代码更改,风险更小。不仅开关回滚速度快,而且开关可以灵活的面向灰度,开关的控制范围也大。可大可小,粒度非常灵活。弹性边界更灵活,不需要因为独立扩容而拆分代码。经常听说最终会拆分成微服务和多个仓库。单体应用和单体仓库只是一种过渡形式。这会让我们思考为什么不一步到位。但事实并非如此,微服务和多仓库并不一定适用于所有人。您可以终生使用Monorepo+FeatureToggle。如何练习Monorepo+FeatureToggle只需按照https://www.branchbyabstraction.com/和https://trunkbaseddevelopment.com/的指导即可。第二步:管控集成需求的代码审查当我们把所有的代码都放到一个代码仓库中时,我们面临的直接问题是代码会不会乱七八糟?您如何控制在何处编写哪些代码?在每行代码写之前,我都会问你。写完每一行代码都需要review吗?因此,我们需要一种自动化机制来强制将代码写入正确的位置。这种机制称为“依赖管理”。如果常见的编程语言是TypeScript,这个叫做package.json如果是Golang,这个叫做go.mod如果是Java,这个叫做POM.xml当我们将代码拆分成多个包(或模块)时,和make这些包(模块)形成特定的依赖关系,可以由编译器检查,控制什么代码必须写在哪里,这样就不需要依赖人来检查了。这种依赖关系如下图所示。插件:尽可能完整的实现一个独立的功能,比如一个完整的面向终端用户的页面,而不是插件之间直接有引用关系。这样做的好处是可以减轻Review的负担。你不需要再盯着每一行代码,你只需要专注于主板的修改。实现步骤是先决定每个插件封装什么数据库表。如果是前端模块,封装了什么样的后端数据接口?因为插件不能引用插件,相应的页面和功能自然会用这些数据库表写在插件里。因为如果是用其他插件写的,是访问不到的。对于需要多个插件数据的功能,我们通过主板来实现。规则。那么当你要显示团购活动列表的时候,自然会选择写在团购插件里面,因为这个插件可以访问这个表。这里所说的“访问”是指可以导入importGroupPurchaseCampaign类型。一个插件不能导入另一个插件定义的类型,但这并不意味着其他插件的数据在运行时不能访问。运行时的数据都是可用的。限制是在编译时,谁可以导入谁。需要主板进来实现“集成需求”应该怎么办。分为以下三类一个界面需要同时显示来自两个插件的数据。比如商品详情页需要定期的商品数据,当前的优惠券活动,当前的限时优惠活动。接口在主板上分为多个插槽,然后不同的插槽由不同的插件实现。一个操作需要来自多个插件的数据来进行综合决策。比如计算价格的功能,需要综合商品的原价,需要获取购物车选择券,需要判断是否全打折。主板上的价格计算过程是有插槽的,然后不同的插槽由不同的插件实现。插件界面需要显示来自其他插件的数据。比如退款申请界面需要显示商品图片等,不同的是整个页面的大部分内容都是由一个插件自己实现的,只有一些地方需要其他插件的数据。所以不值得把整个页面沉入主板来写。实现方式是在主板中声明一个ProductCard组件,然后这个组件由正则商品插件实现,再由返利插件使用。主板的作用与C编程中的“头文件”一样,都是为模块之间的相互调用提供声明。主板的代码尽量少,千万不要在主板上提供增删改查的裸数据接口。主板定义了接口的槽位和进程的槽位,而不是直接暴露数据库的原始数据。技术上如何实现:在一个包中提供声明,在另一个包中编写实现。有两种方法:通过运行时多态性实现。在主板中定义接口,在插件中编写实现该接口的类或函数。然后在启动时,执行“AutoWire”绑定操作。这种绑定的最简单方法是将值分配给类型为函数指针的全局变量。AutoWire也可以通过Spring等依赖注入框架来完成。通过编译期复制粘贴源代码。在编译之前需要对源文件进行处理,然后将其提供给编译器。不管具体的实现技术如何,都不要像下图那样实现。插件之上不应该有包含业务逻辑的附加包(模块)。插件插入主板应该是一个AutoWire,一个没有业务的纯机械过程。业务编排等概念一定不能出现在依赖的顶层。我们在底层主板上实现了所谓的“业务编排”。SaaS可以将自身的功能拆解成多个插件来实现。但通常会有“按需”组装,或者需要为此付费。我们不需要动态组装代码来获得“按需”组装的产品效果。代码可以是一份,只是在运行时通过开关来控制某些插件是否启用。这些开关可以通过配置文件或数据库表来控制。不启用时,相关组件完全隐藏在界面上(即if/else判断),用户不会察觉到该功能的存在。付费购买其实就是付费购买这个switch,不需要像AppleStore那样实际下载安装code。当然,给外包公司做二次开发是完全不同的话题,与本话题无关。是否启用某个插件可以是全局的(为每个商家或租户启用),也可以是在“订单”级别。所谓的订单履行过程需要组合多个插件的功能。对于每一个订单,都有一堆位开关来判断某个插件是否启用,对应的业务数据是什么。例如GroupPurchase+OrderSelfPickup+Order可以组合成团购自取订单。订单在这里只是一个例子,不同类型的业务有自己的领域概念。第三步:控制规范需求的代码审查有了主板和插件,Monorepo已经被分成了多个子目录。每个开发者基本都能知道在哪个目录下写了什么需求,自己经常修改哪些目录。接下来的问题是,如果每个开发者自己写,如果他们之间有重复的实现怎么办?谁会避免同样的事情,被不同的产品经理多次提出,然后被不同的开发人员以不同的姿势去实施,造成浪费和返工?这也是一个代码审查问题。您不能指望一个人审查每一行代码。解决办法就是我们希望有人“闭嘴”,然后这个人保证闭嘴后的代码没有重复实现,建立合理的抽象。如下图所示,所谓“关闭”就是防止上图中绕过“这层抽象”,访问“底层API”的行为。比如所有的编程语言都提供了调用Http的能力。但是我们想封装一个HttpRestfulAPI调用SDK。在这个SDK中,我们统一实现了重试,统一实现了熔断、移除故障节点等功能。避免在每个调用Restful接口的地方都出现重复的trycatch和不一致的重试逻辑。那么就需要有人封装这样一个库,同时强制所有“应该使用这个库的地方”都使用这个库。实施比管理集成需求要麻烦一些。集成需求可以使用包之间的依赖关系来约束在什么地方写什么代码。标准化需求的问题是假设一个业务包,比如团购。它依赖于HttpRestfulSDK,HttpRestfulSDK依赖于Http库。那么就意味着团购包也通过依赖的传递性依赖了Http库。在现有的编程语言中,无法禁止团购包通过传递依赖获取Http库的调用权。这时候我们就需要使用自制的lint工具在编译时进行额外的更严格的依赖检查。通过lint检查,所有访问Http库的代码都强制“闭嘴”在某个目录下。那么我们可以review这个目录下的变化,保证重试逻辑只写一份,而不是散落在各处。这样的lint规则可以检查以下类型的访问是否可以调用API:比如Http库的API是否可以调用自定义类型的指定方法:比如Datetime类型,或者在业务中封装一个Money类型对API的某些参数是否可以传值:比如组件库中的Button组件提供了style属性,我们不想暴露这么灵活的属性。是否可以使用语言和框架的一些特性:比如在vue文件中可以写style,但是我们不希望所有的目录都可以写style又比如。通过lint检查,我们可以保证所有包含样式的前端组件都写在某个目录下,比如RegularUi、SpecialUi。其他目录下的组件只能通过组装RegularUi和SpecialUi目录下的组件来还原自己的设计稿。当然,这是一种“关闭”。我们可以通过Review修改RegularUi和SpecialUi目录下的文件,看看是否有两个开发者试图实现极其相似的页面组件,或者促使两个产品经理相互交流。组件变成相同的行为,避免不必要的实施成本。“关门”的代价是,必然会有很多一次性的、个性化的需求。例如,优惠券界面不同于其他界面。因为样式是封闭的,不能直接写在优惠券的包裹里。于是就有了SpecialUi目录,就是用来写那些被关闭但不能复用的东西。SpecialUi中组件的数量反映了Ui不一致的严重程度。如果每一页都不一样,那就很有艺术感了。也就是说这样的产品不适合收款,所以要单独写,每一页都是纯手工制作的。“封闭式”lint检查的关键是去除对人的主观判断的依赖。我们这里不需要去判断RegularUi是否可以复用。我们宁可把嘴闭得太紧,导致SpecialUi的出现,也要避免人为主观判断的干预。这种“过度”关闭是规范要求自动检查的关键。一旦我们适当地允许一些例外,那么就需要审查每一行代码。“关闭”后的一个风险是强制抽象。当明显不适合复用同一个组件时,仍然复用同一个组件。导致组件变得更加复杂,导致组件被频繁修改。一种对策是控制组件或函数的参数个数,参数越少越好。如果一个函数在Monorepo中有10个地方被调用,但是它的名为IsVipUserPriviliged的??参数只在1个地方被调用并赋值。那么这个IsVipUserPriviliged参数应该是大概率不应该加上的,它是强制抽象的产物。对于IsVipUserPriviliged的处理,直接写在调用的地方比较合适,而不是写在可重用的目录中。好处完成所有三个步骤后,您就拥有了一个“机器人”。帮助您在每个开发者提交代码时检查代码是否写在正确的位置。在通过机器人检查的基础上,只需要关注一些关键目录,其他修改只需要抽查即可。这个机器人可以像拆分微服务一样保证代码不乱。同时,不像微服务,拆分后很难调整。因为代码还在一个仓库里,只是分目录而已,可以随时调整。
