《演进式架构》学习笔记(一)

(此书写得有点杂,但部分内容还可以,以此几篇记录下笔记。)

一、软件架构

为了给出解决方案,架构师工作的第一步是理解业务需求,也即领域需求。这些需求是使用软件来解决问题的动机,但终究只是架构师在构建架构时需要考虑的因素之一。架构师还必须考虑其他很多因素,其中一些比较明确(比如清楚地写在性能服务水平协议里),还有一些则隐含在商业活动中不言自明(比如公司正着手并购重组,软件架构显然也要有变动)。

所以对于软件架构师来说,架构水平体现了他们在权衡业务需求和其他重要因素后找到最佳方案的能力。

p-1.png

在构建软件时,架构师必须明确哪些特征最重要。然而,许多因素是互相矛盾的。

p-2.png

在为架构设计做必要分析的同时,又要处理好各个因素之间不可避免的冲突,架构师在权衡每个架构设计方案的利弊时,常常需要做出非常艰难的折中。

演进式架构

无论我们怎么努力,软件依然变得越来越难以改变。由于各种原因,软件的组成部分不容易变更,而且随着时间推移变得愈发脆弱和难以操作。

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

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

在软件开发体系中,每一项创新或新实践都可能打破现状,迫使系统重新建立平衡。

无论是在软件开发的哪个方面,比如编程平台、编程语言、运维环境、持久化技术等,我们都知道改变会持续发生。虽然无法预测技术或领域格局何时会改变,或哪些变化会持续下去,但我们清楚改变是不可避免的。

如果易于改变是架构的基本原则,那么变更将不再困难。反过来,使架构具备演进能力会导致一组全新的行为出现,进而再次打破整个体系的平衡。

完成架构构建后,如何防止它逐渐退化

有一种不幸的退化叫作架构比特衰减,架构师选择特定的架构模式来满足业务需求及让系统具备某些能力,但这些特征常常意外地随着时间推移而退化。

定义了那些重要的架构特征后,架构师如何保护这些特征不磨损呢?答案是添加演进能力。作为新的架构特征,使其在系统演进时保护其他特征。
演进能力是一种元特征和保护其他所有架构特征的架构封装器。

持续架构指构建架构的过程没有最终状态,它会随着软件开发体系的不断变化而演进,并保护重要的架构特征。我们不会尝试定义整个软件架
构,因为已经存在很多定义了。我们通过引入时间和变化作为头等架构元素来扩展当前的定义。

我们对演进式架构的定义如下:演进式架构支持跨多个维度的引导性增量变更。

增量变更

增量变更描述了软件架构的两个方面:如何增量地构建软件和如何部署软件。

在开发阶段,允许小的增量变更的架构更易于演进,因为对于开发者来说,变更范围相对更小。对部署而言,增量变更指业务功能的模块化和解耦水平,以及它们是如何映射到架构中去的。

增量变更的成功需要一些持续交付实践的配合。并不是任何情况都需要所有这些实践,但通常它们会一起发生。

引导性变更

一旦架构师选择了重要的架构特征,他们会把变更引导进入架构,以保护这些重要特征。为此,我们借用演化计算中的一个概念:适应度函数。该函数是一种目标函数,用于计算潜在的解决方案与既定目标的差距。在演化计算中,适应度函数决定一个算法是否在持续提升。换句话说,随着每个算法变体的产生,基于设计者对算法“适应度”的定义,适应度函数决定每个变体的“适应程度”。

对于演进式架构,随着架构的演进,我们有着类似的需求。我们需要评估机制,来评估变化对架构重要特征的影响,并防止这些特征随着时间的推移而退化。适应度函数的隐喻涵盖多种机制,包括度量、测试和其他检验工具。我们采用这些机制来确保架构不会以不良方式变更。当架构师确定了需要保护的架构特征时,他们会定义一个或多个适应度函数来提供保护。
以往,架构往往要划出一部分作为管理活动,最近架构师才接受了通过架构实现变更的思想。架构适应度函数允许在组织需求和业务功能的上下文中制定决策,并为明晰且可测试的决策奠定了基础。演进式架构并不是毫无约束或不负责任的软件开发方式。相反,它可以在高速变迁的业务、严谨的系统需求和架构特征间找到平衡。适应度函数驱动架构设计决策,并引导架构变更适应业务和技术环境的变化。

多个架构维度

