Go后台项目架构思考与重构 | 深度长文
作者 | 腾讯云后台工程师黄雷
编辑 | 唐小引
来源 | CSDN(ID:CSDNnews)
引言
本文首先介绍了架构的重要性,随后从一个实际项目的重构过程作为主线,逐步引出主流的架构设计思想以及其所解决的实际问题是什么。通过阅读本文,你将学习到:
架构的重要性;
重构的几种模式;
设计原则;
DDD 中领域思想;
项目的可测试性;
项目的可演进性。
实践背景介绍
本文涉及的项目主要用于腾讯云团队 K8s 集群管理的项目,其核心业务包括创建、升级、删除集群和节点、集群监控、巡检等。
▐ 旧工程简介
Dashboard 是该项目最早的版本,主要包含 API 请求处理和异步流程执行等核心功能,是团队最早的核心模块之一。但是随着功能不断增加,Dashboard 早期不合理的架构设计所导致的可读性差、扩展性差,无法单测等问题逐渐暴露出来且愈发严重。为了让 Dashboard 的质量往更好的方向改进,团队决定对其进行重构。
▐ 新工程简介
考虑到直接重写的代价和风险过大,团队决定采用「修缮者」策略,即重创一个工程,承载 Dashboard 新需求的实现,并逐步将旧功能迁移到新工程中,最终达到重写 Dashboard 的效果,Skipper 就是这个新工程。在迁移过程中,团队对 Skipper 的架构设计经过了几次调整,逐步解决了 Dashboard 中存在的问题,最终得到一个较为合理的架构,本文记录了重构过程中的思考,和架构演变的过程。
架构的重要性
▐ 架构的目标
追求好架构的目的到底是什么呢?或者说,我们期望一个好的架构产生什么价值呢?
一个好的架构,其终极目标应当是,用最小的人力成本满足构建和维护该系统的需求。
也就是说,好的架构目标应当是降低人力成本,这里包括的不仅仅是开发成本,还有构建运维成本。而增加软件可变性就是架构达到最终目标的核心途径,即架构主要是通过增加软件的可变性来降低人力成本,毕竟,捏橡皮泥比你在石头上雕刻要轻松得多。
▐ 行为和架构哪个重要?
一个软件的行为固然是很重要的,因为一个不能按预定行为工作的软件是不产生价值的,所以很多程序员认为能实现软件行为是最重要的,根本不该关心架构,反正坏的架构也不是实现不了行为,出了 bug 修复即可。我认为,他们忽略的是随着软件行为的改动,坏的架构将导致他们自己的工作越来越难以进行,改动的代码越来越大,bug 越来越多,项目最终可能不可维护。
一个软件的架构虽然不直接表现在行为上,但其最大的特点就是良好的可变性,即使目前行为不符合预期,也能通过低成本的改动将行为改变到预期。
可运行不可变软件,最终会因为无法改变而导致行为无法迭代或者迭代慢而变成没有价值。可变不可运行的软件,可通过迭代,变成可运行可变软件,所以架构比行为重要。
▐ 恶魔小时候也可爱
一个不太好的架构,在项目初期有时难以察觉,因为此时项目模块少,功能少,依赖关系显而易见,一切显得毫无恶意,甚至有点简洁美。但是,恶魔小时候往往也很可爱。随着项目的增长,模块增加了,开发人员变多了,恶魔长大了。架构带来的问题逐渐暴露了出来,混乱的层次关系,毫无章法的依赖关系,模块权责不清等问题接踵而至。
对开发人员而言,项目理解成本不断增加,添加小功能都要先理清好几个模块的调用关系,难以测试导致上线后 bug 防不胜防,组件无法复用。项目逐渐长成大家闻风丧胆,避而不及的“大恶魔”。
虽然我们也反对过度设计,但是识别,或者说猜测项目未来符合逻辑的可能变动,将架构设计考虑进项目早期是十分有必要的,架构设计和调整应该贯穿项目的整个成长过程。
▐ 识别过度设计
架构设计是为了让未来的修改更加容易,但是未来谁又能完全预测准确呢,架构设计或多或少有一定猜测成分在里面,但是更多的是吸取 IT 行业几十年发展过程中前辈们的经验以及对业务特点的了解所作出的符合一定逻辑的猜测。
那什么算过度设计呢?从架构的目的是降低人力来看,就是该设计目前没有任何强有力的逻辑能推出能在未来降低修改某种行为的人力成本,或者降低某种行为修改成本的同时,大大增加了另外一种行为的修改成本。
▐ 架构的理解成本
架构是有一定理解成本的,甚至架构设计之初会增加一定的系统理解成本,但是一个好的架构理解成本一定不会很高,因为架构的理解也是人力成本。在理解架构设计的意图之前,因为其增加系统的理解成本而否定它的必要性是不合逻辑的。
好的架构,其关键意义在于降低项目发展过程中整体理解成本。
也就是说,架构良好的项目随着业务复杂度增加,项目理解成本增长也是缓慢的。架构不合理的项目随着业务复杂度的增加,整体理解成本可能是指数增长的。
▐ 架构调整需要勇气
一旦你宣布进行项目架构调整,就是宣告现有项目架构不合理,也意味着他人将设计出比当前优秀的架构,这是一件非常需要勇气的事。因为调整的过程中,你会犯错,你需要进行一些猜测,你会和他人产生观点冲突,你有时甚至需要有点固执和执着。
因为架构投资的是未来,但大部分人只着眼于当下。
重构方式
▐ 拆迁者模式
根据当前业务的需求对软件架构重新设计,并组织单独的团队,重新开发一个全新的版本,一次性完全替代原有的遗留系统。
为什么不适合我们?主要有如下几项因素:
人力消耗巨大,需要一边加新需求一边重写旧需求;
无法确保新的工程的设计比旧的好;
重写过程中可能出现业务遗漏。
▐ 绞杀者模式
保持原来的系统不变,当需要开发新功能时,重新开发一个服务,实现新功能,通过不断构建新的服务,逐步使遗留系统失效,最终替换它。
绞杀者模式相对比较适合我们的重构需求,但是存在以下问题:
不希望存在多个服务共存的问题;
希望共享旧工程的 CICD,运维,监控等能力;
重构颗粒度过大,我们希望细到函数级别的重构。
▐ 修缮者模式
将遗留系统的部分功能与其余部分隔离,以新的架构进行单独改造。
修缮者模式特别适合我们的需求。
Dashboard 的架构
▐ 整体架构
Dashboard 核心功能分为两大块,一个是作为 Web API Server,接收 HTTP 请求,另外一个是异步流程处理,用于耗时较长的功能,比如创建集群、集群升级等。
Dashboard 整体采用 MVC 架构 + Controller 模式,这里的 Controller 模式是指通过不断重试,最终将目标对象设置到某种目标状态的模式,比如通过不断重试,将创建中的集群的各部分属性或者依赖的资源,设置到正常集群的状态。Dashboard 的核心模块如图。
MVC Controller:用于接收 HTTP 请求,并调用 Service 进行业务处理;
MVC Service:核心业务逻辑全部落在这一层;
MVC DAO:DB 相关操作都在这一层;
MVC Models: 包含各个对象的字段,比如集群、节点等;
Controller 模式下的各个 Controller:每个 Controller 逻辑差异很大,但是都是调用 Service 进行对象状态的初始化或者设置;
Components:调用外部服务的模块都在这里,比如调用计算资源服务创建虚拟机、调用网络资源服务设置网络等。
Dashboard 虽然有水平分层,但是每一层内部没有组件的设计原则,也没有代码规范,每一层基本都是单一一个包,包内代码质量不高,重复代码较多。
▐ 具体实现
Dashboard 的工程目录如下所示:
每一层一个包
这样看来,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 的架构变动如下图。
外部依赖接口化
在 Skipper 中,对外部服务的调用(Component)都用 Interface 进行抽象,任何模块都不直接使用 Component 的具体实现,这解耦了业务逻辑和外部服务,Component 提供 fake 版本用于单元测试。
充血模型
在 Skipper 中,Models 层只会被 core obj 层和 store interface 所引用,所有其它模块都直接使用包含充血模型的 core obj 层。在 core obj 中,每个对象都是充血模型的,其不仅包含一个或多个对象数据,还包含一些业务方法,比如将对象设置为升级状态,比如将对象生命周期改为 deleting 等等,也就是说,原来处于 dao 中的业务逻辑被上升到 core obj 中,使得 DAO 层薄到只有最基本的 CRUD 操作,这对后面 DB 依赖接口化有巨大帮助。
DB 依赖接口化
由于使用了充血模型,存储层只有最基本的 CRUD,我们很方便得加入了 store interface 来解耦系统和具体存储,store 层还提供基于 gorm 的具体实现,以及 fake 版本的实现用于单元测试。
异步流程
为了解决 Controller 模式存在的问题,Skipper 开发一个 Task 异步流程执行框架,用于执行一次性的异步流程,但依旧保留 Controller 模式的存在,其中 Task Controller 是 Task 异步流程框架的引擎。
(1)Controller 模式用于需要一直运行的全局性旁路,比如节点状态监控,Task 执行监控等;
(2)Task 模式用于复杂的一次性流程,比如升级一个节点,升级一个集群等等。
Service 分包
Skipper 中也有 Service 层,和 Dashboard 不同的是,Skipper 的 Service 会根据业务模块进行分包,比如一个包专门处理集群升级,一个包专门处理监控组件,一个包专门处理巡检等。
Skipper 的 Service 层依旧使用了全局函数,没有进行封装,我们后续将提到,这是 Skipper v1 版本存在的一个问题。
可测试
由于外部服务以及 DB 都可以用 fake 的了,Service 层的代码是可以进行单测的。
▐ 为什么相对 Dashboard 可以降低人力
案例:节点升级
这里以节点升级功能为例,介绍为什么 Skipper v1 相对 Dashboard 能降低人力。
功能简介:节点升级功能是指将一批 k8s 节点上的组件版本从低版本升级至高版本,这是一个比较耗时的流程,所以不能在同步请求中直接完成,需要异步执行,且需要展示升级进度。由于节点升级是高危操作,一批节点升级过程中,需要支持用户随时暂停,取消升级。
Dashboard 中开发过程:如果该功能在 Dashboard 中实现,大概需要以下流程。
考虑节点升级请求参数比较复杂,没法存在现有表中,需要新建一个表用于存储节点升级的参数和进度。
编写对应的 Models。
编写专门用于上述表的 DAO 层代码。
编写一个 Controller 异步流程,要为该 Controller 专门实现暂停,取消等控制机制。
编写专门的旁路进行监控告警。
Service 中实现节点升级核心流程。
由于无法单测,觉得写得差不多了,需要等待测试环境空闲时,部署到测试环境进行调试。注意,测试环境是公共的,别人可能也需要用。
Skipper 中开发过程:如果该功能在 Skipper 中实现,将基于 Task 异步流程实现,大概需要以下流程:
由于 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 中的实现是在集群升级代码中显示调用更新监控配置的函数,这就使得集群监控开发人员必须理解集群升级的代码并知道在哪里调用更新监控配置的函数,这使得集群生命周期模块和监控模块是耦合的。
▐ 进一步探索
为了解决 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,并重写其觉得需要修改的函数。
▐ 组件设计原则
CCP:共同闭包原则
应该将那些会同时修改,并且为相同目的而修改的类放在同一个组件中,而将不会同时修改,并且不会为了相同目的的修改的那些类放在不同组件中。
其实 CCP 是 SRP 有很多相似的地方,我们可以统一描述它们的思想:
将由于相同原因而需改,并且需要同时修改的东西放在一起。将由于不同原因而修改,并且不同时修改的东西放在一起。
CRP:共同复用原则
不要强迫一个组件的用户依赖他们不需要的东西。
这个原则实际上告诉我们应该将那些会被同时用到的代码放在同一个组件中。
ADP:无依赖环原则
组件依赖关系图中不应该出现环。
Golang 编译器实际上已经帮助我们避免了循环依赖。
SDP:稳定依赖原则
依赖关系必须要指向更稳定的方向。
这条原则告诉我们,一个我们预期会经常变更的组件不该被一个难以修改的组件所依赖,否则这个多变的组件也会变得难以被修改。这里所谓的稳定组件,就是指那些被别的组件依赖多的组件,不稳定的组件是那些依赖很多其他组件,但被其他组件依赖少的组件。
有时候我们的稳定组件还是需要依赖不稳定组件,怎么办呢?我们需要在他们中间加入一层稳定的抽象层。
SAP:稳定抽象原则
一个组件的抽象化程度应该与其稳定性保持一致。
SDP 中提到,稳定的组件是不易修改的,这会导致整个项目的架构难以被修改,我们需要通过高度抽象这些稳定的组件,来让其接受修改。
前一个原则 SDP 告诉我们,依赖应该指向更加稳定的方向,而 SAP 告诉我们,越稳定,抽象化程度应该越高,这两个连起来就可以得出另外一个结论:
依赖关系应该指向更加抽象的方向。
▐ 借鉴领域驱动开发
领域驱动开发是一种用于复杂软件的架构设计思想,学习门槛比较高且对团队成员整体架构水平要求较高,其实并不适合完全使用在 Skipper 的开发中,我们只借鉴其中一部分适合于我们项目的思想。
水平分层
在 Skipper v1 中,我们依旧采用了 MVC 分层。但是领域驱动开发,以及《架构整洁之道》都提醒我们,应当存在一个应用层(《架构整洁之道》中称为 Use Cases 层)用于处理依赖多个组件的业务逻辑,各层之间依赖于接口而非实现,且下层不能依赖上层。比如创建一个包含三个节点的集群,就同时需要操作集群模块和节点模块。
领域驱动开发中,每个领域称为 Domain,每个 Domain 有自己的领域实体,并且是充血模型,每个领域的存储也是内聚在领域之中,综合以上,水平分层应当如下。
领域划分与边界
在领域驱动开发中不仅进行了水平分层,还进行了垂直切片,将应用层以下划分成了不同领域(Domain),每个领域责任明确且高度内聚。
领域的划分应该满足单一职责原则,每个领域应当只对同一类行为者负责,每次系统的修改都应该分析属于哪个领域,如果某些领域总是同时被修改,他们应当被合并为一个领域。一旦领域划分后,不同领域之间需要制定严格的边界,领域暴露的接口,事件,领域之间的依赖关系都该被严格把控。
领域事件
领域可以定义事件并发布到事件总线,如果对某个领域事件感兴趣,就可以订阅事件。领域事件可以大大降低各领域间的耦合,且对系统扩展性有巨大好处。例如在 Skipper v1 中,如果划分出了集群监控领域和集群生命周期管理领域,当有一天监控领域决定去掉集群升级过程中对监控配置文件的修改,需要在集群升级代码里找调用监控配置文件升级的地方。而如果采用了领域事件,则只需要让集群生命周期模块发布升级完成事件,并让监控模块订阅或者取消订阅事件进而做出配置文件修改逻辑即可。
Skipper 架构 v2
参考前两文的探索,我们对 Skipper v1 做了一定调整。
▐ 整体架构
下图是 v1 到 v2 的转变,其核心是加入是领域模型,形成高内聚的业务领域组件。
我们将 v1 中的 service 层切成两层,把跨多领域的业务逻辑上拉至 application 层中,让剩下的业务逻辑包含明显的业务边界;
我们再根据各个业务模块的依赖关系紧密程度进行重组,形成领域,每个领域只处理自己领域的业务,每个领域对外暴露一套 Service 接口用于描述该领域对外暴露的能力,领域可以利用 Event Bus 对外发布事件,用于通知外部领域内正在发生的事;
原来全局公用的存储层,现在分散到各个领域自行维护,不同领域可以采用不同的存储;
原来放置全局的 Controller 和 Task Handler,现在由每个领域自行管理,系统依然提供 Controller 和 Task 的引擎(由 Task 领域负责)。这使得领域业务逻辑更加内聚;
注意各模块的依赖关系,我们尽量遵循稳定依赖原则和稳定抽象原则,不稳定模块尽量依赖于稳定模块,如果需要让稳定模块依赖于不稳定模块,我们引入 Interface 进行抽象。
▐ 新领域孵化
我们可以肯定随着业务的发展,会有越来越多的领域被加入到 Skipper 中(目前已经出现”虚拟集群“领域)。
当一个新的领域被加入到 Skipper 中时,根据上边的架构,我们只需要借鉴其他领域的设计,新建一个领域,并在让领域负责人在此领域中迭代需求即可,这过程中,新领域可以依赖其它领域,监听其它领域的事件等等,对其它领域而言都是无感的。
▐ 领域成长与独立
随着领域内业务逻辑越来越复杂,或者因为业务调整,存在某个领域独立出项目的情况(目前”集群监控“领域已准备独立),由于我们的领域是高内聚的,领域独立的难度并不大,对整个项目而言,也只是将剥离的领域从领域层转移至 Infrastructure 层,作为外部服务而已。
由于领域之间总是依赖于接口或者依赖于领域事件,当领域独立时,依赖这个领域的业务逻辑是不需要进行修改的。
▐ 微服务化
可能随着领域不断剥离,项目的领域不断的成为独立的服务,当服务增多时,就需要引入更加统一有效的运维、监控、部署方案,我们相信这才是项目微服务化最自然的方式,我们倾向于项目尽量是单体应用。
▐ 为什么相对 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 集群联邦智能监控系统与巡检系统。
推荐阅读
如何通过深度学习,完成计算机视觉中的所有工作?
看似毫不相干,哲学与机器学习竟有如此大的交集
黑客用上机器学习你慌不慌?这 7 种窃取数据的新手段快来认识一下
“谷歌杀手”发明者,科学天才 Wolfram
清晰架构的 Go 微服务: 程序容器
5分钟!就能学会以太坊 JSON API 基础知识
你点的每个“在看”,我都认真当成了AI
相关文章:

