作者| 黄磊,腾讯云后端工程师
编辑| 唐晓音
头图| CSDN从东方IC下载
制作| CSDN(ID:CSDNnews)
介绍
本文首先介绍了建筑的重要性,然后逐步介绍了主流的建筑设计思维及其解决的实际问题,重点介绍了一个真实项目的重构过程。通过阅读本文,您将了解到:
建筑的重要性。
几种重建模式。
设计原则。
DDD 中的领域思维。
项目的可测试性。
项目的可演化性。
实际背景介绍
本文相关项目主要用于腾讯云团队的K8s集群管理项目,核心职责包括集群及节点的创建、升级、删除、集群监控、巡检。
老项目介绍
仪表板是该项目最早的版本,主要包含处理API请求、运行异步流程等核心功能,是团队最早的核心模块之一。但随着功能数量的不断增加,最初的Dashboard架构设计不合理导致可读性差、扩展性差、无法进行单独测试等问题逐渐显现并愈加严重。为了更好地提高仪表板的质量,团队决定重构仪表板。
引进新项目
考虑到直接重写的成本和风险太高,团队决定采用“fixer”策略。这意味着重新创建项目以实现仪表板的新要求,并逐步将旧功能迁移到新功能。创建项目并最终完成重写仪表板的目标后,这个新项目就是Skipper。在迁移过程中,团队对Skipper的架构设计进行了多次调整,逐步解决了仪表板中存在的问题,最终获得了更加精简的架构。本文记录了我们在重建过程中思维和架构的演变。
建筑的重要性
架构目标
追求好的建筑的目的是什么?换句话说,好的建筑能创造什么价值?
良好架构的最终目标是以最少的劳动力成本满足构建和维护系统的需求。
换句话说,好的架构的目标应该是降低人力成本,这不仅包括开发成本,还包括建设和运维成本。增加软件可变性是架构实现其最终目标的核心方式,架构主要通过增加软件可变性来降低劳动力成本。
行为和架构哪个更重要?
软件行为当然非常重要,不按照预定行为进行行为的软件是没有价值的。该架构完全没问题。糟糕的架构就是糟糕的。并不是说这种行为不能实施。如果存在错误,只需修复它们即可。他们忽略的是,随着软件行为的改变,糟糕的架构让他们的工作越来越难,改变的代码越来越大,bug越来越多,项目就耗尽了。我认为这意味着它会消失。它最终可能变得不可持续。
软件架构虽然不直接影响其行为,但其最大的特点是其可变性,这意味着即使当前的行为不符合预期,也可以以低成本的改变改变为预期的行为。
能够一成不变地运行的软件最终毫无价值,因为它无法更改,而且其行为要么不可重复,要么重复缓慢。架构比行为更重要,因为可变的、不可执行的软件可以通过迭代变成可执行的、可变的软件。
魔王从小就很可爱。
重构的粒度太大,我想重构到功能层面。
修复模式
将遗留系统的某些功能与其余功能分开,并在新架构中单独进行改造。
Fixer 模式特别适合您的需求。
仪表板架构
整体架构
仪表板的主要功能分为两部分:一是接收HTTP 请求的Web API 服务器,二是异步处理和长期处理(例如集群创建或集群升级),用于功能功能。
仪表板整体采用MVC架构+控制器模式,这里的控制器模式指的是通过不断重试最终将目标对象设置为特定状态的模式。正在创建的集群的资源被设置为正常集群状态。仪表板的核心模块如图所示。
MVC控制器:用于接收HTTP请求并调用服务进行业务处理。
MVC 服务:所有核心业务逻辑都驻留在这一层。
MVC DAO:所有与DB相关的操作都在这一层。
MVC Models: 包含每个对象的字段,例如集群、节点等。
控制器模式下的每个控制器:每个控制器都有非常不同的逻辑,但都调用服务来初始化或设置对象的状态。
组件:所有调用外部服务的模块,例如调用计算资源服务创建虚拟机或调用网络资源服务建立网络等,都驻留在此处。
虽然Dashboard是水平层次结构,但每个层次结构内的组件没有设计原则或代码规范,每个层次结构本质上都是一个包,而包内的代码质量不高,存在大量重复代码。
具体实施
仪表板项目目录如下:
每层一包
从这个角度来看,仪表板的层次结构看起来非常清晰。事实上,与不分层相比,仪表板采用MVC 架构进行分层是有意义的。但在具体实施过程中却遇到了很多问题。其中最严重的是每一层只有一个包裹。
例如,Controller包将所有请求捆绑起来,不考虑业务模块,无法区分哪些是集群相关的、哪些是监控相关的、哪些是节点相关的、哪些是网络相关的。 -有关的。
如果我们理解为Controller封装了一个文件、一个请求,那么Service层无论模块、都是全局功能都只有一个封装,可维护性非常差。 Service中的代码量虽然很少,但却是所有层中最大的,而且随着未来功能的增加,Service的规模还会不断增长。
其他层和组件(例如DAO)也是包。
依赖关系很难理解
Dashboard 不知道模块之间的依赖关系,并且可以自由地依赖其他模块,除非发生循环依赖,这使得很难看到模块之间的依赖关系。这直接导致了模块复用的困难;比如Component包中的一些代码依赖于DAO和Config,而DAO和Config对配置文件和DB有很强的依赖性。这意味着,如果您想通过重用组件包来开发一个非常简单的工具,您将需要为您的工具准备一个仪表板配置文件,并且您还需要能够连接到数据库。
各层之间职责不明确
仪表板虽然是分层的,但各层的权限和职责并没有严格执行,因此MVC控制器层和DAO层还包含大量的业务逻辑,甚至服务层。
每层都没有设计
仪表板仅划分水平层次结构;它们不规定每个层次结构内部或之间的通信方法,公共功能可以在每个层次结构内自由暴露。每层之间也直接进行函数调用。
仪表板架构存在哪些问题?
为了解决控制器模式问题,Skipper开发了任务异步流程执行框架。虽然是用来运行一次性异步进程,但是仍然保持了控制器模式的存在,任务控制器是任务的引擎。任务的异步处理框架。
(1)Controller模式用于需要持续运行的全局旁路,例如节点状态监控、任务执行监控等。
(2)任务模式用于节点升级、集群升级等复杂的一次性流程。
服务外包
Skipper 还有一个服务层。与Dashboard不同的是,Skipper的服务是根据业务模块进行分包的。例如,一个包专用于集群升级,一个包专用于处理监控组件,另一个包专用于处理检查。这样的。
Skipper的服务层仍然使用没有封装的全局函数,但是我们稍后会看到这是Skipper v1中的一个问题。
可测试的
您可以模拟外部服务和数据库,从而允许您单独测试服务层代码。
为什么我可以比仪表板减少人数?
案例:节点升级
这里我们以节点升级功能为例,解释一下为什么Skipper v1相比Dashboard可以减少工时。
功能介绍:节点升级功能是指将一批k8s节点上的组件版本从较低版本升级到较高版本。这是一个比较耗时的过程,所以无法直接用同步请求来完成。计划应该异步运行并且升级应该是可见的。升级节点是一个高风险的操作,所以您应该支持您的用户在批量节点升级过程中随时暂停和取消升级。
仪表板中的开发流程:如果您想在仪表板中实现此功能,您可能需要以下流程:
考虑到节点升级请求参数比较复杂,无法存储在现有表中,因此需要新建一张表来存储参数和节点升级进度。
写出相应的模型。
专门为上表创建一个DAO层代码。
为控制器创建异步流程,并专门为控制器实现暂停、取消等控制机制。
创建用于监控和警报的特殊旁路。
将节点升级的核心流程实施到您的服务中。
我感觉已经快写完了,因为无法单独测试,但是需要等待测试环境空闲后再部署到测试环境进行调试。请注意,测试环境是公共的,其他人可能需要使用它。
Skipper中的开发流程:如果在Skipper中实现该功能,它将基于任务的异步流程来实现,因此您可能需要以下流程:
不需要创建新的数据库表,因为任务框架已经提供了参数、进度存储和任务相关的DAO 代码。
Task 实现了集成的暂停、取消等任务控制机制,因此您无需编写任何相关代码。
创建任务处理程序来实现节点升级。
任务具有集成的监控功能,因此您不必重复编写它们。
Skipper允许进行单一测试,因此我们在将其部署到测试环境之前通过单元测试快速调试核心逻辑。
部署到测试环境进行集成测试。此时的错误很少。
Skipper v1 有问题
虽然Skipper v1解决了仪表板的很多问题,但仍然存在很多缺点,在新需求的开发和旧代码的迁移过程中不断暴露出来。
过度设计核心对象
为了使用拥塞模型,Skipper将拥塞模型封装到集群对象等核心对象中,在仪表板中隐藏了多个模型结构,并隐藏了一些字段实际上是JSON字段的事实,使其可见并通过方法公开了集群对象。因为设计时考虑到了多个集群的存在,所以整个对象对外暴露的是接口而不是实体。在实际使用中我们发现接口中嵌入了大量的Get和Set方法来向外界暴露对象属性,非常繁琐,结果发现差异并没有体现出来。公开接口并不能完成抽象集群角色,因为它位于集群的业务逻辑中,而不是集群对象本身。
全局依赖
Skipper v1 使用全局依赖项,因为它将存储中的外部组件和组件视为单例。使用全局依赖意味着整个项目使用一个数据库,但这种方法至少有以下缺点:
(1) 每个模块DB 都是组合的,不能单独保存。目前,所有模块共享存储,但随着模块的增长,模块DB 可以独立。
(2)由于所有外部服务都聚合到了Component中,所以外部服务的使用依赖于所有外部服务,而在使用Component时,需要从世界各地获取对应的Component,是的,会出现很多重复的代码。
模块没有组织好
在Skipper v1中,每一层本质上都是根据功能进行分包的,但是模块并没有组合在一起。某些包之间的依赖关系是显而易见的,并且必须属于模块的不同部分。由于仅使用水平分层,模块的内部代码层分布在项目的每一层,并与其他模块的相应层代码相结合。对于给定的模块,除非存在文档,否则不可能知道该模块向其他模块公开哪些API,因为服务层仍然使用全局函数,其他模块甚至可以直接读写该模块的DB。例如,在集群监控模块中,当集群版本升级到1.16时,对应集群的监控配置必须更新,而Skipper v1实现提供了向集群调用更新后的监控配置的功能。升级代码需要集群监控开发人员了解集群。升级后的代码知道在哪里调用集群生命周期模块和监控模块组合的函数来更新监控配置。
查看更多详情
为了解决Skipper v1 的问题,我们决定重新审视设计原则指南。我们对过度设计比较警惕,不喜欢在Golang中使用太多的设计模式或封装层。然而,我们相信设计原则是所有语言所共有的,因为设计原则只是想法。你就能了解到架构不好的方面,Flavor也会变得更加警觉。
建筑设计原则
架构设计原则是从软件行业几十年的发展中提炼出来的一些指导思想。尽管在实践中很难完全遵循设计原则,但识别违反原则的行为并控制违反原则带来的风险很重要。非常有必要。
SRP:单一职责原则
SRP 是最容易被误解的原则。大多数人看到这个名字就会认为这个原则指的是只做一件事的模块,但事实并非如此。 SRP比较经典的解释是:
软件模块的更改只能出于一个原因。
这里我更喜欢罗伯特叔叔在他的书《架构整洁之道》中解释的内容。
任何软件模块都应该只对一种类型的参与者负责。
这里的演员是指一个或多个有共同需求的人。从我们的实际背景来看,集群生命周期模块和监控模块是由不同的小团队来维护的。在Skipper v1中,监控模块希望支持集群升级期间的配置更新,但需要集群生命周期模块中的代码更改。这是:事实上,这是违反SRP的。
OCP:开闭原则
OCP 由Bertrand Meyer 于1988 年提出。
设计良好的计算机软件应该易于扩展且不易改变。
OCP是系统架构设计的一个关键原则,其首要目的是方便系统扩展,同时限制每次变更的范围。该实现将系统划分为一组组件,并以层次结构组织这些组件之间的依赖关系,以便较高级别的组件不会受到底层组件更改的影响。 Skipper v1 的任务模式遵循开始和结束原则。这是因为当你添加一个新的异步进程时,你只需要实现一个新的处理程序;你不需要改变任务机制的高层代码。
LSP:里氏替换原则
1988 年,Barbara Liskov 在解释如何定义子类型时写道:
这里我们需要一种可替换性:如果对于每个T1 类型的对象o1 都存在一个T2 类型的对象o2,那么如果每个对象o1 都被o2 替换,那么T1 程序P 中定义的所有对象都被o2 替换。如果行为没有变化,则类型T2 是类型T1 的子类型。
面向对象语言有不同的解释。
对基类的所有引用都必须透明地使用其子类的对象。
当然,Golang不是面向对象的语言,没有父类或子类的概念,但Liskov原则为接口的使用提供了重要的指导。
假设我们有接口A 的实现Aa 和Ab。即使特定实现从Aa 更改为Ab,使用接口A 的程序的行为也保持不变。
在Skipper v1中,存储层遵循里氏替换原则,因为存储接口的用户行为在DAO版本实现和假版本实现之间没有变化。 Robert 举了一个著名的反面例子,即正方形和长方形问题《架构整洁之道》。假设类Rectangle 代表一个矩形。假设Square 类集成了Rectangle 来表示一个正方形。使用Rectangle 对象的程序无法用Square 对象替换Rectangle 对象。这是因为矩形可以具有任意长度和宽度,但正方形不能。
ISP:接口分离原则
ISP的定义非常直观。
客户端不应依赖他们不需要的接口。
Skipper v1的Store中定义的接口违反了ISP,因为该接口包含了所有模块的数据库操作接口。基于ISP原则,每个模块应该能够拥有并维护自己独立的Store接口。
DIP:依赖倒置原理
DIP主要指导系统各层的依赖关系。
高层模块不应该依赖于低层模块,两者都应该依赖于它们的抽象,抽象不应该依赖于细节,细节应该依赖于抽象。
当谈到具体实现时,如果你想设计一个灵活的系统,你应该在源代码级别的依赖项中引用抽象类型而不是具体实现。具体实施时,《架构整洁之道》中提出了4项建议。
(1) 应避免在代码中写入与特定实现相关的名称,或其他容易更改的内容。
(2)代码中应多使用抽象接口,避免可修改的具体实现类。
(3)不要为具体的实现类创建派生类。 Golang语言与此本质上是一致的。
(4) 不要用具体的实现来覆盖函数。换句话说,不要重写它。这违反了Skipper v1 的任务模式,因为任务模式要求嵌入所有任务处理程序以减少代码重复。更改并重写似乎需要修改的默认处理程序函数。
组件设计原则
CCP:公共封闭原则
出于相同目的而同时修改的类应放置在同一个组件中,而不同时且出于相同目的而修改的类应放置在单独的组件中。
事实上,CCP 和SRP 之间有很多相似之处,使我们能够以统一的方式解释这些想法。
将因相同原因需要更改的事物和需要同时更改的事物分组。将因不同原因但不同时发生变化的事物分组。
CRP:通用重用原则
不要强迫组件的用户依赖他们不需要的东西。
这个原则实际上是说,同时使用的代码应该放在同一个组件中。
ADP:依赖原则
组件依赖图中不应该有循环。
事实上,Golang编译器帮助我们避免了循环依赖。
SDP:稳定依赖原则
依赖关系应该指向更稳定的方向。
该原则指出,预计会频繁更改的组件不应依赖于难以更改的组件。如果没有依赖关系,更改易失性组件也很困难。这里所谓的稳定组件是指对其他组件依赖程度较高的组件,而不稳定组件是指依赖于许多其他组件,但又不依赖于其他组件的组件,是指强度较低的组件。
如果一个稳定的组件仍然需要依赖一个不稳定的组件怎么办?我们需要在它们之间添加一个稳定的抽象层。
SAP:稳定的抽象原则
组件的抽象级别必须与其稳定性相匹配。
社会民主党声明:
稳定的组件是不易修改的,这会导致整个项目的架构难以被修改,我们需要通过高度抽象这些稳定的组件,来让其接受修改。 前一个原则 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 集群联邦智能监控系统与巡检系统。 今日福利 遇见大咖 由 CSDN 全新专为技术人打造的高端对话栏目《大咖来了》来啦! CSDN 创始人&董事长、极客帮创投创始合伙人蒋涛携手京东集团技术副总裁、IEEE Fellow、京东人工智能研究院常务副院长、深度学习及语音和语言实验室负责人何晓冬,来也科技 CTO 胡一川,共话中国 AI 应用元年来了,开发者及企业的路径及发展方向!