

前言
某次做月度总结的时候,回顾卓越工程的板块,有点讶异团队推进代码风格与结构一致性这件事情已经历时2.5年。过程中的发心、各种纠结、到今天的收益,感觉可以写出来作为一个分享,希望能够给有类似想法的同学一点参考。
整个事情的命题其实是很基础的:程序员(Java服务端)如何把代码写的好看,好维护。其中包括一些从一个团队角度来看的一些思考和实践。

我所在的团队,一部分职责是开发维护直播业务中主播直播售卖商品的时候的CPS佣金体系,完成消费者点击商品的行为跟踪,以及下单后把订单归因到主播,以及后续的计费、扣佣过程。其中要结合平台商业策略和不同参与者之间的商业关系做跟踪、计佣,整个体系非常复杂,一个请求的处理过程涉及到相当长的过程。而其中一句话不慎写错就是很多钱的资损风险。怎么做到一个复杂的应用,让同学能够更容易理解把握,减少出错,这是从具体场景出发的初心,触发我们思考整体的代码结构。
很多年前的时候,MVC框架在流行,那个时候有一种风靡天下的代码分层结构模式:Controller->Service->DAO。这是最基础的一种代码分层结构。Controller负责Web相关的输入输出,DAO负责数据存储查询,中间的Service负责业务逻辑。
随着互联网中间件,以及前后端分离等技术发展这里面出现一些变化,Controller层演化为入口服务,可能是RPC调用、Web请求、消息处理、定时任务处理等等。DAO的旁边出现对其他分布式服务的RPC调用(RPC Consumer、KV存储、发消息等等)。这两层的代码逻辑相对来说都是简单的,其框架结构,基本由中间件或者基础框架所规范。而整个体系中最复杂的业务逻辑层(也就是原来的Service层),是没有代码结构规范的。

简单的业务处理过程中,可能只是查询、读取一下存储,当业务变得足够复杂之后,在业务处理层经常会见到很多不同的代码模式:
最简单的
一个服务方法,内部通过换行、分块、拆出private方法等方式,让代码容易理解
为了并行缩短RT,会用线程池并行调用多个逻辑过程
复杂一点
会出现责任链的模式,把业务处理过程的不同逻辑分片,封装为链式调用的节点
并行场景下,上述责任链模式会优化成为一个并行执行的过程,中间可能要考虑节点之间的依赖
在一个大的主体业务流程中,面对不同业务有少量的差异性,如果配置不易表达,那么就会出现基于业务类型标识的策略模式,很容易出现很多的策略模式代码满天飞
上述这些点,是单个服务或者说单个研发维度看到的事实。从一个团队或者业务域来看,就会看到更多的情况:
-
在同一个应用中,面对复杂度相似的场景,在没有规范的情况下,很容易出现多种写法 -
团队n个应用,同学们能做到在应用里面见树木已经不容易,看通到森林就很难了,代码模式各不相同,应用维度的轮子很常见 -
新人进入,或者人员跨领域的时候,没有可以继承的宏观代码理解模式,就是从细节从头看
上面的这些情况,触发我们思考,是不是可以在整个团队的范围内,去做业务逻辑层的代码规范,做到【书同文】。

解题
作为电商相关的研发,曾经看过淘宝交易交易四大金刚等等应用的代码。可以看到,这些应用的基本代码模式,都是入口服务->bpm流程&活动->领域服务->能力->扩展点。不会有人在buy2里面再自己搞一个责任链,也不会有人在tp3的主流程里面面对某某业务特殊逻辑写一个策略模式的代码。
珠玉在前,所以去解决前面提到的问题,很好的思路就是在应用中以某个框架形式的代码约束,统一代码分层风格,并逐步推广到域内的多个应用,实现规模化的效果收益。
本质上讲,我们是在应对软件编码的复杂性,而应对复杂性的最佳手段就是抽象。所谓约束、风格,都是对复杂逻辑和实现的统一抽象,从而降低人脑认知的成本。
如何落地,按照程序员的习惯,还是造一个轮子吧。毕竟我们的场景,不论从单个业务复杂度,还是业务支撑的类型规模方面相比于业务平台,都是小小弟,所以自己造轮子的唯一目标就是要简单可落地。
我们把代码规范为如下层次(紫色部分是可选的):
入口服务:一个应用提供多个入口服务,做一个完整的业务动作的调用入口;可以是HSF入口,或者Controller调用的服务,或者Mtop接口调用的服务;入口服务的基本职责是参数校验、系统内外对象转换等,核心的业务逻辑应该委托到下面的业务流程-活动,简单的场景就不用流程,而是直接调用领域服务
业务流程与活动:
一个入口服务背后可以对应一个流程定义,实际上是一系列Activity的集合,一般情况下就是顺序一个一个的Activity调过去,Activity可以返回处理失败阻断流程;当然也可以做复杂的编排
一个业务流程里面的不同环节就是业务活动,业务活动可以依赖多个领域服务完成相关逻辑;业务活动一般是各个领域服务调用的胶水代码,一个领域的输出,在活动中可能作为下一个领域调用的输入,从而尽可能保障领域服务之间不发生依赖
领域&领域服务:一个实体对象的完整的管理能力集合叫做一个领域;领域服务就是一个领域对外暴露的服务方法,领域服务和领域对象是一个系统核心要沉淀的可复用资产。
领域服务的扩展模板和扩展点:一个领域服务可以通过泛型指定扩展模板,也就是这个领域服务所允许的扩展点的集合;一个领域服务在某些业务逻辑处理过程中,针对不同业务可能有不同逻辑,那么可以根据业务身份查找当前业务对应的扩展点实现;这就是对不同逻辑点上随处可见的策略模式的规范
规范后的代码分层结构如下图所示:

在建立上述规范的基础上,尽量统一业务层代码模式。同时,因为这个结构的存在,实际上可以去基于结构做代码结构可视化、监控等等的事情。
具体的代码示例可以看后面的【轮子】一节。这里看两个效果图:
团队同学做了Idea的插件,做到流程视图、入口服务、活动之间的导航,进一步提升阅读、编码的效率。截个图:

代码结构可视化,可以辅助应用代码的分析、理解、防腐治理。下面是一个截图,可以看到某个入口服务内部的基本处理过程,以及背后依赖的领域和领域服务。


轮子是2022.06开始造的,作为技术演进的项目,推进方式上主要是两个点:新的服务、应用直接采用规范的代码模式,老的应用和服务,随着业务需求的演进和人力情况逐步迭代。整体推进其实是比较缓慢的的一个过程。到目前大约经历2.5年,代码方面的显性结果如下:
12个服务端应用
600+个入口服务、400+个流程,180+个领域,1k+个领域服务
在这些数字的基础之上,收益可以这么讲:
代码结构基础线的拉升:一个同学,即使是工程编码经历比较少的新同学,有规范可循的情况下,写出来的代码在基本结构上是有下限的;同时对于老鸟程序员,也有对代码结构思考的约束
代码可读性提升:实际上每一个服务,在代码里面找到流程文件一看,就基本知道大概流程,做了哪些事情。再结合可视化的代码架构视图或者IDE插件,就更容易看明白了
趋同的代码模式带来的收益:一个同学从A领域进入B领域,代码模式是一样的,从看到写,都有满满的熟悉感,降低学习成本
当然,任何轮子、规范,都要有人来使用和遵循,人是最本质的要素。

