所有文章 > 正文

万字长文!Go 后台项目架构思考与重构

作者: 黄雷

时间: 2020-04-26 15:14

一个好的架构,其终极目标应当是,用最小的人力成本满足构建和维护该系统的需求。

引言

本文首先介绍了架构的重要性,随后从一个实际项目的重构过程作为主线,逐步引出主流的架构设计思想以及其所解决的实际问题是什么。通过阅读本文,你将学习到:

  • 架构的重要性;
  • 重构的几种模式;
  • 设计原则;
  • DDD 中领域思想;
  • 项目的可测试性;
  • 项目的可演进性。

实践背景介绍

本文涉及的项目主要用于腾讯云团队 K8s 集群管理的项目,其核心业务包括创建、升级、删除集群和节点、集群监控、巡检等。

旧工程简介

Dashboard 是该项目最早的版本,主要包含 API 请求处理和异步流程执行等核心功能,是团队最早的核心模块之一。但是随着功能不断增加,Dashboard 早期不合理的架构设计所导致的可读性差、扩展性差,无法单测等问题逐渐暴露出来且愈发严重。为了让 Dashboard 的质量往更好的方向改进,团队决定对其进行重构。

新工程简介

考虑到直接重写的代价和风险过大,团队决定采用「修缮者」策略,即重创一个工程,承载 Dashboard 新需求的实现,并逐步将旧功能迁移到新工程中,最终达到重写 Dashboard 的效果,Skipper 就是这个新工程。在迁移过程中,团队对 Skipper 的架构设计经过了几次调整,逐步解决了 Dashboard 中存在的问题,最终得到一个较为合理的架构,本文记录了重构过程中的思考,和架构演变的过程。

架构的重要性

架构的目标

追求好架构的目的到底是什么呢?或者说,我们期望一个好的架构产生什么价值呢?

一个好的架构,其终极目标应当是,用最小的人力成本满足构建和维护该系统的需求。

也就是说,好的架构目标应当是降低人力成本,这里包括的不仅仅是开发成本,还有构建运维成本。而增加软件可变性就是架构达到最终目标的核心途径,即架构主要是通过增加软件的可变性来降低人力成本,毕竟,捏橡皮泥比你在石头上雕刻要轻松得多。

行为和架构哪个重要?

一个软件的行为固然是很重要的,因为一个不能按预定行为工作的软件是不产生价值的,所以很多程序员认为能实现软件行为是最重要的,根本不该关心架构,反正坏的架构也不是实现不了行为,出了 bug 修复即可。我认为,他们忽略的是随着软件行为的改动,坏的架构将导致他们自己的工作越来越难以进行,改动的代码越来越大,bug 越来越多,项目最终可能不可维护。

一个软件的架构虽然不直接表现在行为上,但其最大的特点就是良好的可变性,即使目前行为不符合预期,也能通过低成本的改动将行为改变到预期。

可运行不可变软件,最终会因为无法改变而导致行为无法迭代或者迭代慢而变成没有价值。可变不可运行的软件,可通过迭代,变成可运行可变软件,所以架构比行为重要。

恶魔小时候也可爱

一个不太好的架构,在项目初期有时难以察觉,因为此时项目模块少,功能少,依赖关系显而易见,一切显得毫无恶意,甚至有点简洁美。但是,恶魔小时候往往也很可爱。随着项目的增长,模块增加了,开发人员变多了,恶魔长大了。架构带来的问题逐渐暴露了出来,混乱的层次关系,毫无章法的依赖关系,模块权责不清等问题接踵而至。

对开发人员而言,项目理解成本不断增加,添加小功能都要先理清好几个模块的调用关系,难以测试导致上线后 bug 防不胜防,组件无法复用。项目逐渐长成大家闻风丧胆,避而不及的“大恶魔”。

虽然我们也反对过度设计,但是识别,或者说猜测项目未来符合逻辑的可能变动,将架构设计考虑进项目早期是十分有必要的,架构设计和调整应该贯穿项目的整个成长过程。

识别过度设计

