如何让老系统变得更有活力?去哪儿对SPA系统重构的思考

1.背景

对于去哪儿平台而言,酒店业务主要是通过整合不同货源,对客提供优质低价酒店。而我们本次提到的 SPA 系统(全称 supplier-product-adapter,中文全称为供应商产品报价适配系统),负责接入供应商的酒店信息以及报价信息,为上层应用提供了统一格式的报价数据。其在酒店业务定位中处于一个非常关键的位置。

SPA 系统虽然是比较成熟的业务,但其业务复杂度和系统理解成本,是相当大的,这是因为:

  • SPA 侧接入了20+种不同类型的代理商,每种代理商业务逻辑不同;
  • 15年进行过一次比较大的业务调整,主要是针对 C 的业务进行了大量适配和接入工作,对原有设计方案有比较大的侵入和影响;
  • 19年经历过一次系统的合并,2个系统进行了合并处理,但其实现只是代码的迁移,不管是从代码风格、代码设计、还是功能实现上,都有非常大的差别,合并后的系统存在大量重复业务逻辑,整个链路处理显得更加复杂;
  • 复杂的异步编程带来的代码可读性差,导致查问题的速度慢,也是让开发同学一直诟病的问题。

这些历史原因导致了一系列的问题:

  • 现有系统逻辑复杂,学习维护成本高;
  • 业务价值不清楚,线上的逻辑没人懂,不敢轻易动;
  • 业务逻辑耦合严重,需求开发工时变长;
  • 历史需求导致业务比较复杂,线上工单问题较多; 

我们抽了3个月的工单问题来看,团队共计工单284个,其中 SPA 系统就141个,占比达到了50%。其中大部分还是逻辑咨询和问题排查相关。不管是对产品还是研发,要花费相当大的精力去定位和排查。

image.png

2022年技术中心战略是“巩固效率之本,分担产品之忧”。针对上面提到的问题,我们有必要去对系统进行优化和改进,降低业务复杂度,以更快更准确地响应产品的策略迭代。因此,对系统的重构工作成为了迫在眉睫的事情。

2. 复杂度分析

SPA 系统适配了20+种类型的代理商,从标准代理商到各集团代理商,报价的结构和处理逻辑都不同,在SPA内部对各代理商的差异进行了处理,以提供统一的标准的报价结构供上层应用调用,而当后期两个系统合并后,两个系统的逻辑冗杂到一起,造成复杂度飙升;在访问量上,SPA 是一个高 QPS 的系统,日均 QPS 也能达到60K 以上,节假日超 10W+,SPA 通过支持异步,以保证高 QPS 的访问。因此 SPA 系统是一个业务复杂的高并发系统。在业务和技术实现层面,都有着一些亟需解决的问题。

2.1 业务的复杂度

image.png

整个链路中集中处理的 processor,就共计70个,这还不包括分散到各代理商业务内部的处理逻辑。而且部分逻辑存在重复处理和耦合计算,或者是非通用逻辑,或者是已下线业务。这些都造成了业务边界的不清晰,以及处理的复杂性大大提高,代码变成了一个”大泥潭”。同时,这些重复处理的逻辑,在一定程度上也降低了系统的性能。

下图是一个常见的业务逻辑,分散在系统各处调用的例子。

image.png

上述场景,是系统内部对于佣金的处理逻辑,如果要改动佣金的一个小功能,比如赋值逻辑,其中每个流程都可能会涉及改动,依赖耦合严重。不熟悉该场景的人,根本不确定改动后的影响范围是什么。

由此可见,如果系统的业务是不清晰的,那么不管是 PD 数,还是影响范围,都是不可控的。

业务的耦合,不只是开发效率的降低,在风险无法预知的前提下,对系统稳定性而言,也是一个巨大的隐患。

2.2 技术的复杂度

image.png

上述代码,是其中一个环节的实现,整个业务链路中,都是靠 Consumer 传递,完成相关业务逻辑。

管中窥豹,通过一层层的回调完成的逻辑,代码解释性很差,理解成本高。

而且在整个异步流程里,还存在部分同步逻辑,实际上拉低了整体的处理性能。

整体业务链路复杂冗余,加上复杂的异步编程实现,很显然,在这种情况下,我们很难说清楚,我们在做什么,我们的重心在哪儿,更不用提如何去更好的维护和运维了。

3. 解决方案

上面提到的问题,其实从结果上看,带来的都是效率的降低和系统的不稳定性。

其中效率的降低,不仅是工时长、工单问题多,也是系统处理链路冗长造成的响应时间长。从定义测量的角度看,我们做这件事的目的,其实有2个:

  • 主要目的是为了降低业务复杂度,更加明确 SPA 的定位和核心诉求,提升系统可维护性;
  • 次要目的是优化代码实现,精简无效链路,提高代码可读性。

3.1 破局之剑——DDD

DDD 是一套完整而系统的设计方法,通过从战略设计到战术设计的标准设计过程,使得你的设计思路能够更加清晰,设计过程更加规范。其次,DDD 善于处理与领域相关的拥有高复杂度业务的产品开发,通过 DDD 来建立一个核心且稳定的领域模型,更加有利于领域知识的传递与传承。对于我们而言,我们遇到的最大的问题是,业务混杂不清晰,不管是产品、商务、技术,对业务的认知不一致,给后续的功能开发,带来了大量的不确定性,包括但不限于:对业务的理解不一致,对风险评估不足,工时变长等等。

DDD 可以有效解决业务复杂度和软件复杂度问题的这种特点,让我们最终决定,选择 DDD 作为我们项目落地的指导原则。

DDD如何控制软件复杂度

  • 规模: 通过分而治之拆分领域, 然后根据业务能力, 划分出不同的独立自治的限界上下文.分而治之的过程首先是自顶向下持续分解的过程, 然后又是自底向上进行整合的过程.
  • 结构: 需求分为业务需求和质量需求, 通过分层架构隔离业务复杂度和技术复杂度. 理想情况下, 我们应该保证业务规则与技术实现是正交的, 避免业务复杂度和技术复杂度相互影响.
  • 变化. 领域模型不仅封装了领域知识, 而且还进行了适当的抽象来提高拓展性. 至于抽象是否合理应该结合业务进行分析, 需要对业务的发展有一定的洞察, 才能设计出拓展性较强的领域模型.

3.2 领域划分——核心域的建设

image.png

DDD 的核心思想就是,问题域到分析模型到代码模型要保持一致。要切分问题域,首先需要了解问题域的种类:

  1. 通用域: 非应用独有的,多个应用都会有的功能。例如发送邮件,触达等
  2. 核心域:和竞争对手区别开来的区域,或者是在市场上被赋予了竞争优势的区域。
  3. 支撑子域:其余的区域

在 DDD 中,Eric Evans 提倡出一种叫做知识消化(Knowledge Crunching)的方法帮助我们去提炼领域模型。简单来说就是五个步骤:

  • 关联模型与软件实现;

  • 基于模型提取统一语言;

  • 开发富含知识的模型;

  • 精炼模型;

  • 头脑风暴与试验。

对于我们而言,最重要的就是要先找到我们的核心域是什么。如何做到这一点?我们主要通过2步去实现:

  • 通过事件风暴盘点业务资产,梳理现有业务流程,识别业务能力;
  • 通过问题聚焦,提炼关键节点,划定核心业务和非核心业务。

前期我们投入了大量的精力去梳理业务,以及和产运沟通,对模型和业务域不断进行提炼。我们提炼出领域知识,重新梳理业务流程,并形成通用语言(可以包括显示的业务规则、领域名词解释等,形成统一的思维地图),达成共识。

SPA 系统涵盖代理商酒店、房型、以及活动规则、报价等多个问题域,按照分而治之的原则,通过划分核心域、通用域及支撑域,确定业务领域边界,抓住本次重构痛点去进行改进,比如我们本次主要针对核心报价链路进行改进,需要在划分好的领域上去定义新的模型以适应当前系统的演进。

image.png

下图为新的报价处理模型,按照领域边界,对报价流程进行了分阶段拆分。

image.png

  • 代理商内部处理:代理商域的能力在于将代理商侧信息和Q侧数据进行关联,对后续处理屏蔽了代理商维度的差异;

  • 通用报价处理:负责核心报价处理,对代理商返回报价通过相应规则去过滤、聚合、报价处理等;

3.3 架构设计

3.3.1 适配器模型

DDD 中有很多架构设计,不管是洋葱架构、整洁架构,还是六边形架构,其主要目的都是通过“高内聚,低耦合”的方式,以领域为核心,完成业务模型和业务能力建设。那么切回到业务本身,为了理解方便,其实可以将上面提到的设计思想进行简化理解,不管是哪一种架构设计,其实都是围绕能力建设展开的,而能力包括2种:

  • 作为被调用方,承接上游系统的调用;
  • 作为发起方,负责对下游系统的输出

以 SPA 为例,其主要的能力有2个:

  • 作为被调用方,负责按照代理商+酒店+日期维度,提供对外各代理商报价查询能力;
  • 作为发起方,负责适配各代理商的报价获取流程,以及接入各代理商酒店/报价数据,提供不同代理商报价信息接入的能力

