程序员架构修炼之道:如何设计出可持续演进的系统架构?

2022-09-19 18:23:42 浏览数 (1)

摄影图:一只始终保持着警惕的松鼠

概述

本文的主题是:如何构建出能正确应对各种变化的系统?

现代商业中需求不断变化是必然的,这就需要我们设计出一种可以应对这种变化的系统架构——当无法预测变化时,该架构仍然可以朝着正确的方向发展。这个架构是团队成员不断努力的结果,是一个与开发工作紧密结合的过程,它能同时响应不断变化的需求和开发人员的反馈——我们称之为“演进式架构”,它以敏捷的方式拥抱变化。驱动敏捷软件方法论的引擎是内置的反馈环,如测试、持续集成和迭代等。

构建演进式架构的核心是:采取小步变更,然后通过反馈环,让团队的每个成员不断地从系统的发展过程中学习。持续交付的兴起使得演进式架构变得切实可行。

架构师的工作

为了给出解决方案,架构师工作的第一步是理解业务需求,也即领域需求。这些需求是使用软件来解决问题的动机,但终究只是架构师在构建架构时需要考虑的因素之一。

架构师还必须考虑其他很多因素,其中一些比较明确(比如清楚地写在性能服务水平协议里),还有一些则隐含在商业活动中不言自明(比如公司正着手并购重组,软件架构显然也要有变动)。所以对于软件架构师来说,架构水平体现了他们在权衡业务需求和其他重要因素后找到最佳方案的能力。

架构师的工作就是理解和权衡那些“重要的东西”(无论它们是什么)。如下图所示,业务需求与其他架构关注点(由架构师定义)并存。

关于软件架构的各种特征列表,如下。

在构建软件时,架构师必须明确哪些特征最重要。然而,许多因素是互相矛盾的。比如,让软件具备高性能的同时还要实现极大的伸缩性就很困难,因为实现这两者需要谨慎地平衡架构、运维及其他诸多因素。因此,在为架构设计做必要分析的同时,又要处理好各个因素之间不可避免的冲突,架构师在权衡每个架构设计方案的利弊时,常常需要做出非常艰难的折中。

近年来,软件开发核心工程实践的持续发展,给我们提供了条件,使我们得以重新思考架构——随时间的推移要如何变化,以及当这样的演进发生“时,如何保护重要的架构特征。本文将这些部分联系起来,以一种新的方式思考架构和时间。

我们想为软件架构添加一个新的标准“特征”——演进能力。

演进式架构

演进式架构是,支持跨多个维度进行引导性增量变更的架构。

在人类的活动中,“时间”是一个什么时候都不能忽视的核心变化因素。

为什么叫作演进式架构而不是别的?比如增量式、持续式、敏捷式、响应式或应急式,等等。这些术语都不够准确达意。这里所说的演进式架构包含了两个关键特征:增量和引导。

引导的含义反映了我们想实现的架构,即我们的最终目标。

一切都在变化,如何才能长期规划?

生物世界中,环境因自然因素和人为因素而不断变化。20 世纪 30 年代初,澳大利亚的甘蔗受到甲虫危害,导致甘蔗作物严重减产,利润大减。1935 年 6 月,作为应对措施,当时的甘蔗实验站管理总局引入了甘蔗蟾蜍来捕食甲虫,这种蟾蜍原本只产于中美洲和南美洲。短暂喂养后,1935 年 7 月和 8 月在昆士兰州北部投放了甘蔗蟾蜍。因为它们的皮肤有剧毒,并且在当地没有天敌,很快这种蟾蜍就泛滥成灾了。如今,澳大利亚的甘蔗蟾蜍大约有 2 亿只。

这件事告诉我们:向高度动态的(生态)系统中引入变化,可能会产生无法预料的结果。

软件开发体系由所有的工具、框架、库以及最佳实践(软件开发领域的技术积累)构成。和生态系统一样,软件开发体系实现了平衡,开发人员能够理解这个体系并为其添砖加瓦。然而,这种平衡是动态的,随着新事物不断出现,平衡不断被打破和重建。

