本文适合给谁看?若你所在的公司有多条产品线,每条产品线又切分了几个大同小异的产品,每个产品又维护了标准产品和多个客户定制版本的代码分支,我大概能想象你们的工程师每天面对的是怎样的场景。
因此为了帮助读者更好的理解,本文模拟了一个产品从创意产生到产品迭代和多客户定制版本共存的过程,希望能给你带来些许的启发。
1.代码版本管理的痛点
一聊起代码版本管理,一个老生常谈的问题,大家脑海里可能立马就映射到了SVN、GIT等现今常用的代码版本管理工具,或则是联想到了Git Flow工作流程等等。这些都没错,合理利用好版本管理工具和版本管理工作流是做好代码版本管理的基本要素,但仅仅做好这些就能解决下面的问题了吗?
· 我们的代码版本管理就井井有条了吗?
· 就能管理好公司所有产品的代码了吗?
· 就能提高公司各条产品线之间的通用功能的代码复用率了吗?
· 就能减少产品代码和客户定制化项目代码之间的冲突了吗?
相信大部分读者心里还是会有类似的许多问号。作者本人近20年的码农历程,经历了从Source Safe、CVS、SVN到现今的GIT代码管理工具变迁史,之前服务过的几家公司也都有各自的代码管理流程和规范,但是很遗憾,代码管理永远还是那一根根的刺,时不时地给我这老码农的心窝来这么一下子(请读者自行脑补代码merge冲突N多,版本被强制退回,代码段被神一样的队友覆盖等场景)。
可能有读者会比较纳闷,GIT工具功能这么强健,配合上Vincent Driessen大神举荐Git Flow工作流,有解决不了的代码管理问题?不是你们不会用吧?让我们一起来模拟一个场景,来实际体验一下大多数码农XDJM们的日常吧。(如有雷同,实属穿越)
Vincent Driessen:https://nvie.com/about/
2.一款前途无量产品的诞生
某天,产品经理X(为什么是X而不是Y或则Z呢?因为牛X,不是YY的,也不是Zhu队友)兴致匆匆地召集码农XDJM们宣布:我们要开发一个炫酷的,前途无量的资讯处理类产品,造福广大资讯读者,证明“知识就是财富”不是神话。产品需求呢很简单,就如下这么几个步骤:
· 对接数据源A,读取其中的资讯数据
· 对资讯数据做智能标签处理(资讯画像)
· 把处理后的数据送到数据源B
码农XDJM们开始了垒砖。2天后,产品雏形(Ver0.0.1)演示提前进行,顺便给产品经理也展示了代码(没办法,我们公司的XDJM们就是这么任性)。以下用伪代码示例:
/* 应用程序启动类 */ Class Application { function main() { //数据源对接 call readDataSource() //监听队列,读取数据并处理 call listenQueue() } //发送到数据队列中 function readDataSource(){ //从数据源A中读取数据 readFromDS_A; //发送到数据队列中 sendToQueue; } //监听队列,读取数据并处理 function listenQueue() { //读取数据项 readDataItem; //标签处理 call TagEngine.processTags(); //发送到数据源B sendToDS_B; }} /* 标签处理引擎类 */ Class TagEngine { function processTags(){ handleTag; } }
产品经理X看过产品雏形展示后还算满意,顺便又提出了几个改进意见,最主要一点是标签需要分类处理,分别为CategoryA、CategoryB、CategoryC、CategoryD。
码农XDJM们暗喜,so easy,早就摸准你的套路了,这不代码结构早就准备好了,专门独立了标签处理引擎呢。于是稍作修改后,发布了Ver0.1.0版本。代码结构上主要拆分了标签处理分类,示例如下:
/* 标签处理引擎类 */ Class TagEngine { function processTags(){ handleTagCategoryA; handleTagCategoryB; handleTagCategoryC; handleTagCategoryD; } }
之后的一周时间,开发团队致力于标签引擎的模型优化和调优,大大小小迭代了多个版本,但代码结构还是维持在ver0.1.0打下的基础上,因此用GIT来管理代码相当顺利,没有遇到任何的问题。产品版本顺利升级到了ver1.0.0。
3.客制化需求带来的初级挑战
由于产品标签引擎明显高于市场同类竞品的准确率和覆盖率(这里必须要赞一下我们的模型算法团队),很快该产品就得到了客户A的关注,以标准产品功能为基础帮助客户A构建了标签系统。但客户A也提出了一点只适用于客户A的需求,其中CategoryB的处理逻辑和标准产品不太一致需要客制化, 因此在客户A那实施的标签引擎具体代码就变成了如下所示:
/* 标签处理引擎类(客户A) */ Class TagEngine { function processTags(){ handleTagCategoryA; handleTagCategoryB4ClientA; handleTagCategoryC; handleTagCategoryD; } }
客户A的标签系统实施取得巨大成功。与此同时,随着数据的不断积累标签引擎又经历了数次更新升级,实施团队也从客户A那挖掘新的需求需要处理CategoryE类别的标签。
此时,需要同时维护标准产品代码和客户A定制版本,并需要双向merge代码,即便使强如GitFlow工作流,也开始捉襟见肘了(有同感的同学可以举手了)。考虑到还有客户B,客户C等定制版本的出现,产品经理X和研发组决定把标准版本和客制版本合并共同维护。
在合并代码的同时,产品经理X也提出了在只在另一个业务领域Z可用的新的标签类型F,于是标签引擎的代码ver.1.1.0就变成了如下所示的样子。(注:此处为便于说明,并没有采用更符合架构设计的策略模式等常用技巧,原因在于并不会影响本文所要阐述的主旨)
/* 标签处理引擎类 */ Class TagEngine { function processTags(){ handleTagCategoryA; //isClientA Flag内容读取自定义文件 if (isClientA) { handleTagCategoryB4ClientA; } else { handleTagCagegoryB; } handleTagCategoryC; handleTagCategoryD; handleTagCategoryE; //isBusinessZ Flag内容读取自定义文件 if (isBusinessZ) { handleTagCategoryF; } } }
如上所示,通过在定义文件中配置开关,我们可以把标准产品和客制版本的代码合并起来一起维护,今后客户B,客户C的客制化需求也可以依葫芦画瓢。看到这,估计各位读者大神要开始吐槽了,没错,随着客户定制版本的愈来愈多,此处的代码必然会有很多的分支处理,最后依然还会陷入到丑陋的分支地域陷阱。
但不管怎样,至少代码是work的,我们也通过配置实现了多版本的共同管理。此处提醒各位读者,若你的项目开始有类似征兆,是时候考虑重构一下代码结构了,否则等这里的if else多到两个手都数不过来,且夹杂着多层嵌套的时候,多半你会默念”神兽“无数次。
4.业务拓展所带来的挑战升级
很荣幸,产品在客户A这边的大获成功被客户B知道了,于是客户B邀请我们来帮他们实施标签系统,由于客户B的资讯量及其庞大,希望我们的产品能够提高标签处理的效率来提高系统吞吐量。
经过研究发现,各类标签的处理事实上是不需要考虑先后顺序的,完全可以把多个handleTag的处理做成多线程并发来提高整体效率。于是产品代码ver1.2.0又演变成了如下所示的样子。(注:为了简化伪代码的可读性,此处仅仅用把C,D,E三个类别用于多线程处理,也不考虑实际线程如何处理,仅作为示例)
/* 标签处理引擎类 */ Class TagEngine { function processTags(){ handleTagCategoryA; //isClientA Flag内容读取自定义文件 if (isClientA) { handleTagCategoryB4ClientA; } else { handleTagCagegoryB; } // 多线程处理 call threadPoolHandleTags(); //isBusinessZ Flag内容读取自定义文件 if (isBusinessZ) { handleTagCategoryF; } } // 多线程处理 function threadPoolHandleTags(){ ThreadPool.add('categoryC') ThreadPool.add('categoryD') ThreadPool.add('categoryE') ThreadPool.run(); } }
数个月过去了,由于标签引擎绝对领先的准确率,覆盖率,和高效率,以及在多个客户那积累的好口碑,很快一传十,十传百地在众多客户那实施部署并取得极大的成功。标签引擎的核心功能也在不断的演化升级,尽管已经升级到了ver1.5.0,但基础代码的组织架构依然和ver1.2.0保持一致。由于标准产品和客户定制版本在核心上使用同一套代码体系,产品的功能迭代和客户版本的升级维护到现在为止还算可控。
5.跨领域支持引发的变革
终于有一天,另一个业务领域的客户们纷纷询问我们的标签引擎是否可以支持该业务领域的资讯处理。产品经理X和客户们探讨需求后发现,虽然是跨业务领域的,但核心算法逻辑大差不离,主要的区别点如下:
· 该业务领域的标签类型是丰富多样
· 部分标签的产生是有先后依赖关系的
· 不同客户对于标签的先后依赖关系定义是有差异的,比如客户C这里是 标签M->标签N->标签O,而客户D这里是 标签N->标签O->标签M。
经过整理需求后发现,若我们的产品同时需要支持这个业务领域的需求,则必须满足如下的基本需求:
· 数据来源是可多选的,可配置的
· 标签类型不是固定的,需要能随时添加类型
· 标签处理需要支持顺序不定的前后依赖关系
· 需要能够支持多线程并发,以提高处理效率
到目前为止,虽然标签引擎的的核心逻辑因为要应对多个客户,多个业务场景的特殊处理已经演变成从配置文件读取Flag来切分多个分支处理的逻辑,但至少还只需要维护一套核心代码体系,产品迭代基本无压力。
因为并没有切分标准产品版本和客户定制版本而产生繁琐的双向Merge过程,因此用GitFlow工作流可以很好的支持新功能开发,问题修复,新版本发布等一系列快速迭代(具体如何操作请参考文末参考资料GitFlow)。
显然,目前的代码组织方式,并无法同时满足上述4个需求,if else的分支方式无法解决逻辑执行顺序问题,也很难组织同步和异步并发的控制问题。在这种情况下,难道我们需要针对每个客户的不同需求维护不同的代码分支吗?
可以想象,若不得已而这么做的话,今后必然会陷入代码管理的泥沼而不可自拔。每次新功能的发布,问题修复等都需要同步到每个客户版本分支,想想都是非常可怕的事情。稍有经验的开发者这时候肯定会说,我们可以合理利用设计模式,任务调度引擎等技巧来应对诸如此类的困境,来避免切分多个代码分支来管理。
6.多版本代码管理的基本原则与解决方案
没错,在作者看来,代码版本管理的核心就在于如何合理组织你的代码结构以避免各种特殊场景版本分支(比如客户分支),原则上来说除了GitFlow所提倡的分支结构外,不应该再出现其他分支。那么,针对本文的例子,具体怎么做可以避免切分特殊分支呢,一个支持可配置的任务调度引擎显然是一个不错的选择。
这里介绍一个轻量级的任务调度引擎liteFlow (参考资料3),该任务调度引擎支持任务节点的配置,任务节点先后顺序配置,任务节点的串行/并行执行配,保证多线程环境下任务数据流的线程安全等,足以满足大部分的任务调度需求。
稍有遗憾的任务节点尚不支持参数化配置,比如我们的数据来源为kafka的不同topic,若节点支持参数化配置,那么节点代码只需要维护一个,用不同的参数配置来生成不同的节点实例即可,应用的可扩展性更加友好。
为弥补这个小小的遗憾,作者fork了liteFlow做了些许的调整。基于调整后的liteFlow任务调度引擎,我们避免了多个客户分支的代码管理泥沼,只需要为每个客户准备一个任务调度流程配置文件就可以了。流程配置文件示例如下:
<!-- 通用配置 --> <?xml version="1.0" encoding="UTF-8"?> <flow> <nodes> <node id="a" class="tech.deepq.training.flow.NodeA"> <param key="kA1" value="vA1"/> <param key="kA2" value="vA2"/> </node> <node id="b" class="tech.deepq.training.flow.NodeB"> <param key="kB1" value="vB1"/> <param key="kB2" value="vB2"/> </node> <node id="c" class="tech.deepq.training.flow.FlowC"> <param key="kC1" value="vC1"/> <param key="kC2" value="vC2"/> </node> </nodes> <chain name="processTagFlow"> <then value="a,b,c"/> <!-- then表示串行 --> </chain> </flow> <!-- 客户X专用配置 --> <?xml version="1.0" encoding="UTF-8"?> <flow> <nodes> <node id="a" class="tech.deepq.training.flow.NodeA"> <param key="kA1" value="vA1"/> <param key="kA2" value="vA2"/> </node> <node id="b" class="tech.deepq.training.flow.NodeB4ClientX"> <param key="kB1" value="vB1"/> <param key="kB2" value="vB2"/> </node> <node id="c" class="tech.deepq.training.flow.FlowC"> <param key="kC1" value="vC1"/> <param key="kC2" value="vC2"/> </node> </nodes> <chain name="processTagFlow"> <when value="a,b,c"/> <!-- when表示并行 --> </chain> </flow>
参考如上示例配置,通用配置表示顺序执行NodeA,NodeB,NodeC 三个节点,而客户X专用的配置是3个节点并行处理,并且b节点的处理逻辑是客户定制化。由此可见,借助任务调度流程引擎,在代码层面我们只需要专心实现各个任务节点的业务逻辑,无需用代码去控制具体任务流程。既简化了代码逻辑,又解决了代码管理上的痛点。
最后,作者再次重申:代码版本管理的核心关键不在于使用多么强的代码管理工具,而在于如何合理组织代码结构来减少代码版本分支,来从根本上避免各种版本分支的互相merge过程。从技术层面上来说,我们有很多的手段来做到减少代码版本分支,例如设计模式的合理使用,消息中间件解耦,以及本文提到的任务调度流程引擎等等都是很好的选择。
参考资料
1)GIT:https://git-scm.com/book/en/v2
2)GitFlow:https://nvie.com/posts/a-successful-git-branching-model/
3)liteFlow:https://github.com/thebeastshop/liteFlow
4)liteFlow Fork:https://github.com/muhm21cn/liteFlow
关于作者
穆惠明,深擎科技首席架构师。拥有近20年软件开发和架构设计经验,在银行、保险、证券等金融领域的微服务基础架构、容器化部署等方面拥有丰富的实战积累,目前主要负责深擎智能资讯产品群及数据中台的架构设计和落地实现等。