而我们的核心价值,就在于从代理商处拿到的报价,经过Q侧自己的核心报价处理,以规范的格式对外提供报价查询服务。这里的核心报价处理,就是 SPA 的核心业务价值。

在架构设计上,我们并没有照搬现有的架构设计,不管是洋葱架构、整洁架构,还是六边形架构、阿里的 COLA,其本质都是适配器模型。

image.png

一端是 adapter-in,另一边是 adpater-out,分别对应系统能力的输入输出。每一端的触发形式都可以是任意的,比如 http 接口调用、dubbo 服务、MQ 消息驱动等。

只要抓住一个点,保持防腐,保持模型内部高度内聚和完整,就可以灵活应对,选择哪一种具体的架构都可以,甚至可以综合考虑,不需要拘泥于某一种特定的架构。

而对于我们的核心能力而言,更趋向于独立链路调用。因此我们在重构整个报价获取时,主要以适配器模型去拆分业务阶段。这样做有两点好处:

  1. 每个业务阶段的输入输出都是确定的,而针对多种代理商的接入差异,我们聚焦到了其中的代理商报价获取阶段中,屏蔽了上下游其他阶段的差异;
  2. 代理商报价查询时,由于不同代理商的请求方式和报价内容都不同,比如请求方式就有 webservice 服务、http-get 接口、http-post 接口、DB 查询等,我们对 request 和 response 做了一层 Access 行为抽象,具体的代理商采用自定义的适配器模型解析,剥离了技术实现的差异,使得整个代理商报价请求链路更为清晰。

3.3.2 共享内核

在重构前的系统里,为了适配所有的报价请求,定义了一个大而全的 query 对象来承载全部的代理商参数,query 对象贯穿整个调用链路。让我们来看看,这么处理的后果是什么?

  1. 无法明确每种报价请求的具体入参是什么,比如 wrapper 报价和 order 报价的参数,到底有什么不同?比如不同代理商之间到底哪些参数是共用的,哪些是独有的?
  2. 参数贯穿全流程,而 query 对象在每次请求中都应该是一次构建即不可变,但实际中为了兼容,会在各代理商内部做重新赋值操作,这就造成了严重的业务耦合出现

前面几步中,我们确定了领域能力,并对链路进行业务阶段的划分,那么对于每个阶段的输入输出,我们通过防腐层,对各类报价的参数进行了隔离,杜绝了内部外依赖的耦合性,后续的模型自治及发展可以更好的进行,而无需考虑对上下游的依赖。

对于入参信息,我们隔离后设置为属性不可变,保证所见即所得,避免中间业务逻辑进行修改。

在领域内部,为了保持模型的完整性,我们采用了共享内核的模式去构建领域对象。wrapper、order 等报价,内部都采用统一内核完成业务生命周期的传递。

这样做的好处是,通过共享内核,提供了统一的全局视图,使得我们屏蔽了代理商和报价类型的差异,将精力聚焦在各业务关键节点即可。

如下图所示,context 作为共享内核,负责统一处理后续的业务逻辑。

image.png

但是这里有个需要注意的点是,如果我们一股脑把数据都丢到 context 中而不做限制,在系统的演进过程中就会变成一个大泥球,失去了原本设计的理念。对此,我们的解决方案是,对共享内核 context 中数据进行业务分类定义,主要包含3部分:

  • 动态报价参数:例如 入离日期、进订时的房量等,这部分依赖于实时报价请求,属于动态报价参数;

  • 报价静态数据:每次的报价请求虽然不同,但一些基础数据如代理商信息和酒店信息,这部分相对报价请求而言,属于静态数据;

  • 代理商报价及过滤信息:报价和过滤原因其实就是 SPA 的核心处理,依赖报价请求和代理商返回

整体 context 的设计思想就是包含上面3部分,context 对外只暴露 请求参数的修改能力,其他内部封闭,都交由系统内部治理。

后续业务迭代,要确认符合模型规范才可以修改,从而控制 context 的腐化。

3.4 技术实现——spring web flux

3.4.1 技术选型

之前的异步方案,最痛苦的一点是可读性太差,一个 consumer 可能嵌套了六七层,当看到最终的处理逻辑后,再反向一层层去追溯调用链路,导致业务逻辑被淹没在代码细节中。因此我们在选择技术实现时,有着很明确的诉求:

  • 支持异步(更好的支撑系统高并发)

  • 可读性高(聚焦业务流程,减少技术细节干扰)

  • 稳定性(最好是Q侧有落地经验)

  • 支持复杂业务编排(应对复杂多样的代理商业务)

经过对市面上常见的几种异步框架进行了对比。如下所示,最终我们选定了 Spring WebFlux。

image.png

3.4.2 Spring WebFlux 的落地使用

Spring WebFlux 作为响应式编程模型的代表,遵循 Reactive Stream 标准,其不同于常规编程的地方在于,响应式编程是先声明执行过程,真正有消息或者请求触发时,才会真正执行对应业务逻辑。

这里的声明执行过程,其实就是常提到的 webflux 的服务编排能力。

Reactive Stream 的设计采用了观察者模式,主要包括几个重要角色,分别是:Publisher、Subscription、Subscriber。体现在 Spring WebFlux 的实现中,涉及两个主要的类:

  • Flux,同样实现了 org.reactivestreams.Publisher 接口,代表0到 N 个元素的发布者(Publisher)。

image.png

  • Mono,实现了 org.reactivestreams.Publisher 接口,代表0到1个元素的发布者(Publisher)

image.png

具体解释可以参考官网的reactor-core实现:github.com/reactor/rea…

响应式编程,其实可以理解为push,而不是pull,上游数据驱动触发整个执行过程。对应我们业务请求,则总体的编排流程设计如下:

image.png

上下文:就是上面提到的共享内核的概念,承载了我们每一次请求的全链路信息,由于每次请求都是创建的不同的上下文对象,因此线程间是安全的;

check-Action:数据检查机制,主要对上游链路中的数据进行校验,可以做到业务上的快速失败机制,比如对对入离时间的校验,对代理商状态的校验;

baseData & Access:主要目的是获取代理商原始报价数据,由于不同代理商之间的报价获取方式和参数构建逻辑都不同,对此我们提供了统一的 Access 机制对行为进行了抽象;

supplier-chain:代理商个性化逻辑处理,主要包括代理商报价结构转换、内置规则处理、默认属性赋值等

core-chain:这是我们的核心逻辑,由之前零散的70+的 processor,按照业务进行聚焦分类,剔除部分下线业务和代理商个性化业务,目前核心的聚焦 processor 已精简到26个,当然每个 processor 内部又会细分为 filter 和 handler,对外提供统一的单一业务处理功能。

以上就是我们针对整个报价流程的编排设计。但是在实际编码的过程中,我们仍发现了很多需要注意的点。

1. 线程池的切换

从流程设计上,可以看出,我们在报价上下文构建完成之后,通过 subscribeOn() 切换到了另一个线程池,首先这里需要明确的是,线程池的定义并不是随机的,我们按照代理商类型维度创建的多个线程池(类型是有限的,而具体的 supplierId 数量则是不确定的)。之所以切换线程池,是为了保证后续的请求和处理都在各类代理商独有的线程上执行,代理商之间是资源隔离的。

2. 异常统一管理

在以前,我们的异常处理,主要是通过 try-catch,和 Spring 提供的@ControllerAdvice,对系统中的异常进行处理。

而 spring-webflux 除了编排功能外,提供了更优雅的异常处理方式,即 doOnError() + onErrorReturn + onErrorResume.

针对不同场景,我们可以灵活利用这3个方法去完成,比如针对异常的日志或监控,我们通过 doOnError()进行处理,对于异常的默认值,或者异常后的处理逻辑,可以用 onErrorReturn 和 onErrorResume 完成。更友好的编程方式和灵活的异常处理机制,帮助我们可以进行更好的异常管理。

下面两张图是我们某个子环境中业务异常的大类监控和其中一个异常的详情,其实现原理就是通过 doOnError() 的形式在各业务阶段进行的监控埋点。

image.png

image.png

3. 二级缓存的编排构建

一般来说,我们为了降低数据的频繁访问,通过对数据进行缓存,来提升系统的吞吐。常见的有 JVM → Redis → DB 的缓存构建。这个可以通过 spring-webflux 提供的 switchIfEmpty()和 onErrorResume() 优雅的实现。

4. 业务并行度的提升

spring webflux 提供的是全流程异步化,但是如果不做调整,实际上整体的响应时间是不会变的,只是提升了吞吐量。

为了更好的利用资源,我们通过 Mono.zip()对可以并行化的子流程进行了并行处理,举个例子,在某代理商的报价查询时,查询参数的构建 依赖其酒店信息和房型信息中的数据,我们使用 Mono.zip()进行子流程的并行处理,如下图所示:

image.png

5. 重构的注意事项

5.1 重构原则——抓住主要矛盾

重构过程中,要抓住重构的目标是什么,即我们的主要矛盾是什么。这个原则要贯穿整个链路过程中,不然很容易让重构的内容发散。

以 SPA 重构为例,重构前 SPA 在主流程中提供的报价能力(LD 页和 B 页的报价查询)链路是非常复杂,逻辑不清晰,导致业务价值难以提取和界定,我们的重构目的是要优化报价查询链路,明确各报价接口的业务边界,以及内部的领域能力划分,降低产品、运营、研发的理解成本。

除此之外,对于 SPA 提供的 代理商酒店接入能力,实际上也存在可以改进的地方,但是它和我们的目标是不相关的。那么我们有必要做或者说有必要在本次重构中改动这块逻辑吗?

我们给出的答案是:非核心目标不动,放到二期去做。

这么做的好处是什么?

目标始终明确,投入人员的工作内容就不会发散,因为一旦发散,整个问题范围就会变大,从而项目投入增加,项目周期也会拉长。这种没有边际的问题,相信没有人能够解决。

所以,要循序渐进,对问题进行拆解,分阶段完成。在每个阶段里解决主要矛盾,集中精力做最关键的事。

从上面这个例子能够看出,一个项目中,尤其是比较大的项目中,我们在落地实践之前,一定要对系统的边界确定好,划定问题范围,确认子节点的优先级。

  • 我们的问题是什么?找到主要问题点
  • 我们可以做什么,列出待办项
  • 我们应该怎么做?列出优先级,分期实现

5.2 技术负责人——抓大放小

作为技术负责人,要懂得取舍,抓大放小,敢于授权。这一点在复杂的大型的项目中尤为突出。因为复杂,所以整个项目周期会比较长,中间产生的各种各样的问题也会比较多,在项目管理过程中,对于进度和风险的把控就成了决定项目是否成功的关键性因素。

这里提出的“抓大放小,敢于授权”,其主要思想就是,技术负责人要把更多的精力投入到 资源的调配、团队成员的问题解决(比如技术难点、工作压力等)、项目进度的把控等方面,而不是集中精力在底层业务的开发细节上。

5.3 指导方法论的灵活应用

DDD 只是一种指导方法论,指导方法论很重要,这一点毋庸置疑。但完全照搬前人的设计和方案并不一定能给你带来同样的成功。适合自己的才是最好的,不一定完全按照推荐的设计去实现。

比如我们在重构中,利用 DDD 进行领域建模和边界划分,但是在落地时并没有完全照搬六边形、洋葱架构等现成的设计,而是结合我们业务特点,还是使用的 MVC 模式,但是对业务逻辑层,按照业务边界对报价链路进行了阶段划分。

事件风暴,要有决策者,不能发散。而且不要奢望一两次沟通就可以完成,而是要多次碰撞,不断演进,才能定出来模型和阶段,我们前后多达20+次的会议沟通,才定下来最终的模型设计和业务边界。

对结果,我们并不能保证是最正确的,但可以保证是团队内部一致认可的,最合适的划分,贴近我们的业务,这才是合理的结果。

通过 DDD,我们能暴露出很多需要解决的问题,标题很大,但无法落地的经验和设计就是假大空。所以在落地的过程中,就要分业务域划定具体动作。

举个例子,要提升系统稳定性。这个标题太大了,可以展开的点很多,这样发散下来,根本就没办法落地,因此要对当前存在的问题进行拆解,去量化对应的方案,然后才能解决。

6. 效果总结

1、 复杂度降低,通用报价处理由原来的34种处理业务,70+处理器,精简为26个核心业务,核心链路精简26%;接口对外有170个字段,字段去除了38个废弃字段,整体响应时间下降20 ms;

image.png

2、 业务边界清晰,以前的逻辑混杂使用导致的边界问题,现在通过阶段划分,对业务进行边界定义,降低了系统复杂度;

3、 重构前后的圈复杂度 v(G),由原来的64056降低到33674,提高了代码可读性;

4、 需求工时平均下降14%,工单问题数量下降33%,极大提高了系统运维能力

7. 下一步规划

1、 我们目前按照代理商不同,以及整个报价链路中的业务处理类型做了阶段划分,但是具体到每个代理商内部的业务逻辑,并没有进行合理的编排,后面会针对这部分继续进行改进;

2、非报价流程,如酒店、房型接入等,会继续进行优化。

以上就是本次分享的所有内容,最后为大家带来一个内推信息,欢迎优秀的你加入驼厂~

【内推链接】:app.mokahr.com/recommendat…

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYhUVcoZ' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片