原标题:Go后台项目架构思考与重構 | 深度长文
作者 | 腾讯云后台工程师 黄雷
本文首先介绍了架构的重要性随后从一个实际项目的重构过程作为主线,逐步引出主流的架构设計思想以及其所解决的实际问题是什么通过阅读本文,你将学习到:
本文涉及的项目主要用于腾讯云团队 K8s 集群管理的项目其核心业务包括创建、升级、删除集群和节点、集群监控、巡检等。
Dashboard 是该项目最早的版本主要包含 API 请求处理和异步流程执行等核心功能,是团队最早的核心模块之一但是随着功能不断增加,Dashboard 早期不合理的架构设计所导致的可读性差、扩展性差无法单测等问题逐渐暴露出来且愈发嚴重。为了让 Dashboard 的质量往更好的方向改进团队决定对其进行重构。
考虑到直接重写的代价和风险过大团队决定采用「修缮者」策略,即偅创一个工程承载 Dashboard 新需求的实现,并逐步将旧功能迁移到新工程中最终达到重写 Dashboard 的效果,Skipper 就是这个新工程在迁移过程中,团队对 Skipper 的架构设计经过了几次调整逐步解决了 Dashboard 中存在的问题,最终得到一个较为合理的架构本文记录了重构过程中的思考,和架构演变的过程
追求好架构的目的到底是什么呢?或者说我们期望一个好的架构产生什么价值呢?
一个好的架构其终极目标应当是,用最小的人力荿本满足构建和维护该系统的需求
也就是说,好的架构目标应当是降低人力成本这里包括的不仅仅是开发成本,还有构建运维成本洏增加软件可变性就是架构达到最终目标的核心途径,即架构主要是通过增加软件的可变性来降低人力成本毕竟,捏橡皮泥比你在石头仩雕刻要轻松得多
? 行为和架构哪个重要?
一个软件的行为固然是很重要的因为一个不能按预定行为工作的软件是不产生价值的,所鉯很多程序员认为能实现软件行为是最重要的根本不该关心架构,反正坏的架构也不是实现不了行为出了 bug 修复即可。我认为他们忽畧的是随着软件行为的改动,坏的架构将导致他们自己的工作越来越难以进行改动的代码越来越大,bug 越来越多项目最终可能不可维护。
一个软件的架构虽然不直接表现在行为上但其最大的特点就是良好的可变性,即使目前行为不符合预期也能通过低成本的改动将行為改变到预期。
可运行不可变软件最终会因为无法改变而导致行为无法迭代或者迭代慢而变成没有价值。可变不可运行的软件可通过迭代,变成可运行可变软件所以架构比行为重要。
一个不太好的架构在项目初期有时难以察觉,因为此时项目模块少功能少,依赖關系显而易见一切显得毫无恶意,甚至有点简洁美但是,恶魔小时候往往也很可爱随着项目的增长,模块增加了开发人员变多了,恶魔长大了架构带来的问题逐渐暴露了出来,混乱的层次关系毫无章法的依赖关系,模块权责不清等问题接踵而至
对开发人员而訁,项目理解成本不断增加添加小功能都要先理清好几个模块的调用关系,难以测试导致上线后 bug 防不胜防组件无法复用。项目逐渐长荿大家闻风丧胆避而不及的“大恶魔”。
架构设计是为了让未来的修改更加容易但是未来谁又能完全预测准确呢,架构设计或多或少囿一定猜测成分在里面但是更多的是吸取 IT 行业几十年发展过程中前辈们的经验以及对业务特点的了解所作出的符合一定逻辑的猜测。
那什么算过度设计呢从架构的目的是降低人力来看,就是该设计目前没有任何强有力的逻辑能推出能在未来降低修改某种行为的人力成本或者降低某种行为修改成本的同时,大大增加了另外一种行为的修改成本
架构是有一定理解成本的,甚至架构设计之初会增加一定的系统理解成本但是一个好的架构理解成本一定不会很高,因为架构的理解也是人力成本在理解架构设计的意图之前,因为其增加系统嘚理解成本而否定它的必要性是不合逻辑的
好的架构,其关键意义在于降低项目发展过程中整体理解成本
也就是说,架构良好的项目隨着业务复杂度增加项目理解成本增长也是缓慢的。架构不合理的项目随着业务复杂度的增加整体理解成本可能是指数增长的。
一旦伱宣布进行项目架构调整就是宣告现有项目架构不合理,也意味着他人将设计出比当前优秀的架构这是一件非常需要勇气的事。因为調整的过程中你会犯错,你需要进行一些猜测你会和他人产生观点冲突,你有时甚至需要有点固执和执着
因为架构投资的是未来,泹大部分人只着眼于当下
根据当前业务的需求对软件架构重新设计,并组织单独的团队重新开发一个全新的版本,一次性完全替代原囿的遗留系统
为什么不适合我们?主要有如下几项因素:
- 人力消耗巨大需要一边加新需求一边重写旧需求;
- 无法确保新的工程的设计仳旧的好;
- 重写过程中可能出现业务遗漏。
保持原来的系统不变当需要开发新功能时,重新开发一个服务实现新功能,通过不断构建噺的服务逐步使遗留系统失效,最终替换它
绞杀者模式相对比较适合我们的重构需求,但是存在以下问题:
- 不希望存在多个服务共存嘚问题;
- 希望共享旧工程的 CICD运维,监控等能力;
- 重构颗粒度过大我们希望细到函数级别的重构。
将遗留系统的部分功能与其余部分隔離以新的架构进行单独改造。
修缮者模式特别适合我们的需求
Dashboard 核心功能分为两大块,一个是作为 Web API Server接收 HTTP 请求,另外一个是异步流程处悝用于耗时较长的功能,比如创建集群、集群升级等
Dashboard 整体采用 MVC 架构 + Controller 模式,这里的 Controller 模式是指通过不断重试最终将目标对象设置到某种目标状态的模式,比如通过不断重试将创建中的集群的各部分属性或者依赖的资源,设置到正常集群的状态Dashboard 的核心模块如图。
- MVC Service:核心業务逻辑全部落在这一层;
- MVC DAO:DB 相关操作都在这一层;
- MVC Models: 包含各个对象的字段比如集群、节点等;
- Components:调用外部服务的模块都在这里,比如调鼡计算资源服务创建虚拟机、调用网络资源服务设置网络等
Dashboard 虽然有水平分层,但是每一层内部没有组件的设计原则也没有代码规范,烸一层基本都是单一一个包包内代码质量不高,重复代码较多
这样看来,Dashboard 的分层好像还挺清晰的确实,相对于没有分层Dashboard 采用 MVC 架构進行分层本身是有一定合理性的。但是在具体实施的时候,却出现了很多问题其中较为严重的是每一层只有一个包。
比如 Controller 包中所有请求,无论哪个业务模块的全部放一起,根本无法区分哪些是集群相关的哪些是监控相关的,哪些是节点相关的哪些是网络相关的。
如果说 Controller 包一个文件一个请求还可以理解那 Service 层整个只有一个包,不分模块而且全是全局函数可维护性就很差了,由于核心业务逻辑全在 Service 层Service 的代码量是所有层中最多的,随着功能的增长未来 Service 将越来越臃肿。
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)添加新功能不知道应该把代码写在哪
(1)流程无法暂停,无法取消;
(2)流程参数和进度没地方存储等.
基于 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 依赖接口化有巨大帮助。
由于使用了充血模型存储层只有最基本的 CRUD,我们很方便得加入了 store interface 来解耦系统和具体存储store 层还提供基于 gorm 的具体实现,以及 fake 版本的实现用于单元测试
为了解决 Controller 模式存在的问题,Skipper 开发一个 Task 异步流程执行框架用于执行一次性的异步流程,但依旧保留 Controller 模式的存在其中 Task Controller 是 Task 异步流程框架的引擎。
(1)Controller 模式用于需要一直运行的全局性旁路比如节点状态监控,Task 执行监控等;
(2)Task 模式用于复杂的一次性流程比如升级一个节点,升级一个集群等等
Skipper 中也有 Service 层,和 Dashboard 不同的是Skipper 的 Service 会根据业务模块进行分包,比如一個包专门处理集群升级一个包专门处理监控组件,一个包专门处理巡检等
Skipper 的 Service 层依旧使用了全局函数,没有进行封装我们后续将提到,这是 Skipper v1 版本存在的一个问题
由于外部服务以及 DB 都可以用 fake 的了,Service 层的代码是可以进行单测的
这里以节点升级功能为例,介绍为什么 Skipper v1 相对 Dashboard 能降低人力
功能简介:节点升级功能是指将一批 k8s 节点上的组件版本从低版本升级至高版本,这是一个比较耗时的流程所以不能在同步請求中直接完成,需要异步执行且需要展示升级进度。由于节点升级是高危操作一批节点升级过程中,需要支持用户随时暂停取消升级。
Dashboard 中开发过程:如果该功能在 Dashboard 中实现大概需要以下流程。
- 考虑节点升级请求参数比较复杂没法存在现有表中,需要新建一个表用於存储节点升级的参数和进度
- 编写专门用于上述表的 DAO 层代码。
- 编写一个 Controller 异步流程要为该 Controller 专门实现暂停,取消等控制机制
- 编写专门的旁路进行监控告警。
- Service 中实现节点升级核心流程
- 由于无法单测,觉得写得差不多了需要等待测试环境空闲时,部署到测试环境进行调试注意,测试环境是公共的别人可能也需要用。
Skipper 中开发过程:如果该功能在 Skipper 中实现将基于 Task 异步流程实现,大概需要以下流程:
- 由于 Task 框架已经提供了参数进度的存储,以及 Task 相关的 DAO 代码所以不需要创建任何新的 DB 表;
- 由于 Task 已经实现了统一的暂停,取消等任务控制机制不需要编写相关代码;
- Task 有统一的监控,无需重复编写;
- 由于 Skipper 是可单测的在部署到测试环境之前,我们通过单元测试快速调通了核心逻辑;
- 蔀署到测试环境进行集成测试这时候 Bug 已经很少了。
虽然 Skipper v1 解决了 Dashboard 存在的很多问题但是其自身依然有很多不足,在新需求开发和旧代码迁迻过程中不断暴露出来
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 层依旧使用了全局函数,除非有文档说明否则无法知道该模块对其它模块暴露了哪些
作者简介:黄雷,腾讯云后台工程师Kubernetes 技术專家,系统可观测性专家拥有多年大规模 Kubernetes 集群开发运维经验。目前负责腾讯云 TKE 万级规模 Kubernetes 集群治理主导研发超大规模 Kubernetes 集群联邦智能监控系统与巡检系统。