在软件开发体系中,每一项创新或新实践都可能打破现状,迫使系统重新建立平衡。架构师就是在不断地平衡以适应环境变化。

持续交付这项工程实践使得这个平衡过程有了结构性的转变,它将过去孤立的功能(例如运维)合并到了软件开发的生命周期中,这让我们对变化的含义有了新的认识。企业级架构师不能再依赖静态的五年计划了,因为整个软件开发体系在不断变化,任何一个长期计划都可能变得毫无意义。

即便对经验丰富的实践者来说,颠覆性的创新也是难以预测的。比如,像 Docker 这样的容器化技术的崛起就是一个不可预知的行业转变。但我们仍然可以通过一系列小的演进找到一些蛛丝马迹。以前,操作系统、应用服务器和其他基础设施都是商品,需要斥巨资购买使用许可。渐渐地,Linux 变得足以支撑企业及应用,使得购买操作系统的费用降为零。接下来,通过 Puppet 和 Chef 等工具自动配置服务器的 DevOps 实践使得 Linux 运维工作也不再需要成本。一旦开发环境免费并得到广泛应用,势必朝着更加通用和便携的方向发展,于是 Docker 应运而生。但如果没有之前所有的演进过程,容器化就不会发生。

我们所使用的编程平台也在持续演进。新的编程语言提供了更好的应用编程接口(API),提高了对新问题的灵活性和适用性。新的编程语言还提供了不同的范式和概念。例如,引入 Java 替代 C ,降低了编写网络代码的难度并改善了内存管理。回顾过去的 20 年,很多语言一直在持续改进它们的 API,与此同时,新的编程语言往往用于解决新的问题。

无论是在软件开发的哪个方面,比如编程平台、编程语言、运维环境、持久化技术等,我们都知道改变会持续发生。虽然无法预测技术或领域格局何时会改变,或哪些变化会持续下去,但我们清楚改变是不可避免的。因此,我们应该在构建系统的过程中对这一点保持清醒的认识。

多维度的架构

不存在单独的系统。世界是一个整体。如何划分系统边界取决于讨论的主题。 ——Donella H. Meadows

古希腊的物理学,通过固定点分析宇宙,最终由牛顿发展成了经典力学。但是到了 20 世纪初,随着仪器越来越精密,现象越来越复杂,人们逐渐从经典力学转向相对论(爱因斯坦)。科学家意识到之前被视为孤立的现象其实是相互影响的。

自 20 世纪 90 年代起,受到启发的架构师越来越多地将软件架构视作“多维的”。持续交付也将运维纳入了软件架构的范畴。软件架构师往往关注技术架构,但那只是软件项目的维度之一。如果架构师想构建可演进的架构,就必须考虑系统中所有会受变化影响的部分。正如物理学所讲的,万物都是相关联的,架构师深知软件项目是多维的。

为了构建可以不断演进的软件系统,架构师需要考虑很多维度的事情:

  1. 技术架构:架构中的实现部分:框架、依赖的库和实现语言等。
  2. 安全:定义安全策略、指导方针和指定工具来帮助发现缺陷。
  3. 数据:数据库模式、表格布局、优化计划等。通常由数据库管理员(DBA)处理这类架构。
  4. 性能与用户体验
  5. 资源成本
  6. 运维与系统:关注架构如何映射到现有的物理或虚拟的技术设施中,包括服务器、机器集群、交换机、云等。

从概念上划分架构的方法有很多,比如 IEEE 的软件架构定义中的 4 1 视图模型。它关注不同角色的不同视角,将整个系统划分成了逻辑视图、开发视图、进程视图和物理视图。

系统绝不是其组成部分的总和,而是各部分相互作用的产物。