这里简单摘取一点代码来看一下:
/**
* 这是一个HSF接口Provider实现,示例代码中省略了典型的try-catch结构
*/
public Result<RatingDTO> rating(RatingRequest request) {
validateRatingParam(request);
//构建流程上下文并启动处理流程
RatingProcessContext context = new RatingProcessContext();
context.setRatingRequest(request);
ProcessResult processResult = processEngine.start(ProcessNS.PROCESS_RATING, context);
if(processResult.isSuccess()) {
return Result.success(buildRatingResultDTO(context));
}else{
return Result.fail(processResult.getErrorCode(), processResult.getErrorMessage());
}
}
<onion-process-config>
<!--
支持基本的逻辑编排:顺序、并行、条件、跳转、循环、分组、子流程
实际上用的最多的是最基本的顺序编排
-->
<process id="rating_process" name="内容计佣处理流程">
<node-list>
<!--归因计佣相关的activity-->
<activity name="初始化数据" bean="ratingInitActivity"/>
<activity name="在线归因" bean="ratingAttributeActivity"/>
<activity name="归因后初始化数据" bean="ratingAttributeAfterInitActivity"/>
<activity name="黑名单商品校验" bean="ratingBlackCheckActivity"/>
<activity name="按费用项计佣" bean="ratingRatioActivity"/>
<activity name="同步账房处理" bean="ratingAutoPayCenterActivity"/>
<activity name="初始化达人外部引流" bean="ratingInitOuterTrackRuleActivity"/>
<activity name="按费用项构建分佣关系" bean="ratingCommissionListActivity"/>
<activity name="按费用项构建加码分佣关系" bean="ratingExtraCommissionListActivity"/>
<activity name="扣佣结果组装" bean="ratingResultBuildActivity"/>
</node-list>
</process>
<!-- 这里再放一个并行流程的示例 -->
<process id="itemCompleteParallelProcess" name="商品补标流程" parallel="true"
executor="itemCompleteExecutor" timeout="500">
<node-list>
<activity name="商品补标活动A" bean="itemCompleteActivityA"/>
<activity name="商品补标活动B" bean="itemCompleteActivityB"/>
<activity name="商品补标活动C" bean="itemCompleteActivityC"/>
<activity name="商品补标活动D" bean="itemCompleteActivityD"/>
<activity name="商品补标活动E" bean="itemCompleteActivityE"/>
<activity name="商品补标活动F" bean="itemCompleteActivityF">
<dependency-list>
<!-- 整体各个活动之间并行执行,但是F要依赖A执行完成 -->
<dependency ref="itemCompleteActivityA"/>
</dependency-list>
</activity>
</node-list>
</process>
</onion-process-config>
/**
* 归因活动
* @author jiangxin.zcg
* @date 2022/10/25
*/
public class RatingAttributeToActivity implements ProcessActivity<RatingProcessContext> {
private RatingDomainService ratingDomainService;
public ActivityResult execute(RatingProcessContext context) {
AttributeToRequest request = new AttributeToRequest();
request.setRatingRequest(context.getRatingRequest());
//调用领域服务完成归因过程
AttributeResultDTO attributeResultDTO = ratingDomainService.attributeTo(request);
context.setAttributeToTrack(attributeResultDTO.getTrackDTO());
//后面计费的业务实例在这里构建,根据归因结果确定了业务身份
TcpRatingInstance ratingInstance = new TcpRatingInstance();
ratingInstance.setBizInstanceId(BizInstanceId.of(context.getAttributeToTrack().getBizcode(),
context.getRatingRequest().getBizOrderId()));
ratingInstance.setRatingRequest(context.getRatingRequest());
context.setRatingInstance(ratingInstance);
//这里实际上因为有了归因计费的业务实例,也有了业务身份,也就是可以调用ratingDomainService的其他方法,做一些这个业务特有的扩展了
return ActivityResult.SUCCESS;
}
}
public class RatingDomainService implements IDomainService<TcpRatingExtTemplate> {
/**
* 归因领域服务
*/
public AttributeResultDTO attributeTo(AttributeToRequest request){
//找到lastClick
List<TcpTrackDTO> trackList = request.getRatingRequest().getTrackList();
TcpTrackDTO lastClick = trackList.get(trackList.size() - 1);
AttributeResultDTO result = new AttributeResultDTO();
result.setBizcode(lastClick.getBizcode());
result.setTrackDTO(lastClick);
return result;
}
/**
* 计费领域服务
*/
public List<RatingDetailDTO> ratioCalculate(RatioCalculateRequest request){
List<RatingDetailDTO> resultList = Lists.newLinkedList();
BizTypeRatingOutput bizTypeRatingOutput;
for (RatioCalculateInstance instance : request.getRatingInstance().getRatioCalculateInstanceList()) {
BizTypeRatingInput input = new BizTypeRatingInput();
input.setInstance(instance);
//不同的bizType调用扩展点做计费
bizTypeRatingOutput = this.invokeFirstMatched(instance,
TcpRatingExtTemplate.EXT_TCP_BIZTYPE_RATING,
ext -> ext.bizTypeRating(input));
if(bizTypeRatingOutput != null){
resultList.addAll(bizTypeRatingOutput.getRatingDetailList());
}
}
return resultList;
}
}
上面的示例中可以看到顺序执行流程、节点并行执行流程。实际业务过程中能够覆盖绝大多数场景。但是依然会有少量更复杂的场景,需要更加复杂的处理,所以增加了下面的一些能力:
子流程sub-process,类似一个private方法引用一个流程定义
group 是一个组,实际上是一个内联的子流程
process/group都支持composer属性,设定一个bean,完成自定义所有节点的执行编排,比如某个流程需要try执行前4个节点,finally执行节点5;可以结合节点的tags属性,做一些公共的composer

过程中实际上会有不少的点是大家会困惑的,例如:
究竟什么是领域,领域应该如何划分,领域服务的代码如何分治
一些代码究竟应该写在流程-活动这一层,还是领域服务层
流程中的子流程、跳转等结构越来越复杂,流程编排与直接Java编码之间的表意权衡,不能生搬硬套反而导致代码更难以理解
复杂流程的Context对象本身也会存放很多数据,结构上如何做的更加清晰
当然没有银弹的解决方案,但是大家至少有一个认知是对齐的,就是我们的基本目标,是让代码写的容易理解、容易维护。每一次讨论、分歧、没那么完美的达成一致,都是价值。
后续有一些事情还可以探索,例如在规范后的各个层面的代码维度上的监控、告警,辅助问题排查和定位;以及基于当前规范约束的AI代码生成,尤其是作为规范结果的代码结构信息,都可以作为模型的知识输入。希望可以持续推进探索。

本文作者江新,来自淘天集团-直播商业化团队。本团队致力于在电商直播业务中为主播提供有竞争力的商品,同时支撑主播佣金、坑位费商单、平台激励等各种商业模式的发展和创新。作为大团队内和资金最紧密的板块,在稳定性和资损防控方面也更加需要体系化的建设和保障。我们不断地探索和实践新的技术,让平台和主播更加高效的经营,从而打造淘宝直播的生态竞争力。
本文分享自微信公众号 - 大淘宝技术(AlibabaMTT)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。