前途到底是网络工程还是程序设计
本人89年年底生的,现在快满21了,大二的时候过的国家网络工程师考试,并不是cisco的网络支持工程师,大三也就是现在,在学校花销太大,想自己赚点钱,于是在学校招聘会上应聘了一家通信公司ÿ…

FAIL - Deployed application at context path / but context failed to start
IDE报错:FAIL - Deployed application at context path / but context failed to start 编译通过,这个错误原因是很多地方被误用,导致Spring运行时不能解析某些Class导致, 例如:ModelAndView用错 public ModelAndView…

项目ITP(五) spring4.0 整合 Quartz 实现任务调度
2014-05-16 22:51 by Jeff Li 前言 系列文章:[传送门] 项目需求: 二维码推送到一体机上,给学生签到扫描用。然后须要的是 上课前20分钟 。幸好在帮带我的学长做 p2p 的时候。接触过。自然 quartz 是首选。所以我就配置了下,搞了个…
Spring security防止跨站请求伪造(CSRF防护)
因为使用了spring security 安全性框架 所以spring security 会自动拦截站点所有状态变化的请求(非GET,HEAD,OPTIONS和TRACE的请求),防止跨站请求伪造(CSRF防护),即防止其他网站或是程序POST等请求本站点。…
从Ops到NoOps,阿里文娱智能运维的关键:自动化应用容量管理
作者| 阿里文娱高级开发工程师 金呈编辑 | 夕颜来源 | CSDN(ID:CSDNnews)概述1. 背景随着业务形态发展,更多的生产力集中到业务创新,这背后要求研发能力的不断升级。阿里文娱持续倾向用更加高效、稳定、低成本的方式支…