按照架构的维度思考,通过评估重要维度对变化的响应,架构师可以分析不同架构的演进能力。随着系统与互相冲突的问题(伸缩性、安全性、分布式、事务性等)关联得越来越紧密,架构师必须跟踪更多的维度。只有结合所有这些重要维度,思考系统将如何演进,才能构建出可以不断演进的系统。

项目的整个架构范围由软件需求和其他维度构成。当架构和整个体系随着时间的推移一起演进时,我们可以使用适应度函数来保护架构特征,如图所示。

全系统适应度函数允许架构师通过统一的机制思考不同的问题,捕捉和保留重要的架构特征。如下图。

全系统适应度函数对于架构演进至关重要,它为架构师提供了比较和评估不同架构特征的基础。

架构师确定了可审计性、数据、安全性、性能、合法性和伸缩性等,是该应用的关键架构特征。随着业务需求不断变化,每个架构特征都通过适应度函数来保护其完整性。

我们强调架构整体的重要性,但也应意识到,技术架构模式也是架构演进的很大一部分,比如耦合和内聚。

耦合不只和软件项目的结构元素有关。团队结构对架构的影响也非常大。这正是“康威定律”。

1968 年 4 月,梅尔文 • 康威在《哈佛商业评论》上发表了一篇名为“How Do Committees Invent?”的论文。在这篇论文中,康威提出:

社会结构,特别是人与人之间的沟通途径,将不可避免地影响最终的产品设计。

康威描述到,在设计的最初阶段,人们首先需要高瞻远瞩地思考:如何将职责划分为不同的模式。人们很难改变其职责范围外的事情。团队分解问题的方式会左右他们之后的选择,这便是康威定律。

在设计系统时,组织所交付的方案结构将不可避免地与其沟通结构一致。 ——梅尔文 • 康威

所以,作为架构师,不要只关注软件架构和设计,还应关注团队之间委派、分配和协调工作的方式。架构师需要时刻关注团队的分工模式,从而使架构目标和团队结构保持一致——构建与目标系统架构相仿的团队结构,这样项目会更容易实现。

架构耦合

关于架构的讨论常常会归结到耦合——架构各个部分是如何连接并相互依赖的。很多架构师认为耦合是必然之恶,但是如果不依赖其他组件(并与其耦合)又很难构建复杂的软件。

演进式架构注重适当的耦合,即如何确定哪些架构维度间应该相互耦合,从而以最小的开销和成本最大程度地获益。

模块化

平台不同,代码复用机制也不同,但它们都支持将相关代码组成模块。模块化描述了相关代码的逻辑分组。可以以不同的物理方式封装模块。组件就是模块的物理封装。模块意味着逻辑分组,而组件意味着物理划分。

SDK 库

库是其中一类组件,它往往和调用代码在相同的内存地址内运行,通过编程语言的函数调用机制进行通信。库常用作编译时的依赖。

由于大多数复杂应用是由各式各样的组件构成的,因此在应用程序架构中存在很多和库相关的问题。

RPC 服务

另一类组件被称为“服务”,倾向于在自己的地址空间中运行,通过低级网络协议(比如 TCP/IP)、更高级的网络协议(比如简单对象访问协议,SOAP),或表述性状态转移(REST)进行通信。服务相关问题往往在集成架构中出现,因为它造成了运行时的依赖。

所有模块机制都有助于代码复用,在任何级别尝试复用代码都是明智的选择,无论是单一的函数,还是封装好的业务平台。

软件系统以各种方式相互联接。软件架构师通过许多不同的视角分析软件。但是组件级的耦合并不是联接软件的唯一方式。许多业务概念在语义上联接系统的各个部分,这便产生了功能内聚。要想使软件成功地演进,开发人员必须考虑所有可打破的耦合点。

演进式数据

在现代软件项目中,关系型和其他类型的数据存储无处不在,这种形式的耦合通常比架构耦合更成问题。在构建可演进的架构时,需要考虑数据这一重要维度。

构建真正可演进的系统

