【架构】重构二三事

245 阅读13分钟

从2011年开始做电信的网关系统,历经4G、5G两代系统的研发过程,此文用于记录下过程中参与的较大规模的系统重构。

1 初涉网关系统

大概是在2011年下半年,开始做网关系统,当时项目的状态是完成了各个测试的任务,大规模商用的前夕,但是当时新的4G系统代码跟已经商用的2/3G系统代码完成是两套,而实际2/3G到4G的业务是有连续性的,有很多功能是类似的甚至是一样的;如果完全独立,无论是需求开发还是维护都是异常痛苦的。此时2/3/4G的融合势在必行,而且刚好有一个商用前的时间窗。但如何做呢?
系统要做融合,包括工作区、上下文、状态机、相关的公共基础工具函数都用一套、类似的业务逻辑;但刚开始对系统以及协议理解不深刻,决定从系统的各个周边接口开始动手。当时的做法是按接口进行分工,各个接口负责人在对接口的每个字段进行分析,将每个接口不同制式的接入同一到一个接口中。最后完成本阶段改造的时候,一些基础的工具函数、相似的业务逻辑也被一并改造,对应抽取出了基础设施层、一些比较重要的基础业务接口。在过程中也深刻的发现由于状态机、工作区、上下文是两套带来的各种不便,甚至也影响接口融合本身,后续的维护成本依然很高。因此状态机、上下文的合并势在必行。
下一步如何下手呢,因为2/3G当时已经商用,状态机、工作区等融合会从波及几乎所有的流程,没人敢打包票不引入问题,如果直接上,风险无疑是巨大的,到底影响了之前多少的流程 在当时完全没法得知。刚好当时公司里在推行敏捷,TDD、CI等敏捷实践,里面的一些想法刚好很符合我们的需要,就是先给系统加一道防火墙,简而言之就是添加自动化的用例将系统的各种输入输出守护起来,后续任何改动只要改变了预期的输入输出 用例马上会报错,我们就能在第一时间得知代码修改的影响。实践下来是二级测试,各模块的单元测试和整系统的集成测试。

2 系统的一道防火墙

这里重点介绍下模块级的单元测试,这个与代码开发过程紧密相关,而且基本能实时反馈代码所造成的影响。要做模块的单元测试,首先得选一个合适的测试框架,该框架要提供基本的用例管理、运行、开发框架;一定要轻量级,运行要快,经过一番试验,最后选定了google 的gtest。此时还要考虑测试的边界,被测系统是谁,哪些要模拟。
我们的系统实际也是在搭建在公司的平台系统之上的,平台可以理解提供了上电管理、消息分发、定时器机制、db访问、配置等网管功能;这些对于业务层来说不是我们需要保证其正确性的,由平台部门保障,因此这些不需要纳入被测系统,可以将其模拟掉。意味着我们要打桩接管上电管理、消息分发、db等。
另外gtest只提供了简单的函数级的输入输出的原生支持,而电信系统的业务不仅仅是一个函数的调用,往往是一系列的消息交互的流程。这意味着需要模拟周边给本模块发送的消息,以驱动被测系统的行为;并且在一个用例中需要多次向被测系统发送消息。因此可以想见的是,这个单元测试系统需要三部分,测试工具(gtest)、测试框架(模拟平台系统的功能、提供简单的语法支持方便编写流程用例)、测试用例(发送给被测系统消息的构造,被测系统发出消息的校验)。
测试框架在几位核心人员开发出来以后,和部分业务人员结对开始写测试用例,最终确认了第一版的语法,消息发送builer,消息校验assert。在铺开写测试用例后,碰到一个十分棘手的问题,随着用例的增多,用例编译时间越来越长,到一定规模后基本无法忍受,急需解决编译时间长的问题。开始的时候采取的方法是引入分布式编译incredBuild,缓解了一段时间,用例增多后,该问题依然突出。分析后发现,有如下几个方面导致编译问题:

  1. 运用了大量的模版,代码膨胀厉害。
  2. 每条消息都会依赖很多公共的字段,只要一个字段发生变更,都会导致大量的文件重编。

第一个问题主要是我们对系统中重要字段都进行了建模,让字段拥有方法去构建消息,而大部分消息中同一个字段都类似,所以使用了模版。优化的方向是,让字段只看到消息中对应的字段,不看到消息,这样消息在变化时就不会影响到字段了。
第二个问题就比较棘手了,后来想到的解决方案是,采用类似java中的反射机制,消息构造时,不再依赖具体的字段,而是通过字符串名称进行依赖,这样字段变换就不会影响到消息了,代价就是要实现这个反射机制。
经过第一阶段的铺开实施后,对用例的编写也想得更加清楚了。我们一个消息的构建函数中会依赖各个字段,此时我们提供的只是各个字段一套默认值,能保证基本的流程能跑通,即最小数据集。其它流程可以在上层提供语法来根据需要修改消息中的字段值,以此来实现跑通各个流程。有了各个流程的用例之后,在这个基础上,引入字段映射集合来描述某个字段在某个流程的各种输入输出,通常是边界值、典型场景值;为此对每个字段还引入了样本的概念,特别是一个业务概念是涉及复杂结构的时候,能够很清晰的表达出业务概念。
后续我们的用例也越写越顺,终于建立的模块级的防火墙,而且后来也做到了入库强制自动跑用例,用例不过让入库。还有一个意外收获是,通过用例我们也能很清晰的看到某个业务场景具体的流程是什么样子,某个字段的输入输出在各个流程是什么样子,相当于得到了一个随版本实时变化的文档。

3 状态机