JAVA目录树(全功能),Java+ajax实现
我自己要的功能全实现了 一:双击选中项进行编辑 二:右键菜单功能(新增,删除,修改,自定义颜色什么的) 三:选中项进行拖动事件 四:输入项进入搜索(并自动选中结果项) 2011.03.14更新功能 …

SiteMesh介绍
1. SiteMesh简介 SiteMesh是由一个基于Web页面布局、装饰以及与现存Web应用整合的框架。它能帮助我们在由大量页面构成的项目中创建一致的页面布局和外观,如一致的导航条,一致的banner,一致的版权等等。它不仅仅能处理动态的内容,…
商汤提基于贪心超网络的One-Shot NAS,达到最新SOTA | CVPR 2020
出品 | AI科技大本营(ID:rgznai100)导读:在CVPR 2020上,商汤移动智能事业群-3DAR-身份认证与视频感知组提出了基于贪心超网络的One-Shot NAS方法,显著提升了超网络直接在大规模数据集上的搜索训练效率,并在…

多样化实现Windows phone 7本地数据访问5——深入Rapid Repository
上一篇多样化实现Windows Phone 7本地数据访问<4>——Rapid Repository 中初步的介绍Repid Repository作为Windows phone 7数据库存储原理Repid具有特点以及数据CRUD基本操作.Rapid Repository 是一个基于WP7开源的数据库. 上周联系Rapid 数据库的作者Sean McAlinden.有…
调试Tomcat源码
需要调试Tomcat源码其实很简单, 1.保持你的Tomcat安装文件和源码是版本一致 http://tomcat.apache.org/download-80.cgi 下载安装版和源码2个版本 2.建立Java自由格式项目 先在IDE里配置好Tomcat,这个不复杂。 然后新建一个项目,这个需要…