架构设计是为了让未来的修改更加容易,但是未来谁又能完全预测准确呢,架构设计或多或少有一定猜测成分在里面,但是更多的是吸取 IT 行业几十年发展过程中前辈们的经验以及对业务特点的了解所作出的符合一定逻辑的猜测。

那什么算过度设计呢?从架构的目的是降低人力来看,就是该设计目前没有任何强有力的逻辑能推出能在未来降低修改某种行为的人力成本,或者降低某种行为修改成本的同时,大大增加了另外一种行为的修改成本。

架构的理解成本

架构是有一定理解成本的,甚至架构设计之初会增加一定的系统理解成本,但是一个好的架构理解成本一定不会很高,因为架构的理解也是人力成本。在理解架构设计的意图之前,因为其增加系统的理解成本而否定它的必要性是不合逻辑的。

好的架构,其关键意义在于降低项目发展过程中整体理解成本。

也就是说,架构良好的项目随着业务复杂度增加,项目理解成本增长也是缓慢的。架构不合理的项目随着业务复杂度的增加,整体理解成本可能是指数增长的。

202004260501.jpg
架构调整需要勇气

一旦你宣布进行项目架构调整,就是宣告现有项目架构不合理,也意味着他人将设计出比当前优秀的架构,这是一件非常需要勇气的事。因为调整的过程中,你会犯错,你需要进行一些猜测,你会和他人产生观点冲突,你有时甚至需要有点固执和执着。

因为架构投资的是未来,但大部分人只着眼于当下。

重构方式

拆迁者模式

根据当前业务的需求对软件架构重新设计,并组织单独的团队,重新开发一个全新的版本,一次性完全替代原有的遗留系统。

为什么不适合我们?主要有如下几项因素:

  • 人力消耗巨大,需要一边加新需求一边重写旧需求;
  • 无法确保新的工程的设计比旧的好;
  • 重写过程中可能出现业务遗漏。
绞杀者模式

保持原来的系统不变,当需要开发新功能时,重新开发一个服务,实现新功能,通过不断构建新的服务,逐步使遗留系统失效,最终替换它。

绞杀者模式相对比较适合我们的重构需求,但是存在以下问题:

  • 不希望存在多个服务共存的问题;
  • 希望共享旧工程的 CICD,运维,监控等能力;
  • 重构颗粒度过大,我们希望细到函数级别的重构。
修缮者模式

将遗留系统的部分功能与其余部分隔离,以新的架构进行单独改造。

修缮者模式特别适合我们的需求。

Dashboard 的架构

整体架构

Dashboard 核心功能分为两大块,一个是作为 Web API Server,接收 HTTP 请求,另外一个是异步流程处理,用于耗时较长的功能,比如创建集群、集群升级等。

Dashboard 整体采用 MVC 架构 + Controller 模式,这里的 Controller 模式是指通过不断重试,最终将目标对象设置到某种目标状态的模式,比如通过不断重试,将创建中的集群的各部分属性或者依赖的资源,设置到正常集群的状态。Dashboard 的核心模块如图。

202004260502.jpg
  • MVC Controller:用于接收 HTTP 请求,并调用 Service 进行业务处理;
  • MVC Service:核心业务逻辑全部落在这一层;
  • MVC DAO:DB 相关操作都在这一层;
  • MVC Models: 包含各个对象的字段,比如集群、节点等;
  • Controller 模式下的各个 Controller:每个 Controller 逻辑差异很大,但是都是调用 Service 进行对象状态的初始化或者设置;
  • Components:调用外部服务的模块都在这里,比如调用计算资源服务创建虚拟机、调用网络资源服务设置网络等。

Dashboard 虽然有水平分层,但是每一层内部没有组件的设计原则,也没有代码规范,每一层基本都是单一一个包,包内代码质量不高,重复代码较多。

具体实现

Dashboard 的工程目录如下所示:

202004260503.jpg

每一层一个包

这样看来,Dashboard 的分层好像还挺清晰的。确实,相对于没有分层,Dashboard 采用 MVC 架构进行分层本身是有一定合理性的。但是在具体实施的时候,却出现了很多问题,其中较为严重的是每一层只有一个包。

