Fork me on GitHub

如何让一个历史包袱重的模块重获新生

[TOC]

如何让一个历史包袱重的模块重获新生

之前重构了一个历史包袱重,代码多且复杂,性能不好的模块。积累了一些经验,分享给uu们。

重构的背景是,这个模块是售货袋,也就是直播中的可购买商品列表。当时存在如下几个问题,

  1. 严重的贫血领域模型。对象类里只是单纯的getter/setter方法,没有任何行为。所有的查询、处理、组装都是通过service类里的方法堆积出来的,最主要的查询方法有250多行。
  2. 有大量的重复代码和重复调用,根本原因还是代码逻辑不清晰。不知道大家有没有在开发的时候遇到这样的情况:当你需要某个接口的数据在前面已经查过了但藏的比较深,你根本就没看到,或者想获取只能大幅改动代码并回归测试一遍。
  3. 这个模块使用了流程引擎,因为代码是割裂的,可读性差,不方便排查问题。

针对这三个问题,本文从三个方面展开,一是如何实践DDD的,对系统进行领域拆分后,用能力下沉治理由贫血症引起的失忆症。二是针对售货袋这种读多写少的场景,对接口主流程进行治理,介绍了一些在这个场景下比较常见的手段。三简单介绍了为什么售货袋不需要流程引擎。

一、实践DDD

领域拆分

原本直播是根据技术领域区划分的:直播应用、云服务应用、数据库操作应用。这样划分的问题是,所有业务的代码都堆积在一个大应用里,非核心业务很容易影响到核心业务。

重构后,根据领域去划分为:直播间、主播、商品、云服务,去掉了数据库操作应用这种以技术划分的应用。

这样划分后,对于人,团队的分工会更加明确,一个开发人员可以全身心的投入到相关的一个单独的上下文中。经常听到的话是“商品相关的需求/问题,找xx同学”。

对于事,可以减少系统依赖和耦合关系。

治理贫血模型

为什么贫血模型不好呢?因为它没有对同类代码进行分类和封装,直接表现是贫血模式下的主流程会穿插很多不重要的步骤。当时售货袋模块的查询购物袋基本信息这个主流程就有250行左右的代码,包含了查询逻辑的过滤、RPC接口调用、查询结果处理、返回值组装等。主流程代码意图会渐渐不明确,我们将这种情况称为由贫血症引起的失忆症。

能力下沉

对于这种情况,可以通过能力下沉去治理。什么叫能力下沉呢?即将有逻辑的代码封装到合适的实体里。

打个比方,判断一个商品是否在秒杀状态的逻辑:“商品有秒杀促销并且在秒杀状态”。就是一个很好的例子,应当这个逻辑下沉到商品实体里。

坏代码的的味道,随处可见这种代码,

1
2
3
4
5
6
7
8
9
10
11
//如果是秒杀状态
if(dto.getPromotionType() == PromotionType.SEC_KILL.getValue()
&& dto.getSeckillType() == SecKillType.SEC_KILL.getValue()){
do some thing......
}
......
//如果不是秒杀状态
if(dto.getPromotionType() != PromotionType.SEC_KILL.getValue()
|| this.getSeckillType() != SecKillType.SEC_KILL.getValue()){
do some thing......
}

好代码示例,在商品的实体新增方法,将逻辑下沉。

1
2
3
4
 public boolean isSecKillStatus() {
this.getPromotionType() == PromotionType.SEC_KILL.getValue()
&& this.getSeckillType() == SecKillType.SEC_KILL.getValue();
}

主流程里:

1
2
3
4
5
6
7
8
9
//如果是秒杀状态
if(dto.isSecKillStatus()){
do some thing......
}
......
//如果不是秒杀状态
if(!dto.isSecKillStatus()){
do some thing......
}