为了构建可以不断演进的软件系统,架构师不能只考虑技术架构。

每个项目都有许多维度,架构师在考虑架构演进时必须要想到。下面是一些影响现代软件架构演进能力的常见维度。

p-3.png

以上每个视角构成一个架构维度——为了支持特定视角而有意进行的划分。

从实用角度来看,不论如何对关注点进行分类,架构师都需要保证这些维度不磨损。不同的项目有不同的关注点,这导致每个项目都有特定的维度。对于新项目,以上任何技术都能提供有用的见解,但是对于现有的项目,我们必须处理眼前的实际情况。

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

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

p-4.png

在图 1-3 中,架构师确定了可审计性、数据、安全性、性能、合法性和伸缩性是该应用的关键架构特征。随着业务需求不断变化,每个架构特征都通过适应度函数来保护其完整性。
我们强调架构整体的重要性,但也应意识到,技术架构模式及相关议题也是架构演进的很大一部分,比如耦合和内聚。

康威定律

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

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

正如康威所描述的,当技术人员将问题分解成更小的块,使其更易于委派时,就会产生协调问题。很多组织为了解决协调问题,会设置正式的沟通结构或是建立森严的等级制度,但这样的解决方案往往是僵化的。

在很多组织中,团队是根据职能来划分的。比如分为前端开发、后端开发、数据库开发。在这样的组织中,管理层从人力资源的角度简单地按照职能划分团队,没有充分考虑工程效率。虽然每个团队都有其擅长的领域(比如构建一个视图,增加一个后端 API 或服务,或者开发一个新的存储机制),但是当需要发布新的业务功能或特性时,三个团队都要参与其中。各个团队通常都会针对眼前的任务优化效率,而不是针对那些更抽象的战略业务目标(特别是有工期压力时)。这会导致各团队往往专注于交付各自的组件,而不关注端到端的特性价值,导致这些组件可能无法高效协作。
在这样的团队编制下,由于每个团队都在不同的时间忙于自己的组件,因此那些依赖所有团队的特性需要花费更长的时间。例如,修改目录页这样常见的业务变更涉及 UI、业务规则和数据库模式的变更。如果每个团队都各自为战,那么他们必须协调时间表,这将增加实现该特性所需的时间。

康威在论文里提到:“每当新的团队组建,其他团队的职责范围会缩小,能够有效执行的可选设计方案也会随之变少。”换句话说,人们很难改变其职责范围外的事情。软件架构师需要时刻关注团队的分工模式,从而使架构目标和团队结构保持一致。

很多构建架构(如微服务)的公司围绕服务边界构建团队,而不是按孤立的技术架构来划分。称之为“康威逆定律”。以这种方式组织团队是理想的,因为团队结构会影响软件开发的很多维度,并且会反映问题的大小和范围。
构建与目标系统架构相仿的团队结构,这样项目会更容易实现

为何演进

在演进式架构的定义中,引导的含义反映了我们想实现的架构,即我们的最终目标。

为了构建能实实在在演进的架构,架构师必须支持真正的变化,而不是权宜之计的考虑。

二、适应度函数

架构的适应度函数的定义如下:架构的适应度函数为某些架构特征提供了客观的完整性评估。

适应度函数能够保护系统所需的各种架构特征。由于业务驱动、技术能力及其他诸多不同因素,系统和组织对架构的具体需求会有很大区别。有些系统要求很高的安全性,有些要求可观的吞吐量或低延迟,还有一些要求更好的故障恢复能力。这些对系统的考量形成了架构师所关心的架构特征。从概念上来讲,适应度函数体现了系统架构特征的保护机制。

我们也可以将全系统适应度函数看作适应度函数的集合,其中每个适应度函数对应架构的一个或多个维度。当适应度函数所对应的维度间存在冲突时,使用全系统适应度函数有助于我们做出必要的权衡。类似的问题在处理多功能优化时很常见,想同时优化所有值是不可能的,因此我们必须做出选择。处理架构适应度函数时也一样,架构师熟知的经典案例是:由于加密的开销巨大,性能和安全性可能冲突,因此架构师必须做出艰难的权衡。架构师在调和对立力量时,很多时候为权衡犯难,比如在伸缩性和性能之间做出权衡。然而,对架构师而言,比较这些不同的特征是永恒的难题,因为它们从根本上是不同的(就好像苹果和橙子那样),并且所有利益相关者都认为自己所关心的特征最为重要。全系统适应度函数允许架构师通过统一的机制思考不同的问题,捕捉和保留重要的架构特征。图 2-1 展示了较小的适应度函数和它们所构成的全系统适应度函数之间的关系。

