软件架构设计的核心:抽象与模型、“战略编程”

2022-09-07 17:08:03 浏览数 (2)

0. 引子:人类怎样应对复杂性?

复杂性

在任何程序(可以向外延伸到其他很多领域)的生命周期中,复杂性都会不可避免地增加。 程序越大,工作的人越多,管理复杂性就越困难,程序员在修改系统时将所有相关因素牢记在心中变得越来越难;这会减慢开发速度并导致错误,从而进一步延缓开发速度并增加成本。

很多大型系统的本质问题是复杂性问题,数百个甚至更多的微服务相互调用/依赖,组成一个组件数量大、行为复杂、时刻在变动(发布、配置变更)当中的动态的、复杂的系统。

如果,我们将领域问题的复杂度与技术细节的复杂度混合在了一起,这最终将导致——整体复杂度的指数级增长。

复杂性的一个衡量维度:

  • 可维护性/可修改性
  • 一致性
  • 可读性/清晰性
  • 可测试性

降低系统复杂性:一致性

Singe Source of Truth(SSO)

一致性是降低系统复杂性并使其行为更明显的强大工具。如果系统是一致的,则意味着相似的事情以相似的方式完成,而不同的事情则以不同的方式完成。一致性会产生认知影响力:一旦您了解了某个地方的工作方式,就可以使用该知识立即了解其他使用相同方法的地方。如果系统的实施方式不一致,则开发人员必须分别了解每种情况。这将花费更多时间。

一致性减少了错误。如果系统不一致,则实际上两种情况可能不同,但两种情况可能看起来相同。开发人员可能会看到一个看起来很熟悉的模式,并根据以前对该模式的遭遇做出错误的假设。另一方面,如果系统是一致的,则基于熟悉情况的假设将是安全的。一致性允许开发人员以更少的错误来更快地工作。

一致性是投资心态的另一个例子。确保一致性的工作将需要一些额外的工作:确定约定,创建自动检查程序,寻找类似情况以模仿新代码,以及进行代码审查以教育团队。这项投资的回报是您的代码将更加明显。开发人员将能够更快,更准确地了解代码的行为,这将使他们能够以更少的错误来更快地工作。

一致性示例

一致性可以应用于系统中的许多级别。这里有一些例子。

编码样式。如今,开发组织通常拥有样式指南,这些样式指南将程序结构限制在编译器所强制执行的规则之外。现代风格指南解决了一系列问题,例如缩进,大括号放置,声明顺序,命名,注释以及对认为危险的语言功能的限制。样式指南使代码更易于阅读,并且可以减少某些类型的错误。 接口。具有多个实现的接口是一致性的另一个示例。一旦了解了接口的一种实现,其他任何实现都将变得更易于理解,因为您已经知道它将必须提供的功能。 设计模式。设计模式是某些常见问题的普遍接受的解决方案,例如用于用户界面设计的模型视图控制器方法。如果您可以使用现有的设计模式来解决问题,则实现会更快地进行,更有可能起作用,并且您的代码对读者来说也会更明显。 不变量。不变式是始终为真的变量或结构的属性。例如,存储文本行的数据结构可能会强制要求每行以换行符终止。不变式减少了代码中必须考虑的特殊情况的数量,并使推理行为的方式变得更加容易。

确保一致性

一致性很难维护,尤其是当许多人长时间从事一个项目时。一组人可能不了解另一组中建立的约定。新来者不了解规则,因此他们无意间违反了约定并创建了与现有约定冲突的新约定。以下是建立和保持一致性的一些技巧:

  • 文档规范。创建一个列出最重要的总体约定的文档,例如编码样式准则。将文档放置在开发人员可能会看到的位置,例如项目 Wiki 上的显眼位置。鼓励新成员加入小组阅读文档,并鼓励现有人员不时审阅该文档。Web 上已经发布了来自各个组织的一些样式指南;考虑从其中之一开始。对于局部性更强的约定,例如不变式,请在代码中找到合适的位置进行记录。如果您不写下约定,那么其他人不太可能会遵循它们。
  • 执行机制。即使有好的文档,开发人员也很难记住所有约定。实施约定的最佳方法是编写一个检查违规的工具,并确保除非通过检查程序,否则代码无法提交到存储库。自动检查器对于底层语法约定特别有用。