比如 Controller 包中,所有请求,无论哪个业务模块的,全部放一起,根本无法区分哪些是集群相关的,哪些是监控相关的,哪些是节点相关的,哪些是网络相关的。

如果说 Controller 包一个文件一个请求还可以理解,那 Service 层整个只有一个包,不分模块,而且全是全局函数可维护性就很差了,由于核心业务逻辑全在 Service 层,Service 的代码量是所有层中最多的,随着功能的增长,未来 Service 将越来越臃肿。

其它层,如 DAO,甚至 Component 也是一个包。

依赖关系混乱

Dashboard 没有关注各个模块之间的依赖关系,只要不产生循环依赖就可以随意依赖别的模块,所以模块之间依赖十分混乱。这直接导致模块难以复用,例如 Component 包中部分代码依赖 DAO,依赖 Config,而 DAO 和 Config 又强依赖了配置文件和 DB。这导致如果要复用 Component 包开发一个很简单的工具,都需要给工具准备 Dashboard 配置文件,甚至需要能连上 DB。

各层之间权责不明

Dashboard 虽然进行了分层,但是各层的权责并没有严格实施,导致 MVC controller 层和 dao 层也包含了大量业务逻辑,甚至有大量与 service 层重复的业务逻辑。

每层内部没有设计

Dashboard 只划分了水平分层,但是对每一层内部,以及各层之间的通信方式没有做出规定,各层内部可以随意暴露公共函数。各层之间也是直接进行函数调用。

Dashboard 的架构导致了哪些问题?

上一节介绍了 Dashboard 架构的基本情况,这节更详细的介绍在 Dashboard 的架构下所衍生出的具体问题,这些问题便是 Skipper v1 着重需要解决的。

贫血模型导致 DAO 层臃肿

MVC Models 层中的对象只有数值,没有方法,所有对象的业务逻辑,无论轻重,都在其他层,这种模型称为贫血模型。相对的,如果对象不仅包含数值,还包含基本的方法,例如自身生命周期设置,版本设置等等,就称为充血模型。Dashboard 是贫血模型,这导致 DAO 层比预期的要厚的多,因为包含了大量业务逻辑,比如设置默认字段,判断字段是否是有效值等等,这些本应该是对象自身才知道的业务逻辑。厚重的 DAO 层会导致 DAO 层难以通过 Interface 进行抽象,想换一种存储简直是不可能的任务。

无法单测

上文提到,Dashboard 中依赖关系十分混乱,而且一层只有一个包,这导致想进行单元测试是不可能的,因为对一个简单的函数单测,你可能需要直接连 DB,哪怕你函数里根本不查 DB。Dashboard 中各层之间是直接调用全局函数的,并没有通过 Interface 进行隔离,这就导致想进行单测就必须通过 Monkey 来进行全局函数打桩,不仅无法并发单测,还对体系结构有要求,因为 Monkey 只支持 AMD64 体系结构。

模块划分不清

dashboard 只进行了水平分层,但是同层没有分模块,这导致:

(1)想复用模块功能但是不知道对应的函数是哪个;

(2)添加新功能不知道应该把代码写在哪。

Controller 模式能力不足

Dashboard 使用 Controller 模式进行异步操作,但是 Controller 模式在持久化和异步流程控制上能力较为薄弱。

(1)流程无法暂停,无法取消;

(2)流程参数和进度没地方存储等.

Skipper 架构 v1

整体架构

基于 Dashboard 存在的问题,我们设计了 Skipper 项目架构的 v1 版本,这个版本依然使用 MVC 分层,但是针对 Dashboard 的问题,重点关注了外部依赖接口化、DB 依赖接口化、充血模型、Task 异步流程、模块划分等。Dashboard 到 Skipper v1 的架构变动如下图。

202004260504.jpg

外部依赖接口化

在 Skipper 中,对外部服务的调用(Component)都用 Interface 进行抽象,任何模块都不直接使用 Component 的具体实现,这解耦了业务逻辑和外部服务,Component 提供 fake 版本用于单元测试。