p-5.png

全系统适应度函数对于架构演进至关重要,它为架构师提供了比较和评估不同架构特征的基础。与对待那些更具针对性的适应度函数不同,架构师很可能不会评估全系统适应度函数,尽管它为日后确定架构决策的优先级提供了指导。

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

什么是适应度函数

数学上,函数从有效输入值集合中获得输入,将其转换为有效输出值集合中的唯一元素。在软件中,我们也普遍使用“函数”来指代可实现的东西。

正如敏捷软件开发中的验收标准,适应度函数可能无法通过软件的方式实现(比如出于监管原因必须手动完成的某个过程),于是架构师还必须定义手动的适应度函数来指导系统演进。虽然我们倾向于实施自动化检查,但是有些项目无法自动化所有适应度函数。

也可以通过适应度函数保持代码规范。圈复杂度是常用的代码衡量指标,用来衡量函数或方法的复杂度。架构师可以设置一个阈值上限,然后在持续集成中运行单元测试来保护它,最后使用工具评估该衡量指标。在前面的例子里,架构师决定何时运行适应度函数来评估性能。对于代码规范而言,开发人员希望出现不规范的代码时构建立即停止,从而能积极地解决问题。

尽管很有必要,但是由于复杂性及其他约束,开发人员有时无法完整地执行所有适应度函数,比如对产生硬故障的数据库进行故障转移的时候。虽然自恢复的过程或许(也应该)是全自动的,但手动触发测试会更好。另外,尽管我们鼓励使用自动化脚本,但是手动确定测试成功与否或许更高效。

最终,所谓“适应度函数引导演进式架构”,指的是通过单独的适应度函数评估单个架构选择,同时通过全系统适应度函数确定变更的影响。适应度函数共同指出架构中对我们重要的部分,使我们能够在软件开发过程中做出各种关键又令人烦恼的权衡。

适应度函数将许多已有的概念统一为一个整体机制,让架构师可以统一思考许多现有的(往往是临时的)“非功能性需求”测试。收集重要的架构阈值和需求作为适应度函数,使得以前模糊又主观的评价标准变得更加具体。我们利用了大量现有机制来构建适应度函数,包括传统的测试、监控等工具。当然,并非所有测试都是适应度函数,只有当测试有助于验证架构问题的完整性时,它才是适应度函数。

适应度函数分类

适应度函数有很多不同的分类方式,可以依据其范围、运行频率、动态性及其他因素对其进行分类,必要时还可以对不同分类进行组合。

 原子适应度函数与整体适应度函数

原子适应度函数针对单一的上下文执行,用来校验架构的某一维度,比如某个用来验证模块间耦合的单元测试。
对于某些架构特征,开发人员不能只是孤立地测试各个架构维度。整体适应度函数在共享的上下文中运行,综合检验架构的多个维度,比如安全性和伸缩性。开发人员设计整体适应度函数来保证原子级特性能够正常地协同工作。

显然,我们无法测试架构特征的所有组合,所以架构师需要使用整体适用度函数有选择性地测试那些重要的交互。在做出选择和确定优先级的过程中,架构师和开发人员会评估通过适应度函数实现特定测试场景的难度,从而评估该特征的价值。通常,架构关注点之间的交互决定架构的质量,而这正是整体适应度函数要解决的问题。

触发式适应度函数与持续式适应度函数

适应度函数间的另一个区别是执行频率。触发式适应度函数基于特定的事件执行,比如开发人员执行单元测试、部署流水线执行单元测试或质量保障人员执行探索性测试。触发式测试包含了传统测试,比如单元测试、功能性测试、行为驱动开发(BDD),还涉及其他测试开发人员。

持续式测试不是按计划执行,而是持续不断地验证架构的某些方面,比如事务处理速度。监控驱动开发(MDD)是另一种日益普及的测试技术。它通过监控生产环境来评估技术和业务的健康程度,而不是仅仅依赖测试。这些持续式适应度函数比标准的触发式测试更为动态。

静态适应度函数与动态适应度函数

