对抗复杂度的圣杯战争:软件架构究竟该如何设计?

2024年 2月 7日 33.7k 0

软件架构是计算机技术经典中的经典,在实际的生产环境中,我们往往面临着架构设计短板、接口老化、代码腐化等一系列问题,在飞速发展的业务需求下如履薄冰地艰难前行。好的架构一定是长出来的,但这背后往往更依赖于一个深度思考、高度可扩展的架构设计。本篇文章作者将为你详细拆解架构设计的道、法、术、器,助你一篇文入门架构设计的海洋!

   熵增定律

熵的概念最早起源于物理学,用于度量一个热力学系统的无序程度。不幸的是,热力学法则决定了宇宙中的熵会趋向最大化。虽然软件开发不受绝大多数物理法则的约束,但我们无法躲避来自熵增加的重击。当软件中的无序化增加时,程序员会说“软件在腐烂”。有些人可能会用更乐观的术语来称呼它,即“技术债”,潜台词是说它们总有一天会偿还的——恐怕不会还了。

   破窗效应

破窗效应(英语:Broken windows theory)是犯罪学的一个理论, 此理论认为环境中的不良现象如果被放任存在,会诱使人们仿效,甚至变本加厉。以一幢有少许破窗的建筑为例,如果那些窗不被修理好,可能将会有破坏者破坏更多的窗户。最终他们甚至会闯入建筑内,如果发现无人居住,也许就在那里定居或者纵火。一面墙,如果出现一些涂鸦没有被清洗掉,很快墙上就布满了乱七八糟、不堪入目的东西;一条人行道有些许纸屑,不久后就会有更多垃圾,最终人们会理所当然地将垃圾顺手丢弃在地上。这个现象,就是犯罪心理学中的破窗效应。software = soft + ware软件架构的终极目标是,用最小的人力成本来满足构建和维护该系统的需求 。--《架构整洁之道》对于每个软件系统,我们都可以通过行为架构两个维度来体现它的实际价值。软件研发人员应该确保自己的系统在这两个维度上的实际价值都能长时间维持在很高的状态。

   行为价值

软件系统的行为是其最直观的价值维度 。程序员的工作就是让机器按照某种指定方式运转,给系统的使用者创造或者提高利润 。行为价值 = 按照需求文档编写代码,并且修复任何 Bug。需求转化为代码。

   架构价值

软件发明的目的,就是让我们可以以一种灵活的方式来改变机器的工作行为 。对机器上那些很难改变的工作行为,我们通常称之为硬件 ( hardware ) 。为了达到软件的本来目的,软件系统必须够 “软”一一也就是说,软件应该容易被修改。当需求方改变需求的时候,随之所需的软件变更必须可以简单而方便地实现 。架构价值 = 灵活、低成本。上图:面条式代码,不利于灵活修改。

   哪个价值更重要

上图:艾森豪威尔矩阵。软件系统的第一个价值维度:系统行为,是紧急的,但是并不总是特别重要。软件系统的第二个价值维度:系统架构,是重要的,但是并不总是特别紧急。软件架构设计的主要目标是支撑软件系统的全生命周期,设计良好的架构可以让系统便于理解、易于修改、方便维护,并且能轻松部。软件架构的终极目标就是最大化程序员的生产力,同时最小化系统的总运营成本。软件架构师这一职责本身就应更关注系统的整体结构,而不是具体的功能和系统行为的实现。软件架构师必须创建出一个可以让功能实现起来更容易、修改起来更简单、扩展起来更轻松的软件架构。请记住:如果忽视软件架构的价值,系统将会变得越来越难以维护, 终会有一天,系统将会变得再也无法修改。如果系统变成了这个样子,那么说明软件开发团队没有和需求方做足够的抗争, 没有完成自己应尽的职责。软件设计的核心在于降低复杂性---《软件设计的哲学》

   复杂性的定义

对抗复杂度的圣杯战争:软件架构究竟该如何设计?-1系统的总体复杂度(C)由每个部分的复杂度(cp)乘以开发人员在该部分上花费的时间(tp)加权。在一个永远不会被看到的地方隔离复杂性几乎和完全消除复杂性一样好。子模块的复杂度 Cp 是一个经验值,它关注几个现象:▶︎ 变更放大:复杂性的第一个征兆是,看似简单的变更需要在许多不同地方进行代码修改。▶︎ 认知负荷:复杂性的第二个症状是认知负荷,这是指开发人员需要多少知识才能完成一项任务。▶︎ 未知的未知:复杂性的第三个症状是,必须修改哪些代码才能完成任务,或者开发人员必须获得哪些信息才能成功地执行任务,这些都是不明显的。

   复杂性的原因