开源 免费 java CMS - FreeCMS1.9 全文检索
项目地址:http://code.google.com/p/freecms/ 全文检索 从FreeCMS 1.7開始支持 仅仅有创建过索引的对象才干被lucene类标签查询到。 信息类数据会在信息更新、审核、删除、还原操作时自己主动进行全文检索处理。1. 创建索引 从左側管理菜单点击创建索引进入。 您能够…

Spring Mock单元测试
针对post和get import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.…

tar、gzip、gunzip、bzip2、zip、unzip
tar [参数] 文件或目录名 •参数: -c: 建立新的备份档文件。 -f: 对普通文件进行操作。这个参数通常是必选的。 -r: 向备份档文件追加文件。 -x: 从备份档文件中解出文件。 -t: …
港科大谢丹阳教授问诊未来,预测长远趋势与转折点
阳春三月,万象更新,2020年注定是不平凡的一年!有激荡就会遇见变革,有挑战就会迎来机遇。今天总会过去,未来将会怎样?香港科大商学院内地办事处重磅推出全新升级的《袁老师访谈录》全新系列【问诊未来院长系…

数字签名与数字证书
先看一下百度百科对数字签名和数字证书的解释: 数字签名: 将报文按双方约定的HASH算法计算得到一个固定位数的报文摘要。在数学上保证:只要改动报文中任何一位,重新计算出的报文摘要值就会与原先的值不相符。这样就保证了报文的不…
Spring源码分析【3】-SpingWebInitializer的加载
SpingWebInitializer的加载 Spring基于注解的配置代码: public class SpingWebInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {Overrideprotected Class<?>[] getRootConfigClasses() {return new Class<?>[]{RootCon…
PyTorch 1.5发布,与AWS联手推出TorchServe
导读:近日 PyTorch 发布了 1.5 版本的更新,作为越来越受欢迎的机器学习框架,PyTorch 本次也带来了大的功能升级。此外, Facebook 和 AWS 还合作推出了两个重要的 PyTorch 库。作者 | 神经星星来源 | HyperAI超神经(ID:…

更改时区,时间
整更改ubuntu时区,时间,localePublished by 笨二十一 at 11:56 上午 under Linux/Unix,服务器更改时区,时间执行tzselect按照提示进行选择时区sudo cp /usr/share/zoneinfo/Asia/ShangHai /etc/localtime执行sudo ntpdate cn.pool.ntp.orgcn.pool.ntp.org是位于中国…
throttle与debounce的区别
前几天看到一篇文章,我的公众号里也分享了《一次发现underscore源码bug的经历以及对学术界拿来主义的思考》具体文章详见,微信公众号:文中讲了大家对throttle和debounce存在误解,同时提到了《高程3》中实现节流方法存在一些问题&a…
Spring源码分析【0】-框架的基础:继承和接口调用链
Spring源码大量的使用继承和接口调用,现举个例子,不搞清楚这个无法看代码。 public class A extends B{public void f1() {System.out.println("f1 in a");} }public abstract class B extends C {protected abstract void f1();protected Str…
华人计算机视觉科学家黄煦涛逝世,众多AI大牛发文缅怀
当地时间2020年4月25日,华人计算机科学家黄煦涛教授在美国印第安纳州逝世,享年84岁。黄煦涛教授主要从事教学与图像处理、模式识别、计算机视觉和人机交互等方面的研究工作,一生出版了 14 本书,发表了 400 多篇学术论文。在学术研…

七年之痒,再见理想
不确定“再见理想”是“再见了,理想”还是“再次燃起理想”,稀里糊涂地对这句话有感觉。作为程序员,总会有自己的技术价值观和技术理想。工作七年多,开始痒了。 程序员的生活总是喜忧参半,出入体面的写字楼,…

HTML5学习笔记二 HTML基础
一、HTML 标题 HTML 标题(Heading)是通过<h1> - <h6> 标签来定义的. <h1>标题一</h1> <h2>标题二</h2> <h3>标题三</h3> 二、HTML 段落 HTML 段落是通过标签 <p> 来定义的. <p>亲吻你的手还…
程序员感叹一年只能存下15万太少了……网友:潸然泪下
最近有程序员网友晒出自己的年终奖,税后高达15.7万!看到这个情形,很多网友表示自己“被打鸡血了”。他强调学习的重要性,学习仍然是在这个时代下,普通人能够逆袭,给家人更好生活的一把利器!今天…
Spring源码分析【2】-Tomcat和Sping的连接点
Tomcat是怎么调用上Spring的呢?需要找到这个连接点。 答案就在org.apache.catalina.startup.ContextConfig的processServletContainerInitializers方法 new WebappServiceLoader() 回到processServletContainerInitializers 进入org.apache.catalina.startup.Weba…

优化数据库的方法及SQL语句优化的原则
优化数据库的方法: 1、关键字段建立索引。 2、使用存储过程,它使SQL变得更加灵活和高效。 3、备份数据库和清除垃圾数据。 4、SQL语句语法的优化。(可以用Sybase的SQL Expert,可惜我没找到unexpired的序列号) 5、清理删…

各大浏览器 CSS3 和 HTML5 兼容速查表
2019独角兽企业重金招聘Python工程师标准>>> 不知不觉中,支持 CSS3 和 HTML5 的浏览器变得越来越多,甚至包括最新版的 IE,当然,所谓支持仅仅是部分支持,因为 CSS3 和 HTML5 的W3C 规范都尚未形成。如果你现…
Spring源码分析【1】-Tomcat的初始化
org.apache.catalina.startup.ContextConfig.configureStart() org.apache.catalina.startup.ContextConfig.webConfig() 进入org.apache.catalina.startup.ContextConfig.processServletContainerInitializers processServletContainerInitializers参考:Spring源…
360金融首席科学家张家兴:只靠AI Lab做不好AI中台 | 独家专访
「AI 技术生态论」 人物访谈栏目是 CSDN 发起的百万人学 AI 倡议下的重要组成部分。通过对 AI 生态顶级大咖、创业者、行业 KOL 的访谈,反映其对于行业的思考、未来趋势判断、技术实践,以及成长经历。 本文为 「AI 技术生态论」系列访谈第21期࿰…

Delphi 正则表达式语法(3): 匹配范围
// [A-Z]: 匹配所有大写字母var reg: TPerlRegEx; begin reg : TPerlRegEx.Create(nil); reg.Subject : CodeGear Delphi 2007 for Win32; reg.RegEx : [A-Z]; reg.Replacement : ◆; reg.ReplaceAll; ShowMessage(reg.Subject); //返回: ◆ode◆ear ◆elphi 200…