本次分享的主题是火山引擎数智平台VeDI旗下的A/B测试平台 DataTester 实验管理架构升级与DDD实践。这里说明的一点是,代码的第一目标肯定是满足产品需求,能够满足产品需求的代码都是好代码。而本文中对代码的好坏的评价完全是从架构的视角,结合代码的可读性、可维护性与可扩展性去分析的。
在一个产品或者代码仓库的发展过程中,如果不对代码的质量加以控制、不引入原则与规范的约束、不及时的采取手段,那么随着时间的流逝,大概的发展轨迹将会如下图所示。
在项目的早期迭代非常迅速,一个需求可能一周就可以完成开发测试与上线,研发效率也保持在较高的水平。此时一切还都是有序的状态。
随着功能的迭代,模块与模块之间、功能与功能之间可能会出现联动与复用的逻辑,如果不加以重构,可能就慢慢变成了技术债。加上人员投入增加与人员流动,新人可能对原来的设计思路并不了解,会出现仅看代码无法了解功能的情况,认知负荷开始上升,慢慢的会发现虽然投入的人力增加了,但是研发的效率开始越来越慢。系统混乱开始慢慢增加。
虽然效率降低,但是功能的迭代还在进行。但即使只是一天就能搞定的小需求,涉及到的改动也会有多处,且不确定要改多少个地方才能保证系统的正常运行。此时整个系统的认知负荷已经过载,仅仅写好代码还不够,还需要清晰地了解历史代码的功能逻辑,否则稍加不慎就会引入oncall或者投诉。随着oncall的增多,研发的人力又被占用,进一步降低了研发效率,需要额外的时间偿还技术债。此时系统已经变得非常混乱,即将变为无序状态。
随着混乱的进一步恶化,团队的战斗力几乎归零,仅能够维护现有功能,新增需求很难在短时间内完成开发上线。产品的发展技术陷入停滞,效率几乎降为零。此时系统已经变为完全混乱的状态。
与“复杂”代码的斗争
在 DataTester 项目早期,由于需求简单直接,功能估期基本准确。但是随着产品规模扩大、场景复杂度增加,能明显感觉到功能的开发依赖和需要考虑的东西越来越多。
下面简单罗列了功能模块与系统熵递增的关系。可以看出从最初的编程实验,到后边的可视化与多连接实验,又到后边的父子实验、push实验,再到最后的内外合并,整个系统的复杂程度越来越高,如果不及时采取措施,那么后续的维护与扩展将会耗费非常非常多的人力。
回顾软件工程的历史发展,包括面向对象、微服务以及各种领域模型等,它们都代表了针对系统复杂性的不同应对策略。正如John Ousterhout教授在他的著作
《A Philosophy of Software Design》中所强调的,复杂性可以定义为那些使得软件变得难以理解和修改的因素,而软件技术的发展史也是与“复杂度”斗争的历史。
那到底什么是复杂度?John Ousterhout教授在书中明确指出,复杂度是指那些使得软件难以理解和修改的因素。复杂的系统通常具备三个明显特征,由John教授抽象为以下三个方面:
- 变更放大(Change amplification): 这指的是看似简单的变更需要在许多不同地方进行代码修改。在此情况下,开发者可能未能及时地进行代码重构或提取公共逻辑。相反,他们可能采用了快速复制粘贴的方式来开发代码,以节省时间和减小影响已存在的稳定模块的风险。然而,当需求变化时,就需要在多个地方进行代码修改。
- 认知负荷(Cognitive load): 这表示系统的学习和理解成本相当高,因此降低了开发人员的生产效率。高认知负荷意味着开发者需要花费更多的时间和精力来理解系统的结构和工作方式。
- 未知的未知(Unknown unknowns): 这意味着开发者不知道必须修改哪些代码才能确保系统正常运行,也不知道对代码的更改是否会引发线上问题。这是复杂性中最令人头疼的表现之一,因为它带来了不确定性和风险。
导致复杂性的原因可以概括为两个方面:依赖性与模糊性。过多的外部依赖导致功能变更的放大,并会增加认知负荷,而信息的模糊会增加未知的未知。而这些表象又会反过来提升系统复杂性,以此往复加速系统的“衰败”。
幸运的是答案是否定的。软件工程已经发展了60多年,我们遇到的问题,前辈们肯定也遇到过,我们有充分的理论和方法来对抗系统的逐渐混乱。如下图所示,虽然系统复杂度上升是无法避免的,但是适时的重构可以减缓系统混乱的速度。
随着时间的推移,DataTester 开发经历了多个阶段的发展,每个阶段都伴随着不同的技术、方法和挑战,每个阶段也有各自的主要矛盾与次要矛盾。团队的发展过程中,也需要适时的进行组织架构调整,以适应新环境新的挑战。只有变化才是唯一不变的东西。和团队管理也非常类似,在这个不断变化的环境中,适时的重构变得至关重要。重构是指在不改变软件外部行为的前提下,对代码内部结构进行调整和优化的过程,目的是提高代码的可读性、可维护性和性能。在不同阶段,重构都有其独特的意义和价值。
比如在 DataTester 迭代初期,我们的目标可能是尽快上线功能,提高产品竞争力,那么此时应优先业务迭代。而随着反馈越来越多、需求越来越多,会有更多新的功能上线。没有人可以预知未来会有什么功能加入,会有什么业务场景,所以如果不能随着产品的迭代及时调整代码与架构,那么混乱的速度增加是必然的。
产品的交付需要从人力、时间与质量三个维度去进行评估,其中的时间即经常所说的“能不能按期交付”。产品的研发与上线需要PM\BE\FE\UX\QA一起协力,而这里主要关注BE视角遇到的一些问题。每个双周都是对一些工作进行估期,但是排期却很难进行准确评估。导致该问题的原因可以分为以下几类:
- PRD描述不够周全,往复讨论无形中拉长了开发周期
- 技术方案考虑不够严谨,忽略了一些兼容与适配问题
- 历史包袱导致新功能的开发,需要在很多地方做适配与调整,并且会影响其它功能
上述第三个问题的出现,就意味着代码中的”坏味道“已经很严重了。评估出来的工作量和实际的工作量大相径庭也是在意料之中的。如果这时候的开发同学对原有功能了解的不够深入,那么结果可想而知。乐观的情况下,新功能的开发只需要完成该模块需要的开发工作,这就对代码的封装与隔离性要求非常高。
那么既然重构如此重要,那为什么没有被重视或者没有及时执行呢?我们可以尝试从常见的理由来发掘深层次的原因,可归为以下三类:
-
不是我不想做,而是不知道怎么做
- 代码腐化严重,缺少相关规范的沉淀与指导
- 人员流动导致原始设计思路无法继承
-
不是我不想做,而是别人都是这样做的
-
不是我不想做,而是没有时间做
- 缺少长远视角,认为重构是浪费时间的事情,对无业务帮助
- 重构短期无法从业务侧看到明显的收益
- 代码质量未受到重视
随着混乱的增加,团队生产力也持续下降,趋向于零。当生产力下降时,管理层就只有一件事可做了:增加更多人手到项目中,期望提升生产力。对于可以拆解的任务,增加人力确实可以缩短交付时间提升效率。但对于复杂的系统,新人并不熟悉系统的设计,他们搞不清楚什么样的修改符合设计意图,什么样的修改违背设计意图。而且,他们以及团队中的其他人都背负着提升生产力的可怕压力。于是,他们会制造更多的混乱,驱动生产力向零那端不断下降。因此,可以说补充人力在一定条件下是可以提升整体的进度与效率,但这并不绝对,特别是对于混乱的系统。
架构设计的思考
网上有一张比较有意思的图片,如下,评价代码质量的唯一标准即code review会议室中,每分钟传出的WTF次数。
The only valid measurement of code quality: WTFs/min
当然代码的好坏要从可扩展、可维护、可测试、可读性等多方面综合考虑。虽然有很多规范与法则,但是如果过度设计也并不见得是好的代码。因此在业务需求与规范之间如何权衡,也是一门“艺术”。
代码是思维活动的产物,不同的开发者有着不同的思维模式,因此需要好的原则与规范的约束。"道法术器"是古代中国哲学思想中的概念,常用于描述宇宙和人生的基本原理和法则。那是不是也可以用于指导软件的开发呢?
对于软件的架构设计,同样可以从以下四个层级进行思考,从上到下依次递进:
"道"是指宇宙万物的根本原则,也可以理解为事物运行的规律和基本法则。不以人的主观意愿而转移。熵增定律可以被视为一条使整个宇宙变得绝望的法则,它被理解为事物结构不可避免的逐渐衰退。熵增定律无法避免,就像生老病死无法避免,但是我们可以通过一些手段,延缓“最终无序”的到来。
"法"是指宇宙和人生的治理法则与方法论,对应到代码开发中可以归类为一些经典的原则与思想。软件工程经过60多年的发展,沉淀了很多有指导意义的方法论。比如SOLID原则、各种设计模式,以及大道至简的架构设计思想:抽象、封装与隔离。这些方法论都可以助理我们进行代码重构,及时降低系统的复杂程度。
"术"指的是技能、技术和实践方法。在软件开发中,"术"可以表示编程技术、框架的使用、代码架构等。方法论往往都是思想上的指导,不同的人可能也有着不同的理解,真正落地的时候还需要一些业务框架与编程范式,比如有领域驱动设计、MVC架构、依赖注入与面向对象等。这些原则都可以帮我们更好的进行代码分层与依赖反转,进而实现高内聚、低耦合的业务代码。
"器"是指工具和资源,用于实践和应用"道法术"的原则。在软件开发中,"器"可以包括开发工具、版本控制系统、自动化测试工具等,采用微服务架构可以更好的实现功能的隔离,而单元测试与CI/CD则可以更好的加速功能的迭代与系统的重构。
无论是方法论层面还是工具层面,目前都已经很成熟了。在写代码的时候多加一步思考,在功能完成之后对业务代码进行适当的重构,就会达到很好的效果,也应当成为一种习惯。
代码可能存在哪些潜在问题?
下面将罗列一些项目中实际存在问题,这些虽然从规范与架构视角看是有问题的,但是从满足业务需求的视角看都是好的代码。其实大家都能意识到这些代码存在的潜在问题(不是我不想做,而是别人都是这样做的),但是模块”牵一发动全身“,且不断有新的业务加进来,不是简单的改动就能完成的,因此”坏味道“只会慢慢恶化。
无业务分层
目前python的后端代码没有层级关系,整体属于标准的过程式代码,一个功能函数可能成百上千行,所有的功能都在一个函数里面堆积完成。虽然做过一些功能函数的拆分,但是整体还是过程式的逻辑处理。业务逻辑的封装与隔离几乎没有。
循环/重复查库
目前在koi中,django的使用大大方便了外部数据的获取,但是也导致了外部调用的泛滥。比如在不同的函数中可能都需要Application得数据,但是传参只传了app_id,那么就很可能导致再一次查表的操作,这种逻辑在koi中是非常多的。另一方面由于django的封装很容易让大家忽略这是一个外部调用,因此很容易写出在循环中查库的场景。
逻辑冗余/分散
不同的校验函数都堆积到了一起,这就导致一方面校验函数的单测很难编写,另一方面校验功能难以复用,以至于出现很多校验逻辑存在重复编写的情况,导致逻辑冗余。
函数职责复杂
接上述例子,在这个校验函数里还有业务逻辑或者数据转换的操作,后续的改动将更加难以维护与测试。数据校验与业务逻辑应该分开,做好隔离才能方便后续扩展与测试。
未做抽象
未做足够抽象表现为不同实体在做着类似的操作,但是没有对操作进行统一的封装与隔离处理,比如下方代码中实现开启接口,涉及很多实验类型的开启操作,都是通过if else插入自己的逻辑。如果抽象合理的话应该是不同实验都去实现一个实验开启的接口,在主业务流程里看不到差异化处理,这样才能做到比较好的业务隔离。将复杂的功能隐藏在简单的接口后边,才是更好的抽象。
耦合严重
目前功能的外部调用与业务逻辑完全混在一起,这里不作举例。因此业务逻辑对外部依赖非常严重,一个接口的变更或者字段的修改可能就会导致业务逻辑出现问题。外部依赖应该是服务于业务逻辑的,外部依赖的变更不应影响到业务逻辑,这样才能实现依赖反转。
通过重构解决上述提到的问题。基于现有业务场景对实验管理模块进行重构,解决问题,构建高内聚低耦合的业务代码,提升代码的可读性、可维护性、可扩展性与可测试性。
通过提高可扩展性,大大缩短后续内部功能开发所需的开发时间;通过封装实现代码的复用;通过隔离减少功能间的相互影响,减少bug出现的概率;通过依赖反转与关注点分离实现高内聚低耦合的业务代码。
通过架构重构提升整体团队的架构与规范意识,提升整体技术水平,增强团队战队力。
如何进行实验管理重构?
接下来将对实验管理重构的具体工作进行介绍。DDD主要解决代码“写在哪里”的问题,但是具体的实现细节还是需要根据业务的具体场景做相应的处理。本次重构从数据结构定义、业务校验、业务逻辑与领域对象构建等方面对重构的具体工作进行介绍。
模块梳理
目前产品现有及后续部分新增功能梳理如下图所示。
领域建模
按照 DataTester 的实验功能,可以将实验域细分为四个领域模块:日志、实验、实验层管理和工作流程。其中实验层管理和工作流程分别由其它服务模块接管,因此在实验仓库下需要重构与完善的即日志模块与实验核心模块。而层管理仅做了对内部署的适配,对外部署仍未完成适配,因此在此次重构过程中会对层相关的逻辑做一定的功能抽象,方便后续内外统一后的对接。
日志域
日志域主要对外暴露获取操作日志的接口,对内提供领域对象的change-tracking能力,生成所需格式的操作日志文件。具体的,日志目前有操作日志和全局操作历史两部分。除此之外,期望能够通过ChangLog域提供的change tracking能力,优化数据库操作,减少不必要的save与update操作。
实验域
实验域相比日志域的业务逻辑更为复杂一些。基于可扩展与可复用的原则,对实验的功能拆分成三个部分,分别为BaseExperiment
、ExperimentExtension与ExperimentPlugin
。模块的拆分其实都是在
BaseExperiment功能如其名是实验最基础且通用能力,除了常用的实验与版本的一些操作外,还包括实验收藏、Demo实验的特殊处理、邮件通知、版本管理、指标管理与目标受众等部分。这些功能后续是否需要统一抽象到IExperimentPlugin
接口中,可以视后续迭代而定。
版本管理
版本管理作为实验比较核心的模块,也可以看做是基础能力,因为实验的本质就是管理一系列不同的差异化配置,然后结合线上流量看效果。和实验版本相关的功能都可以在该实体中实现或者扩展,比如关闭单个实验组、可视化实验下页面信息编辑等。其中,白名单和版本息息相关,因此将白名单相关处理逻辑放到该版本管理模块下进行统一处理。另外对于穿山甲特殊场景,需要加载预置版本配置,这种场景在实际设计的时候需要注意其通用性。
实验层
实验层管理部分后续会统一放到实验仓库里面维护。目前实验中只有对层的一些简单操作与校验功能。
指标管理
指标管理和版本管理类似,都是实验过程中比较重要的模块。在该模块中主要处理指标的增删改查与关联关系的维护,比如实验指标关联关系、实验与指标组关联关系等。
目标受众
目标受众也即实验模块中的filter条件,用于对请求的用户做路由配置。由于其涉及到的业务逻辑较多,因此单独抽出TargetRule实体对这部逻辑进行处理。后续还将负责过滤条件的参数转换(backend)与一些关联条件的创建,比如过滤条件与分群、服务端过滤参数的关联关系等。
业务流程
对实验的主流程进行总结,可以发现任何实验的操作都可以抽象成三个步骤,即数据校验、特定逻辑处理与数据持久化。这也为设计可扩展与可插拔的代码架构提供了可行性。具体的实验创建的主流程如下图所示,按功能类型可以大概分为三个部分:validator、process与save。
- validator对数据进行校验,如有不符合的数据将会直接返回错误。
- process处理业务逻辑,包括数据转换与构建聚合根等操作,出现问题也会直接返回并报错。
- save为最后的持久化逻辑,当数据持久化报错也会返回,并取消事务。
在系统分层中,各个模块负责的主要功能大致如下图所示。不同层各司其事完成业务逻辑,下面示例示例代码为实验开启接口对应领域服务的具体实现。
去除步骤依赖
在实验创建的交互上,通常需要几步完成元信息的创建,并且在第四步时会将实验从草稿态转为调试态。但从restful规范来讲,资源的创建、更新与部分更新(状态修改)应该是通过不同的操作来实现。并且如果将创建的操作与实验操作的步骤进行绑定,将会大大限制可扩展性。目前koi代码中已经实现了部分资源创建、更新与步骤的解耦,但是还是存在与步骤强绑定的操作。此次实验RPC服务重构将彻底摒弃步骤依赖,与步骤进行完全解耦。
具体的业务逻辑变更如下图所示,可以看出对于实验创建的过程,可以选择一次完成所有字段的创建,也可以自行分多个步骤完成整体数据的构建,调试前会对数据完整性进行验证。
为了实现上述功能,需要对实验idl中的字段类型进行调整,将所有的字段除了id均改为optional字段,这样服务就可以获取此次接口调用需要更新的字段,除此以外,基于这些信息还可以实现数据的按需校验,下面将对自动校验的实现进行介绍。
自动数据校验
数据校验在业务逻辑代码中作用比较重要,关系整个后续业务逻辑能否正确运行。对参数的校验根据其具体业务逻辑与场景,可以分为字段校验、依赖校验、功能校验与逻辑校验四个部分。
-
字段校验
比较常见的校验类型,比如实验的名称不能超过多少个字符,实验类型是不是合法等。
-
依赖校验
依赖校验顾名思义,在业务逻辑中依赖了其它模块,比如指标,需要校验下指标是不是合法的等。
-
功能校验
功能校验,比如用户是否对某个资源有权限,又比如实验里面的配置冲突等。
-
逻辑校验
逻辑校验主要是一些具体的业务逻辑,比如父子实验中子实验开启与父实验结束时,会涉及时间范围的校验等。
首先看下通常数据校验是如何实现的,以下是在tob侧老版本feature flag中的校验方法。如果一次请求包含的实体或者值对象不完整,那么就会出现很多是否设置某些字段的判断;且创建需要的校验与更新所需的校验需要分开处理无法复用。这种校验虽然能够实现校验的功能,但是新增校验字段可能需要在创建与更新等校验函数里面做同步改动,如果有遗漏就会出现问题。那么就需要考虑一种机制能够按需校验,根据设置字段自动构建构建函数完成对参数的校验。
通常校验逻辑会写在正式的业务逻辑之前,但考虑到数据有依赖关系以及校验需要完整的领域模型,因此此次 DataTester 重构将数据校验作为聚合的一部分。为了实现业务逻辑与数据校验的完全解耦以及更好的支持后续的扩展,本次通过Validator对象实现了
通过一个json数据结构对需要构建的模块进行配置,每增加一个模块或者模块的子元素,需要自行在构造函数中做对应的实现,下面通过一个实验开启需要配置的模块示例来做说明。
如上图所示,此时构建的领域对象包含应用信息、实验基础与扩展插件四个部分。其中应用信息主要是常用的app_id和product_id等;实验基础信息构建了实验版本信息、实验所在层的traffic信息与同层实现列表;扩展部分需要父子实验中父实验的基本信息、版本信息与所在层的traffic map信息,智能实验部分需要获取traffic map信息;插件模块中获取了平滑生效与实验工作流程两部分信息。其中每一个key需要有对应的实现。具体到实现部分,为了统一函数签名,采用闭包的方式封装repo实现,举一个获取extension模块的例子如下所示:
业务逻辑处理
业务逻辑部分按照实验域的划分,分为三个部分。
BaseExperiment
部分业务逻辑比较简单,可以视情况增加业务逻辑,原则上这些entity或者value object在构建聚合的时候均已完成创建。
ExperimentExtension
部分主要是根据不同实验类型做一些扩展,可以根据实际场景做各自的业务逻辑操作。
ExperimentPlugin
部分主要是实验粒度的高级功能扩展,这里通过职责链模式进行扩展,在抽象的接口中完成不同操作下的业务逻辑。
下面用实验开启的业务逻辑为示例作说明。由于采用了面向对象的编程方式,业务模型也叫编程“充血模型”,每个实体都拥有丰富的方法。而对实验开启的操作,也按照上面的划分从三个模块进行操作,每个模块直接或者间接的提供了Start方法,进而完成整体的业务。具体到extension与plugin部分,在聚合中依赖统一抽象的接口,不关注其具体实现,具体是编程实验还是可视化实验,亦或是加入了精准熔断或者实验审批,都将通过统一的方法调用完成相应的业务逻辑。
另一方面,由于不同的扩展和插件通过接口的方式做了隔离,后续新增模块或者修改部分模块的操作,将会变得非常容易,只需要关注当前的修改即可,不用担心影响到其它模块。
在业务执行的过程中,整体业务逻辑呈现从上到下、逐级划分的一个过程。一个功能将层层拆分最终落到一个单一职责的函数或者方法中,这与组织管理有着异曲同工之处。这样一来函数或者方法将尽可能的复用,单一功能的函数或者方法将更容易测试。这种从上到下的实现方式,和滑铁卢编程风格中所属的关注数据流动非常类似,上层只需要做好任务的拆分,底层按照要求实现即可。最终开发者只需要关注单个功能的实现,将大大降低认知负荷,更容易规避潜在的bug。
我偶然发现了一种极其强大的编程哲学,那就是你应该忽略代码,那只是计算机要遵循的一大堆指令。相反地,你要专注于数据,弄清楚它如何流动。 --《滑铁卢编程风格》
外部服务调用
业务调用
在一些 DataTester 的业务场景中,业务逻辑执行结束后需要调用第三方依赖完成一些操作,对于比较统一的处理比如实时生效需要发送的消息队列,在领域服务中统一处理接口。而对于一些特殊的case,比如可视化实现在开启的时候需要调用圈选模拟器创建热力图,并把热力图的id存到version表中,而其它实验类型可能又不需要类似的外部操作。为了差异化处理这种业务需求,在领域服务中增加了外部服务调用模块。
目前有两种外部服务调用类型,一种是基于业务场景(tob或者internal)的差异化处理,一种是基于实验类型的差异化处理,相关UML类图如下所示。后续如果有新的业务场景可以在此处进行扩展。
数据持久化
数据持久化的实现还是还是参考架构规范中的实现形式,将事务放到领域服务层。另外,除了对数据库的调用,相关依赖方的调用比如微服务间的调用均统一封装在repo层,如需添加新的依赖只需进行依赖注入即可,方便统一管理。
总结
虽然目前这套拆分开起来能够支撑 DataTester 的业务场景与业务需求,但是并非是一个“终极架构”。很多细枝末节的差异化处理仍存在。随着后续业务场景的持续丰富,当前架构可能还需要做进一步扩展。目前的架构设计预留了一定的伸缩空间,如果后续增加了比较特殊的实验类型,在实验创建过程中没办法与目前的主流程复用,就可以将相关逻辑上升到extension模块等。
现在架构重构到了攻坚阶段,没有产品形态的重新设计,没有产品功能的完全统一, 技术重构就不可能进行到底。已经取得的重构成果还可能得而复失,架构上产生的新的问题也不可能从根本上进行解决,合并也就可能变成了“缝合”。
目前重构工作已经结束并且已经上线到各环境,并高效支撑了数十种实验类型的日益迭代工作。后续会对领域对象做归类细化操作,确保能将领域对象作为工具库使用,通过不同的组合排列实现新的功能,支持新增业务场景与需求;更进一步会将服务能力通过插件进一步开放,沉淀为插件市场,实现中台能力与BP业务方的共赢。
重构效果:
- 需求开发效率提升30%
- 性能提升约50%
以上就是火山引擎A/B测试平台 DataTester 实验管理架构升级的实现,希望对大家有所启发。
火山引擎DataTester作为火山引擎数智平台VeDI旗下的核心产品,源于字节跳动长期的技术和业务沉淀。目前,DataTester已经服务了上百家企业,包括美的、得到、博西家电、乐刻健身等知名品牌。这些企业在多个业务环节中得益于DataTester的科学决策支持,实现了业务的持续增长和优化。