当前位置: 首页 > 科技观察

从业务开发中学习和理解架构设计_0

时间:2023-03-12 20:04:10 科技观察

作者|张东爱(DangAi)前言在软件开发领域,经常会遇到架构这个词汇。在我最初的印象中,建筑是一个非常高级的词汇。它似乎代表着复杂的工程结构、高层次的抽象设计、最新的开发语言特性等等。对于当时只专注于编写业务逻辑的我来说,不禁对架构产生了敬畏之情。工作中很少讨论架构,有一些高深莫测的描述,但从来没有人清楚地解释过架构是干什么的。那么,到底什么是建筑?架构和业务之间的关系是什么?我们在看一些关于建筑的书籍或资料时,难免会接触到一些对建筑的定义或描述。例如:约束、规则、边界、实体关系、模型定义等。但是理解这些概念并不能帮助我们设计出更好的架构。当我们将设计原则应用于架构设计时,难免会感到空虚无聊,总觉得少了点什么。虽然我们在建筑设计上做了很多,但好像什么都没做。因为只是针对架构设计本身,很难解释它产生的价值。那么,好的架构设计的出发点是什么?一个好的架构应该是什么样的??去年,我有一个任务:对我们当前项目的代码进行重新拆分和合并,理清模块之间的关系,控制项目中模块依赖的复杂度。这似乎是一项非常简单的工作。如果你找到不同的更合理的目录划分方案,你可以尝试实现它。但这是一项非常困难的工作,因为我们首先要回答有哪些模块以及模块之间的依赖关系的问题。其实回到任务本身,我们不只是想对代码文件进行重组和划分。我们的目标是解耦业务模块,定义和明确业务模块之间的依赖规则。面对这样的目标,我们需要从业务的角度更清晰地定义和划分模块,然后从工程结构的角度来确定模块之间的关系。所以,代码目录的调整其实就是对业务场景和工程结构的理解和设计。代码目录的结构代表了我们的项目结构,也是业务场景划分的抽象描述,也是模块定义和模块依赖关系的展示。在设计代码目录划分方案的过程中,看了一些工程结构设计方面的资料,看了一些架构设计方面的书籍。对结构有一定了解。这篇文章就是这次学习和任务完成过程的反思和沉淀。我希望能够回答上面提到的几个问题:建筑到底是什么?架构和业务的关系,设计一个好的架构的出发点是什么?一个好的架构应该是什么样的?架构的定义是什么?首先,建筑是一个中国语汇。它的定义是:人对一个结构中的元素和元素之间关系的主观映射的产物。从这个定义中我们可以看出,传统的架构描述的是系统中有哪些元素以及元素之间的关系。在建筑学领域,architecture也用来描述建筑物的结构。?作为计算机领域的一个词汇,体系结构的定义是:对软件整体结构和组件的抽象描述,用于指导大型软件系统各个方面的设计。其实也是一个定义有什么,有什么关系的问题。从工程角度解读架构设计的作用无论是在建筑领域还是计算领域,我们通常都用工程来描述这类工作的项目。比如我所在的部门是工程技术中心,我是一名工程程序员。我们可以称之为工程的工作项目包括:建筑工程、军事工程、水利工程、生物工程、软件工程等。在完成项目的过程中,建筑设计实际上是推动工程实施的一部分。那么在工程架构设计中会考虑哪些因素,其在工程实施中的作用又是什么呢?假设,读者,你目前是一名建筑工程师,负责建造一栋房子。虽然我们还没有真正盖过房子,但是在设计房子的整体结构的时候,一定要关心这些:房子的用途。首先要弄清楚房子是干什么用的,层数多少。与用途密切相关,不同用途的房屋层数也有不同的房屋外观。房子的布局定义了这所房子的外观。定义应该如何更好地使用这所房子等等。我们将上述属性称为房屋的基本能力。作为一个可靠的建筑工程师,你肯定会着重设计这些:水电的走向。这个非常重要。保证房屋的安全和使用的方便性,承重抗压。房子的使用寿命在很大程度上取决于这个和更多。我们将上述属性称为安全性和性能。另一方面,你可能并不关心房子的装修风格、地板的颜色、衣柜的品牌等等。我们将这些应用程序称为详细信息。综上所述,在设计房屋的工程结构时,更多的是关注底层设计,而不是过多的技术细节。因此,我们可以给出架构的作用定义:在明确用途的基础上定义使用的规则和约束,提供基础支撑能力,保障安全性、性能和生命周期。软件架构设计的原则和要求至此,我们已经明确了在进行架构设计时必须遵循的前提和原则:明确目的。此外,还有对架构设计的要求:提供基础能力,保证安全、性能等。同样,延伸到计算机领域。我们在设计软件架构时还必须遵循的原则是:架构设计必须从业务场景出发,这其实是明确目的的前提。架构设计必须从业务出发,面向业务变化。只有明确了自己的业务场景和业务目标之后,基于此的架构设计才能真正产生业务价值。脱离业务场景设计的架构,再新颖、再先进,也绝不是好的架构。架构设计必须落入业务场景,验证我们不能仅从基础能力、安全性、性能等方面来判断一个架构的好坏。架构支持业务发展的能力、面对业务变化的灵活性、持续演进的能力,都是评估的因素。另外,我们要求软件架构必须是灵活的,能够满足未来业务持续发展的需求。业务场景在不断变化,架构也必须具备跟随业务形态不断演进的能力。架构设计的核心是保证对业务变化有足够灵活的响应,这就要求架构设计能够识别业务的核心领域。因此,无论是面对当前还是未来,架构设计都需要真正识别和理解业务问题。架构设计原则本章介绍了软件架构设计中可以遵循的几个原则。其实这些设计原则在功能模块的设计中也可以参考。SRP单一职责原则1.一个函数只负责完成一个功能2.任何模块只负责某一类actor3.一个类或函数应该有一个且只有一个原因被改变。在实际编码中,我们仍然可以看到很多违反单一职责的例子,比如超长的函数体。一个函数做了很多事情,实际上它负责的功能太多了,很多变化都需要修改这个函数,这样就很难控制变化的范围。我们可以将一个大函数拆分成小函数,小函数体负责单个函数,相应的更加灵活。所以我们建议你多写一些小的函数体。但是在功能拆分的过程中不要过度封装和抽象。OCP开闭原则1.易于扩展和抗修改模块应该易于扩展和控制修改。这是我们在第一次学习编程语言时被教导的设计原则。开闭原则帮助我们设计更灵活的模块,同时控制模块变化的范围。LSPLiskov替换原则1.所有对父类的引用都可以用子类替换而不改变行为。使用里氏代换原则可以保证父类的可重用性。主要用来判断抽象和继承关系的设计是否合理,即一个类是否应该具有某种属性,一个类是否是另一个类的子类。举个典型的例子,骑马也是骑马,骑白马也是骑马,骑黑马也是骑马。那么白马和黑马都是马的子类,符合LSP。以下是违反LSP原则的两个典型示例。这也是网上很常见的一个例子。首先是正方形不是矩形。类矩形{public:int32_tgetWidth()const{returnwidth;}int32_tgetHeight()const{returnheight;}virtualvoidsetWidth(int32_tw){width=w;}virtualvoidsetHeight(int32_th){height=h;}私有:int32_t宽度=0;int32_theight=0;};classSquare:publicRectangle{public:voidsetWidth(int32_tw)override{Rectangle::setWidth(w);矩形::设置高度(w);}voidsetHeight(int32_th)override{//...}};voidreSize(Rectanglerect){while(rect.getHeight()<=rect.getWidth()){rect.setHeight(rect.getWidth()+1);}}方形类Square继承了矩形类Rectangle,重写了函数setWidth和setHeight。在函数reSize中,将父类Rectangle对象替换为子类Square后,会出现死循环,程序出现异常。不符合LSP原则。所以正方形不是长方形。第二个是鸵鸟不是鸟。鸟类{public:int32_tgetVelocity()const{returnvelocity;}private:int32_tvelocity=0;//飞行速度};鸵鸟类:publicBird{};voidcrossRiver(Birdbird){int32_tdistance=1000;int32_telapsed=distance/bird.getVelocity();}鸟Brid有飞行速度属性,鸵鸟Ostrich继承类Brid,飞行速度默认为0。在函数crossRiver中,将基类Brid对象替换为子类Ostrich对象后,得到的飞行速度为0,出现除0异常。不符合LSP原则。所以鸵鸟不是鸟。在这两个例子中,结合里氏代入原理,我们得出了两个奇怪的结论,违背了几何学和生物学的常识。其实问题出在我们的抽象和接口的设计上。比如前面例子中的reSize函数,它的条件判断就存在问题。对于一个长方形的物体,宽高不一定一定要相等,所以用宽高相等作为循环的条件是不合理的。对于后一个例子,飞行不是鸟类的统一特征,所以抽象的鸟不应该有飞行速度的属性,也不应该有飞行的接口。那么我们应该如何处理这个问题呢。准确的说,鸟类可以有一个会不会飞的接口,然后是一个速度属性。会飞的鸟返回飞行速度,而鸵鸟返回步行速度。所以用里氏代换原则来验证我们的接口和抽象设计是否合理,也可以验证继承关系是否合理。ISP接口隔离的原则是不看你不需要什么。使用接口类细化功能模块。每个接口类负责某一类明确的功能来指导我们进行接口设计的原则。类似于单一职责原则,多个单一接口负责更简单、更易维护的功能,优于一个庞大的接口。在设计界面时,尽量保持界面小巧、简洁和正交,这为业务层提供了更多的灵活性。一个大的接口可能会做业务层不想做的事情,也会在业务层需要扩展功能的时候让改动的范围过大。DIP依赖??倒置原则(DependencyInversion)为了保证系统的灵活性(易于修改)和稳定性(修改范围小),在依赖中应该避免引用具体的类接口,这样比实现更稳定,所以尽量避免修改函数实现对依赖这个接口的模块的影响在依赖关系中是最强的。尽量避免继承具有特定实现的类。该原则的目的是降低模块之间的耦合度,使底层模块更易于修改和替换。当下层功能发生变化时,可以控制对上层业务的影响范围,使整个系统更加稳定和灵活。DIP原理在后面的章节介绍架构设计方法的时候也会多次提到。这五个设计原则统称为SOLID原则。《整洁架构之道》里面有更详细的介绍。奥卡姆剃刀原理奥卡姆剃刀原理并不是在软件开发领域提出来的,而是在哲学领域提出来的。奥卡姆剃刀原理对科学和哲学的发展极其重要,因为它告诉人们理论要尽可能简洁,理论中一切不影响结论的多余部分都应该去掉。就像奥卡姆剃刀原理的精髓一样,它的描述非常简洁有力:非必要勿增实体。我们也可以称之为简单即美丽的原则。通俗的描述是:用尽可能少的步骤完成一件事情。或者,如果对一件事有两种解释,则使用最简单或可证伪的一种。正是因为有了奥卡姆剃刀,我们才更相信哥白尼的日心说,更相信牛顿和爱因斯坦。否则,地球是宇宙中心的理论是正确的,但地球周围其他行星和恒星的轨道公式过于复杂,很容易被自然现象证伪。奥卡姆剃刀原理在很多介绍软件设计方法的书籍和资料中也多次提到。应用到软件开发领域,确实给了我们很多启发。试想一下,如果我们遇到过这样的场景:苦苦向别人解释为什么某个模块会设计成添加注释多于代码来解决一个问题。当我们费力去解释和解释某段代码时,真正的问题不是我们解释不够,或者观众不够聪明,无法理解,而是代码设计本身没有体现其业务语义出色地。其实太多的解释和评论都是多余的,可以用奥卡姆剃刀剃掉。引入一个模块来解决一个问题也是工作中经常遇到的问题。某些模块损坏且难以维护的原因有很多。例如,最初的设计与业务场景的契合度不高;编码规范不够好,后续修改不合规矩;后继者不完全理解作者的意图。etc.而程序员往往有一个想法,当一个模块难以维护时,最好的办法就是用一个新的模块替换它。其实这种方法没有触及问题的本质。在找到模块损坏的原因并制定标准化的模块设计方案之前,我们无法保证新模块不会出现与旧模块相同的问题。因此,想开发一个新模块来替代旧模块,很大程度上是在回避思考旧模块的问题,新模块很可能会降级到与旧模块相同的水平。如果您无法回答这个矛盾的问题,请使用奥卡姆剃刀移除新模块。新模块是多余的,并没有解决真正的问题。奥卡姆剃刀原理保证了解决问题的方法简单有效,同时也约束了我们去思考更根本的问题,而不是在问题的表面采取最省力的方法.其他设计原则概述DRY(DontRepeatYourself):保证代码的可重用性,避免代码逻辑重复YAGNI(YouAintGonnaNeedIt):代码应该易于扩展,但要避免过度设计,不要写到代码中。KISS(KeepItSimple,Stupid):想复杂的事情,把事情简单化POLA(PrincipleofLeastAstonishment):最小惊讶原则。代码应该是合乎逻辑的和有纪律的,对读者的冲击最小。界面设计避免创新。几种常用的架构设计分层架构分层架构是指基于特定的业务模型,按照功能模块对代码进行分层组织。每一层代表相关功能的集合。分多少层没有明确的规定,一般可以分3-4层或更多。在分层架构中,依赖关系是自上而下的,上层依赖于下层,不可颠倒。级别越低,越通用,越基础。层级越高,越有活力,越偏向于业务。分层架构设计根据依赖规则的严格程度分为严格分层架构和松散分层架构。严格的分层架构要求每一层只能访问它直接依赖的层,不能访问它间接依赖的层。松散分层架构允许每一层访问它下面的任何层。严格的分层架构最大限度地减少了层与层之间的耦合,但不够灵活。当上层需要访问下层间接层的能力时,必须从上到下穿透。松散的分层架构在保证依赖规则的前提下提供了足够的灵活性,所以大部分的分层架构都是松散的。分层架构设计简洁易懂。将抽象事物按照其基本特征进行分类,符合我们的思维习惯,也易于理解。分层的架构设计保证了各层内部良好的内聚性,降低了层与层之间的耦合度,有利于基础能力的积累和复用,也有利于控制变更带来的风险。?另一方面,分层架构设计虽然定义了多个层,但层与层之间的界限并不是特别明确。可能很难确定新添加的模块应该放在哪一层。或者随着业务逻辑的变化,以后可能需要调整模块的层级。在分层架构中,上层模块直接依赖于下层模块,下层模块的实现直接暴露给上层模块。修改或替换下层模块时,需要修改上层模块,对上层业务影响很大。业务实现和基础能力并没有完全解耦。六边形架构也称为端口适配器架构。为了解决具体实现依赖于基础能力的问题,采用依赖倒置设计的方法将项目分为内部和外部。内在是具体的业务逻辑,外在是依赖的基本能力。内部的业务逻辑不再直接依赖于外部的基础能力,而是全部依赖于它的抽象定义。使用依赖注入将外部实现传递到内部业务逻辑中。接口用于内部和外部的交互,内部业务逻辑访问基础能力时可以直接调用抽象接口。六边形架构解决了业务逻辑直接依赖外部模块的问题,它们都依赖于抽象,而不依赖于直接实现和细节。它们通过定义的接口直接交互。由于业务逻辑与外部模块之间没有直接依赖关系,因此在修改和替换外部模块时,只需按照接口定义实现功能即可,无需改变业务逻辑。洋葱圈架构(CleanArchitecture)洋葱圈架构,又称干净架构,是一种结合了分层架构、六边形架构和领域驱动设计特点的架构设计方法。洋葱圈架构是六边形架构的进一步延伸,仍然是外部依赖和内部依赖。参考领域驱动设计,将依赖层次划分为3-4层甚至更多。从内到外分别是:领域模型、业务逻辑、领域服务、基础能力、外部模块等。洋葱圈架构具有六边形架构的优点,采用依赖倒置原则,使内部商业模式不再直接依赖于外部基础能力。外部模块的更改和替换不影响内部业务逻辑。采用领域驱动的设计方法对实体和模型进行划分,有利于业务规则的抽象和业务模型的建立,更好地支持未来的业务迭代。洋葱圈架构将业务实体、业务模型和业务实现保留在内层,保证了业务模型和实现的稳定性,避免了外部模块变化带来的影响。比如让第三方SDK或者数据库系统属于最外层,通过依赖注入的方式将它们的实现传递到内部逻辑中。更换第三方SDK或数据库系统时,根据接口定义实现具体细节即可。无需更改内部逻辑。领域驱动设计方法领域驱动设计简称DDD(Domain-DrivenDesign)。准确地说,它不是一种架构设计方法,而是一种以业务分析和划分驱动系统架构设计的软件开发方法。强调识别业务的核心问题域,确定问题边界,同时分解问题域,降低分析的复杂度。DDD强调通过关注业务核心来提升业务价值。下面是DDD的一些核心概念,我们做一些简单的介绍。域:具有定义的范围和边界的业务问题域。它实际上是对我们要解决什么业务问题的抽象描述。比如提供用户当前位置、目的地位置、提供到达信息等,就是高德地图的问题域。子域:根据不同的业务规则,将大的问题域拆分成小的问题域。比如高德地图的问题域太大,无法解决。我们可以将问题域拆分为定位、POI搜索??、路线规划等子问题域。有界上下文:域之间的抽象边界。封装领域内的概念、规则和模型。实体:具有唯一标识符和生命周期的对象。比如展示给用户的POI气泡就是一个实体,它有状态,有确定的生命周期。值对象:没有唯一标识和生命周期,依附于实体存在的对象。例如,POI信息是一个值对象,它本身没有状态,只能依附于POI气泡这个实体而存在。聚合:域内实体、值对象的集合。封装集合与外部世界之间的交互。使用DDD分析和拆解业务问题后,可以使用任何架构设计方法,无论是分层架构,六边形架构,还是整齐的架构。但是DDD需要架构设计从实际的业务场景出发,理解业务的核心问题。架构需要清晰的概念和规则设计,保证业务模型的稳定性。采用分层架构演示DDD的领域设计方法,项目分为4层:基础设施层、领域层、应用层和用户界面层。我们使用的架构方案是鹰巢,我所在的团队鹰巢事业群负责高德地图规划导航业务能力的实现。向下连接引擎层,包括定位引擎、导航引擎、渲染引擎等,向上连接前端JS层。鹰巢除了承担功能庞大、逻辑复杂的导航服务外,还负责封装引擎能力,并将这些封装能力暴露给上层JS。在代码目录划分之前,鹰巢的功能实现也是按照模块化设计的,但是模块之间并没有明确的依赖关系。任何代码都可以相互引用,这就导致了项目中各个模块之间错综复杂的调用关系。很难说出某个模块应该位于何处以及应该如何引用它。虽然我们一直把工程代码分为框架层和业务层,但是框架层和业务层之间的依赖关系并不明确。业务层依赖框架层,框架层又依赖业务层,不符合分层架构的设计原则,所以鹰巢的工程架构不属于分层架构。我们在去年的代码目录划分工作中,最终参考了领域驱动的设计方法,对代码目录进行了重新梳理和划分。工程代码整体分为4层:基础能力、业务层、工具层和接入层。下面是整体结构图:适配层与后面4层不在同一个仓库,包含了与前端JS交互的必要能力封装。按照模块的划分规则,我们可以说鹰巢的工程架构属于松散分层架构结合领域驱动设计。其特点是:按照领域驱动设计对项目代码进行组织和划分,在业务层按照不同业务领域划分代码模块,采用分层架构设计将项目划分为多个层次。上层依赖下层,下层不能依赖上层。任何模块都可以调用下层的任何模块,属于松散架构。更灵活工程技术中心的C++能力层(包括地图引擎层)在工程技术中心的语言能力框架中实现,从鹰巢、MapEngine到基础库全部采用C++语言实现。使用统一的流程来控制它们的开发、构建和集成。在引擎架构升级之前的相当长一段时间内,都属于松散的分层架构。下面是简化版的结构图:事实上,包括引擎库在内的C++层有几十上百个代码仓库。层次很多,从上层到下层的依赖复杂。如果把所有的依赖都抽出来,就是一个复杂的网络。虽然整体架构还是遵循分层架构的设计原则:只有上层可以依赖下层。但是由于依赖层次和关系的复杂性,下层代码的变更对上层的影响很大,在构建过程中经常会出现库版本不匹配的冲突。这使得上层业务层经常处于不稳定状态,不利于上层业务的快速迭代。另外,下层能力的升级,必然需要上层业务层适配较大的工作量。在去年的引擎架构升级中,抽取了抽象层,让各个仓库依赖于抽象接口,不再依赖于具体的实现。抽象层包括:InterfaceApp、InterfaceAR、InterfaceARWalk、InterfaceHorus、InterfaceMap、InterfaceVMap、InterfaceTBT、InterfacePosEngine等。例如Eagle'sNest和TBT都依赖InterfaceTBT抽象层,利用依赖倒置的原理设置实例化对象应用程序初始化时,TBT到鹰巢。Aerie通过调用实例化对象的抽象接口来访问TBT的功能。同样,EagleNest和渲染都依赖于InterfaceMap抽象层。这种方式使得上层的业务层更加稳定。只要保证抽象层接口的稳定性,业务层基本不会受到下层变化的影响。而且,当下层进行能力升级时,只需要根据抽象接口定义实现相应的能力即可,不需要业务层进行适配。在这方面,引擎架构升级后,引擎具有干净架构的特点。但并不能称之为整洁的架构,因为从更大的角度来看(包括基础库和Native层),它仍然是一个松散的分层架构。因此,我们可以称其为松散的分层架构,具有整齐的架构特征。总结一下架构设计的学习和理解,我觉得难点是:即使知道了很多原理,还是很难把事情做好。很多设计原则是针对不同的业务场景提出的,有些原则本身就是矛盾的。无论是架构设计方法还是设计原则,都不是金科玉律,更谈不上放之四海而皆准。它们的价值在于告诉我们什么该放弃,什么该遵守。我们不用那些技术官僚的词汇,而是用更接地气的描述,设计原则只要求我们简洁、规范、易懂。架构设计不高端,产生的价值不明显。真正能产生价值的,在于我们现在走的路:如何理解我们的业务问题。?参考资料和书籍:应用架构之道:分离业务逻辑和技术细节?:https://www.cnblogs.com/alisystemsoftware/p/13846127.htmlTheOnionArchitecture?:https://jeffreypalermo.com/2008/07/the-onion-architecture-part-1/《架构整洁之道》,《领域驱动设计-ThoughtWorks洞见》,《代码精进之路-从码农到工匠》,《UNIX编程艺术》附录项目:指基于一定的设定目标,应用相关科学知识和技术手段,进行改造将一个(或某些)现有实体(自然的或人造的)转变为有组织的一群人预期使用的人造产品:指提高效率、降低成本、确保质量保证的过程,其目的是促进多人合作,实现强健项目的手段和措施