背景
由于业务需要,最近又需要对业务系统中的一块代码做一些修改,加一些新功能。 这块代码做的事情主要是:根据系统设定的一些不同类型的价格,计算得到用户在终端上看到的价格,比如针对不同的客户级别展示不同的价格、又或者针对单个客户设定一个价格等等。
但因为年代久远,年久失修,这块代码已经变得非常复杂,很难维护了,而且原作者已经离职多年。 每每翻看这部分代码都十分头疼,几度想要重构,但又有一些不去重构的理由,比如:
- 影响范围广:用户在终端看到的大多数价格都是通过这块代码计算得到的,一旦出错,影响面很大。
- 逻辑复杂:不过这里指的是代码写得过于复杂了,其实这块业务本身并不复杂,属于比较正常的线性结构,只是流程多了几个步骤。
- 没有办法完全理解原作者写这段代码的思路,主要由于:
- 代码不规范:变量名、函数名、注释等等都不规范,理解成本偏高。
- 类的层次结构混乱:没有比较明显的层次结构(比如说自顶向下、逐步细化这样去实现)。
- 比较分散的业务逻辑数据:这里主要指的是一条数据,被拆分为多个字段,不同字段丢进不同的 hash 表里面,在使用的时候再从不同的 hash map (关联数组)里面取出来。操作起来非常繁琐,也不符合直觉。
- 充斥了太多奇怪的逻辑,因此也很难保证重构后的代码就是正确的。
鉴于以上原因,一直没有动手去重构这块代码,后续的业务改动只是在原有的基础上做了一些小的修改。 但是很多时候,一些简单的业务逻辑,都需要花费不少时间去理解这块代码,去找到正确的修改点,非常痛苦。
新功能怎么做?
跟往常一样,还是先去找找从什么地方改比较合适,然后再去改。 结果也跟往常一样,在代码里面跳来跳去,然后找到新代码应该待的那个地方。 但是还有令人头疼的地方:
- 新业务所需要的一些依赖的数据,从哪里获取?如何使用?这个问题还好解决,遵循原来的逻辑,可以在
class
里面定义一个字段,在构造函数里面初始化,然后在需要的地方使用。 - 旧的代码里面包含了大量的 hash map(也就是关联数组),大部分时候,用起来还好。但是,导致的结果是,后续维护的时候,如果不写清楚注释,无法得知这个变量里面装了什么字段,只能通过调试的方式打印出来。这样的代码,不仅不好维护,而且也不好理解。
当然,我们也可以选择忍着头疼,继续往下写,但是如果新加入的代码出 bug 了,我们也得忍着头疼去找 bug,显然不是一个好的选择。
为什么选择重构?
对于这块遗留代码(legacy code),为了后续不那么头疼,最终选择了重构。当然也不是一时兴起,主要是因为时机成熟了:
- 修改旧代码的成本已经高过重构然后再修改的成本了(包括理解旧代码的成本、修改成本、后续维护的成本)。
- 这块业务本身并不复杂,只是代码写得复杂了,重构后会更好维护,会有更加清晰的结构。就算出现 bug,也能快速定位以及修复。
- 这一两年,对于如何写好这类代码有了一些新的认识,可以尝试将这些新的认识应用到这块代码中(此前很多次有过重构的念头,但是一直都没有什么好的办法)。
如何重构及为何
原代码有太多可圈可点的地方,但是本文只讲述个人觉得最重要的地方(嗯,也就是 "主要矛盾"):
- 如何得到一个良好的抽象(可以简单地理解为类),以及这个抽象应该包含什么属性、能力(方法)?
- 如何组织这些抽象概念,使得代码更加清晰?
个人以为,处理好了这两个问题,大概率可以得到相对比较好维护的代码。
什么是抽象?
在开始这一小节之前,有必要讲一下抽象。关于抽象的定义,百度百科中是这样说的:抽象是从众多的事物中抽取出共同的、本质性的特征,而舍弃其非本质的特征的过程。
这么说可能有些抽象,举个不那么恰当的例子,我们可以对比一下下面两个图:
第一个图是一个具象的图,经常会有人拿这个表情来作为调侃。然后当我们见多了之后,有人直接发图二了,虽然上面只有几根线条,但是在特定场景下,我们看这几根线条就能知道对方想表达什么了。
从图一到图二的过程,我们可以理解为抽象的过程。这个过程中,抽取了其中最关键的特征,舍弃了其他的特征。
在实际的开发中,其实我们代码中的那些对象也都是一个个抽象出来的概念,不可能涵盖一个真实对象的所有特性,比如 Sku
对象,用来表示商品的时候,我们只会给它加上我们实际业务所需要的那些特性,比如颜色、价格等等。
定义虽然这么说,但落实到代码中的时候,并不需要抽取那么多共同的、本质性的特征,我们只需要业务处理所需要的那些特征。
又或者,在我们数据库模型的基础上,根据实际业务引申出一些新的抽象,比如员工,可以引申出经理、普通员工等等。这其实是在原有抽象的基础上,添加一些新的特性,从而产生新的抽象。 在实际开发中,可能后者对我们来说作用更大,很多时候我们都是在处理各种模型之间的关系,而不是处理单一的对象。
我们的数据库模型是根据业务流程所需要的各种数据根据其不同种类、属性、特性拆分开了的,为了更好地处理我们的业务逻辑,我们可以在业务代码中再将一些有关联的数据组织起来,形成一个新的抽象,从而更好地实现复用。
如何得到一个良好的抽象?
我们可能习惯了将所有的业务逻辑丢到 service
里面去实现,似乎 service
是个万能的东西,这样造成的结果是,我们会丢失很多业务上的语义,丢失业务场景,
带来最大的麻烦是,后续维护的人理解起来会非常费劲,需要通过人脑将那些割裂的信息重新组织起来,这是一个非常费劲的过程,往往会造成一些理解上的偏差。
当然,一些简单的 CURD
并不在本文的讨论范围之内。
面向对象分析(OOA)告诉我们,我们可以通过分析业务逻辑,找到其中的对象,然后将这些对象抽象出来,形成一个类,通过这个步骤,我们得到了数据库表、ORM 模型。 在此基础上,针对不同的业务场景,我们也可以衍生出不同的类(这是本文主要讲述的东西),一个更加契合我们实际业务场景的类。
简单来说,我们可以通过下面这几个步骤来得到这么的一个类:
- 为新的类命名:命名的规则可以是
业务场景 + 实际要操作的对象
。 - 为这个新的类加上需要处理的业务场景所必须的属性。
- 为这个新的类加上需要处理的业务场景所必须的方法。
下面举一个比较实际的例子,比如,假设我们需要组合一些信息来计算用户在商城实际购买商品的价格,我们有如下几个模型:
Sku
:包含商品本身的的一些基本信息。User
:包含用户的一些基本信息,其中包含了用户的等级。SkuLevelPrice
:记录了Sku
对应的不同等级的价格。SkuUserPrice
:记录了针对单个用户设置的价格。
我们遵循一下上面说到的三个步骤,实践一下:
- 命名:
PriceSku
。我们的业务场景是计算价格,操作额度对象是Sku
,这个命名也许不太好,但 “又不是不能用”。不管怎样,起码反映了业务和要操作的对象,不算太糟糕,另外主要是,我也没有想到更好的命名(但是很多时候,对于命名的这种考量是非常有意义的)。 - 为
PriceSku
添加几个属性:Sku
、User
、SkuLevelPrice
、SkuUserPrice
。这些属性是我们计算价格所必须的,也是我们的业务场景所必须的。 - 为
PriceSku
添加一个个方法:getPrice
。这个方法的作用是计算用户在商城实际购买商品的价格。
最终的伪代码如下:
kotlin 代码解读复制代码class PriceSku:
// 属性定义
sku: Sku // 商品对象
user: User // 用户对象
skuLevelPrice: SkuLevelPrice // 商品等级价格对象
skuUserPrice: SkuUserPrice // 用户特定价格对象
// 构造函数
constructor(sku, user, skuLevelPrice, skuUserPrice):
this.sku = sku
this.user = user
this.skuLevelPrice = skuLevelPrice
this.skuUserPrice = skuUserPrice
// 方法定义
method getPrice():
// 检查是否有针对单个用户的价格
if this.skuUserPrice.isPriceSetForUser(this.user.id):
userPrice = this.skuUserPrice.getPriceForUser(this.user.id)
return userPrice // 返回用户特定价格
// 如果没有用户特定价格,则根据用户等级获取价格
if this.skuLevelPrice.hasPriceForLevel(this.user.level):
return this.skuLevelPrice.getPriceForLevel(this.user.level) // 返回等级价格
// 如果都没有,返回默认价格(可以根据需求定义)
return this.sku.getDefaultPrice() // 返回默认价格
在这个例子中,我们将计算价格的逻辑抽象出来,形成了一个新的类 PriceSku
,这个类包含了我们计算价格所必须的属性和方法,这样我们就可以在其他地方直接使用这个类,而不用再去关心这个类的内部实现。
同时,如果我们需要修改计算价格的逻辑,只需要修改这个类的 getPrice
方法即可,在主流程中加一个 if-else 语句。
看到这个,可能有人会条件反射般地想到这可以用策略模式(有不少文章经常吹嘘要消灭 if-else,各种标题党)。确实,这种场景是可以用策略模式的,但这应该发生在 PriceSku
变得越来越庞大的时候,而不是一开始就用策略模式。
一上来就用策略模式,可能会让代码变得更加复杂,不利于维护。如果我们担心代码在后续业务复杂之后变得难以维护,另外一个选择是想办法让目前的代码写得更简洁一点,更容易理解一点,
而不是在业务变得复杂之前,先把代码写复杂了,那样就南辕北辙了。
如何组织这些抽象概念?
通过上一步,我们得到了一个可用的类了,但是在我们开始使用的时候,我们可能会发现,好像还是不太好用。 因为在实际的业务场景中,我们很多时候都是需要做一些批量的查询,比如,用户查看一个商品列表的时候,我们需要查询一组商品的价格,而不只是一个商品的价格。
在这种场景下,我们能想到的一个很简单的办法是,写一个循环来逐个获取,这样也是可行的。只是存在一个问题是,如果很多地方都需要做批量的处理,会有比较多冗余的代码。 但是这对我们来说应该毫无难度,我们可能会选择在某个地方定义一个批量查询的方法,不管是什么地方,总会有一个地方,service 也好、controller 也好。
然后某一天,有一个新的需求,也是跟价格相关的,我们需要改造一下 PriceSku
,让它只返回某个商品的会员等级价,同样的,我们也需要做批量的查询。
然后也难不住我们,在 PriceSku
里面添加一个获取会员等级价的方法,然后在某个地方再写一个批量查询的方法。到目前为止还没有什么问题。
但是问题还是存在的,我们似乎缺少了一个地方,这个地方作为承载所有批量操作 PriceSku
的地方。当然放 service 也能跑,又不是不能用,只是最终结果是 service 越来越膨胀,承载的职责越来越多。
在这种情况下,我们可以考虑定义一个 PriceSkuCollection
类,在里面做那些批量操作,比如下面这样:
kotlin 代码解读复制代码class PriceSkuCollection:
// 属性定义
priceSkus: List // 价格对象列表
// 构造函数
constructor(priceSkus):
this.priceSkus = priceSkus
// 方法定义
method getPriceForAll():
prices = []
for priceSku in this.priceSkus:
prices.append(priceSku.getPrice())
return prices
// 只获取会员等级价
method getLevelPriceForAll():
levelPrices = []
for priceSku in this.priceSkus:
levelPrices.append(priceSku.getLevelPrice())
return levelPrices
通过这种方式,我们可以有如下几个好处:
- 内聚性更强:
PriceSkuCollection
只负责批量操作PriceSku
,不会有其他的职责。 - 代码更加清晰:
PriceSkuCollection
里面的方法都是批量操作PriceSku
的方法。 - 同时可以在此基础上定义一些获取元数据的方法,当然,在我们这个例子中,并没有什么元数据可供我们获取。但是如果某一组
PriceSku
本身是一组有实际业务意义的数据,那么我们这种定义方式就有非常大的优势。
比如,如果我们的一组 PriceSku
代表了用户加入到采购车的所有商品,那么我们可以在 PriceSkuCollection
里面定义一个 getTotalPrice
方法,用来计算这一组 PriceSku
的总价格。
当然,在实际业务场景中,我们需要获取的元数据不仅仅是价格,还有很多其他的数据,比如数量等等,这时候我们就可以在 PriceSkuCollection
里面定义一些方法来获取这些数据。
这个时候,PriceSkuCollection
就变成了一个非常实用的类,可以用来处理一组 PriceSku
的数据。
好了,说了这么多,我想表达的是,在建立起对业务的抽象的时候,我们还得考虑一下抽象的层次性,本文的例子中,我们通过 PriceSku
和 PriceSkuCollection
两个类,将业务抽象分成了两个层次,这样可以更好地组织我们的代码。
其中,PriceSku
用来处理单个 Sku
的价格计算,PriceSkuCollection
用来处理一组 Sku
的价格计算,职责非常明确,代码也更加清晰。
有时候业务逻辑可能并没有那么复杂,但是因为抽象的层次混乱、或者完全没有抽象的层次,各种不同抽象层次的代码混杂在一起,从而使得业务看起来非常复杂。 业务的复杂是不可避免的,任何一个项目只要在发展,都会越来越复杂,合理组织代码,将代码分成不同的抽象层次,是我们应对复杂业务的一个重要手段。
网络通信已经非常复杂了,但是网络通信的最底层也不过是网络中传输的二进制数据,但是很多年以前就形成了 TCP/IP 协议,这个协议就是一个非常好的抽象,将网络通信分成了不同的层次,每一层都有自己的职责, 每一层只处理一件事情,下层为上层提供服务,上层调用下层的服务,这样就形成了一个网络通信的协议栈。 而 HTTP 是对 TCP/IP 协议的进一步抽象,将网络通信分成了请求和响应,有了 HTTP,我们开发网络应用就不用自己去处理 TCP/IP 协议了,这样我们就可以更加专注于业务逻辑的处理,而不用去处理网络通信的细节。
同样的,识别出我们业务逻辑中的抽象,然后将这些抽象分成不同的层次,是我们应对复杂业务的一个重要手段。时间长了,新的业务开发的时候,成本更低,因为不再需要理解那些没有抽象的代码,我们需要处理的是一个个具有具体业务意义的抽象。
总结
本文前面的三分之一是在讲述很多人都会面临的一些困境,如果你也有类似的困境,也许可以参考一下本文的一些思路。 总的来说,在基本的数据库表的 ORM 模型基础上,我们可以做更多的抽象,我们可以对需要复用的业务逻辑进行抽象,然后将这些抽象分成不同的层次, 在不同层次的抽象中,处理其对应层次的业务逻辑,然后为更上一层抽象提供服务。
评论记录:
回复评论: