前言
缺席的架构
本文介绍了笔者主导的一个重构项目。将一个迭代了8年的系统进行了整体架构升级,以清理架构中腐化的部分,保障业务迭代的效率。我认为架构是系统的基石,是系统中稳定且不经常变动的部分。是不断生长产品功能的基础,像人的骨架一样起到支撑作用。好的架构是拥抱变化的,是对变化友好的,使变化更容易发生,不容易出错。但是互联网产品的发展有着特殊性,那就是“快”。很多互联网产品都是朝生夕死。在这样的背景下,系统迭代往往追求短平快,快速上线功能,快速验证产品是否适应市场的口味。在追求快的背景下,架构的设计往往是被牺牲掉的。这样带来的坏处就是,不断的累加产品功能导致系统是缺乏架构设计的。缺乏合理架构带来的影响不是即时生效的,它会延迟到某个时间点,逐渐拖慢产品迭代效率,累加系统风险,在某个时间点像地雷一样爆发。在这个时间点,大家才会把注意力放在架构设计上。
问题的滞后性
华佗是东汉末年的著名医生,但华佗却说,他在其兄弟三人中,医术是最低的,他的大哥、二哥医术都更为高超。但其兄长二人皆不及华佗有名,时人问其故。华佗说,其大哥能见微知著,治病于未发;其二哥能从小病中看出大病,因而能提前防治大病,故其兄长二人很少治疗大病,世人皆不知其兄长医术过人。而华佗自己则只能待病症显现,已经成大病时方能发现、治疗。久之,则其大哥、二哥不若华佗有名也。在软件开发中这样的故事也在上演,因为缺乏设计导致软件迭代后期到处救火的人比善于设计的人看起来工作量更多。
业务背景
闲言少叙,书归正文。接下来会介绍一下业务背景和产品功能和升级前的系统架构。
产品介绍
店小蜜是阿里巴巴旗下的官方智能客服产品。服务于数十万淘宝/天猫商家,日均对话轮次数千万。为商家提供全托管的机器人接待和辅助人工客服的人机协同模式,以提高客服的工作效率。
架构介绍
原有系统依赖流程编排和事件驱动。系统对外的核心接口只有一个,但是这个接口内部的功能非常复杂。大致的流程是,理解用户问题->查询商家配置的答案->选取最优答案->生成用户响应。当然这里的流程是最简化的流程,就像把大象装进冰箱只需要三步一样。看起来简单,但是每一步会涉及的很多细节。整个执行流程被拆分为几十个步骤,得益于流程编排框架,可以动态的调整每个步骤的执行顺序。随着业务的迭代,整个流程编排错综复杂,最后到了人类没法维护的地步。在流程编排的基础上又引入事件机制,在核心步骤执行前后发送事件,通过监听事件来扩展功能。引入事件机制只是一个临时补丁,可以让迭代的成本降低,但是并没有解决架构的根本问题。
老架构存在的问题
高内聚低耦合是架构设计的终极目标,而现有系统却是低内聚高耦合的。
1、功能实现低内聚
先看看高内聚的要求。高内聚意味着每个模块功能单一且明确。每个模块只负责一个功能或一组紧密相关的功能。模块内部的实现细节对外界是隐藏的,外部只需知道如何与模块进行交互。模块内的代码修改不容易影响到其他模块。
从产品视角看同一个功能,却被分散得实现在多个模块中。进行一个功能迭代时需要修改多个模块的代码,这种方式很容易出现修改遗漏的问题。举个低内聚的例子。
🌰
用户可以点击卡片与机器人进行交互,而处理用户点击请求的逻辑分散在4个模块中。每个模块分别处理部分场景。第一个模块处理点击请求,解析出问题点击请求中携带的场景和问题信息。第二个模块针对部分点击请求,处理点击请求产生答案。第三个模块针对问题列表点击请求,查询知识库产生答案。第四个模块处理尺码表技能/活动技能的点击请求。4个模块相互独立,拼凑在一起可以处理用户的点击请求。处理点击是一个完整的功能,应该收口在一个组件中。在组件内部可以将不同的点击类型分发给不同的子组件,形成组件的层级关系。
2、模块之间耦合高
看反例之前先看看低耦合的要求。低耦合意味着组件之间相互独立,依赖关系弱。一个模块的变化不会影响到其他模块,修改一个模块的实现不会引起其他模块的变动。模块之间通过明确的接口进行交互,接口之外的实现细节对外透明。可以比较容易地替换或增加新的模块,而不会影响系统的其他部分。
不同功能之间存在强依赖关系。不同业务逻辑之间通过线程上线文共享数据。某一个功能无法单独拎出来复用。
3、组件职责不明确
组件缺乏明确的职责,可以进行任意逻辑的实现。这样就降低了代码的可读性和可维护性。每个组件的入参都是一样的,都是一个上下文,它类似于一个Map的结构。先执行的组件往上下文中写数据,后执行的组件从上下文中取数据。你无法明确的知道上下文中有什么数据,你也不知道当前功能依赖哪些数据。组件职责应该明确体现在出参入参的设计上,从入参上可以知道组件依赖什么数据,从出参上可以知道它能产生什么数据。
🌰
这是目前系统核心组件的方法设计。每个功能的入参都是一样的,本质上是一个Map容器。
public void process(AIEngineContext aiEngineContext) {
//todo
}
4、严重依赖线程上下文
线程上下文破坏了业务组件的职责设计。一个方法入参上声明了A、B、C三个数据,但是代码实现由通过ThreadLocal获取上文存储的D数据。这样带来的坏处如下:一是复用此功能可能会产生副作用风险,导致线程上下文数据污染。二是使用到了魔法数据,让理解业务功能变得困难。
🌰
5、编程模型混乱
因为没有统一的数据获取规范,所以随着人员的更替,系统中存在了众多数据获取方式。导致多种编程模型共存。
🌰
例如、如何获取对话中生效的商品。目前架构中有3种方式。
方式一
Long itemId = aiEngineContext.getUserData("itemId");
方式二
Product product = intention.getProductA();
方式三
Product product = intentionQuery.getClickProductA()
多种编程模型带来了理解成本的增加,同时破坏了数据的一致性。当更新生效商品时很容易出现遗漏的情况,导致当前生效商品不一致。
升级方案
解决复杂领域的软件问题,就需要用到领域驱动设计(DDD)的思想。透过错综复杂的业务知识,抽象出核心领域模型。基于核心领域模型,统一掉运营、产品、技术、测试等不同工种的语言,让大家理解的概念达成统一。
领域概念
在对话机器人领域有如下领域概念。
概念 | 介绍 |
意图理解 | 知道用户要问什么问题,包括他要咨询的商品、订单、用户的情绪等关键信息。有了这些背景信息,才能更好的解决用户的问题。 |
技能路由/DM | 根据意图和背景信息决定当前的用户问题应该由哪个技能来承接。 |
技能执行 | 执行DM决策产生的技能。可以并行也可以串行。 |
主技能 | 产出的结果可以作为独立答案展示给用户。 |
追加技能 | 在主答案的基础上,追加附加信息。例如,营销导购类型的技能。 |
答案排序 | 技能执行之后可能产生多个技能结果。在这个步骤进行技能结果的优先级进行过滤、排序、选择。 |
后处理 | 对话流程的最后一步,可以执行数据更新等操作。 |
技能化
基于上面抽象出的领域概念,可以设计出职责明确的组件。将系统中的业务逻辑分门别类的填充到不同的组件中,然后根据业务规则编排组件执行顺序,最终组成了整个对话流程。
这套架构设计将整个对话流程抽象为了5个步骤。
第一步,了解用户的意图。根据用户关注的商品、订单等基础数据,通过NLP语言模型理解用户想表达的真实诉求。
第二步,基于意图选择技能。基于对话场景和内置的路由规则,选择合适的技能来处理用户问题。
第三步,执行技能。可以采用串行/并行的方式执行技能,尝试生成答案。
第四步,对技能结果进行选择。第三步技能执行之后可能存在多个结果,从多个结果中选取最优结果最为最终答案。
第五步,执行收尾逻辑。进行统计日志的打印,更新会话信息等操作。
新架构带来的变化
约束组件职责
没有任何的约束就会导致混乱,混乱就就意味着不断增加的维护成本高。
新框架严格的按照职责来设计组件,将业务逻辑分散在职责明确的组件中,功能的实现更内聚,职责更明确。框架带来了规范的同时也带来了约束,实现业务逻辑时不会像以前那样想实现在哪就实现在哪了,而要考虑这个业务逻辑实现成现有的哪个组件更合适。现有组件不满足时,就要考虑在框架层新增组件或者调整组件职责。
基础功能下沉
执行统计、异常处理、诊断功能。这些通用的功能对业务层透明,只要实现框架定义的接口,就默认具备以上这些功能。
架构简洁清晰
当两种架构都可以解决问题时,当然要选择简单的架构。简单意味着容易理解,容易修改,不易出错。符合大道至简的设计哲学。
诊断工具
随着系统架构的升级,相应的提供了一套诊断工具。好的系统设计不仅要能实现功能,同时也要具备可观测,易诊断的能力。日常开发中,很大一部分时间都花在了问题排查上。老的架构通过全局共享对象来使多个模块可以做到数据共享。这种方式的一个弊端就是很难轻松的知道某个模块对数据产生了怎样的修改。合理的组件设计可以轻松的做到这一点。通过诊断工具可以查看系统中的执行路径、关键信息、耗时、异常等诊断数据,让整个系统诊断变得更加轻松。
远程技能
新架构支持远程技能。技能的实现可以在不同的应用中,在对话管理系统中注册应用后可以通过rpc的方式融合到新架构。
卡片协议
新架构同时提供了自己的卡片协议。老架构中通过大而全的单体对象表示机器人响应结果。因为缺乏抽象,不同功能的数据简单平铺在对象中,导致对象字段迅速膨胀,含义模糊,逐渐腐化。卡片协议内置了常见的展示类型,技能不仅要返回业务结果,还负责将业务结果转换为期望的展示形式。适配层负责将卡片协议转换成端相关的展示形式。
总结
本次架构升级本质上是一个重构的过程。保证业务无损的情况下,顺利完成切换,期间未发生故障。回头想一下,要完成迭代了8年之久祖传应用的架构升级其实是非常具有挑战性的一件事。毕竟系统中遗留了众多祖传代码,稍有不慎就会导致功能不可用。