202004260505.jpg

充血模型

在 Skipper 中,Models 层只会被 core obj 层和 store interface 所引用,所有其它模块都直接使用包含充血模型的 core obj 层。在 core obj 中,每个对象都是充血模型的,其不仅包含一个或多个对象数据,还包含一些业务方法,比如将对象设置为升级状态,比如将对象生命周期改为 deleting 等等,也就是说,原来处于 dao 中的业务逻辑被上升到 core obj 中,使得 DAO 层薄到只有最基本的 CRUD 操作,这对后面 DB 依赖接口化有巨大帮助。

202004260506.jpg

202004260507.jpg

DB 依赖接口化

由于使用了充血模型,存储层只有最基本的 CRUD,我们很方便得加入了 store interface 来解耦系统和具体存储,store 层还提供基于 gorm 的具体实现,以及 fake 版本的实现用于单元测试。

202004260508.jpg

异步流程

为了解决 Controller 模式存在的问题,Skipper 开发一个 Task 异步流程执行框架,用于执行一次性的异步流程,但依旧保留 Controller 模式的存在,其中 Task Controller 是 Task 异步流程框架的引擎。

(1)Controller 模式用于需要一直运行的全局性旁路,比如节点状态监控,Task 执行监控等;

(2)Task 模式用于复杂的一次性流程,比如升级一个节点,升级一个集群等等。

202004260509.jpg

Service 分包

Skipper 中也有 Service 层,和 Dashboard 不同的是,Skipper 的 Service 会根据业务模块进行分包,比如一个包专门处理集群升级,一个包专门处理监控组件,一个包专门处理巡检等。

Skipper 的 Service 层依旧使用了全局函数,没有进行封装,我们后续将提到,这是 Skipper v1 版本存在的一个问题。

202004260510.jpg

可测试

由于外部服务以及 DB 都可以用 fake 的了,Service 层的代码是可以进行单测的。

为什么相对 Dashboard 可以降低人力

案例:节点升级

这里以节点升级功能为例,介绍为什么 Skipper v1 相对 Dashboard 能降低人力。

功能简介:节点升级功能是指将一批 k8s 节点上的组件版本从低版本升级至高版本,这是一个比较耗时的流程,所以不能在同步请求中直接完成,需要异步执行,且需要展示升级进度。由于节点升级是高危操作,一批节点升级过程中,需要支持用户随时暂停,取消升级。

Dashboard 中开发过程:如果该功能在 Dashboard 中实现,大概需要以下流程。

202004260511.jpg
  • 考虑节点升级请求参数比较复杂,没法存在现有表中,需要新建一个表用于存储节点升级的参数和进度。
  • 编写对应的 Models。
  • 编写专门用于上述表的 DAO 层代码。
  • 编写一个 Controller 异步流程,要为该 Controller 专门实现暂停,取消等控制机制。
  • 编写专门的旁路进行监控告警。
  • Service 中实现节点升级核心流程。
  • 由于无法单测,觉得写得差不多了,需要等待测试环境空闲时,部署到测试环境进行调试。注意,测试环境是公共的,别人可能也需要用。

Skipper 中开发过程:如果该功能在 Skipper 中实现,将基于 Task 异步流程实现,大概需要以下流程:

202004260512.jpg
  • 由于 Task 框架已经提供了参数,进度的存储,以及 Task 相关的 DAO 代码,所以不需要创建任何新的 DB 表;
  • 由于 Task 已经实现了统一的暂停,取消等任务控制机制,不需要编写相关代码;
  • 创建一个 Task Handler,实现节点升级;
  • Task 有统一的监控,无需重复编写;
  • 由于 Skipper 是可单测的,在部署到测试环境之前,我们通过单元测试快速调通了核心逻辑;
  • 部署到测试环境进行集成测试,这时候 Bug 已经很少了。
Skipper v1 存在问题

虽然 Skipper v1 解决了 Dashboard 存在的很多问题,但是其自身依然有很多不足,在新需求开发和旧代码迁移过程中不断暴露出来。

core obj 过度设计

Skipper 为了采用充血模型,在 core obj 中进行了封装,例如 cluster 对象,隐藏了 Dashboard 中的多个 models 结构体,隐藏了某些字段实际是 JSON 字段的,对外暴露出带有方法的 cluster 对象,设计时候考虑了多种集群存在的可能性,所以整个对象对外不是一个实体,而是暴露了一个 Interface。而在实际使用时,发现为了对外暴露对象属性,Interface 中充斥了大量的 Get 的 Set 方法,显得很笨重,而且由于不同类型集群的差异并不体现在 cluster 对象本身,而是 cluster 的业务逻辑中,所以暴露 Interface 并没有达到抽象集群的作用。

全局依赖

skipper v1 认为像 store, component 中的外部组件都是单例的,所以使用了全局依赖。使用全局依赖使得整个工程用的是一个 DB,这样的方式至少存在以下几个弊端:

(1)各模块 DB 是耦合的,无法分开存储,虽然目前所有模块确实共用存储,但是随着模块的成长,模块 DB 独立也是有可能的;

(2)Component 里聚合所有外部服务这使得使用任何一个外部服务,就会依赖于所有外部服务,使用 Component 的地方都需要从全局获取对应的 Component,重复代码较多。

模块不内聚

虽然 Skipper v1 中,各层基本都按功能进行分包了,但是模块并不内聚,一些包之间依赖关系很明显,应该属于一个模块的不同部分,并且由于只使用了水平分层,模块的内部各层代码分散到项目各层中并和其他模块对应层代码耦合在一起。针对某一模块,由于 Service 层依旧使用了全局函数,除非有文档说明,否则无法知道该模块对其它模块暴露了哪些 API,其它模块甚至可以直接读写该模块的 DB。例如集群监控模块,当 1.16 版本的集群升级时,需要更新对应集群的监控配置,Skipper v1 中的实现是在集群升级代码中显示调用更新监控配置的函数,这就使得集群监控开发人员必须理解集群升级的代码并知道在哪里调用更新监控配置的函数,这使得集群生命周期模块和监控模块是耦合的。

202004260513.jpg
进一步探索

为了解决 Skipper v1 中的问题,我们决定重新审视一下设计原则相关的指导。我们比较警惕过度设计,也不喜欢在 Golang 中使用过多设计模式以及层层封装,但是我们相信,设计原则是所有语言通用的,因为设计原则只是一种思考的方向,让你对架构的坏味道更加警觉。

架构设计原则

架构设计原则是软件行业几十年发展总结出的一些具有指导意义的思想,虽然在实践时,很难完全遵循设计原则,但是识别其中违反原则的地方,并控制由于违反原则带来的风险是很有必要的。

SRP:单一职责原则

SRP 是最容易被误解的原则,因为大多数人看到名字,就以为该原则指的是一个模块只做一件事,但其实不是这样的。SRP 较为经典的描述是:

任何一个软件模块都应该有且仅有一个原因被修改。

这里我更喜欢 Robert 大叔在其著作《架构整洁之道》中描述的:

任何一个软件模块都应该只对一类行为者负责。

这里的行为者是指一个或多个有共同需求的人。从我们的实践背景下,集群生命周期模块和监控模块是不同的小团队在维护,而 skipper v1 中,监控模块想支持集群升级时更新配置,却需要改动集群生命周期模块代码,这其实就违反了 SRP。

OCP:开闭原则

OCP 是 Bertrand Meyer 于 1988 年提出的:

设计良好的计算机软件应该易于扩展,同时抗拒修改。

OCP 是我们进行系统架构设计的主导原则,其主要目的是让系统易于扩展,同时限制其每次被修改所影响的范围。实现方式是通过将系统划分为一系列组件,并且将这些组件间的依赖关系按层次结构进行组织,使得高层组件不会因底层组件被修改而受到影响。Skipper v1 中 Task 模式是符合开闭原则的,因为如果要添加一个新的异步流程,只要实现一个新的 Handler 即可,并不需要修改 Task 机制高层代码。

LSP:里氏替换原则

1988 年,Barbara Liskov 在描述如何定义子类型时候写下这样一段话:

这里需要一种可替换性:如果对每一个类型为 T1 的对象 o1,都有类型为 T2 的对象 o2,使得以 T1 定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型。

面向对象语言中有另外一种解释:

所有引用基类的地方必须能透明地使用其子类的对象。

当然,Golang 不是面向对象语言,没有父类,子类的概念,但是里氏原则对于 Interface 的使用有着重要的指导意义,即:

假设存在接口 A 的实现 Aa 和 Ab,使用接口 A 的程序在传入的具体实现由 Aa 改成 Ab 时,行为不发生变化。

在 Skipper v1 中,store 层是符合里氏替换原则的,因为使用 DAO 版本的实现和使用 fake 版本的实现,store 接口使用者行为是不变的。Robert 在《架构整洁之道》给出了一个著名的反面例子,即正方形长方形问题。假设 Class Rectangle 表示长方形。假设 Class Square 集成了 Rectangle 表示正方形。使用 Rectangle 对象的程序并不能用 Square 对象来替换 Rectangle 对象,因为 Rectangle 长宽可以随意设置,但是 Square 却不行。

ISP:接口隔离原则

ISP 的定义十分直观:

客户端不应该依赖它不需要的接口。

在 Skipper v1 中 Store 中定义的接口违反了 ISP,因为该接口包含了所有模块的数据库操作接口,基于 ISP 原则,我们应该让每个模块自己拥有并维护自己单独的 Store 接口。

DIP:依赖反转原则

DIP 主要指导我们系统各层的依赖关系:

高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节;细节应该依赖抽象。

从具体实现而言,如果想设计一个灵活的系统,在源码层次的依赖关系中,就应该多引用抽象类型,而非具体实现。在具体实施时,《架构整洁之道》中给出了 4 点建议:

(1)应该避免在代码中写入与任何具体实现相关的名字,或者是其他容易变动的事物名字;

(2)应在代码中多使用抽象接口,尽量避免使用那些多变的具体实现类;

(3)不要在具体实现类上创建衍生类,Golang 语言天生就符合这一点;

(4)不要覆盖包含具体实现的函数,即别重写,在 Skipper v1 的 Task 模式中违反了这一条,因为 Task 模式为了减少代码重复,所有 Task Handler 都需要内嵌 Default Handler,并重写其觉得需要修改的函数。

202004260514.jpg

202004260515.jpg

组件设计原则

CCP:共同闭包原则

应该将那些会同时修改,并且为相同目的而修改的类放在同一个组件中,而将不会同时修改,并且不会为了相同目的的修改的那些类放在不同组件中。

其实 CCP 是 SRP 有很多相似的地方,我们可以统一描述它们的思想:

将由于相同原因而需改,并且需要同时修改的东西放在一起。将由于不同原因而修改,并且不同时修改的东西放在一起。

CRP:共同复用原则

不要强迫一个组件的用户依赖他们不需要的东西。

这个原则实际上告诉我们应该将那些会被同时用到的代码放在同一个组件中。

ADP:无依赖环原则

组件依赖关系图中不应该出现环。

Golang 编译器实际上已经帮助我们避免了循环依赖。

SDP:稳定依赖原则

依赖关系必须要指向更稳定的方向。

这条原则告诉我们,一个我们预期会经常变更的组件不该被一个难以修改的组件所依赖,否则这个多变的组件也会变得难以被修改。这里所谓的稳定组件,就是指那些被别的组件依赖多的组件,不稳定的组件是那些依赖很多其他组件,但被其他组件依赖少的组件。

有时候我们的稳定组件还是需要依赖不稳定组件,怎么办呢?我们需要在他们中间加入一层稳定的抽象层。

202004260516.jpg

SAP:稳定抽象原则

一个组件的抽象化程度应该与其稳定性保持一致。

SDP 中提到,稳定的组件是不易修改的,这会导致整个项目的架构难以被修改,我们需要通过高度抽象这些稳定的组件,来让其接受修改。

前一个原则 SDP 告诉我们,依赖应该指向更加稳定的方向,而 SAP 告诉我们,越稳定,抽象化程度应该越高,这两个连起来就可以得出另外一个结论:

依赖关系应该指向更加抽象的方向。

借鉴领域驱动开发

领域驱动开发是一种用于复杂软件的架构设计思想,学习门槛比较高且对团队成员整体架构水平要求较高,其实并不适合完全使用在 Skipper 的开发中,我们只借鉴其中一部分适合于我们项目的思想。

水平分层

在 Skipper v1 中,我们依旧采用了 MVC 分层。但是领域驱动开发,以及《架构整洁之道》都提醒我们,应当存在一个应用层(《架构整洁之道》中称为 Use Cases 层)用于处理依赖多个组件的业务逻辑,各层之间依赖于接口而非实现,且下层不能依赖上层。比如创建一个包含三个节点的集群,就同时需要操作集群模块和节点模块。

领域驱动开发中,每个领域称为 Domain,每个 Domain 有自己的领域实体,并且是充血模型,每个领域的存储也是内聚在领域之中,综合以上,水平分层应当如下。

202004260517.jpg

领域划分与边界

在领域驱动开发中不仅进行了水平分层,还进行了垂直切片,将应用层以下划分成了不同领域(Domain),每个领域责任明确且高度内聚。

领域的划分应该满足单一职责原则,每个领域应当只对同一类行为者负责,每次系统的修改都应该分析属于哪个领域,如果某些领域总是同时被修改,他们应当被合并为一个领域。一旦领域划分后,不同领域之间需要制定严格的边界,领域暴露的接口,事件,领域之间的依赖关系都该被严格把控。

202004260518.jpg

领域事件

领域可以定义事件并发布到事件总线,如果对某个领域事件感兴趣,就可以订阅事件。领域事件可以大大降低各领域间的耦合,且对系统扩展性有巨大好处。例如在 Skipper v1 中,如果划分出了集群监控领域和集群生命周期管理领域,当有一天监控领域决定去掉集群升级过程中对监控配置文件的修改,需要在集群升级代码里找调用监控配置文件升级的地方。而如果采用了领域事件,则只需要让集群生命周期模块发布升级完成事件,并让监控模块订阅或者取消订阅事件进而做出配置文件修改逻辑即可。

202004260519.jpg

Skipper 架构 v2

参考前两文的探索,我们对 Skipper v1 做了一定调整。

整体架构

下图是 v1 到 v2 的转变,其核心是加入是领域模型,形成高内聚的业务领域组件。

202004260520.jpg

202004260521.jpg
  • 我们将 v1 中的 service 层切成两层,把跨多领域的业务逻辑上拉至 application 层中,让剩下的业务逻辑包含明显的业务边界;
  • 我们再根据各个业务模块的依赖关系紧密程度进行重组,形成领域,每个领域只处理自己领域的业务,每个领域对外暴露一套 Service 接口用于描述该领域对外暴露的能力,领域可以利用 Event Bus 对外发布事件,用于通知外部领域内正在发生的事;
  • 原来全局公用的存储层,现在分散到各个领域自行维护,不同领域可以采用不同的存储;
  • 原来放置全局的 Controller 和 Task Handler,现在由每个领域自行管理,系统依然提供 Controller 和 Task 的引擎(由 Task 领域负责)。这使得领域业务逻辑更加内聚;
  • 注意各模块的依赖关系,我们尽量遵循稳定依赖原则和稳定抽象原则,不稳定模块尽量依赖于稳定模块,如果需要让稳定模块依赖于不稳定模块,我们引入 Interface 进行抽象。
新领域孵化

我们可以肯定随着业务的发展,会有越来越多的领域被加入到 Skipper 中(目前已经出现”虚拟集群“领域)。

当一个新的领域被加入到 Skipper 中时,根据上边的架构,我们只需要借鉴其他领域的设计,新建一个领域,并在让领域负责人在此领域中迭代需求即可,这过程中,新领域可以依赖其它领域,监听其它领域的事件等等,对其它领域而言都是无感的。

领域成长与独立

随着领域内业务逻辑越来越复杂,或者因为业务调整,存在某个领域独立出项目的情况(目前”集群监控“领域已准备独立),由于我们的领域是高内聚的,领域独立的难度并不大,对整个项目而言,也只是将剥离的领域从领域层转移至 Infrastructure 层,作为外部服务而已。

由于领域之间总是依赖于接口或者依赖于领域事件,当领域独立时,依赖这个领域的业务逻辑是不需要进行修改的。

202004260522.jpg
微服务化

可能随着领域不断剥离,项目的领域不断的成为独立的服务,当服务增多时,就需要引入更加统一有效的运维、监控、部署方案,我们相信这才是项目微服务化最自然的方式,我们倾向于项目尽量是单体应用。

为什么相对 v1 可以降低人力

案例:增加集群创建失败通知机制

功能简介:集群创建目前成功率虽然符合 SLA,但是依然不是 100% 的,我们希望当集群创建失败时能第一时间通知我们。通知本身是一个比较简单的需求,完全可以分配给新人来做。

Skipper v1 中开发:如果在 Skipper v1 中开发,我们面对的最大问题是开发人员必须知道集群创建失败的具体位置,这只有集群创建流程的开发人员才知道,为了加入通知功能,新人不得不去请教集群创建流程的开发人员,并且需要修改集群创建流程,由于修改了集群创建流程,还需要走测试,虽然通知功能的代码不多,但是由于要修改集群创建流程,导致了人力成本的增加。

Skipper v2 中开发:如果在 Skipper v2 中开发,只需要单独创建一个领域,专门用于系统各种需要触达我们的通知,然后订阅对应事件即可,比如该例子中,就是订阅集群创建失败事件。这种开发模式,不需要修改集群创建流程代码,一切改动都在关键事件通知领域进行,且基于这种开发方式,就不会让事件通知代码散落在各个领域中。

总结

本文是一次 Golang 项目重构的思考与记录,首先讨论了为什么架构是重要的,又介绍了几种可行的重构方式。基于实际的项目,我们介绍了旧工程 Dashboard 项目的架构和其中的问题,针对这些问题,我们尝试着去设计一个更优秀的架构 Skipper v1。但是,随着迁移的进行,我们发现 Skipper v1 中依旧存在一些如模块不内聚,充血模型过度设计等问题,为了更好地解决已知的架构问题,我们参考了《架构整洁之道》以及 DDD 的一些思想,再结合 Skipper v1 的实际情况,设计出了 Skipper v2 的架构。

参考文献:

[1]Robert C. Martin.Clean Architecture[M].Prentice Hall:,September 20, 2017

[2]Eric Evans.Domain-Driven Design[M].Addison-Wesley Professional:,August 30, 2003

[3]乔梁.持续交付 2.0[M].人民邮电出版社:,2018-12-25

[4]https://github.com/bxcodec/go-clean-arch

[5]https://github.com/marcusolsson/goddd

[6]https://engineering.grab.com/domain-driven-development-in-golang

作者简介:黄雷,腾讯云后台工程师,Kubernetes 技术专家,系统可观测性专家。拥有多年大规模 Kubernetes 集群开发运维经验。目前负责腾讯云 TKE 万级规模 Kubernetes 集群治理,主导研发超大规模 Kubernetes 集群联邦智能监控系统与巡检系统。

本文经授权转载自“CSDN微信公众号,作者:黄雷(腾讯云后台工程师)。感谢原作者的分享。

往期回顾:

Science:新冠低质量论文泛滥,科研人员不该“趁疫情危机”丢掉道德使命

迄今最高效的太阳能电池问世!能量转换效率突破 47.1%

疫情下这些国家的贫民窟都怎么样了?

[关于转载]:本文为“CSDN”微信公众号文章,转载本文请联系原作者获取授权。谢谢您的合作。

二维码 扫码微信阅读
推荐阅读 更多