赋予现有架构演进能力取决于三个因素:组件耦合度、工程实践成熟度,以及开发人员构建适应度函数的难易程度。

组件间的耦合很大程度上决定了技术架构的演进能力。然而,如果数据模式死板、僵化,即便最具演进性的技术架构也注定会失败。清晰解耦的系统易于演进,充满耦合的系统则会妨碍演进。想构建出真正可演进的系统,架构师必须考虑架构中所有受影响的维度。

除了技术层面的耦合,架构师还必须考虑和保护系统中组件的功能内聚。当从一种架构迁移到另一种架构时,功能内聚性决定了组件重构后的最终粒度。

选择架构前,需要理解面临的业务问题。

这并不意味架构师可以随心所欲地分解组件,而是说基于特定的问题上下文,组件的大小应该是适当的。例如,有些业务问题相较其他问题耦合度更高,比如有着大量事务的系统。试图构建极其解耦的架构与这类问题相左,因此是徒劳的。

单体架构

很多架构师最终从单体应用迁移到基于服务的架构。考虑如下图所示的迁移起始状态。

“共享一切”的单体架构是迁移的起点。

在新项目中构建细粒度服务很简单,但是在已有的项目中构建则很困难。因此,我们如何将上图所示的架构迁移到基于服务的架构(见下图)呢?

微服务架构

“尽可能少共享”、基于服务的架构是迁移目标。

实施如图所示的架构迁移,会带来一系列挑战:服务粒度、事务边界、数据库问题以及如何处理共享组件等。

架构师必须清楚实施该迁移的原因,并且确保不是盲目地赶时髦。将架构划分为领域,加上更好的团队结构和运维的隔离,会使增量变更更容易,这是演进式架构的关键组成之一,因为工作的重点和实际的产出是相互匹配的。

在分解单体架构时,确定正确的服务粒度是关键。创建大型服务能够缓解一些问题,例如事务上下文和服务编制,但是无法将单体应用分解到更小的粒度。粒度过小的组件则会导致过多的服务编排和消息通信所带来的开销以及服务间的相互依赖。

定义新的服务边界

为了迈出架构迁移的第一步,开发人员要定义新的服务边界。团队可以通过多种划分方式将单体应用分解成服务。

业务功能分组

企业可能有清晰的业务划分直接对应于 IT 能力。模仿当前业务沟通层级构建的软件,无疑应验了康威定律。

事务边界

许多业务需要依附于大量事务边界。当分解单体应用时,架构师经常发现事务耦合是最难解开的。

部署目标

增量变更使得开发人员可以按照不同的计划有选择地发布代码。例如,相比库存部门,市场部门可能希望更新频率更高。如果运维准则非常重要,例如发布速度,那么围绕运维问题划分服务是合理的。类似地,系统的某个部分可能对运维特征有极致的要求(例如伸缩性)。围绕运维目标划分需求使得开发人员能够(通过适应度函数)跟踪服务的健康状态和其他运维服务指标。

较大的服务粒度意味着微服务中许多固有的协调问题都不会存在,因为服务越大,单个服务所包含的业务上下文就越多,但同时操作难度也越大(又一个架构权衡的例子)。

计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决,除非该问题是由间接层太多导致的。 ——Dave Wheeler 和 Kevlin Henney

当然,开发人员增加的间接层越多,服务间的导航就会变得越复杂。

在将应用从单体架构迁移到基于服务的架构时,架构师必须注意现有系统中模块的连接方式。不成熟的划分方式会带来严重的性能问题。

单体应用中的连接点成为了集成架构连接,伴随着延时、可用性和其他问题。因此,与其一次性完成整个迁移,更加实用的方法是循序渐进地将单体应用分解成服务,关注事务边界、结构耦合和其他固有特征来创建若干个架构重建迭代。

首先将单体应用分解成几大块,修复集成点,然后清理和重复这个过程。逐步迁移是微服务领域的首选迁移方式。

当从单体应用迁移时,首先构建少量大型服务。

在顶部构建新的层

人脑的进化并不是在理想的纯净环境中进行的,其能力也从未精心雕琢过。相反,大脑的每一层都基于其下方的原始层。人类大部分核心自主行为由大脑的各个部分主控,例如呼吸、饥饿等,这与爬行动物的大脑没什么区别。进化过程并没有将核心机制整体更换,而是在顶部构建新的层。

在大型企业中,软件架构遵循类似的模式。与其重建各项能力,大多数企业会努力适应现有的一切。尽管我们喜欢在纯净的理想环境中讨论架构,但现实世界往往展现出相反的混乱状态,技术债、优先级冲突和有限的预算很常见。

在大型企业中,架构正如人脑一样:底层系统依旧处理着关键的业务细节,但也伴随着过去的包袱。企业不愿意放弃还在工作的系统,这导致集成架构的挑战不断升级。

赋予现有架构演进能力极具挑战。这是因为,如果开发人员从未将架构构建得易于变更,那么演进能力便不太可能自然地出现。

架构师无法轻松地将大泥团转变成现代微服务架构,无论他多么有天赋。然而通过为项目增添一些灵活性,便能在不改变其整体架构的情况下改善项目。

控制耦合点(关注点)

外部依赖

外部依赖是所有开发平台的一个共同特征,其中包括工具、框架、库和其他来自互联网并(更重要的)通过互联网进行更新的资产。软件开发处在高耸的层层抽象之上,每一层抽象都建立在下层抽象之上。例如,操作系统便是开发人员无法控制的外部依赖。除非公司要编写自己的操作系统及其他所有支持代码,否则他们必须仰仗外部依赖。

经由构建工具,大多数项目会依赖于繁多的第三方组件。开发人员喜欢外部依赖,因为它们能带来好处,但是很多开发人员忽略了随之而来的代价。当我们依赖第三方代码时,开发人员必须采取防御措施来预防可能的意外,例如破坏性的变更、未经通知的删除等。管理项目的这些外部组件是构建演进式架构的关键。

防腐层

构建即时防腐层来隔离库的更新。

使用防腐层有助于系统的演进性。虽然架构师无法预测未来,但至少可以降低变更的成本,以免受到太多负面影响。

控制应用中的耦合点,特别是外部资源,是架构师的关键职责之一。

在需要的时候添加依赖。作为架构师,需要记住依赖在提供好处的同时,还会施加约束。确保从中获得的好处多过更新和管理依赖所带来的成本。

打破耦合点

当耦合点妨碍了演进或其他重要的架构特征时,通过fork组件代码或重复来打破耦合点。

服务模板

微服务架构被设计为无共享架构——组件间尽可能地解耦,遵循限界上下文原则。服务间需要避免耦合的组件主要涉及领域对象、数据库模式以及其他会阻碍演进的耦合点。

然而,为了确保一致性,开发团队经常希望统一管理技术耦合的某些方面(这遵循了“去除不必要的可变性”的建议)。例如组件监控、日志及其他诊断工具。由于微服务架构中的组件不断变化,因此这些工具对该架构至关重要。当运维人员必须管理上千个服务时,如果服务团队忘记为服务添加监控能力,可能导致灾难性后果。该服务一旦部署,就可能消失在黑洞中,因为在这些环境中,如果服务无法被监控,那就无异于隐形。但在高度解耦的环境中,团队如何保持一致呢?

服务模板是保证一致性的常见方案。其中包含一系列的公共基础设施库,例如服务发现、监控、日志、度量、认证 / 授权等。使用服务模板仅将合适的架构部分耦合在一起,例如基础设施组件,团队可以从耦合中获益。服务模板是微服务架构的一部分,专门用来解决微服务架构中的技术架构问题。

在大型组织中,由共享基础设施团队管理服务模板。服务开发团队将模板作为项目脚手架,在其中编写业务行为。如果日志工具需要升级,那么共享基础设施团队能独立于服务团队而管理升级,服务团队不用知道(关心)该变更的发生。如果发生了破坏性的变更,那么部署流水线在准备环境时就会失败,并尽快向开发人员发出警告。