现在回想刚开始的4G代码的状态机是比较接近与我们最终状态机的样子的;二2/3G的状态机就比较原始了,状态机的逻辑跟业务逻辑是混在一起的,基本就是switch case,当前处于什么状态收到哪条消息对应的处理是什么,要通过代码串一个流程是非常痛苦的。 当时重构的时候,还是偏向传统的状态机思维,想着把状态机相关的逻辑和业务处理逻辑分离开,于是抽象出了两张表,一张状态迁移的表,一张为各状态下可以处理的消息以及对应消息处理函数的表;从效果看,到达了预期的目标,状态机从业务代码逻辑中抽取出来了,确实清晰了不少。带要从代码将一个完整流程串起来还是很费劲的。路在何方呢?
恰巧当时公司敏捷之风正甚,各种分享很多,另外一个地区研究所的事务状态机 让我们眼前一亮。阅读一个流程的代码犹如在阅读协议流程,十分贴合电信系统的业务的特点。该状态机的仅仅只有两个状态 空闲态和工作态,一个事务流程里 可以包含各个子流程,子流程按我们业务系统的特点可以细化到向周边发送或接收到周边的一条消息的处理,当然也可以是我们系统中比较独立的自过程。并且提供了一些基本的原语,比如send、recv、timeout等。基本上可以对着协议写业务状态机代码即可。
但这里有一个问题,他们的框架是基于c++封装了一套完整的事务语法以及对应的事务框架实现;而当时我们的代码基本都是c,如果直接引入c++,一个是人员的c++技能不具备;而是那套事务框架很复杂,我们吃不透,后续维护是个大问题。当时项目组里有个大牛就提出用外DSL的方式,相当于我们自己定一套语法,大家按这套语法写状态机代码,再定义一个代码生成器自动生成对应的c代码。按这个思路,最终做出来的时候,效果差不多。而且之前冲突处理那块在这一次重构中也抽取出来了,也适用外DSL的方式进行描述,主要的思路是,将相应的事务流程打标签,相当于将流程分类,根据不同的标签,提供通用的冲突策略;而且还支持指定的两个特定流程之间的冲突策略。整个坐下来流程清晰了很多,头疼的冲突处理也可视化了。极大的减少了开发人员在开发需求所需关注的内容,只要关注本身的业务逻辑即可。

3 关于六边形架构

电信系统涉及各种协议,当时接口的编解码,均是散落在各个业务逻辑里面。其次还有一大块就是上下文的处理接口,也是散落各处。导致的结果是,各个开发人员写了大量重复的逻辑,编解码逻辑渗透到各个业务逻辑中。当时也是借鉴了其它人实践的六边形架构,基本的想法是 将各个编解码接口、上下文db接口封装起来,按业务需要提供接口供边界内的业务代码使用,将电信业务逻辑跟各种协议编解码、db接口隔离开;让开发人员尽量聚焦要做的业务逻辑处理,而且边界上的接口可以独自做ut测试即可,反馈速度更快。

4 系统分层

编解码、db接口剥离出去后,实际我们的业务逻辑依然比较复杂,各种业务逻辑交织在一起。好在之前的状态机改造,至少从最上层看到的都是一个事务流程,第二层就是各个子流程。实际已经有了一定的分层。但到各个子流程里的各种业务逻辑,也是散落各处,充斥着散弹式修改的坏味道。急需将这些业务逻辑也归归类,也即按接口的方式进行抽象。这样一个事务流程中可以调用多个业务逻辑接口,类似与用一颗颗珍珠串一根项链。并且这次也将公共的函数全部抽取到基础设施层,如:日志打印、统计、工具函数等。 实施之后整个系统开始变得有序,至少给开发人员限定了一些框子,不能完全天马行空了。并且对于抽象出得业务层,也可以单独做UT,可以更细粒度得守护业务功能,反馈速度也更快,不用等到串流程的时候再去做验证。相当于测试也分层了。

5 上下文操作接口

在我们的系统中,有工作区和上下文两种db记录,工作区的声明周期较短 仅为一个事务流程,而上下文的声明周期为用户上线到用户下线的整个周期,期间可以做多次流程。一个事务流程,要么成功,要么失败,成功的时候我们才可以将工作区中的信息同步到上下文中。
因此我们的代码中存在大量的,在流程开始的时候,将消息中的值保存到工作区,在流程成功后,再将工作区中的值同步到上下文中;并且过程中如果发现某个字段值有改变还会有不同的业务处理,因此也存在大量的代码用于判断字段值是否改变。 这些大量的代码都十分类似,大量的流程需要处理,漏掉一个就是一个问题。
针对这个也进行了讨论,觉得有必要将这些操作(比较变化、同步到上下文)尽量自动化,让开发人员无感知,开发人员只需要赋值即可。还是利用DSL武器,对几个上下文进行描述,然后用代码生成器生成对应的set、get接口;我们在set接口里会自动判断字段是否变化,并进行记录;过程中只需要调用对应字段的isChange接口就能知道该字段是否改变。并且在事务流程结束的时候,自动将变化的字段同步到上下文中。 这个有进一步给开发人员减负,让他们聚焦本质的业务处理逻辑。

6 结束语

整个大的重构历经数年,都是维护过程中发现痛点,然后在合适的实际用合适的解决方案去应对;我们的一个经验是这种大的重构千万不能闭门造车,多到外面看看,你碰到的问题,人家也有。期间最大的困难就是让开发人员接收这个改变,诀窍是在实施重构前充分讨论,达成一直,实施的时候一定要各个主要开发团队选派骨干当作种子进行培养,让他们在团队里去影响他人。