1. 模型:抽象与分层

“无名,万物之始也; 有名,万物之母也”。(老子的《道德经》第一章) 译文:无,可以用来表示天地浑沌未开之际的状况,而有,则是宇宙万物产生之本原的命 名。

抽象(Abstraction)

抽象的使用是计算机科学中最为重要的概念之一。例如,为一组函数规定一个简单的应用程序接口(API)就是一个很好的编程习惯,程序员无需了解它内部的工作便可以使用这些代码。不同的编程语言提供不同形式和等级的抽象支持,例如 Java 类的声明和 C 语言的函数原型。操作系统中也存在着很多的抽象:

在处理器里,指令集结构提供了对实际处理器硬件的抽象。使用这个抽象,机器代码程序表现得就好像它是运行在一个一次只执行一条指令的处理器上。底层的硬件比抽象描述的要复杂精细得多,它并行地执行多条指令,但又总是与那个简单有序的模型保持一致。只要执行模型一样,不同的处理器实现也能执行同样的机器代码,而又提供不同的开销和性能。

文件是对 IO 的抽象,虚拟存储器是对程序存储器的抽象,而进程是对一个正在运行的程序的抽象。我们再增加一个新的抽象:虚拟机,它提供对整个计算机(包括操作系统、处理器和程序)的抽象。虚拟机的思想是 IBM 在 20 世纪 60 年代提出来的,但是最近才显示出其管理计算机方式上的优势,因为一些计算机必须能够运行为不同操作系统(例如,Microsoft Windows、MacOS 和 Linux)或同一操作系统的不同版本而设计的程序。

模块化设计

抽象与模块化设计的思想紧密相关。抽象是实体的简化视图,其中省略了不重要的细节。抽象是有用的,因为它们使我们更容易思考和操纵复杂的事物。在模块化编程中,每个模块以其接口的形式提供抽象。该接口提供了模块功能的简化视图;从模块抽象的角度来看,实现的细节并不重要,因此在接口中将其省略。

在抽象的定义中,“无关紧要”一词至关重要。从抽象中忽略的不重要的细节越多越好。但是,如果细节不重要,则只能将其从抽象中省略。常见的不当抽象可能包含以下两种:

首先,它可以包含并非真正重要的细节。当这种情况发生时,它会使抽象变得不必要的复杂,从而增加了使用抽象的开发人员的认知负担。 第二个错误是抽象忽略了真正重要的细节。这导致模糊不清:仅查看抽象的开发人员将不会获得正确使用抽象所需的全部信息。忽略重要细节的抽象是错误的抽象:它可能看起来很简单,但实际上并非如此。 例如,考虑一个文件系统。文件系统提供的抽象省略了许多细节,例如用于选择存储设备上的哪些块用于给定文件中的数据的机制。这些详细信息对于文件系统的用户而言并不重要(只要系统提供足够的性能即可)。但是,文件系统实现的一些细节对用户很重要。大多数文件系统将数据缓存在主内存中,并且它们可能会延迟将新数据写入存储设备以提高性能。一些应用程序(例如数据库)需要确切地知道何时将数据写入存储设备,因此它们可以确保在系统崩溃后将保留数据。因此,将数据刷新到辅助存储的规则必须在文件系统的接口中可见。

我们不仅依靠抽象来管理复杂性,而且不仅在编程中,而且在日常生活中无处不在。微波炉包含复杂的电子设备,可将交流电转换为微波辐射并将该辐射分布到整个烹饪腔中。幸运的是,用户看到了一个简单得多的抽象,它由几个按钮控制微波的定时和强度。汽车提供了一种简单的抽象概念,使我们可以在不了解电动机,电池电源管理,防抱死制动,巡航控制等机制的情况下驾驶它们。

分层存储模型

另外一个典型的抽象模型,就是计算机的存储管理模型。