抽象与复用

开发人员构建抽象来摆脱在最底层无尽的思考。如果开发人员需要将来自硬件驱动的二进制数字转换为文本来进行编程,他们将无法完成任何工作。现代软件成功的原因之一在于我们能建立有效的抽象。

在抽象范围的另一端存在着另一种复用陷阱,它隐藏在套装软件、平台和框架中。

抽象

20 世纪 90 年代后期,IBM 一组开发人员着手设计一系列可复用的业务组件,这些组件是用当时的企业级 Java 编写的,并将所有的业务功能封装到广泛的类别中,例如账目、库存、销售等。IBM 一度声称该项目是世上最庞大的 Java 项目。在项目交付了头几个核心模块后,开发人员开始使用这个框架,发现很多功能都是多余的,同时又缺失了许多关键功能——最终这个框架走向了灭亡。

旧金山项目体现了架构师和开发人员的终极妄想,他们想凭借自己天生直觉来将一切事物分门别类。但整洁的解决方案无法解决现实世界中一些混乱的事物,例如业务流程。

旧金山项目以失败告终,因为人们逐渐认识到了一个令人深省的事实:

即无论开发人员多么努力,他们都无法将事物提炼得足够精细,这便是无限回归问题的一部分:一些命题依赖于其他命题而成立,没有止境。

在软件领域中,无限回归表现为人们想要用终级的细节详细描述任何事物,但在任何现有细节之下总是存在另一层更细粒度的细节。

复用

复用软件更像是器官移植而不是拼装乐高积木。 ——John D. Cook

虽然一直以来开发语言的设计者向开发人员承诺的是“乐高积木”,但似乎我们还是只有“器官”。复用软件很难并且不会自动出现。很多管理者乐观地认为开发者编写的任何代码都可复用,但事实并非总是如此。很多公司尝试并成功编写出真正可复用的代码,但这是有意为之并且困难重重。开发人员通常花费大量时间尝试构建可复用的模块,结果却几乎无法复用。

讽刺的是,开发人员为了代码复用所付出的努力往往适得其反。为了复用代码,需要引入额外的选项和决策点以适应不同的用途。开发人员为实现可复用所添加的钩子越多,对代码的基本可用性损害越大。

代码复用性越高,其可用性越低。很多时候,复制粘贴(代码),要比设计模式管用。

换句话说,代码的易用性和复用性往往成反比。当开发人员构建可复用的代码时,他们必然会为了将来开发人员以各种方式使用该代码添加特性。所有针对未来的特性都使得开发人员更难将代码用于单一目的。

重复优于耦合

微服务避免代码复用,遵循重复优于耦合的理念。该理念认为复用意味着耦合,因此微服务架构是极度解耦的。然而,微服务的目标并不是追求重复,而是隔离领域内的实体。

复用所带来的好处是虚幻的,除了其自身缺陷,它还会引入耦合。因此,虽然架构师了解重复的缺点,但他们利用重复抵消了耦合过多对架构的局部损害。

复用代码可以是资产,也可能是潜在的责任。我们要确保代码中引入的耦合点不会和其他架构目标产生冲突。例如,微服务架构通常使用服务模板来将服务的各个部分耦合在一起,帮助统一特定的架构关注点,例如监控和日志。

总结

不要为了架构而构建架构,构建架构是为了解决问题。 在选择架构前,要始终理解问题域,不要本末倒置。

构建演进式架构意味着团队有信心在架构层开展增量变更。业务人员害怕破坏性的变更。如果开发人员构建允许增量变更、比旧架构更强大的架构,将会带来业务和工程上的双赢。

参考阅读

[美] 尼尔 • 福特 [美] 丽贝卡 • 帕森斯 [澳] 帕特里克 • 柯 译者:周训杰. “演进式架构”

0 人点赞