静态适应度函数的结果是固定的,比如单元测试的二进制结果——成功或失败。该类型囊括了预定义期望值的适应度函数,比如二进制、数字区间、集合包含等。这类适应度函数通常会用到各种衡量指标。例如,架构师会为代码中的方法定义可接受的平均圈复杂度,并通过嵌在部署流水线的度量工具对其进行检查。
动态适应度函数依赖基于额外上下文变化的因素。某些值会视具体情况而定,比如在大规模运行的情况下,大多数架构师会采用较低的性能指标。例如,某公司可能基于伸缩性将性能指标设置为特定范围内的浮动值——在较大的规模下允许较低的性能。

 自动适应度函数与手动适应度函数

显然,架构师喜欢自动化,自动化也是增量变更的一部分。

然而,尽管我们希望软件开发中的每个部分都实现自动化,但某些部分却抗拒自动化。有时,系统的某些关键维度就无法自动化,例如对合法性的要求。在为某些问题域构建应用时,出于法律原因,开发人员必须手动认证来进行变更,这样的操作无法自动完成。类似地,某些团队可能希望项目变得更具演进性,但缺乏合适的工程实践。例如,在某些特定的项目上,大部分的质量保障工作仍然是手动的,并且这种情况在短期内不会改变。在以
上两种(及其他)情况下,我们需要人为操作验证的手动适应度函数。
由此可见,虽然尽可能地消除手动步骤可以提高效率,但许多项目仍依赖必要的手动过程。我们需要为这些特征定义适应度函数,在部署流水线时添加手动阶段对其进行验证。

 临时适应度函数

虽然大多数适应度函数在变更发生时被触发,但架构师可能想通过时间组件评估适应度。例如,当项目使用了某个加密库时,架构师或许想创建一个临时适应度函数,在该加密库发生重大更新时发出提醒。升级前破坏(break upon upgrade)测试是这类适应度函数的另一种常见用法。例如,在某些平台的项目(比如 Ruby on Rails)中,一些开发人员不想等到下个版本发布时才能使用那些吸引人的新特性,于是他们将这些新特性直接移植到当前版本。但是移植过来的特性往往与实际发布的版本不兼容,因此开发人员通过升级前破坏测试来封装移植特性,迫使升级时重新对其进行评估。

预设式高于应急式

虽然在项目初期,架构师会定义大多数适应度函数,因为它们阐明了架构的特征,但有些适应度函数在系统开发阶段才显现。架构师无法在开始时就知晓架构的所有重要部分。

 针对特定领域的适应度函数

某些架构有着特定的关注点,比如特殊的安全或监管需求。

尽早确定适应度函数

团队应该尽早确定适应度函数,将其作为初步理解全局架构关注点的一部分。团队还应该尽早确定系统适应度函数,来帮助他们确定想实现的变更。比较实现不同架构特征(及其适应度函数)的价值和难度,有助于更早地设置高风险工作的优先级,从而做出能够应对变化的设计。

没能确定适应度函数的团队将面临如下风险:

  • 做出错误的设计选型,最终导致软件构建失败。
  • 做出的设计选型在时间和成本上出现不必要的浪费。
  • 系统无法轻松应对日后的环境变化。

对于任何软件系统,团队都应该尽早确定最重要的适应度函数及其优先次序。这有助于架构师将大型系统拆解成更小的系统,使每个系统对应较少的适应度函数。

适应度函数可以简单分为三类。

p-6.png

将适应度函数的执行结果可视化至明显的公共区域,能使开发人员记得在日常编码中考虑它们,保持关键部分和相关适应度函数的活力。

对适应度函数进行分类有助于确定设计决策的优先级。如果一个设计决策对某个关键适应度函数有特定影响,那么应该花费更多时间和精力进行探针试验(时间可控的试验性编码工作)来校验设计的架构。有些团队采取基于集合的开发方式,它是精益和敏捷流程中的开发实践,用于同时设计多个解决方案。它以构建多套方案为代价来换取未来决策的可选方案。

审查适应度函数

适应度函数审查以会议的形式进行,会上主要业务和技术利益相关者会一起讨论如何修改适应度函数以满足设计目标。例如,当市场份额或用户数量显著增长时,或者引入新的功能或业务能力时,又或者大规模检修现有系统时,都必须审查适应度函数。

适应度函数审查大致涉及如下几点:

  • 审查已有的适应度函数。
  • 审查当前适应度函数的相关性。
  • 确定每个适应度函数的规模或大小的变化。
  • 确定是否有更好的方法测量或测试系统的适应度函数。
  • 发现系统可能需要支持的新的适应度函数。

