架构设计一直是技术人的关注热点,如何设计一个更优的架构对于实际的业务来说至关重要。本文腾讯云专家将从自身从事的一个Kubernetes集群管理项目为例,重点剖析在项目开发过程中的三次架构演进历程,即针对项目最早版本的Dashboard架构出现的问题,重写了一个新的Skipper架构,在此基础上,继续不断优化,最终得到一个较为合理架构的经历。
通过本文,你将了解到架构设计的原则、重构的几种模式、DDD中领域思想等,希望本文能为同行带来参考。
本文涉及的项目主要用于腾讯云团队Kubernetes集群管理的项目,其核心业务包括创建、升级、删除集群和节点、集群监控、巡检等。
Dashboard是该项目最早的版本,主要包含API请求处理和异步流程执行等核心功能,是团队最早的核心模块之一。但是随着功能不断增加,Dashboard早期不合理的架构设计所导致的可读性差、扩展性差,无法单测等问题逐渐暴露且愈发严重。为了让Dashboard的质量往更好的方向改进,团队决定对其进行重构。考虑到直接重写的代价和风险过大,团队决定采用“修缮者”策略,即重创一个工程,承载Dashboard新需求的实现,并逐步将旧功能迁移到新工程中,最终达到重写Dashboard的效果,Skipper就是这个新工程。在迁移过程中,团队对Skipper的架构设计经过了几次调整,逐步解决了Dashboard中存在的问题,最终得到一个较为合理的架构,本文记录了重构过程中的思考,和架构演变的过程。
一个好的架构,其终极目标应当是:用最小的人力成本满足构建和维护该系统的需求。也就是说,好的架构目标应当是降低人力成本,这里包括的不仅仅是开发成本,还有构建运维成本。而增加软件可变性就是架构达到最终目标的核心途径,即架构主要是通过增加软件的可变性来降低人力成本。
一个软件的行为固然是很重要的,因为一个不能按预定行为工作的软件是不产生价值的,所以某些开发者认为能实现软件行为是最重要的,根本不该关心架构,反正坏的架构也不是实现不了行为,出了bug修复即可。但随着软件行为的改动,坏的架构将导致他们自己的工作越来越难以进行,改动的代码越来越大,bug越来越多,项目最终可能不可维护。一个软件的架构虽然不直接表现在行为上,但其最大的特点就是良好的可变性,即使目前行为不符合预期,也能通过低成本的改动将行为改变到预期。
可运行不可变软件,最终会因为无法改变而导致行为无法迭代或者迭代慢而变成没有价值;可变不可运行的软件,可通过迭代,变成可运行可变软件。
所以架构比行为重要。
一个不太好的架构,在项目初期有时难以察觉,因为此时项目模块少,功能少,依赖关系显而易见,一切显得毫无恶意,甚至有点简洁美。随着项目的增长,模块增加了,开发人员变多了,架构带来的问题逐渐暴露了出来,混乱的层次关系,毫无章法的依赖关系,模块权责不清等问题接踵而至。对开发人员而言,项目理解成本不断增加,添加小功能都要先理清好几个模块的调用关系,难以测试导致上线后bug防不胜防,组件无法复用。项目逐渐长成大家闻风丧胆,避而不及的“大恶魔”。
架构设计是为了让未来的修改更加容易,但是未来谁又能完全预测准确呢,架构设计或多或少有一定猜测成分在里面,但是更多的是吸取IT行业几十年发展过程中前辈们的经验以及对业务特点的了解所作出的符合一定逻辑的猜测。
那什么算过度设计呢?从架构的目的是降低人力来看,就是该设计目前没有任何强有力的逻辑能推出能在未来降低修改某种行为的人力成本,或者降低某种行为修改成本的同时,大大增加了另外一种行为的修改成本。
架构是有一定理解成本的,甚至架构设计之初会增加一定的系统理解成本,但是一个好的架构理解成本一定不会很高,因为架构的理解也是人力成本。在理解架构设计的意图之前,因为其增加系统的理解成本而否定它的必要性是不合逻辑的。
好的架构,其关键意义在于降低项目发展过程中整体理解成本 也就是说,架构良好的项目随着业务复杂度增加,项目理解成本增长也是缓慢的。架构不合理的项目随着业务复杂度的增加,整体理解成本可能是指数增长的。
根据当前业务的需求对软件架构重新设计,并组织单独的团队,重新开发一个全新的版本,一次性完全替代原有的遗留系统。
没有采用拆迁者模式的原因:
保持原来的系统不变,当需要开发新功能时,重新开发一个服务,实现新功能,通过不断构建新的服务,逐步使遗留系统失效,最终替换它。
绞杀者模式还存在以下问题:
保持原来的系统不变,当需要开发新功能时,重新开发一个服务,实现新功能,通过不断构建新的服务,逐步使遗留系统失效,最终替换它。
将遗留系统的部分功能与其余部分隔离,以新的架构进行单独改造。这种模式特别符合我们的需求。
Dashboard核心功能分为两大块,一个是作为Web API Server,接收http请求,另外一个是异步流程处理,用于耗时较长的功能,比如创建集群、集群升级等。Dashboard整体采用MVC架构 controller模式,这里的controller模式是指通过不断重试,最终将目标对象设置到某种目标状态的模式,比如通过不断重试,将创建中的集群的各部分属性或者依赖的资源,设置到正常集群的状态。Dashboard的核心模块如图。
Dashboard的工程目录如下:
这样看来,Dashboard的分层好像还挺清晰的,确实,相对于没有分层,Dashboard采用MVC架构进行分层本身是有一定合理性的,但是在具体实施的时候,却出现了很多问题,其中较为严重的是每一层只有一个包。
比如Controller包中,所有请求,无论哪个业务模块的,全部放一起,根本无法区分哪些是集群相关的,哪些是监控相关的,哪些是节点相关的,哪些是网络相关的。
如果说Controller包一个文件一个请求还可以理解,那Service层整个只有一个包,不分模块,而且全是全局函数可维护性就很差了,由于核心业务逻辑全在Service层,Service的代码量是所有层中最多的,随着功能的增长,未来Service将越来越臃肿。
其它层,如DAO,甚至Component也是一个包。
Dashboard没有关注各个模块之间的依赖关系,只要不产生循环依赖就可以随意依赖别的模块,所以模块之间依赖十分混乱。这直接导致模块难以复用,例如Component包中部分代码依赖DAO,依赖config,而DAO和config又强依赖了配置文件和DB。这导致如果要复用Component包开发一个很简单的工具,都需要给工具准备Dashboard配置文件,甚至需要能连上DB,下图是Godepgraph工具生成的Component依赖图。
Dashboard虽然进行了分层,但是各层的权责并没有严格实施,导致MVC Controller层和DAO层也包含了大量业务逻辑,甚至有大量与Service层重复的业务逻辑。
Dashboard只划分了水平分层,但是对每一层内部,以及各层之间的通信方式没有做出规定,各层内部可以随意暴露公共函数。各层之间也是直接进行函数调用。
现在更详细地介绍一下在Dashboard的架构下所衍生出的具体问题,这些问题是Skipper v1着重要解决的。
MVC Models层中的对象只有数值,没有方法,所有对象的业务逻辑,无论轻重,都在其他层,这种模型称为贫血模型。相对的,如果对象不仅包含数值,还包含基本的方法,例如自身生命周期设置,版本设置等等,就称为充血模型。Dashboard是贫血模型,这导致DAO层比预期的要厚的多,因为包含了大量业务逻辑,比如设置默认字段,判断字段是否是有效值等等,这些本应该是对象自身才知道的业务逻辑。厚重的DAO层会导致DAO层难以通过Interface进行抽象,想换一种存储简直是不可能的任务。
上文提到,Dashboard中依赖关系十分混乱,而且一层只有一个包,这导致想进行单元测试是不可能的,因为对一个简单的函数单测,可能需要直接连DB,哪怕函数里根本不查DB。Dashboard中各层之间是直接调用全局函数的,并没有通过Interface进行隔离,这就导致想进行单测就必须通过monkey来进行全局函数打桩,不仅无法并发单测,还对体系结构有要求,因为monkey只支持amd64体系结构。
Dashboard只进行了水平分层,但是同层没有分模块,这导致:
Dashboard使用controller模式进行异步操作,但是controller模式在持久化和异步流程控制上能力较为薄弱。
基于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异步流程框架的引擎:
Skipper中也有Service层,和Dashboard不同的是,Skipper的Service会根据业务模块进行分包,比如一个包专门处理集群升级,一个包专门处理监控组件,一个包专门处理巡检等。Skipper的Service层依旧使用了全局函数,没有进行封装(后续将提到,这是Skipper v1版本存在的一个问题)。
由于外部服务以及DB都可以用fake的了,Service层的代码是可以进行单测的。
这里以节点升级功能为例,介绍为什么Skipper v1相对Dashboard能降低人力。
功能简介:节点升级功能是指将一批Kubernetes节点上的组件版本从低版本升级至高版本,这是一个比较耗时的流程,所以不能在同步请求中直接完成,需要异步执行,且需要展示升级进度。由于节点升级是高危操作,一批节点升级过程中,需要支持用户随时暂停,取消升级。
Dashboard中开发过程:如果该功能在Dashboard中实现,大概需要以下流程:
Skipper中开发过程 :如果该功能在Skipper中实现,将基于task异步流程实现,大概需要以下流程:
虽然Skipper v1解决了Dashboard存在的很多问题,但是其自身依然有很多不足,在新需求开发和旧代码迁移过程中不断暴露出来。
Skipper为了采用充血模型,在core obj中进行了封装,例如cluster对象,隐藏了Dashboard中的多个Models结构体,隐藏了某些字段实际是Json字段的,对外暴露出带有方法的cluster对象,设计时候考虑了多种集群存在的可能性,所以整个对象对外不是一个实体,而是暴露了一个Interface。
在实际使用时,发现为了对外暴露对象属性,Interface中充斥了大量的Get的Set方法,显得很笨重,而且由于不同类型集群的差异并不体现在cluster对象本身,而是cluster的业务逻辑中,所以暴露Interface并没有达到抽象集群的作用。
Skipper v1认为像store, Component中的外部组件都是单例的,所以使用了全局依赖。使用全局依赖使得整个工程用的是一个DB,这样的方式至少存在以下几个弊端:
虽然Skipper v1中,各层基本都按功能进行分包了,但是模块并不内聚,一些包之间依赖关系很明显,应该属于一个模块的不同部分,并且由于只使用了水平分层,模块的内部各层代码分散到项目各层中并和其他模块对应层代码耦合在一起。
针对某一模块,由于Service层依旧使用了全局函数,除非有文档说明,否则无法知道该模块对其它模块暴露了哪些API,其它模块甚至可以直接读写该模块的DB。例如集群监控模块,当1.16版本的集群升级时,需要更新对应集群的监控配置,Skipper v1中的实现是在集群升级代码中显示调用更新监控配置的函数,这就使得集群监控开发人员必须理解集群升级的代码并知道在哪里调用更新监控配置的函数,这使得集群生命周期模块和监控模块是耦合的。
为了解决Skipper v1中的问题,设计原则相关的指导被重新审视。虽然比较警惕过度设计,也不喜欢在Golang中使用过多设计模式以及层层封装,但设计原则是所有语言通用的,因为设计原则只是一种思考的方向。
架构设计原则是软件行业几十年发展总结出的一些具有指导意义的思想,虽然在实践时,很难完全遵循设计原则,但是识别其中违反原则的地方,并控制由于违反原则带来的风险是很有必要的。
SRP是最容易被误解的原则,因为大多数人看到名字,就以为该原则指的是一个模块只做一件事,但其实不是这样的。SRP较为经典的描述是任何一个软件模块都应该有且仅有一个原因被修改。也就是Robert在《架构整洁之道》中描述的任何一个软件模块都应该只对一类行为者负责。
这里的行为者是指一个或多个有共同需求的人。在实践中,集群生命周期模块和监控模块是不同的小团队在维护,而Skipper v1的监控模块想支持集群升级时更新配置,却需要改动集群生命周期模块代码,这其实就违反了SRP。
OCP是Bertrand Meyer于1988年提出的设计良好的计算机软件应该易于扩展,同时抗拒修改。
OCP是进行系统架构设计的主导原则,其主要目的是让系统易于扩展,同时限制其每次被修改所影响的范围。实现方式是通过将系统划分为一系列组件,并且将这些组件间的依赖关系按层次结构进行组织,使得高层组件不会因底层组件被修改而受到影响。Skipper v1中task模式是符合开闭原则的,因为如果要添加一个新的异步流程,只要实现一个新的handler即可,并不需要修改task机制高层代码。
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的定义十分直观:客户端不应该依赖它不需要的接口,在Skipper v1中store中定义的接口违反了ISP,因为该接口包含了所有模块的数据库操作接口,基于ISP原则,应该让每个模块自己拥有并维护自己单独的store接口。
DIP主要指导系统各层的依赖关系,高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节;细节应该依赖抽象。
从具体实现而言,如果想设计一个灵活的系统,在源码层次的依赖关系中,就应该多引用抽象类型,而非具体实现。在具体实施时,《架构整洁之道》中给出了4点建议:
应该将那些会同时修改,并且为相同目的而修改的类放在同一个组件中,而将不会同时修改,并且不会为了相同目的的修改的那些类放在不同组件中。
CCP是SRP有很多相似的地方,统一描述他们的思想就是将由于相同原因而需改,并且需要同时修改的东西放在一起。将由于不同原因而修改,并且不同时修改的东西放在一起。
不要强迫一个组件的用户依赖他们不需要的东西。这个原则指出应该将那些会被同时用到的代码放在同一个组件中。Dashboard的依赖关系图显示Dashboard严重违反了共同复用原则。
组件依赖关系图中不应该出现环。Golang编译器实际上已经帮助避免了循环依赖。
依赖关系必须要指向更稳定的方向。这条原则指出,一个预期会经常变更的组件不该被一个难以修改的组件所依赖,否则这个多变的组件也会变得难以被修改。这里所谓的稳定组件,就是指那些被别的组件依赖多的组件,不稳定的组件是那些依赖很多其他组件,但被其他组件依赖少的组件。稳定组件需要依赖不稳定组件时怎么办呢?在他们中间加入一层稳定的抽象层。
一个组件的抽象化程度应与其稳定性保持一致。SDP中提到,稳定的组件是不易修改的,这会导致整个项目的架构难以被修改,需要通过高度抽象这些稳定的组件,来让其接受修改。前一个原则SDP指出:依赖应该指向更加稳定的方向。而SAP指出:越稳定,抽象化程度应该越高。这两个连起来就可以得出另外一个结论:依赖关系应该指向更加抽象的方向。
领域驱动开发是一种用于复杂软件的架构设计思想,学习门槛比较高且对团队成员整体架构水平要求较高,其实并不适合完全使用在Skipper的开发中,只借鉴其中一部分即可。
Skipper v1中依旧采用了MVC分层。但是领域驱动开发,以及《架构整洁之道》都指出,应当存在一个应用层(《架构整洁之道》中称为use cases层)用于处理依赖多个组件的业务逻辑,各层之间依赖于接口而非实现,且下层不能依赖上层。比如创建一个包含三个节点的集群,就同时需要操作集群模块和节点模块。
领域驱动开发中,每个领域称为Domain,每个Domain有自己的领域实体,并且是充血模型,每个领域的存储也是内聚在领域之中,综合以上,水平分层应当如下。
在领域驱动开发中不仅进行了水平分层,还进行了垂直切片,将应用层以下划分成了不同领域(Domain),每个领域责任明确且高度内聚。领域的划分应该满足单一职责原则,每个领域应当只对同一类行为者负责,每次系统的修改都应该分析属于哪个领域,如果某些领域总是同时被修改,他们应当被合并为一个领域。一旦领域划分后,不同领域之间需要制定严格的边界,领域暴露的接口,事件,领域之间的依赖关系都该被严格把控。
领域可以定义事件并发布到事件总线总,如果对某个领域事件感兴趣,就可以订阅事件。领域事件可以大大降低各领域间的耦合,且对系统扩展性有巨大好处。
例如在Skipper v1中,如果划分出了集群监控领域和集群生命周期管理领域,当有一天监控领域决定去掉集群升级过程中对监控配置文件的修改,需要在集群升级代码里找调用监控配置文件升级的地方。而如果采用了领域事件,则只需要让集群生命周期模块发布升级完成事件,并让监控模块订阅或者取消订阅事件进而做出配置文件修改逻辑即可。
参考前两文的探索,Skipper v1做了一定调整。
下图是v1到v2的转变,其核心是加入是领域模型,形成高内聚的业务领域组件。
随着业务的发展,会有越来越多的领域被加入到Skipper中(目前已经出现"虚拟集群"领域)。
当一个新的领域被加入到Skipper中时,根据上边的架构,只需要借鉴其他领域的设计,新建一个领域,并在让领域负责人在此领域中迭代需求即可,这过程中,新领域可以依赖其它领域,监听其它领域的事件等等,对其它领域而言都是无感的。
随着领域内业务逻辑越来越复杂,或者因为业务调整,存在某个领域独立出项目的情况(目前"集群监控"领域已准备独立),由于我们的领域是高内聚的,领域独立的难度并不大,对整个项目而言,也只是将剥离的领域从领域层转移至infrastructure层,做为外部服务而已。
由于领域之间总是依赖于接口或者依赖于领域事件,当领域独立时,依赖这个领域的业务逻辑是不需要进行修改的。
可能随着领域不断剥离,项目的领域不断的成为独立的服务,当服务增多时,就需要引入更加统一有效的运维、监控、部署方案,这才是项目微服务化最自然的方式,项目应尽量是单体应用。
以增加集群创建失败通知机制为例,目前,这个集群创建成功率虽然符合SLA,但是依然不是100%的。我们希望当集群创建失败时应该能第一时间收到通知,而通知本身是一个比较简单的需求。
Skipper v1中开发:在Skipper v1中开发,最大问题是开发人员必须知道集群创建失败的具体位置,这只有集群创建流程的开发人员才知道,为了加入通知功能,新人不得不去请教集群创建流程的开发人员,并且需要修改集群创建流程,由于修改了集群创建流程,还需要走测试,虽然通知功能的代码不多,但是由于要修改集群创建流程,导致了人力成本的增加。
Skipper v2中开发:在Skipper v2中开发,只需要单独创建一个领域,专门用于系统各种需要触达我们的通知,然后订阅对应事件即可,比如该例子中,就是订阅集群创建失败事件。这种开发模式,不需要修改集群创建流程代码,一切改动都在关键事件通知领域进行,且基于这种开发方式,就不会让事件通知代码散落在各个领域中。
架构调是一件整需要勇气的事情。一旦宣布进行项目架构调整,就是宣告现有项目架构不合理,也意味着必须将设计出比当前优秀的架构。这个过程中,架构师可能会犯错,需要进行一些猜测,或者和他人存在诸多观点冲突,有时甚至需要有点“固执”才会成功。
我们反对过度设计,但是识别,或者说猜测项目未来符合逻辑的可能变动,将架构设计考虑进项目早期是十分有必要的,架构设计和调整应该贯穿项目的整个成长过程。永远记得架构投资的是未来,不能只顾当下。
原文链接: https://mp.weixin.qq.com/s/FT1mU_3yH50BHPVsEVFGyQ