我们知道,在由半导体器件、电路板组成的计算硬件体系中,根本没有操作系统、TCP/IP、内存分配、垃圾回收等等概念模型。聪明的人类(这些人通常就是计算机科学家了),就是靠着杰出的想象力与抽象能力,设计出了计算机存储分层抽象模型:

一个32位操作系统的例子。其中,1GB为操作系统的内核空间,用户无法更改,这部分不用管它;剩下的3GB位用户的内存空间,这是供用户程序使用的。

分层(Layering)

想必,分层是宇宙创造万物的方式。从地球构造到鸡蛋构造,从太阳系组成到细胞结构,无不如此。

地球分层构造图:

鸡蛋结构图:

太阳系:

细胞结构:

操作系统分层图:

软件系统由层组成,其中较高的层使用较低层提供的功能。例如:

在文件系统中,最上层实现文件抽象。文件由可变长度的字节数组组成,可以通过读写可变长度的字节范围来更新该字节。文件系统的下一个下一层在固定大小的磁盘块的内存中实现了高速缓存。调用者可以假定经常使用的块将保留在内存中,以便可以快速访问它们。最低层由设备驱动程序组成,它们在辅助存储设备和内存之间移动块。

在诸如 TCP 的网络传输协议中,最顶层提供的抽象是从一台机器可靠地传递到另一台机器的字节流。此级别在较低级别上构建,该级别可以尽最大努力在计算机之间传输有限大小的数据包:大多数数据包将成功交付,但某些数据包可能会丢失或乱序交付。

网络分层协议

从最底层的物理链路层层层向上封装抽象,解决了复杂的网络通信的问题。同样的,任何复杂的问题,通过分层最终总能够回归最本质、最简单。

DDD 领域分层架构

DDD 分层架构遵循了“关注点分离”原则,将属于业务逻辑的关注点放到: 1、领域层(Domain Layer)中, 2、而将支撑业务逻辑的技术实现放到基础设施层(Infrastructure Layer)中。 3、应用层(Application Layer),扮演了双重角色。一方面它作为业务逻辑的外观(Facade),暴露了能够体现业务用例的应用服务接口;另一方面它又是业务逻辑与技术实现的粘合剂,实现二者之间的协作。

下图“分层架构“展现的就是一个典型的领域驱动设计分层架构。蓝色区域的内容与业务逻辑有关,灰色区域的内容与技术实现有关,二者泾渭分明,然后汇合在应用层。应用层确定了业务逻辑与技术实现的边界,通过直接依赖或者依赖注入(DI,Dependency Injection)的方式将二者结合起来。

从上到下的层次隔离

为了将我们的应用部署到服务器上,我们需要为其配置一个运行环境。从底层到顶层有这样的运行环境及容器:

  1. 隔离硬件:虚拟机
  2. 隔离操作系统:容器虚拟化
  3. 隔离底层:Servlet 容器
  4. 隔离依赖版本:虚拟环境
  5. 隔离运行环境:语言虚拟机
  6. 隔离语言:DSL

实现上这是一个请求的处理过程,一个 HTTP 请求会先到达你的主机。如果你的主机上运行着多个虚拟机实例,那么请求就会来到这个虚拟机上。又或者是如果你是在 Docker 这一类容器里运行你的程序的话,那么也会先到达 Docker。随后这个请求就会交由 HTTP 服务器来处理,如 Apache、Nginx,这些 HTTP 服务器再将这些请求交由对应的应用或脚本来处理。随后将交由语言底层的指令来处理。

2.静态视角:结构

从结构开始

什么是结构(Structure)?结构,是由组成整体的各部分的搭配和安排。古人写毛笔字,有云:“结构圆备如篆法,飘颺洒落如章草。”(晋·卫夫人《笔阵图》)现代人做软件结构设计,依然追寻着这样一种美感——简洁、优雅、小巧玲珑若珍珠宝石一般的美。

在软件架构领域,“结构”包括软件元素,它们之间的关系,元素和关系的属性,以及每个元素的引入和配置的基本原理(ISO/IEC 42010:20072)。

这里定义了架构的三要素:职责明确的模块或者组件、组件间明确的关联关系、约束和指导原则。

软件系统的架构是一种隐喻,类似于建筑物的体系结构,是一种整体与局部关系的抽象描述,架构是软件系统内部设计中最重要而又模糊的方面。有系统的地方就需要架构,大到航空飞机,小到一个电商系统里面的一个功能组件,都需要设计和架构。

架构, (1)是对系统中的实体,以及实体之间的关系,所进行的抽象描述, (2)是对事物的功能与形式元素之间的对应关系,所做的分配, (3)是对元素之间的关系,以及元素同周边环境之间的关系所做的定义。

架构能将目标系统按某个原则进行切分,切分的原则,是要便于不同的角色进行并行工作,结构良好的创造活动要优于毫无结构的创造活动。

为什么需要架构?

举一个盖房子的例子。我们偶尔会从新闻上听到某某房子倒塌、高架塌陷等悲惨事件,但是想想背后的原因,是不是因为房子、桥梁结构不合理,施工偷工减料,质量检查没有履行职责等原因导致?

这个在软件工程领域,道理是相通的。一个没有经过合理设计就匆忙开发上线的系统,迟早要还“技术债”。

架构并不由系统的功能决定,而是由系统的非功能属性决定。

架构需要: 1、控制系统复杂性,将核心业务逻辑和技术细节的分离与解耦。 2、保证系统高可用。 3、提升团队整体的研发效能。

架构师的职责是: 1、努力训练自己的思维,用它去理解复杂的系统, 2、通过合理的分解和抽象,理解并解析需求, 3、创建有用的模型, 4、确认、细化并扩展模型,管理架构; 5、进行系统分解形成整体架构, 6、能够正确的技术选型, 7、能够制定技术规格说明并有效推动实施落地。

3.动态视角:运动变化的关系

战略与战术:动态演化的视角

大多数程序员的编程行为,都是战术思维方式,着眼于使功能尽快运行。 但是,如果您想要一个好的设计,则必须采取更具战略性的方法,在此上花费时间来制作干净的设计并解决问题。

战术编程(Tactical Programming)

在战术编程方法中,核心关注点是,使某些功能正常工作,例如新功能或错误修复。 乍一看,这似乎是完全合理的:还有什么比编写有效的代码更重要的呢? 但是,战术编程几乎不可能产生出良好的系统设计。 战术编程的问题是它是短视的。 如果您是战术编程人员,那么您将只是尽快完成任务,您不会花费太多时间来寻找最佳设计。您只想尽快使某件事起作用。您告诉自己,可以增加一些复杂性或引入一两个小错误,如果这样可以使当前任务更快地完成,则可以。

如果您进行战术编程,则每个编程任务都会带来一些此类复杂性。 为了快速完成当前任务,他们每个人似乎都是一个合理的折衷方案。 但是,复杂性迅速累积,尤其是如果每个人都在战术上进行编程的时候。

不久之后,某些复杂性将开始引起问题,但是,您会告诉(qi pian)自己,使下一个功能正常工作比返回并重构现有代码更为重要。从长远来看,重构可能会有所帮助,但是肯定会减慢当前的任务。

因此,您需要快速修补程序来解决遇到的任何问题。这只会增加复杂性,然后需要更多补丁。

很快代码变得一团糟,但是到现在为止,情况已经很糟糕了,清理它需要花费数月的时间。您的日程安排无法容忍这种延迟,解决一个或两个问题似乎并没有太大的区别,因此您只是在战术上保持编程。

几乎每个软件开发组织,都有至少一个将战术编程发挥到极致的开发人员:战术龙卷风(The tactical tornado)。

战术龙卷风是一位多产的程序员,他抽出代码的速度比其他人快得多,但完全以战术方式工作。实施快速功能时,没有人能比战术龙卷风更快地完成任务。

在某些组织中,管理层将战术龙卷风视为英雄。但是,战术龙卷风留下了毁灭的痕迹。他们很少被将来必须使用其代码的工程师视为英雄。通常,其他工程师必须清理战术龙卷风留下的混乱局面,这使得那些工程师(他们是真正的英雄)的进步似乎比战术龙卷风慢。

在战术编程中,您将不断增加一些复杂性,这些复杂性将来会引起问题。

战略编程(Strategic programming)

成为一名优秀的软件设计师的第一步是要意识到仅工作代码是不够的。引入不必要的复杂性以更快地完成当前任务是不可接受的。最重要的是系统的长期结构。任何系统中的大多数代码都是通过扩展现有代码库编写的,因此,作为开发人员,最重要的工作就是促进这些将来的扩展。因此,尽管您的代码当然必须工作,但您不应将“工作代码”视为主要目标。您的主要目标必须是制作出出色的设计,并且这种设计也会起作用。这是战略计划。

战略性编程需要一种投资心态。您必须花费时间来改进系统的设计,而不是采取最快的方式来完成当前的项目。这些投资会在短期内让您放慢脚步,但从长远来看会加快您的速度。一些投资将是积极的。例如,值得花一些时间为每个新类找到一个简单的设计。而不是实施想到的第一个想法,请尝试几种替代设计并选择最简洁的设计。试想一下将来可能需要更改系统的几种方式,并确保设计容易。编写好的文档是主动投资的另一个例子。

如果您进行战略性编程,则将不断对系统设计进行小幅改进。

权衡:ROI

一开始,战术性的编程方法将比战略性方法更快地取得进展。但是,在战术方法下,复杂性积累得更快,从而降低了生产率。

随着时间的流逝,战略方针会带来更大的进步。注意:此图仅用于定性说明;我不知道对曲线精确形状的任何经验测量。

相反,如果您进行战术编程,则可以将第一个项目完成的速度提高 10%到 20%,但是随着时间的推移,复杂性的累积会降低开发速度。不久之后,您的编程速度至少会降低 10–20%。您将很快退回在开始时节省的所有时间,并且在系统的整个生命周期中,与采用策略性方法相比,您的开发速度将更加缓慢。

可持续迭代演化的系统:演进式架构(Evolutionary Architecture)

与传统的前期、重量级的企业架构设计相比,我们建议采用演进式架构(Evolutionary Architecture)。它提供了企业架构的好处,却没有试图准确预测未来所带来的问题。

演进式架构不需要猜测组件将如何被重用,而是支持适应性,使用适当的抽象、数据库迁移、测试套件、持续集成和重构来收获系统内发生的重用。

尽管我们尽了最大努力,但复杂度仍会随着时间的推移而增加,但是更简单的设计使我们能够在复杂性压倒性优势之前构建更大,功能更强大的系统。复杂性的应对永远不会是一劳永逸,我们需要不断地推陈出新,是动态、渐进的重塑自己对软件系统的认识,不断认识问题和寻找更优解的持续迭代:

互联网行业的软件系统,很难一开始就做出完美的设计,通过一个个功能模块衍生迭代,系统才会逐步成型;对于现存的系统,也很难通过一个大动作,一劳永逸地解决所有问题。系统设计是需要持续投入的工作,通过细节的积累,最终得到一个完善的系统。因此,好的设计是日拱一卒的结果,在日常工作中要重视设计和细节的改进。

  1. 通过使代码更简单和更清晰(Obvious)来消除复杂性。例如: 减少特殊场景的处理,或变量命名一致性都能降低系统复杂性。代码能够描述程序的工作流程和结果,却很难描述开发人员的思路,而注释和文档可以。此外,通过注释和文档,开发人员在不阅读实现代码的情况下,就可以理解程序的功能,注释间接促成了代码抽象。好的注释能够帮助解决软件复杂性问题,尤其是认知负担和不可知问题(Unknown Unknowns)。
  2. 通过分层或者分模块来封装它,对复杂问题的抽象然后分而治之,以便程序员可以在系统上工作而不会立即暴露其所有复杂性。这种方法称为模块化设计。在模块化设计中,软件系统分为模块,例如面向对象语言的类。这些模块被设计为彼此相对独立,以便程序员可以在一个模块上工作而不必了解其他模块的细节。
  3. 专业化分工和代码复用,促成了软件生产率的提升。比如硬件工程师、软件工程师(底层、应用、不同编程语言)可以在无需了解对方技术背景的情况下进行合作开发;同一领域服务可以支撑不同的上层应用逻辑等等。其背后的思想,无非是通过将系统分成若干个水平层、明确每一层的角色和分工,来降低单个层次的复杂性。同时,每个层次只要给相邻层提供一致的接口,可以用不同的方法实现,这就为软件重用提供了支持。分层是解决复杂性问题的重要原则。
  4. 与分层类似,分模块是从垂直方向来分解系统。分模块最常见的应用场景,是如今广泛流行的微服务。分模块降低了单模块的复杂性,但是也会引入新的复杂性,例如模块与模块的交互

4. 软件架构编年史

  • 20 世纪 50 年代
    • 非结构化编程
    • ~1951 – 汇编
  • 20 世纪 60 年代
    • 结构化编程
    • 分层: 用户界面、业务逻辑数据存储都在一层
    • ~1958 – Algol
  • 20 世纪 70 年代
    • 过程式/函数式编程
    • ~1970 – Pascal
    • ~1972 – C
    • 1979 – MVC 模式(Model-View-Controller)
  • 20 世纪 80 年代
    • 面向对象编程 (但其思想在 20 世纪 60 年代晚期已经第一次提出)
    • 分层: 两层,第一层是用户界面,第二层是业务逻辑和数据存储
    • ~1980 – C
    • CORBA – 通用物件请求代理架构(尽管1991 年才推出第一个稳定版,但最早使用可以追溯到 20 世纪 80 年代)
    • ~1986 – Erlang
    • ~1987 – Perl
    • 1987 – PAC 即 HMVC 模式(Hierarchical Model-View-Controller)
    • 1988 – LSP(里氏替换原则) (~SOLID)
  • 20 世纪 90 年代
    • 分层: 三层,第一层是用户界面,第二层是业务逻辑(以及浏览器作为客户端时的用户界面展现逻辑),第三层是数据存储
    • ~1991 – 消息总线
    • ~1991 – Python
    • 1992 – EBI 架构(Entity-Boundary-Interactor) 即 EBC 或 EIC
    • ~1993 – Ruby
    • ~1995 – Delphi, Java, Javascript, PHP
    • 1996 – MVP 模式(Model-View-Presenter)
    • 1996 – OCP, ISP, DIP (~SOLID), REP, CRP, CCP, ADP
    • 1997 – SDP, SAP
    • ~1997 – 面向方面编程
    • ~1997 – Web 服务
    • ~1997 – ESB – 企业服务总线 (尽管创造该术语的书籍 2004 年才出版,但这个概念早已被使用)
  • 21 世纪 00 年代
    • 2002 – SRP (~SOLID)
    • 2003 – 领域驱动设计
    • 2005 – MVVM 模式(Model-View-ViewModel)
    • 2005 – 端口和适配器架构即六边形架构
    • 2006 – CQRS 与 ES (命令查询职责分离与事件溯源)
    • 2008 – 洋葱架构
    • 2009 – 微服务(Netflix)
  • 21 世纪 10 年代
    • 2010 – DCI 架构(Data-Context-Interaction)
    • 2012 – 整洁架构
    • 2014 – C4 模型

5. 编程哲学:禅与计算机程序设计艺术

代码语言:javascript复制
 美丽胜于丑陋。 
 显式优于隐式。 
 简单胜于复杂。 
 复杂胜于复杂。 
 扁平比嵌套好。 
 疏胜于密。 
 可读性很重要。 
 特殊情况不足以打破规则。 
 尽管,实用第一,简洁第二。 
 错误永远不应该无声无息地过去。 
 除非明确沉默。 
 面对暧昧,拒绝猜测的诱惑。 
 应该有一种——最好只有一种——显而易见的方法来做到这一点。 
 虽然这种方式一开始可能不明显,除非你是荷兰人。 
 现在总比没有好。 
 虽然永远不会比现在好。 
 如果实现很难解释,那就是一个坏主意。 
 如果实现容易解释,这可能是一个好主意。 
 命名空间是一个非常棒的主意——让我们做更多这样的事情吧!

Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!

6. 架构师

架构师,是一个既能掌控整体全局,又能洞悉局部瓶颈,并依据具体的业务场景,给出解决方案的团队领导型人物。

0 人点赞