能力下沉后有如下好处:

  • 1.抽取了复用代码,从而消除重复代码,如果逻辑变了,只需改动一处代码。
  • 2.用方法的命名增加了代码的可读性。因为你抽取肯定是要抽一个方法的吧,那方法肯定得取一个名字吧,而这个名字会出现在主流程中,起一个易懂名字能极大的提高的可读性。用方法名解释代码方面比注释更直接优雅。
  • 3.封装,看主流程的时候,其实不关心商品是怎么被判断为秒杀商品的,这段逻辑会打断阅读者的思路。

二、主流程治理

将模块进行领域驱动改造后,大部分可复用的能力会沉淀到实体里,售货袋查询的主流程就清晰了很多。我们还可以做的更好一点,用一些读多写少场景的手段,对主流程进行代码改造。

原本主流程是逻辑是:查询十几个下游的信息、对查询处理各种if else判断、对返回实体的组装。

分析一下,在主流程里其实不需要太复杂的模式,把同样的动作放在一层,同层里使用组合方法,尽量保持主流程里的都是重要简单代码。

改造后分为:

agg参数组装层

这一层是为了将各入口进行处理,各入口的都能使用统一的查询流程。前面提到之前的主流程有250行代码,但由于没有合并入口,这样的主流程方法有4份,使用agg参数组装层就可以将这4套代码合并成4套。下面具体分析这四套是怎么合并的。

首先是,全量查询和增量查询的合并,全量查询查的全部商品,查询时会先查缓存。另一种是只查5个商品,叫增量精准查询,会调用RPC接口实时计价。原本提供给客户端的是两个接口,两个接口代码各一套,有大量重复代码。改造后,保留两个入口,合并两个接口底层逻辑,对于下层来说,它也不关心是哪个入口的查询。

然后是新人专享商品和普通商品的合并,同样的,在agg层判断用户身份组装待查询的商品,对于下一层来说,它也不关心是商品是否是新人专享。

各入口共享主流程的大部分代码,对于差异化查询,需要各自处理。

1
2
3
if (context.isNesUserItem()) {
.......
}

线程上下文

在文章最开始,提到了一种情况“当你需要某个接口的数据在前面已经查过了但藏的比较深,你根本就没看到,或者想获取只能大幅改动代码并回归测试一遍” 尤其是对于售货袋这种读多写少的场景。

解决方法是:在主流程中引进一个上下文的概念,每个请求独享一个上下文。所有下文可能会用到的必要放到这个上下文里。

注意,这里说的上下文和DDD的限界上下文不一样,这里的上下文相当于一次请求的线程局部变量,DDD的限界上下文是指划分领域模型的边界。

数据查询层

调用接口查询数据,这一层尽量简单,查数据塞进上下文就好了。

组装层

将数据进行一些逻辑处理,然后返回实体类。

三、去除流程引擎

首先流程引擎是什么?我们这边的流程引擎是将代码分成一个个节点,每个节点配置化,期望新需求可以通过配置的方式开发。这东西之前被引入到了售货袋,但给这个模块只带来了负担,几乎没有任何好处,我没有见过售货袋的某个需求是通过代码编排实现的。

它最致命的缺点降低了代码的可读性,提高了排查问题的成本,引入了额外的复杂度,使得开发者有学习成本。代码很难阅读,因为他是用配置去把代码连在一起的,也就是说你必须一边看配置一边找代码。阅读尚且如此,更不要说问题排查。

它适用于有流程编排的场景,比如首页,首页经常有需求变更,并且变更的内容大同小异,甚至有周期性。

总之,在这次重构里,我废弃了售货袋模块的流程引擎。

最后

对DDD,我之前也读过关于领域模型的一些书,当时那些书都不太讲人话读也没读懂,各种名词云里雾里,后来被复杂的代码折磨,需要思考怎么去重构,带着问题再去找解决方案,很多概念就明朗起来了,变得对我有帮助了。

本文主要介绍了,在一个历史包袱重的模块,如何使用DDD进行治理代码的一些经验。其实对这个模块的重构,还有很多性能方面的改造,也许下次再写一篇“如何提高接口性能50%以上”,这篇就只放关于代码风格的内容吧。