复杂性是由两件事引起的:依赖性和模糊性。依赖关系是软件的基本组成部分,不能完全消除。实际上,我们在软件设计过程中有意引入了依赖性。每次编写新类时,都会围绕该类的 API 创建依赖关系。但是,软件设计的目标之一是减少依赖关系的数量,并使依赖关系保持尽可能简单和明显。复杂性的第二个原因是晦涩。当重要的信息不明显时,就会发生模糊。一个简单的例子是一个变量名,它是如此的通用,以至于它没有携带太多有用的信息(例如,时间)。或者,一个变量的文档可能没有指定它的单位,所以找到它的唯一方法是扫描代码,查找使用该变量的位置。复杂性不是由单个灾难性错误引起的;它堆积成许多小块。单个依赖项或模糊性本身不太可能显著影响软件系统的可维护性。之所以会出现复杂性,是因为随着时间的流逝,成千上万的小依赖性和模糊性逐渐形成。最终,这些小问题太多了,以至于对系统的每次可能更改都会受到其中几个问题的影响。复杂性的增量性质使其难以控制。可以很容易地说服自己,当前更改所带来的一点点复杂性没什么大不了的。但是,如果每个开发人员对每种更改都采用这种方法,那么复杂性就会迅速累积。一旦积累了复杂性,就很难消除它,因为修复单个依赖项或模糊性本身不会产生很大的变化。

   拒绝战术编程

战术编程致力于完成任务,新增加特性或者修改 Bug 时,能解决问题就好。这种工作方式,会逐渐增加系统的复杂性。如果系统复杂到难以维护时,再去重构会花费大量的时间,很可能会影响新功能的迭代。战略编程,是指重视设计并愿意投入时间,短时间内可能会降低工作效率,但是长期看,会增加系统的可维护性和迭代效率。

   奥卡姆剃刀原则

又被称作“简单有效原理”:“如无必要,勿增实体”。这个原理广泛应用于哲学、科学、管理学、经济学等等众多领域。在软件架构/软件开发领域, 该原则同样适用:less is more, simple is best。

   一致性

一致性是降低系统复杂性并使其行为更明显地强大工具。如果系统是一致的,则意味着相似的事情以相似的方式完成,而不同的事情则以不同的方式完成。一致性会产生认知影响力:一旦你了解了某个地方的工作方式,就可以使用该知识立即了解其他使用相同方法的地方。如果系统的实施方式不一致,则开发人员必须分别了解每种情况。这将花费更多时间。尤其对于一个大规模系统,往往需要多个团队共同开发完成,如果不遵循一致原则,就会导致整个平台的建设缺乏完整性和规范性,各个子系统各自为政,业务功能重复开发,技术实现五花八门,服务集成复杂低效,信息冗余制造出知识壁垒。一致性包括各个方面, 主要包括:架构,技术选型,代码规范,流程,机制,工具,平台,解决方案一致,思考问题的角度等。

   正交性

“正交性”是从几何学中借来的术语。如果两条直线相交成直角,它们就是正交的,比如图中的坐标轴。用向量术语说,这两条直线互不依赖。沿着某一条直线移动,你投影到另一条直线上的位置不变。在计算技术中,该术语用于表示某种不相依赖性或是解耦性。如果两个或更多事物中的一个发生变化,不会影响其他事物,这些事物就是正交的。在设计良好的系统中,数据库代码与用户界面是正交的:你可以改动界面,而不影响数据库;更换数据库,而不用改动界面。正交的好处:▶︎ 提高生产率:改动得以局部化,促进复用 M+N--> M*N。▶︎ 降低风险:问题隔离,更容易测试。▶︎ 团队的正交:康威定律,如果团队的组织有许多重叠,各个成员就会对责任感到困惑。每一次改动都需要整个团队开一次会,因为他们中的任何一个人都可能受到影响。

   可逆性

