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

清理结构的方法看完这篇文章就够了!

时间:2023-03-19 11:49:59 科技观察

本文将从软件系统的价值出发,先了解架构工作的价值和目标,再了解架构设计的依据、指导思想(设计原则)、方法和组件粒度拆分,以及组件之间的关系。相互依赖的设计、组件边界的多种解耦方法、权衡取舍、降低组件间通信成本的方法,最终将指导我们做出正确的架构决策和架构设计。1、软件系统的价值架构是软件系统的一部分,所以要理解架构的价值,首先要明确软件系统的价值。软件系统的价值有两个方面,行为价值和架构价值。行为价值是软件的核心价值,包括需求的实现、可用性的保证(功能bug、性能、稳定性)。这几乎占据了我们90%的工作内容,支持业务先赢是我们工程师的首要职责。如果业务清晰稳定,架构的价值可以忽略不计,但业务通常不明朗,发展迅速,那么架构就极其重要,因为架构的价值在于让我们的软件(Software)更软(柔软的)。可以从两个方面来理解:当需求发生变化时,所需的软件更改必须简单方便。实施变革的难度应该与变革的范围成正比,与变革的具体形态无关。当我们只关注行为的价值而不关注建筑的价值时会发生什么?这是书中记载的真实案例。随着版本的迭代,工程师团队规模不断壮大,但代码总行数趋于稳定。相应地,更改每一行代码的成本增加,工程师的工作效率降低。在老板看来,公司的成本增长很快,如果收入跟不上,就会开始亏损。可见架构价值的重要性,然后从著名的紧迫重要性矩阵出发,看看我们如何处理行为价值与架构价值之间的关系。在重要紧急矩阵中,做事的顺序是:1.重要紧急>2.重要不紧急>3.不重要但紧急>4.不重要不紧急。实现行为价值的需求通常由PD提出,比较紧迫,但并不总是特别重要;architecturalvalue的工作内容,一般是开发同学提出来的,很重要但基本不是很紧急,短期内不做也可以。不能死。所以行为价值的事情落在1和3(重要且紧急,不重要但紧急),而建筑价值落在2(重要但不紧急)。我们开发同学一定要把混在一起的1和3分开,然后低头敲代码,把我们的架构工作插入进去。2.架构工作的目标。前面已经解释了架构的价值。追求建筑的价值是建筑工作的目标。说白了,就是用最少的人力成本来满足搭建和维护系统的需要。如果再细化一点,就是支持软件系统的整个生命周期。循环使系统易于理解、易于修改、易于维护、易于部署。对于生命周期中的每一个环节,优秀的架构都有不同的追求:开发阶段:组件不使用大量复杂的脚手架;不同的团队负责不同的组件,避免不必要的协作。部署阶段:部署工作不要依赖成堆的脚本和配置文件;组件越多,部署工作就越繁重,部署工作本身就毫无价值。你做的越少越好,所以减少组件的数量。运行阶段:架构设计要考虑不同的吞吐量和不同的响应时间要求;架构应该起到揭示系统运行的作用:用例、功能、行为设置都应该是开发者可见的一级实体,以类为代表,功能或模块的形式占据明显的位置,命名能够清晰描述相应的功能。维护阶段:降低勘探成本和风险。探索成本正在挖掘现有的软件系统,以确定在何处以及如何最好地添加新功能或解决问题。风险在于,当进行更改时,可能会出现新的问题。3、编程范式其实所谓的架构就是一种限制,限制源代码放在哪里,限制依赖关系,限制通信方式,但是这些限制都是比较高级的。编程范式是最基本的限制,它限制了我们的控制流和数据流:结构化编程限制了控制权的直接传递,面向对象编程限制了控制权的间接传递,函数式编程限制了赋值。相信大家看到这里一定很疑惑,什么是直接控制权转移,什么是间接控制权转移,别着急,后面我会详细解释。这三种编程范式中最新的一种已有半个世纪的历史。半个世纪没有提出新的编程范式,以后也未必可以。因为编程范式的意义就在于限制,限制了控制权的转移,限制了数据的赋值,除此之外没有什么可以限制的。有趣的是,这三种编程范式提出的时间顺序可能与你的直觉相反。从前到后的顺序是:函数式编程(1936)、面向对象编程(1966)、结构化编程(1968)。1、结构化程序设计结构化程序设计证明人们可以用顺序结构、分支结构和循环结构三种结构构造任何程序,并限制了goto的使用。遵循结构化程序设计,工程师可以像数学家一样推理和证明自己的程序,并用代码将一些已证明的结构串联起来。只要他们证明这些额外的代码是确定性的,他们就可以推断出整个程序的正确性。性别。前面说过,结构化编程限制了控制权的直接传递,其实就是在限制goto语句。什么是直接控制权转移?它是一个函数调用或一个goto语句。该代码不在原流程中继续执行,而是执行其他代码,你指定执行什么代码。为什么要限制goto语句?因为goto语句的某些用途会阻止模块被递归地拆分成更小的、可证明的单元。使用分解来拆分大问题是结构化编程的核心价值。事实上,按照结构化编程,工程师无法像数学家那样证明自己的程序是正确的。他们只能说他们的程序没有像物理学家那样被证伪(没有发现bug)。数学公式和物理公式最大的区别是数学公式可以证明,而物理公式不能证明。只要目前的实验数据不证伪,我们相信它是正确的。程序也是如此,所有的测试用例都通过了,没有发现问题,我们认为这个程序是正确的。2.面向对象编程面向对象编程包括封装、继承和多态。从架构的角度来看,这里只关注多态性。多态性让我们通过函数调用更方便、更安全地在组件之间进行通信,它也是依赖倒置(使依赖与控制流的方向相反)的基础。在非面向对象的编程语言中,我们如何实现解耦组件之间的函数调用?答案是函数指针。例如,在用C语言编写的操作系统中,定义如下结构体来解耦具体的IO设备。IO设备的驱动只需要将函数指针指向自己的实现即可。structFILE{void(*open)(char*name,intmode);无效(*关闭)();整数(*读)();无效(*写入)(字符);void(*seek)(longindex,intmode);}这种组件间通过函数指针进行通信的方式非常脆弱。工程师必须严格按照约定初始化函数指针,并严格按照约定调用这些指针。只要一个人不遵守约定,整个程序就会产生极难追踪和消除的Bug。因此,面向对象编程限制了函数指针的使用,取而代之的是接??口实现、抽象类继承等多态方法。前面说过,面向对象编程限制了控制权的间接传递,实际上是限制了函数指针的使用。什么是间接控制权转移?意思是代码不在原来的流程中继续执行,而是去执行其他的代码,但是你不知道执行的是什么代码。您只需调整一个函数指针或接口。3.函数式编程函数式编程有很多定义,也有很多特点。从架构的角度,我们只关注它的无副作用和非修改状态。在函数式编程中,函数必须保持独立,所有的函数都是返回一个新的值,没有其他行为,尤其是外部变量的值不能被修改。前面说了,函数式编程限制赋值,指的就是这个特性。架构领域的所有竞争问题、死锁问题、并发问题都是由可变变量引起的。如果有足够大的存储量和计算量,应用程序可以采用事件追踪的方法,使用完全不可变的函数式编程,只通过交易记录从头计算状态,从而避免上述问题。使一个软件系统完全没有可变变量目前是不现实的,但是我们可以通过将需要修改状态的部分和不需要修改状态的部分分开,在不需要修改状态的组件中使用函数式编程成单独的组件,提高了系统的稳定性和效率。总而言之,如果没有结构化编程,就无法从可证伪的逻辑片段构建程序。如果没有面向对象编程,跨越组件边界将是一个非常麻烦和危险的过程。函数式编程让组件更加高效和稳定。没有编程范式就不可能进行架构设计。4.与编程范式相比,设计原则与架构的联系更为紧密。设计原则是架构设计的指导思想。它们指导我们如何将数据和函数组织到类中,以及如何将类链接到组件和程序中。相反,架构的主要工作是将软件分解为组件。设计原则指导我们如何拆解,拆解的粒度,组件之间依赖的方向,组件解耦的方式。有很多设计原则。我们架构设计的主导原则是OCP(开闭原则)。在类和代码层面,有:SRP(单一职责原则)、LSP(里氏代换原则)、ISP(接口分离原则)、DIP(依赖倒置原则);在组件层面,有:REP(Reuse,ReleaseEquivalencePrinciple),CCP(CommonClosurePrinciple),CRP(CommonReusePrinciple),处理组件依赖问题的三个原则:无依赖环原则,稳定依赖原则,稳定抽象原则。1.OCP(OpenandClosedPrinciple)设计良好的软件应该易于扩展,同时又能抵抗修改。这是我们架构设计的指导原则,其他原则都是为这个原则服务的。2.SRP(SingleResponsibilityPrinciple)任何一个软件模块都应该有一个且只有一个理由被修改。“修改原因”是指系统的使用者或拥有者。在翻译中,任何模块只对一个用户负责。这个原则指导我们如何拆分组件。比如CTO和COO都需要统计员工的工作时间。目前,它们需要相同的统计方法。我们重复使用一组代码。这个时候COO说周末工时统计要乘以2,按照这个要求修改完成。代码,CTO可能要来骂街了。当然,这是一个非常简单的例子。在实际项目中,服务于多个价值主体的代码很多,带来了极大的探索成本和修改风险。另外,当一段代码有多个所有者时,就会出现代码合并冲突的问题。3.LSP(LiskovSubstitutionPrinciple)当同一接口的不同实现相互替换时,系统的行为应该保持不变。该原则指导接口及其实现。你一定很困惑。如果实现相同的接口,则它们的行为必须相同。这不一定是真的。假设一个长方形的系统行为是:area=width*height,让正方形实现长方形的接口,当调用setW和setH时,正方形实际上做了同样的事情,设置了它的边长。这时候下面的单元测试用矩形可以通过,用正方形就不行。实现了相同的接口,但系统行为发生了变化。这是违反LSP的经典案例。矩形r=...r.setW(5);r.setH(2);assert(r.area()==10);4.ISP(InterfaceSegregationPrinciple)不依赖于任何不必要的方法、类或组件。这个原则指导我们的界面设计。当我们依赖一个接口而只使用它的一些方法时,我们实际上已经依赖了一些不必要的方法或类。当这些方法或类发生变化时,会导致我们的类重新编译,或者我们的组件重新编译Deployment,这些都是不必要的。所以我们先定义一个小接口,去掉使用的方法。5、DIP(DependencyInversionPrinciple)越过组合边界的依赖方向总是与控制流的方向相反。这个原则指导着我们设计组件间依赖关系的方向。依赖倒置原则是一个可操作性很强的原则。当你想修改组件之间的依赖方向时,把组件之间需要通信的类抽象成一个接口。只要接口放在边界的哪里,依赖就会指向那一侧。6.REP(Reuse,ReleaseEquivalentPrinciple)软件复用的最小粒度应该等于其发布的最小粒度。说白了就是复用一段代码,抽取成组件。这个原则指导我们组件拆分的粒度。7、CCP(CommonClosurePrinciple)为同一目的同时修改的类应该放在同一个组件中。CCP原则是SRP原则的组件级描述。这个原则指导我们组件拆分的粒度。对于大多数应用程序,可维护性的重要性远远大于可重用性。相同原因引起的代码修改必须在同一个组件中。如果分散在多个组件中,那么开发、提交、部署成本都会上升。8.CRP(CommonReusePrinciple)不要强迫一个组件去依赖它不需要的东西。CRP原理是对ISP原理的组件级描述。这个原则指导我们组件拆分的粒度。相信大家一定有这样的体会,组件A是集成的,但是组件A又依赖于组件B和C,即使你根本不用组件B和C,你也要集成。这是因为你只使用了组件A的部分能力,而组件A中额外的能力又带来了额外的依赖。如果遵循共同复用的原则,需要拆分A,只保留自己要使用的部分。REP、CCP、CRP这三个原则之间存在竞争关系。REP和CCP是使组件变大的内聚原则,而CRP原则是使组件变小的排他性原则。遵守REP、CCP而忽略CRP,你会依赖太多未使用的组件和类,而这些组件或类的改变会导致你自己的组件有太多不必要的发布;遵循REP、CRP而忽略CCP,因为组件拆分得太细,一个需求变更可能需要更改n个组件,成本巨大。一个优秀的架构师应该能够在上述的三角张力区域内定位到最适合研发团队现状的位置。比如在项目初期,CCP比REP更重要。随着项目的发展,这个最合适的位置必须不断调整。.9.无依赖环原则。健康的依赖关系应该是有向无环图(DAG)。相互依赖的组件实际上形成了一个大的组件。这些组件需要一起发布并一起进行单元测试。我们可以使用依赖倒置原则DIP解除依赖循环。10.稳定依赖原则依赖必须指向一个更稳定的方向。这里一个组件的稳定性是指它的变更成本,与它的变更频率没有直接关系(变更频率更多的是与需求的稳定性有关)。影响组件变更成本的因素有很多,例如组件的代码大小、复杂性和清晰度。最重要的因素是依赖于它的组件的数量。让组件难以修改的最直接的方法之一就是让许多其他组件依赖于它!组件稳定性的定量度量是:不稳定性(I)=出站依赖项数/(入站依赖项数+出站依赖项数)。如果发现有违反稳定依赖原则的情况,解决方法是通过DIP来反转依赖。11.稳定抽象原则一个组件的抽象程度应该与其稳定性相一致。为了防止高层架构设计和高层策略难以修改,通常将稳定的接口和抽象类抽象为单独的组件,使得具体实现的组件依赖于接口组件,这样其稳定性就不会影响其扩展性。组件抽象度的定量描述为:抽象度(A)=组件中抽象类和接口的个数/组件中类的个数。以不稳定性(I)为横轴,抽象度(A)为纵轴,那么只包含抽象类和接口的最稳定的组件应该位于左上角(0,1),并且最不稳定的只包含具体实现类的组件,没有任何接口的组件应该位于右下角(1,0),它们的连接是主序线。位于线上的组件,其稳定性和抽象级别匹配,是设计良好的组件。位于(0,0)附近区域的组件非常稳定(注意这里的稳定指的是变化的代价),非常具体的组件,因为它们的抽象层次低,决定了它们频繁变化的命运,但是有有很多其他组件依赖于它们,改变起来非常痛苦,所以这个区域被称为痛苦区。右上角区域的组件,没有其他组件依赖于它们,自身的抽象层次很高,很可能是旧代码,所以这个区域被称为无用区。另外,点到主序线的距离Z可以用来表示成分是否遵循稳定抽象原则,Z值越大,成分越违反稳定依赖原则。五。架构工作的基本原则在了解了编程范式和设计原则之后,让我们看看如何将它们应用于拆分组件、处理组件依赖性和组件边界。架构工作有两条准则:尽可能多地保留尽可能长的选项。这里的optional指的是无关紧要的设计细节,比如选择哪种存储方式,使用哪个数据库,或者使用哪个web框架。业务代码应该和这些选项解耦,数据库或者框架像插件一样切换,业务层对切换过程完全无动于衷。低层解耦就可以解决,不要用高层解耦。后面将详细描述组件之间的解耦方法。这里强调的是,边界处理越完善,开发部署成本就越高。所以不完全边界能解决就不要用完全边界,低层解耦能解决就不要用高层解耦。6.组件拆分首先,我们需要定义一个组件:组件是一组描述如何将输入转换为输出的策略语句。在同一个组件中,策略变化的原因、时间和级别是相同的。从定义上可以看出,组件拆分需要从两个维度进行:按层次拆分和按变化原因拆分。此处更改的原因是业务用例。按变化原因拆分组件的例子有:订单组件和聊天组件。按层次划分,可以分为:业务实体、用例、接口适配器、框架和驱动。业务实体:关键业务数据和业务逻辑的集合,与接口、存储、框架无关,只有业务逻辑。用例:特定场景下的业务逻辑,可以理解为输入+业务实体+输出=用例。接口适配器:包含了整个MVC,以及存储、设备、接口等的接口声明和使用。策略离系统的输入输出越远,它的层级就越高,所以业务实体就是最低层,框架和驱动是最高层。7.组件依赖处理组件拆解分层后,依赖就很容易处理了:依赖从数据流控制流中解耦,链接到组件的层级,始终从低层指向高电平,如下图所示。攻略越具体,等级越低,外挂越多。切换数据库是框架驱动层的事情。接口适配器完全不知道。切换显示是接口适配器级别的问题。用例完全无意识,切换用例不会影响业务实体。8.组件边界处理一个完整的组件边界包括什么?首先,必须将跨越组件边界进行通信的两个类抽象为接口。另外,需要声明一个专用的输入数据模型和一个专用的返回数据模型。想想每一个你可以通过查看每次通信时发生的数据模型转换来了解维护组件边界的成本是多少。除非必要,我们应该尽量使用不完整的边界来降低维护组件边界的成本。不完整的分界有3种方式:省去最后一步:声明接口,分完之后还是放在一个组件里面,到时候再拆解编译独立部署。单向边界:一个正常的边界至少有两个接口,分别抽象调用者和被调用者。这里只定义了一个接口,高层组件使用接口调用底层组件,底层组件直接引用高层组件的类。Portal模式:控制权的间接传递,不是通过接口和实现,而是通过Portal类。这样就不需要声明连接端口了。除了完全边界和不完全边界的区分,边界的解耦方式也可以分为三个层次:源代码层次:在接口和类依赖上解耦,但放在同一个组件中,通常在不同的下游路径。与不完整边界的最后一步省略相同。部署层:拆分成不同的可以独立部署的组件,比如iOS静态库和动态库,它们实际上运行在同一台物理机上,组件通常通过函数调用进行通信。服务级别:运行在不同的机器上,通过url、网络数据包等进行通信。从上到下,(开发、部署)成本依次递增。如果低层解耦已经满足需求,则不再进行高层解耦。