我们希望架构在引导下演进,所以我们在架构的不同方面设置约束来防止架构朝着错误的方向演进。

实施增量变更

演进式架构支持跨维度进行引导式增量变更。

演进式架构的定义暗含了增量变更,这意味着这种架构更容易实现小的增量变更。
论增量变更的两个方面:首先是开发方面,涵盖如何构建软件;然后是运维方面,涵盖如何部署软件。

构件

在持续交付及其工程实践的推动下,近年来很多能在架构级别提供灵活性的构件成为了主流。

软件架构师必须决定系统的构成方式,他们通常绘制不同规格的图表来完成此项工作。架构师常错误地将架构视为一个待解的方程。随着时间推移,业务和技术的不断变化要求架构师采用四维视图描绘架构,这使得演进成为重中之重。

软件中的一切都是动态的。

为了在现实世界中生存,现代架构必须是可部署和可变的。
只有成功完成了架构设计、实现、升级和无法避免的变更后,甚至当架构能够经受由前期未知的未知因素引起的反常事件带来的考验时,架构师才能评价架构的长期有效性。

可测试性

可测试性(架构特征是否能够通过自动化测试验证其正确性)是软件架构中一个被经常忽略的特性。但由于缺乏工具的支持,通常很难测试架构的各个部分。
但是,架构的某些方面确实可以轻松测试。
任何人都可以管理适应度函数,并且不同的团队和角色可以共同承担该职责。

一旦架构师确定了适应度函数,就应该确保及时地对其进行评估。自动化是持续评估的关键。部署流水线是进行此类评估的常用工具。使用部署流水线,架构师可以决定执行适应度函数的类别、时间和频率。

部署流水线

持续交付描述了部署流水线机制。和持续集成服务器类似,部署流水线在“监听”到变化后执行一系列验证步骤,每一步都更加复杂。

部署流水线还提供了执行架构适应度函数的理想方式——它适用于任何验证标准,在多个阶段包含不同抽象程度和复杂程度的测试,并在系统发生任何变更时执行这些测试。

在部署流水线中执行适应度函数时,不同类型的适应度函数通常会发生交叉。

p-7.png

p-8.png

目标冲突

敏捷软件开发流程告诉我们:开发人员越早发现问题,那么解决问题的成本将越低。考虑所有架构维度的一个副作用是早期针对不同维度的目标会互相冲突。例如开发人员所在的组织后续想追求最激进的变更频率来支持新功能。代码的快速变更意味着数据库结构的快速变更,但 DBA 更关心稳定性,因为他们构建的是数据仓库。这两个演进目标在技术架构和数据架构间相互冲突。

显然,考虑到影响根本业务的诸多因素,团队必须做出一些妥协。使用架构维度识别部分架构关注点(并通过适应度函数对其进行评估),让我们能对不同的关注点进行“苹果和苹果”(同类事物)的比较,使优先级更加明确。
目标冲突无法避免。但是,尽早发现和量化这些冲突可以使架构师做出更明智的决定,制定出更清晰的目标和原则。

假设驱动开发和数据驱动开发

数据驱动开发的例子——使用数据来驱动变更,并集中精力于技术变更。另一个类似的方法是假设驱动开发,该方法更关注业务
问题而非技术问题。

在《精益企业》这本书中,Barry O’Reilly 介绍了假设驱动开发的现代化过程。在这个过程中,团队应该利用科学手段,而不是收集正式的需求然后花费时间和资源将功能构建到系统中。一旦团队创建出应用的最小可行产品(无论是新产品还是维护现有产品),他们便能在构思新功能时建立假设,而不是需求。假设驱动开发的假设是根据假设来检验的,什么试验可以确定结果以及用什么验证假设意味着应用开发的走向。

驱动敏捷软件方法论的引擎是内置的反馈环,如测试、持续集成和迭代等。然而包含应用程序最终用户的反馈环已经脱离了团队的控制。使用假设驱动开发,我们能以一种前所未有的方式将最终用户纳入构建流程,从他们的行为中学习并构建出对其真正有价值的系统。

为了产生可观的结果,试验应该进行足够长的时间。通常最好找到某种可衡量的方式来确定更好的结果,而不是通过弹出窗口等形式的调查来打扰客户。