自世纪之交以来,我们看到了以下服务端架构的“最佳实践”:▶︎ 大铁块 ;▶︎ 大铁块的联合;▶︎ 带负载均衡的商用硬件集群;▶︎ 将程序运行在云虚拟机中;▶︎ 将服务运行在云虚拟机中;▶︎ 把云虚拟机换成容器再来一遍;▶︎ 基于云的无服务器架构;▶︎ 最后,无可避免地,有些任务又回到了大铁块。 你能为这种架构的变化提前准备吗?做不到。你能做的就是让修改更容易一点。将第三方 API 隐藏在自己的抽象层之后。将代码分解为多个组件:即使最终会把它们部署到单个大型服务器上,这种方法也比一开始做成庞然大物,然后再切分要容易得多。---《程序员修炼之道》

   DRY(Don't repeat yourself)

重复是软件的原罪之一,“Dont repeat yourself” 告诉我们应该尽可能地消灭重复和冗余。重复使软件的阅读,修改,测试变得复杂,消灭重复,是使软件变得简单的手段之一。DRY 不只是代码的重复, 还包括以下任何你能想到的方面:▶︎ 数据定义。▶︎ API重复。▶︎ 人员重复。▶︎ 代码与文档之间的重复。▶︎ 代码与注释之间的重复。▶︎ 工具重复。▶︎ 服务重复。

   设计两次

设计软件非常困难,因此你对如何构造模块或系统的初步思考不太可能会产生最佳的设计。如果为每个主要设计决策考虑多个选项,最终将获得更好的结果:设计两次。大型软件的设计已经复杂到没人能够一次就想到最佳方案,一个仅仅“可行”的方案,可能会给系统增加额外的复杂性。

   分层与抽象

软件系统由不同的层次组成,层次之间通过接口来交互。在严格分层的系统里,内部的层只对相邻的层次可见,这样就可以将一个复杂问题分解成增量步骤序列。由于每一层最多影响两层,也给维护带来了很大的便利。分层系统最有名的实例是 TCP/IP 网络模型。在分层系统里,每一层应该具有不同的抽象。TCP/IP 模型中,应用层的抽象是用户接口和交互;传输层的抽象是端口和应用之间的数据传输;网络层的抽象是基于 IP 的寻址和数据传输;链路层的抽象是适配和虚拟硬件设备。如果不同的层具有相同的抽象,可能存在层次边界不清晰的问题。

   复杂性下沉

不应该让用户直面系统的复杂性,即便有额外的工作量,开发人员也应当尽量让用户使用更简单。如果一定要在某个层次处理复杂性,这个层次越低越好。举个例子,Thrift 接口调用时,数据传输失败需要引入自动重试机制,重试的策略显然在 Thrift 内部封装更合适,开放给用户(下游开发人员)会增加额外的使用负担。与之类似的是系统里随处可见的配置参数(通常写在 XML 文件里),在编程中应当尽量避免这种情况,用户(下游开发人员)一般很难决定哪个参数是最优的,如果一定要开放参数配置,最好给定一个默认值。复杂性下沉,并不是说把所有功能下移到一个层次,过犹不及。如果复杂性跟下层的功能相关,或者下移后,能大大下降其他层次或整体的复杂性,则下移。go routine:调度器复杂性下沉到语言层面。Service Mesh:服务治理和调度下沉到基础设施层。

   SOLID

  • 单一职责原则 Single Responsibility

一个代码组件(例如类或函数)应该只执行单一的预设的任务。注意,不等于每个模块都应该只做一件事,这只是一个面向底层实现细节的设计原则,并不是 SRP 的全部。准确描述是任何一个软件模块都应该只对某一类行为者负责,把变更原因不同的函数放入不同的类中。一个软件系统的最佳结构高度依赖于开发这个系统的组织的内部结构。这样,每个软件模块都有且只有一个需要被改变的理由。

  • 开放封闭原则 Open Close

程序里的实体项(类,模块,函数等)应该对扩展行为开放,对修改行为关闭。换句话说,不要写允许别人修改的类,应该写能让人们扩展的类一个设计良好的计算机系统应该在不需要修改的前提下就可以轻易被扩展。

  • 里氏替换原则 Liskov Substitution

里氏替换原则的内容可以描述为:“派生类(子类)对象可以在程式中代替其基类(超类)对象。”LSP可以且应该被应用于软件架构层面,因为一旦违背了可替换性,该系统架构就不得不为此增添大量复杂的应对机制。

  • 接口隔离原则 Interface Segregation

指明客户(client)应该不依赖于它不使用的方法。接口隔离原则(ISP)拆分非常庞大臃肿的接口成为更小的和更具体的接口,这样客户将会只需要知道他们感兴趣的方法。这种缩小的接口也被称为角色接口(role interfaces)。接口隔离原则(ISP)的目的是系统解开耦合,从而容易重构,更改和重新部署。

  • 依赖倒置原则 Dependency Inversion

程序要依赖于抽象接口,不要依赖于具体实现。简单地说就是要求对抽象进行编程,不要对实现进行编程,这样就降低了客户与实现模块间的耦合。应在代码中多使用抽象接口,尽量避免使用那些多变的具体实现类。依赖倒置原则则强调:为了让依赖关系是稳定的,不应该由实现侧根据自己的技术实现方式定义接口,然后强迫上层(即客户)依赖这种不稳定的 API 定义,而是应该站在上层(即客户)的角度去定义 API(正所谓依赖倒置)。但是,虽然接口由上层定义,但最终接口的实现却依然由下层完成,因此依赖倒置描述为:上层不依赖下层,下层也不依赖上层,双方共同依赖于抽象。

   组件耦合

大型软件系统的构建过程与建筑物修建很类似,都是由一个个小组件组成的 。所以,如果说 SOLID 原则是用于指导我们如何将砖块砌成墙与房间的,那么组件构建原则就是用来指导我们如何将这些房间组合成房子的 。组件是软件的部署单元,是整个软件系统在部署过程中可以独立完成部署的最小实体。▶︎ 无循环依赖:组件依赖关系图中不应该出现环▶︎ 稳定依赖原则:依赖关系必须要指向更稳定的方向 。▶︎ 稳定抽象原则:一个组件的抽象化程度应该与其稳定性保持一致。

   划分边界

软件架构设计本身就是一门划分边界的艺术 。边界的作用是将软件分割成各种元素,以便约束边界两侧之 间的依赖关系。

架构师们所追求的目标是最大限度地降低构建和维护一个系统所需的人力资源。系统最消耗人力资源的是系统中存在的耦合——尤其是那些过早做出的、不成熟的决策(与系统的业务需求,也就是用例无关)所导致的耦合。

需要先将系统分割成组件,其中一部分是系统的核心业务逻辑组件,而另一部分则是与核心业务逻辑无关但负责提供必要功能的插件。然后通过对源代码的修改,让这些非核心组件依赖于系统的核心业务逻辑组件。

通过划清边界,我们可以推迟和延后一些细节性的决策,这最终会为我们节省大量的时间、避免大量的问题。这就是一个设计良好的架构所应该带来的助益。

DDD 中也有关于限界上下文的描述, 有助于我们进行边界划分:

   通信与集成

  • 防腐层 ACL

防腐层其实是设计思想“间接”的一种体现,引入一个中间层,有效隔离限界上下文之间耦合 防腐层经常扮演:适配器、调停者、外观等角色(设计模式中常见几种结构型模式) 防腐层往往属于下游限界上下文,用以隔绝上游限界上下文可能发生的变化。

对付遗留系统时,防腐层可谓首选利刃。

  • 开放主机服务 OHS

开放主机服务就是上游服务用来吸引更多下游调用者的诱饵设计开放主机服务,就是定义公开服务的协议,包括通信的方式、传递消息的格式(协议)。同时,也可视为是一种承诺,保证开放的服务不会轻易做出变化 因为开放主机服务一般位于上游,对应多个下游,所以不建议为每个下游都做一个防腐层,实践时候防腐层一般在下游。

对抗复杂度的圣杯战争:软件架构究竟该如何设计?-2

  • 发布订阅事件

即使是确定了发布语言规范的开放主机服务,仍然会导致两个上下文之间存在耦合关系,下游限界上下文必须知道上游服务的 ABC(Address、Binding 与 Contract),对于不同的分布式实现,还需要在下游定义类似服务桩的客户端。

采用发布/订阅事件的方式可以在解耦合方面走得更远,当确定了消息中间件后,发布方与订阅方唯一存在的耦合点就是事件,准确地说,是事件持有的数据。

   保持开放

所有的软件系统都可以分解为两大部分:策略和细节。策略要素体现了所有的业务规则和过程。该策略是系统的真正价值所在。细节是使人类,其他系统和程序员能够与策略沟通,但不影响策略行为的必要条件。它们包括 IO 设备,数据库,Web 系统,服务器,框架,通信协议等等。

  • 数据库是细节

从架构的角度来看,数据库是一个非实体——它是一个细节,没有上升到一个架构元素的层级。它与软件系统的架构之间的关系更像是门把手与你家的建筑架构的关系。

  • WEB 是细节

GUI 是一个细节。Web 是一个 GUI。所以 Web 是一个细节。而且,作为一名架构师,你希望将这样的细节置于与你的核心业务逻辑分离的边界之后。

  • 框架是细节

你可以使用框架,只是不要耦合它。保持在疏远的距离(Keep it at arm’s length)。将框架视为属于体系结构外围的一个细节。不要让它进入内部圈子。

如果框架要你从它的基类派生你的业务对象,说不!代之以派生代理,并将这些代理保留在作为业务规则插件的组件中。

不要让框架进入你的核心代码。相反,按照依赖规则将它们集成到组件中,组件再插入(plug in)核心代码的。

统一建模语言(Unified Modeling Language,UML)是一种面向对象的通用建模语言,可以用于对软件密集型系统的制品进行可视化、详述、构造和文档化。

UML 可以方便技术人员之间进行方案沟通, 技术传承,可以挑选常用的几个进行学习。

▶︎ 用例图

▶︎ 类图

▶︎ 流程图(泳道图)

▶︎ 时序图

▶︎ 状态图

来源:腾讯开发者

相关文章

塑造我成为 CTO 之路的“秘诀”
“人工智能教母”的公司估值达 10 亿美金
教授吐槽:985 高校成高级蓝翔!研究生基本废了,只为房子、票子……
Windows 蓝屏中断提醒开发者:Rust 比 C/C++ 更好
Claude 3.5 Sonnet 在伽利略幻觉指数中名列前茅
上海新增 11 款已完成登记生成式 AI 服务

发布评论