一种行之有效的软件架构实践,能帮助产品开发组织更快、更好地交付软件。但在讨论“有效的实践”之前,我们需要先理解什么是软件架构。这个术语在软件行业被频繁提及,而且往往带着一定的随意性。收紧我们的定义非常重要,因为本书中的实践与对架构的一个严格而完整的定义密切对齐。
软件架构常被等同于软件设计,但二者实际上截然不同。设计是某一特定时点上软件组件的具体排列,这些组件共同构成一个软件系统。当我们开发该系统的每个版本并决定该版本如何运作时,我们就在为该版本做设计。
那么,当我们创建同一系统的下一次迭代时会发生什么?当然,我们会修订其设计:这正是引入变化、使一个版本区别于另一个版本的方式。但我们也不会把它全盘抛弃、从头再来;每一个后续的设计都与之前的设计相关联。
架构是一个用于迭代地创建一组相关设计的模板。架构本身也需要被设计,但它不止于“一个设计”。因此,有效的软件架构实践并非只产出一个好的设计;它为创建成十上百、乃至成千上万的设计奠定基础。这便是软件架构的潜力,也是有效的软件架构实践所作出的承诺。
那么,什么才构成“架构” ?标准在架构中扮演着重要角色,因此用 IEEE 标准中的定义开启我们的讨论是恰当的:
【架构】是系统的基本组织,体现在其组件、这些组件彼此及与环境的关系,以及支配其设计与演化的原则之中。[1]
接下来,我们逐句拆解这一定义,以便彻底理解。
基本组织(Fundamental Organization)
设想你有一个由一百个组件构成的软件产品。组件的具体性质并不重要;它们可以是服务、类库、容器、函数或插件。关键在于,你的产品由这些组件组成,组件之间的交互共同实现了产品的功能与特性。
现在再设想一下:对每个组件,你用一个随机数生成器来决定它的类型(服务、类库等)以及它的通信方式。很多时候这两者是相关联的——例如,一个代码库(类库)通常通过本地过程调用被调用,而服务则不是。没关系;我们就从对每个组件“合理适用”的通信方式里随机挑选。
你大概已经意识到:要让这些组件协同工作将会非常困难。不同组件需要不同的实现技术、工具链和部署方式。当我们尝试把组件“接”起来时,会出现大量不匹配问题,我们得在本地调用、远程调用、消息传递与函数调用之间来回“翻译”。从随机性出发,我们构造了一个缺乏基本组织的系统。所幸,这个系统只存在于我们的想象中。
现实中没人会这样做,每一个真实的系统都多少具有某种基本组织。系统的基本组织往往在一定程度上受外部因素的约束。例如,若你在构建一款移动应用,你的元素主要会是类库,并且大多通过本地过程调用通信。相反,若你在构建一款云端产品,你可能会围绕服务来组织系统。
然而,当我们谈论一个系统的架构时,通常指的是超越这些外部约束的基本组织。比如,云端服务必然通过网络通信。但这种通信是请求-响应,还是消息传递?任何在二者之间做出选择的系统,都在其上层结构上围绕该方法进行了根本性的组织。
图 1.1 展示了不同建立方式对系统基本组织所带来的影响。左侧图所示,随机化的系统由不同类型的组件(用不同形状表示)通过不同的机制(用不同线型表示)进行通信,彼此关系也杂乱无章。外部约束(中间图)往往会限定组件类型与通信机制,从而使形状与线型更为统一。然而,外部约束很少会对系统内部的关系施加组织。右侧图展示了一个具有清晰基本组织的系统:它采用一致的组件类型、一致的通信机制,并拥有结构化的关系。
图 1.1 基本组织的层次。 随机系统(左)混杂了多种组件与关系类型。大多数系统至少会受到一些外部约束(中),对这些类型有所限定。组织良好的系统(右)通过进一步的约束带来额外的一致性。
翻译如下(保留原有小标题与行文结构,便于对照):
系统(Of a System)
在本书中,我们会频繁使用“系统”这个词。它出现在我们的架构定义里,也在本章前面至少被提到过好几次。那么,什么是系统?
就我们的目的而言,系统是指一组协同工作的软件组件,用以提供一种或多种能力。系统可以很大:它们或许包含成百上千个组件,并在相近数量的计算机上运行。但系统也可以很小:运行在电池供电的无线传感器上的嵌入式软件,同样是一个系统。
系统不必孤立运作。举例来说,如果你在开发无线传感器的软件,那么对你来说,系统的边界可以按“哪些软件运行在传感器上”来划定。该传感器(连同其他传感器)会把数据发送给另一个处理这些数据的系统。对你而言,这个处理系统可能构成你的环境的一部分,而非你的系统;比如,它也许由另一支团队开发。
系统也可以由其他系统组装而成。换句话说,一个新系统可以被定义为两个或多个更小系统的组合,或许还会额外加入一些组件。举例而言,由无线传感器系统与数据处理系统组合而成的单一系统,就能提供“监测”这一能力。
因此,当我们在架构语境下使用“系统”这个术语时,我们允许根据关注范围来设定系统边界。本书涵盖的软件架构的各个方面,适用于上述任意规模的系统。诚然,有些关注点在小规模下更容易处理,所以你采用架构实践的力度与严谨度可以根据手头系统的需要进行调整。
体现在其组件中(Embodied in Its Components)
一个系统的基本组织并不容易改变。关于技术、通信、结构等方面的隐含决策,会体现在遵循该组织方式的各个组件之中。毕竟,正是这些组织方式在组件中的实现,赋予了整个系统其形之所以然。
我们很容易低估这些决策的“深入程度”。当桌面计算让位于移动与云端解决方案时,许多在桌面代码上投入巨资的公司都在寻找路径,想把他们在这些代码库上的巨大投入带入新的形态。
起初,这个挑战看起来也许像是移植问题。也许代码需要在新的 CPU 架构上运行,或适配不同的操作系统。这些改变未必轻松,但也并非不可能。此外,这些代码库中的许多过去已经经历过类似的移植,例如从 Windows 迁移到 macOS。
然而,大多数桌面应用的基本组织,远不止“CPU 指令集”或“操作系统”。举例来说,其中绝大多数桌面应用都是围绕这样一个前提来组织的:它们能够访问一个快速、可靠、本地的磁盘。这一点如此基础,以至于许多桌面应用的架构师甚至不会专门指出它——别无他选,根本无需明说。
结果,在这些应用中,“对磁盘快速、可靠访问”的假设往往不仅体现在每个组件里,几乎渗透到组件中的每一行代码。需要读取一些配置数据?用户偏好?保存进度?没问题!调用几次文件系统 API,问题就解决了。
把这些数据迁移到云端,会打破这一假设,也会打破依赖它的每一行代码。数据也许仍然存在,但获取它可能很慢(因为要走网络)且不可靠(依然因为要走网络)。或者此刻根本无法访问(网络中断),虽然稍后又可能恢复可用。
图 1.2 展示了这些假设如何体现在系统的组件中。左图中,桌面应用的组件直接且各自独立地连接到文件系统;它们假定并依赖对数据的快速、即时访问。
图 1.2 系统的基本组织体现在其组件中。 左图:围绕文件系统组织的结构体现在每个组件里。右图:组织被转移为通过缓存来调解数据访问。
组件当然可以被编写成能够应对这种不确定性,但它们必须体现一种不同的基本组织。它们必须意识到:数据来源可能访问缓慢,甚至暂不可达。因此,它们往往会围绕本地缓存来组织,且需要投入大量时间与精力来管理数据在缓存与存储之间的流动方式。
在图 1.2 的右图中,另一种组织方式把组件绑定到一个缓存上,该缓存进一步在本地存储与云存储之间进行调解。在这种架构中,组件同样会内化这样一个假设:自己的数据可能在也可能不在缓存里。一旦发生缓存未命中,访问数据要么很慢(因为需要经由网络取回),要么不可能(网络中断时)。
围绕“文件系统抽象”来组织并不能真正解决这个问题。构建一个能弥合不同桌面操作系统之间差异,甚至弥合移动与桌面操作系统差异的文件系统抽象,并不难。但对于云端数据而言,这却是一个错误的抽象,因为它依旧沿用了“快速、本地访问”的假设。这些基础性假设既可以体现在代码里,也同样容易被体现在接口与抽象之中。
需要明确的是,架构的基础组织如何体现在组件中,远不止存储与文件系统这一件事。这只是一个例子,用来说明架构如何奠定假设,而这些假设又如何可能被“焊死”在各组件的每一行代码里。我们将在后文不止一次地回到这一点——这既是架构之所以有价值的关键方面,也是架构之所以困难的关键原因。
它们彼此之间的关系(Their Relationships to Each Other)
作为程序员,我们往往更重视组件而非连接。组件让人感觉更“实体”——在软件世界里能算得上“摸得着”的东西——因为它们由我们编写的代码组成。组件可以被编译、打包、分发和交付,几乎让人觉得它们是有形的。
但组件本身并不有趣。软件只有在这些组件以有意义的方式彼此连接时才“活”起来。因此,如何连接、连接哪些应当是有意为之,而非偶然碰巧。
一些知名架构把关系摆在了最重要的位置。以 Unix 的 shell 架构为例,它由两类原语组成:程序与流(stream) 。流是有方向的:有输入端与输出端。程序从零个或多个输入中读取,再向零个或多个输出写入。shell 的工作是把一个程序的输出与下一个程序的输入连接起来,形成数据流经的管道。
Unix 架构对“程序内部如何运作”并没有太多要求。程序可以用不同语言编写,可以处理二进制或文本数据,等等。多数程序都相对小巧,专注做好一件事。(这种强调“小而专一”的原则本身就是 Unix 的一个架构原则——关于“原则”我们稍后还会谈。)
相对而言,程序之间的关系则更受关注。默认情况下,每个程序有一个输入流(stdin)和两个输出流。输出被分为“标准输出”(stdout)与用于错误的特殊流(stderr)。当然也可以处理更多输入或输出流,但会较为繁琐。
这种做法的美在于简洁而强大。不同作者在不同时间编写的程序,用户都能方便地把它们拼接组合,以实现全新且意想不到的结果。而之所以可以做到,并非因为对程序本身施加了严苛约束,而是因为程序之间关系的设计。
这种在开发完成之后再组合组件以实现新结果的现象,是网络效应的一个例子。网络效应令人兴奋,因为它能以线性投入换来组合式爆发的价值。尽管本书并不专门讨论平台与网络效应,但平台、网络效应与架构之间存在着深刻联系。
然而,连接所带来的“组合魔法”也可能反噬架构。在 Unix 模式下,程序可以组合,但它们并不内在地相互依赖。一旦系统包含了大量相互依赖的互联组件,这些关系就会从助力变为阻力。
当关系缺乏治理时,依赖的数量往往会不断增长。虽然这些依赖可能是一次加一个地引入,但由此产生的系统复杂度却可能更快地膨胀,甚至很快就超出任何人理解与管理的能力范围。
如果你曾参与过那种“某些组件不敢动,因为一改就会牵一发动全身”的系统,那么你就经历过组件关系过度纠缠的系统。这样的场景说明:管理关系与管理组件本身同样基础且关键。
它们与环境的关系(Their Relationships to the Environment)
系统从不在真空中运行。有时,它们可能是硬件上唯一运行的软件,此时硬件就是其首要的环境关注点。但更多时候,系统运行在另一个系统之上或之中。
例如,考虑一个程序(一个系统)与其运行所依赖的操作系统(同样是一个系统)之间的关系。操作系统会对其承载的程序强加一种基本组织。这种组织不可回避:程序由操作系统启动,并在执行期间乃至终止时,在不同程度上受操作系统的监控与控制。如果二者之间没有一些基本约定,程序就根本无法运行。
不同操作系统对程序的基本组织定义程度差别极大。以 Unix 为例,其强加的组织相对有限。程序通过调用一个约定名称(“main”)的函数并传入一组参数来启动,因此这些元素必须存在。诚然,Unix 程序的典型结构涉及许多惯例与 API,但真正必需的并不多。正因如此,Unix 成功且容易地支持用多种类型、语言和结构来编写程序。
相比之下,iOS 平台要“更有主见(opinionated)”得多。iOS 应用并不存在单一的入口点,而是应响应一整套函数。这与应用的生命周期密切相关:在 Unix 模式下,程序启动、运行直到完成手头任务,然后退出;而在 iOS 上,应用会被启动、切到前台、转入后台、为节省资源而停止、又因用户交互或通知而重启,等等,复杂得多!
在 Unix 上,你可以选择采用 iOS 程序的那类基本组织。再次强调,Unix 并不强行施加组织;程序设计者拥有相当大的裁量权。但在 iOS 上,你的应用在很大程度上必须围绕 iOS 所规定的模型进行根本性组织。这种程序与 iOS 环境的关系,因而成为架构的主要驱动因素之一。
面向多种环境的关系(Relationships to Multiple Environments)
更“有主见”的环境会与代码复用产生张力。构建复杂应用成本高昂,许多软件生产者希望一次编写、多处使用。从架构师视角看,管理“系统与环境的关系”的问题,就演变为管理系统与多种环境的关系。
当不同环境施加的基本组织各不相同,甚至在最糟情况下相互冲突时,这会很难办。通常有几种常见的应对方式:
- 忽略环境,以其他方式组织软件。通常这代价很高,因为你要花大量时间与精力去复刻原本可以从环境里“免费获得”的行为;而且这些复刻从不完美,差异在用户眼里往往就像缺陷。
- 创建抽象层,把两个或多个环境适配到单一模型。对于不需要与环境能力深度集成的系统,这策略常常有效;例如它常常在游戏中运作良好。抽象层可以是系统的一部分,也可以外置,有时它们本身就成为产品。
- 拆分系统为两个子系统:为每个目标环境分别编写的“环境特定”核心,以及在各环境间共享的“与环境无关”边缘。尽管这听上去与抽象层方法有些相似,但它是不同的策略,因为环境能力并未被抽象化。在这种情况下,环境特定层会按需深入(但理想情况下不更深) ,以与环境无关的业务逻辑建立连接。
像大多数工程问题一样,并不存在一个对管理这些环境关系放之四海而皆准的答案。
支配其设计的原则(Principles Governing Its Design)
到目前为止,IEEE 对架构的定义主要聚焦于描述系统的现状:其基本组织、组件以及组件间的关系。现在,它把目光转向为什么会这样组织、为什么有这些组件、以及为什么包含这些关系。
设计是一种决策活动,每一次选择都会决定某些形式与功能方面的取舍。原则则是指导决策的规则或信念。因此,架构原则就是指导关于一个系统的决策的规则与信念,帮助确定其基本组织。
良好的架构原则会明确对系统什么最重要——例如可靠性、安全性、可扩展性,等等——并引导设计朝这些品质靠拢。举个例子,一个原则可以声明“通知投递系统应当以速度优先于可靠性”,随之就能支持“选用更快但较不可靠的消息传递技术”的决策。
原则还可以通过在众多可选项中指定首选做法来加速决策。例如,一个原则可以规定横向扩展优先于纵向扩展。我们也许倾向于把软件工程看作纯粹由事实与分析驱动——系统要么满足需求、要么不满足——但现实中往往有多种设计都能满足需求。因此,设计也涉及在一组“可接受的备选方案”之间做判断性选择。
重要的是,原则不仅规范我们能做什么,也规范我们不能做什么。设想一个由一组服务构成的系统:一个合理的设计原则或许是“每个服务都应能独立部署且不中断服务”。一方面,这个原则赋权于各服务的架构师,让他们可以自行决定何时、如何发布更新;另一方面,它也对他们设限:其部署策略不得要求其他服务同时更新,或在更新期间临时停用那些服务。于是,这条原则在打开部分选项的同时,也关闭了另一些选项。
如果缺乏约束,团队可能会在潜在的设计空间里耗费过多时间试探,这不仅拖慢决策,还常常只能带来边际改进。这就是典型的报酬递减:每新增一个选项都需要大量探索时间,却未必比已考虑的选项带来实质收益。一个限制探索范围的约束,往往能节省大量时间与精力,同时仍然产出同样好的结果。当然,要想奏效,被限制后的设计空间仍须包含合适的解——因此原则必须被慎重制定。
选择自由过多还会导致缺乏一致性,从而破坏系统的基本组织。示意地说,假设三四个子系统需要在 A 与 B 两种做法之间选择;若两者大致等效且没有进一步指引,系统很可能最终混用两种做法。由于这种结果在没有相应收益的情况下增加了复杂度,它是净负面的,应当避免。一个把所有这些决策引导到同一选项的原则,既能加速决策,也能约束结果,推动简化与一致。
当我们不主动设定原则时,原则往往会被环境替我们设定。这通常意味着我们最终会默认为类似“在现有组织边界内办事”“最小化范围”“最快上市”的隐含原则。这些原则都没有指向更好的产品,也不会带来它。通过有意的过程来确立并遵循原则,是架构师最具影响力的活动之一。
架构与设计(Architecture versus Design)
在 IEEE 的定义中,最能阐明架构与设计差异的,就是它明确指出原则支配设计这一点。
任何相当复杂的系统都包含成百上千个设计。这些设计通常以层级方式组织:系统的高层设计、更为细化的子系统设计,等等。服务、类库、接口、类、模式(schema)——每一项都需要各自的设计。
这些设计彼此相关,因为它们描述的是必须协同工作的系统要素。因此,在审视系统层级时,我们常常以自顶向下的方式推进设计:先创建子系统,定义边界与基本行为,然后在子系统内再行细分。
上述每一项(服务等)都需要自己的设计,但它们并非在真空中产生。它们必须服从于更大一层的子系统设计,并与同层同伴协作以实现子系统功能,最后还要表达自身内部结构。
当然,这些设计并不会在同一时间完整且同时出现。我们以团队方式工作,因而设计会并行推进。随着时间推移,新的设计被创建,现有设计被扩展、更新与修订。除非一个系统被封存不再演进,否则设计就是一个持续过程。
架构是在时间维度上管理这些设计。要做到这一点,架构师必须思考不仅仅是设计本身,还要确立治理性原则,以便让无数设计在任一时点、以及沿时间演进的过程中,都能汇聚成一个一致的整体。
这并不是说架构师不该做设计。如果某项目的原则无法在设计中落地,确立这些原则也无济于事;而要验证能否落地,唯一途径就是把它们付诸实践。但架构师也不能把全部时间都花在设计上,否则就不会再从事“架构”本身了。
以及演化(And Evolution)
我们构建的系统并非静止不变。发布节奏各不相同,但任何成功的系统都必须演化以保持相关性。这可能通过小改小更的积累、不那么频繁的重大变更,或二者的某种组合来实现——但它必须发生。
因此,到目前为止我们所描述的一切——系统的组织、其组件及其关系、其原则与设计——也都必须随之演化。那么,这一切是如何发生的?
理想情况下,这种演化应当带着清晰意图被治理,由一组经过慎重考量、适配目的的原则来引导。我们可以把架构原则理解为以两种方式发挥作用:支配设计,以及支配设计的演化。一套原则,两种运作方式。
举例来说,我们或许坚持这样一条原则:服务应当通过定义明确的接口实现松耦合(对任何设计云端软件的人来说,这都是常见且合理的原则)。在设计这些服务时,这条原则完全合理且可操作。
然而,它对这些服务的演化讲得不多。随着我们添加功能、甚至新增服务,我们可以保持这种性质,它在演化中起到一种基本约束的作用。但它并没有回答一些关键问题:例如如何修改现有接口、何时向现有服务添加功能、何时创建一个新服务。
向现有系统添加新功能是系统演化最容易的形式之一,却仍可能出错。比如,假设两个团队都在为其服务扩充功能:一个团队选择新增服务,倾向于把系统拆成更多、更小的服务;另一个团队选择把新功能加到现有服务里。
当出现这种情形时,系统的演化方式正在侵蚀其基本组织。问题不在于哪个做法“对”、哪个“错”——从抽象上看,二者都可能是扩展系统功能的恰当响应。真正的损害来自不一致的响应:它们让系统在没有必要的情况下变得更复杂。支配系统演化的原则应当提前防范此类情况,明确将采用哪种方式。
添加新功能也许是演化问题中最容易的一版;更难的版本出现在原则本身需要改变的时候。比如,早先的原则聚焦于交付速度,偏好把代码添加到单体服务中;后来团队转而采用“构建更小、松耦合、可独立更新与部署的服务”的原则。
这种问题往往发生在团队从未言明的、强调最小变更与快速交付的设计原则,转向有意识地关注可靠性、可维护性与质量的原则之时。此时,仅将新原则应用到新增或更新的设计并不够。像安全性、可扩展性与成本这类属性常常会被最薄弱的环节所限制——也就是那些没有处理这些关注点的组件。在极端情况下,要真正落实这些原则,可能需要重做每一个既有设计。
这正是软件开发中最难的问题之一:如何把系统从一套原则演化到另一套原则。同时,它也是最常见的问题之一,因为我们在把前一两个版本推向市场时所看重的原则,往往与拥有一个成功且经验证的产品之后所看重的原则截然不同。我们的优先级自然会从“尽快出货”,转向“构建高质量、可持续的产品”。
面对这一挑战的一种回应,是发起一次**“大重构/重架构”项目:重建系统中的每一个要素,使之与新优先级对齐。但这种做法不是演化**,而是革命。它也鲜有成功,因为这要求开发团队在旧与新两条线上都持续投入。再加上同时推进两项工作的管理开销,你等于把持续开发的成本翻了三倍。很少有团队能承受这种投入。
好消息是:有效的软件架构实践即便在这种最具挑战性的情形下也能发挥作用。一支有效的架构团队可以规划出一条可演进的变更路径。关键在于把演化理解为系统的自然状态,而不是在压力之下被强加的事件。一个有效的架构过程让变更变得内生、可预测、可控。归根结底,治理系统演化的能力,正是架构角色的本质。
总结(Summary)
软件系统由组件及其关系构成。系统的架构是这些组件与关系的组织方式,以及支配其设计与演化的原则。架构既描述系统的现状,也描述其未来状态。
当系统的组织缺乏治理时,决策往往被外部因素驱动:尊重组织汇报线、最小化变更范围、尽快交付……这些都可能很重要,但它们可能不利于打造一个基本组织清晰的系统。
我们可以通过把原则应用到系统的设计与演化上,更好地管理系统的基本组织。这些架构原则不必取代其他因素——快速出货也许依然重要——但它们必须进入讨论。与其他工程学科一样,软件架构涉及相互竞争目标之间的权衡。
演化是软件架构的内在属性。那些用架构原则持续驱动变更(在合适的时候采纳新原则)的团队,能够让系统不断演进,以交付新能力并提升安全性、可靠性、可维护性以及系统的其他方面。
归根结底,架构在软件开发中的角色是对系统进行整体性且有意图的观照:从识别系统的基本组织、描述其组件与关系开始;再通过设定支配设计的原则来实现该基本组织;最重要的是,建立支配这些设计、组件与关系随时间而变化的原则。