从业务开发中学习和理解架构设计
前言
在软件开发领域经常会接触到架构这个词汇,在我最初的印象中,架构是一个很高级的词汇。它似乎代表了复杂的工程结构、高层次的抽象设计、最新的开发语言特性等等。对于当时只专注于写业务逻辑的我来说,不免心生对架构的敬畏。工作中对架构的讨论很少,出现则是一些高级晦涩的描述,但是从来没有人清楚地解释过架构做了哪些事。所以,架构到底是什么?架构和业务之间是什么关系?
当我们看一些关于架构的书籍或者资料,不免会接触到一些对架构的定义或者描述。比如:约束、规则、边界、实体关系、模型定义等等。但是懂得这些概念并不能帮助我们设计出来更好的架构,当我们套用设计原则进行架构设计时,不免会觉得空洞乏味,总觉得少了点什么。
虽然我们为架构设计做了很多事,但是似乎什么也没做,因为只针对架构设计本身来说,很难说清楚它所产生的价值。所以,好的架构设计的出发点是什么?好的架构应该是什么样的呢?
在上个财年,我有一个任务:将我们当前工程的代码进行重新的拆分和组合,以厘清模块间的关系,控制工程中模块依赖的复杂度。这看起来是一个很简单的工作,找到一个不同于当前的且更合理的目录划分方案,就可以尝试落地实施。但是这又是一个很困难的工作,因为我们首先要回答有哪些模块、模块间是什么依赖关系的问题。
其实,回到任务的本身,我们并不是只想对代码文件进行重新组织和划分,而是要将业务模块解耦合,定义并明确业务模块间的依赖规则。面对这样的目标,我们需要首先从业务视角更清晰地定义和划分模块,然后从工程结构视角确定模块间的关系。
所以,代码目录调整实际上是一个对业务场景、工程结构理解和设计的问题。代码目录的结构代表了我们的工程结构,也是业务场景划分的抽象描述,更是模块定义以及模块依赖关系的展现。
在设计代码目录划分方案的过程中,看了一些工程结构设计的资料,读了一些关于架构设计的书。对于架构有了一些理解。本文是对这段学习和任务完成过程的思考和沉淀。我希望能够回答上面提到的几个问题:
1.架构到底是什么?架构和业务之间的关系
2.好的架构的设计出发点是什么?好的架构应该是什么样的
什么是架构
架构的定义
从工程化解读架构的作用
房屋用途。首先要明确这栋房子是干什么用的 房屋层数。和用途紧密相关,不同用途的房子层数也是不一样的 房屋外观。定义这栋房屋应该长什么样 房屋的布局。定义这栋房屋应该怎么更好地被使用,等等
软件架构设计的原则和要求
架构设计一定要从业务场景出发
架构设计一定要落到业务场景中去验证
架构设计的原则
SRP 单一职责原则
一个函数只负责完成一个功能
任何一个模块只对某一类行为者负责
一个类或者函数应该有且仅有一个被改变的理由
在实际的编码中,我们还是可以看到很多违反单一职责的例子,比如超长的函数体。一个函数内做了很多事,实际上就是负责了太多的功能,很多的变更都要修改这个函数,这导致很难控制变更影响的范围。
我们可以将大函数拆分成小函数,小函数体负责的功能更加单一,相应的也会更加灵活。所以建议大家多写一些小的函数体。但是不要在函数拆分的过程中进行过度的封装和抽象。
OCP 开闭原则
易于扩展,抗拒修改
LSP 里氏替换原则
所有引用父类的地方都可以替换成子类,而行为不发生改变
1. class Rectangle {
2. public:
3. int32_t getWidth() const {return width;}
4. int32_t getHeight() const {return height;}
5.
6. virtual void setWidth(int32_t w) {
7. width = w;
8. }
9. virtual void setHeight(int32_t h) {
10. height = h;
11. }
12.private:
13. int32_t width = 0;
14. int32_t height = 0;
15.};
1. class Square : public Rectangle {
2. public:
3. void setWidth(int32_t w) override {
4. Rectangle::setWidth(w);
5. Rectangle::setHeight(w);
6. }
7. void setHeight(int32_t h) override {
8. // …
9. }
10. };
11.
12. void reSize(Rectangle rect) {
13. while (rect.getHeight() <= rect.getWidth()){
14. rect.setHeight(rect.getWidth() + 1);
15. }
16. }
正方形类Square继承自矩形类Rectangle,并且重写了函数setWidth和setHeight。在函数reSize中,将父类Rectangle对象替换成子类Square后,将会出现死循环,程序出现异常。不符合LSP原则。
所以正方形不是矩形。
第二个是鸵鸟不是鸟。
1. class Bird {
2. public:
3. int32_t getVelocity() const {return velocity;}
4. private:
5. int32_t velocity = 0; // 飞行速度
6. };
7. class Ostrich : public Bird {
8. };
1. void crossRiver(Bird bird) {
2. int32_t distance = 1000;
3. int32_t elapsed = distance / bird.getVelocity();
4. }
鸟类Brid具有飞行速度的属性,鸵鸟类Ostrich继承自类Brid,飞行速度默认为0。在函数crossRiver中,将基类Brid对象替换成子类Ostrich对象后,获取的飞行速度为0,出现了除0异常。不符合LSP原则。
所以鸵鸟不是鸟。
在这两个例子中,结合里氏替换原则, 我们得出了两个奇怪的结论,违背了几何学和生物学的常识。其实问题在于我们对抽象和接口的设计上。比如前一个例子中reSize函数,它的条件判断是有问题的。对于一个矩形对象,宽高不一定非得相等,所以将宽高相等作为循环的条件是不合理的。
对于后一个例子,飞行并不是鸟类的统一特征,所以抽象的鸟类不应该拥有飞行速度这个属性,也不应该具有飞行的接口。那么我们应该怎么处理这个问题呢。准确来说,鸟类可以具有是否可以飞行的接口,然后有一个速度属性。可以飞行的鸟返回飞行速度,而鸵鸟返回行走速度。
所以,里氏替换原则用于验证我们的接口和抽象设计是否合理,同时也可以验证继承关系是否合理。
ISP 接口隔离原则
不依赖于自己不需要的东西
使用接口类的方式细化功能模块,每个接口类负责某一类明确的功能
指导我们进行接口设计的原则。类似于单一职责原则,多个单一的接口负责的功能更简单,更易于维护,这比一个庞大的接口要好。在做接口设计时要尽量保证接口的小巧、简洁和正交,这样给业务层提供了更多的灵活性。一个大的接口可能会做业务层并不希望做的事,同时当业务层需要扩展功能时也会使变更影响的范围过大。
DIP 依赖反转原则(依赖倒置)
为了保证系统的灵活性(易于修改)和稳定性(修改影响范围小),在依赖关系中应该避免引用具体的类
接口比实现更稳定,所以尽量避免修改函数实现时对依赖该接口的模块的影响
继承关系是依赖关系中最强的,尽量避免继承自有具体实现的类
这个原则目的在于降低使模块间的耦合度,并且使底层模块更易于被修改和替换。当下层功能发生变化时可以控制对上层业务的影响范围,使得整体系统更加稳定和灵活。
DIP原则在后面章节介绍架构设计方法时也会多次提到。
以上这五个设计原则统称为SOLID原则。在《整洁架构之道》中有比较详细的介绍。
奥卡姆剃刀原则
奥卡姆剃刀原则不是在软件开发领域提出的,而是在哲学领域提出的。奥卡姆剃刀原则对科学和哲学的发展都极为重要,因为它告诉人们理论应该尽量简洁,理论中一切不影响结论的多余部分都应该被剔除掉。
正如奥卡姆剃刀原则的精髓一样,它的描述非常简洁有力:如非必要,勿增实体。
我们也可以称它为简单即为美原则。通俗的描述是:用尽量少的步骤完成一件事。或者,如果对于一个事物有两种解释,采用最简单或能被证伪的那种。正是因为奥卡姆剃刀原则,我们才更加相信哥白尼的日心说,更相信牛顿和爱因斯坦。否则,地球是宇宙中心的理论也没错,只是其他行星和恒星环绕地球的轨道公式也太复杂了,而且也容易被自然现象证伪。
在众多的介绍软件设计方法的书籍和资料中也多次提到过奥卡姆剃刀原则。应用到软件开发领域,它确实给了我们很大的启示。设想一下我们是不是遇到过这样的场景:
费力地向别人解释某个模块为什么那么设计
为某段代码加的注释比代码都多
为了解决一个问题而引入一个新的模块
当我们费力说明和解释某个代码设计时,真正的问题并不在于我们解释的不够充分,或者听众不够聪明理解不了,而在于代码设计本身没有很好地体现其业务语义。实际上过多的解释和注释都是多余的,是可以被奥卡姆剃刀砍掉的。
对于为了解决一个问题而引入一个模块也是在工作中经常遇到的问题。有很多原因导致某些模块变得腐化难以维护,比如最初的设计没有很好地贴合业务场景;编码规范不够好,后面的修改也没有遵守规则;接手者没有完全理解作者的意图就着手修改等等。
而程序员也经常会有的一个想法是:当一个模块难以维护了,最好的方法是用一个新模块替换掉它。实际上这种方法并没有触及问题的本质,在没有找到导致模块腐化的原因之前,在没有制定规范的模块设计方案之前,我们都不能保证新模块不会有旧模块一样的问题。
所以,想开发新模块替换掉旧模块很大程度上是在逃避对旧模块问题的思考,新模块也很有可能沦落到旧模块一样的地步。如果回答不了这个矛盾的问题,还是用奥卡姆剃刀把新模块剔除掉吧,新模块是多余的,并没有解决真正的问题。
奥卡姆剃刀原则保证解决问题的方法是简单有效的,同时也约束我们应当思考更根本的问题,不能浮于问题表象采用最省力的方法。
其他的设计原则概览
DRY(Dont Repeat Yourself)
保证代码的可复用性,避免代码逻辑的重复
YAGNI(You Aint Gonna Need It)
代码应易于扩展,但要避免过度设计,不要编写当前用不到的代码。
KISS(Keep It Simple, Stupid)
把事情想复杂,做简单
POLA(Principle of Least Astonishment)
最小惊奇原则。代码应合乎逻辑和规范,给阅读者最少的惊吓。接口设计避免标新立异。
常用的几种架构设计
分层架构
分层架构是指基于具体的业务模型按照功能模块将代码进行分层组织。每一层代表了一组相关功能的集合。具体分为几层没有明确的规则,通常可以分为3-4层或者更多。在分层架构中,依赖关系是由上往下,上层依赖于下层,不能反向依赖。越往下的层次越通用,偏向于基础能力。越往上层次越动态,偏向于业务。
分层架构设计按照依赖规则的严格程度分为严格型分层架构和松散型分层架构。严格型分层架构要求每一层只能访问其直接依赖的层,不能访问其间接依赖的层。松散型分层架构允许每一层访问位于其下方的任意一层。严格型分层架构使得各个层之间的耦合度降到最低,但是灵活性不足,当上层需要访问下面间接层的能力时必须从上往下层层穿透。松散型分层架构在保证依赖规则的前提下提供了足够的灵活性,所以大部分分层架构都是松散型的。
分层架构设计简洁易懂。对抽象事物按照基础特征进行分类,符合我们的思维习惯,易于理解。分层架构设计保证每一层内部有较好的内聚性,减少了层与层之间的耦合度,易于基础能力的沉淀和复用,也易于控制变更带来的风险。
另外一方面,分层架构设计虽然定义了多个层,但是层与层之间的边界并不是特别清晰。对于新增的模块有可能难以确定应该放在哪一层。或者随着业务逻辑的变化,未来可能需要调整模块所属的层次。分层架构中,上层模块对下层模块有直接的依赖,下层模块的实现直接向上层模块暴露。在修改或者替换下层模块时需要修改上层模块,对上层业务的影响较大。业务实现与基础能力没有完全解耦合。
六边形架构
又称为端口-适配器架构。为了解决具体实现依赖于基础能力的问题,采用依赖倒置设计方法将工程分为内部和外部。内部是具体的业务逻辑,外部是依赖的基础能力。内部业务逻辑不再直接依赖于外部基础能力,而是都依赖于其抽象定义。使用依赖注入的方式将外部实现传入内部业务逻辑中。内部和外部使用接口进行交互,内部业务逻辑访问基础能力时直接调用其抽象接口即可。
六边形架构解决了业务逻辑直接依赖外部模块的问题,它们都依赖于抽象,不依赖于直接的实现和细节。它们直接通过定义好的接口进行交互。因为业务逻辑和外部模块没有直接的依赖关系,在修改和替换外部模块时只需要按照接口定义实现功能,不需要改动业务逻辑。
洋葱圈架构(整洁架构)
洋葱圈架构又称为整洁架构,结合了分层架构、六边形架构和领域驱动设计特点的架构设计方法。洋葱圈架构是对六边形架构的进一步扩展,依赖关系依然是外部依赖内部。参考领域驱动设计,将依赖层次划分为3-4层甚至更多。从内向外依次为:领域模型、业务逻辑、领域服务、基础能力、外部模块等。
洋葱圈架构具有六边形架构的优点,采用依赖倒置的原则使内部业务模型不再直接依赖于外部基础能力。外部模块的变动和替换不影响内部业务逻辑。采用领域驱动设计的方法划分实体和模型,利于业务规则的抽象和业务模型的建立,对未来业务迭代的支持较好。洋葱圈架构使业务实体、业务模型和业务实现处在里层,保证了业务模型和实现的稳定,避免受到外部模块变动的影响。
例如,使三方SDK或者数据库系统属于最外层,使用依赖注入的方法将它们的实现传入内部逻辑。当替换三方SDK或者数据库系统时,按照接口定义实现具体细节即可。不需要对内部逻辑进行改动。
领域驱动设计方法
领域驱动设计简称为DDD(Domain-Driven Design)。准确来说它不是一个架构设计方法,而是一种以业务分析和划分来驱动系统架构设计的软件开发方法。它强调识别业务的核心问题域来确定问题边界,同时将问题域进行分解降低分析的复杂度。DDD强调通过关注业务核心提升业务价值。
下面是DDD的一些核心概念,我们做一些简单的介绍。
领域:有确定的范围和边界的业务问题域。实际上是我们要解决什么业务问题的抽象描述。比如提供给用户当前位置、目的地位置且提供到达信息是高德地图的问题域。
子域:将大的问题域根据业务规则的不同拆分成的小问题域。比如高德地图的问题域太大了,难以解决。我们可以将问题域拆分成定位、POI搜索、路线规划等子问题域。
界限上下文:领域之间的抽象边界。封装了领域内的概念、规则和模型。
实体:具有唯一标识的、存在生命周期的对象。比如展示给用户可见的POI气泡是一个实体,它有状态和确定的生命周期。
值对象:没有唯一标识和生命周期的对象,依附于实体而存在。比如POI信息是值对象,本身没有状态,只能依附于POI气泡这个实体而存在。
聚合:领域内一组实体、值对象的集合。封装了集合与外界的交互。
使用DDD对业务问题进行分析和拆解后,可以采用任何一种架构设计方法,无论是分层架构、六边形架构或者整洁架构等。但是DDD要求架构设计从实际的业务场景触发,理解业务的核心问题。架构需要明确概念、规则的设计,并且保证业务模型的稳定性。使用分层架构展现DDD的领域设计方法,将工程分为4层:基础设施层、领域层、应用层和用户接口层。
我们所用的架构方案
C++能力层(包括业务层和引擎层)
在我们当前所使用的语言能力框架中,从业务层、引擎层到基础能力层都是C++语言实现。使用统一的流程管控它们的开发、构建、集成。它们都属于松散型分层架构,以下是简化版的结构图:
实际上,包括引擎层在内的C++层有几十上百个代码仓库,层次众多,且从上层到下层的依赖关系复杂。如果将所有的依赖关系绘制出来,将是一个复杂的网状。虽然整体架构依然遵守了分层架构的设计原则:只能上层依赖下层。
但是因为依赖层次和关系的复杂,导致下层代码的改动对上层的影响很大,在构建时也经常出现库版本不匹配的冲突。这使得上层业务层经常处于不稳定状态,不利于上层业务的快速迭代。并且下层能力升级也必然需要上层业务层做大工作量的适配。
在引擎架构升级的工作中,抽离出抽象层,使得各个仓库都依赖于抽象接口,不再依赖于具体的实现。抽象出来的抽象层命名具有统一的前缀Interface,例如:InterfaceApp、InterfaceMap等。
如果一个模块A需要依赖另外一个模块B的能力,首先两个模块都依赖同一个抽象层,同时使用依赖倒置的原则在合适的时机将模块B的实例化对象设置给模块A。模块A的逻辑中通过调用实例化对象的抽象接口访问模块B对应的能力。这种方式使得上层的业务层比较稳定,只要保证抽象层接口的稳定性,业务层基本上就不会受到下层改动的影响。而且,当下层进行能力升级时,只要按照抽象接口定义实现对应能力即可,不需要业务层做适配。
从这方面来讲,在引擎架构升级后,整体架构具有整洁架构的特征。但是并不能完全称为整洁架构,因为从更大的视角来看(将前端和Native层包括进去),依然是松散型的分层架构。所以,我们可以称之为具有整洁架构特征的松散型分层架构。
总结
对于架构设计的学习和理解,我认为很难的一点是:即使懂得很多道理还是很难把事情做好。众多的设计原则都是在不同业务场景下提出的,有些原则之间本身就是矛盾的。无论是架构设计方法还是设计原则,它们不是金科玉律,更不可能放之四海而皆准。它们的价值在于告诉我们应该摒弃什么,应该遵守什么。我们不用那些技术官僚的词汇,用更接地气的描述来说,架构设计也只是要求我们做到简洁、规范和易于理解而已。架构设计并不高端,它本身所产生的价值并不明显,真正能够产生价值的在于我们当前正在走的路:如何理解我们的业务问题。
注:文中部分配图来自网络,如有侵权,请联系我们删除。
附录
工程:是指以某组设想的目标为依据,应用有关的科学知识和技术手段,通过有组织的一群人将某个(或某些)现有实体(自然的或人造的)转化为具有预期使用价值的人造产品过程
工程化:是指以提高效率、降低成本、保证质量保证为目的从而促进多人合作,实现功能强大,健壮性好的项目的手段和措施
本文参考资料和书籍
《复杂性应对之道-COLA框架》
《DDD领域驱动设计:知识点与思考》
《应用架构之道:分离业务逻辑和技术细节》
《The Onion Architecture》
《架构整洁之道》
《领域驱动设计-ThoughtWorks洞见》
《代码精进之路-从码农到工匠》
《UNIX编程艺术》
关注「高德技术」,了解更多