从本质上说,架构与“孤立的学科”背道而驰。架构团队不可能指望把一组静态需求当作输入,然后“消失”数周或数月,直到最后才端出一份完整、定稿的架构。
此外,需求从来不能完整呈现某项任务的全部上下文。即便是“很全面”的需求,也不可能覆盖产品的每个方面。架构团队应当熟悉产品当前的设计、其历史与演化、其客户与市场,以及其销售渠道与方式。这些考量都会影响架构。
同理,成功的产品也从来不是“真正完成”的。每一次迭代都是走向某个后续版本的一步。那些未来的版本也许已被规划、被假定,或只是远到暂时无从着手。尽管如此,架构团队的工作是在时间维度上引导一系列设计,并必须决定每一轮迭代的边界在哪里。
本章将把这些多样的考量汇聚起来。每个小节都提供一些洞见,以帮助我们把握架构发生所处的上下文。
概念(Concepts)
从核心上看,每一个软件系统都在实现一组概念[2]。概念是软件所赋予生命的逻辑模型,但不包含实现细节。举例而言,作为一个概念,“邮件”涵盖消息、发件人、收件人、邮箱。作为一个概念,“邮件”与邮件应用通过何种协议与邮件服务通信(如 POP3 或 IMAP),或该应用是运行在浏览器里还是安装版应用无关。事实上,作为一个概念,“邮件”与软件无关:在我们所知的电子邮件出现之前,发件人—>收件人邮箱这一模式早已长期、反复地成功运作。
尽管概念居于核心,许多系统却未能清晰识别它们所体现的概念。这就留下一个空间,让参与该系统的每个人——体验设计师、产品经理、工程师、架构师等——各自构建对系统概念的“私有视图”。而这种空间往往滋生困惑、复杂与错误。
不存在没有概念的系统。人们正是借由概念来思考系统;不构建系统所蕴含概念的心智模型,就无法思考系统。我们的目标,是让所有参与构建(与使用)系统的人在概念的数量、行为与含义上达成共同理解。若没有这种对齐,每个人都会有各自不同的理解。
这并不意味着系统中的概念是固定的,更不意味着它们在一开始就已知。识别并定义一组充分且有用的概念,归根结底也是过程的一部分。和过程的其他部分一样,概念宜迭代式地发展。它们会随着我们对系统的认识而改变,也会随着系统为满足新需求而演化而改变。
不过,概念在架构中扮演着独特角色。当我们设计接口或把职责划分给各个系统组件时,我们在做的是架构决策。架构师与工程师理应做这些决策;这些决策应被共享,但不要求与其他学科对齐。
相反,概念若要有效,必须跨所有产品研发学科达成对齐。概念不仅体现在代码中,也体现在用户体验、产品文档,乃至有时体现在市场材料中。这并不是说架构师不能参与定义这些概念——他们当然应当参与。它的意思是:不止架构师可以创建这些定义;概念是架构发挥作用的上下文的一部分。
概念也区分不同的产品。更好的概念并不总能胜出,但有时它们能带来难以撼动的优势。例如,考虑**窗口(window)**这一用户界面概念——即每个应用都在共享屏幕上占据自己的区域与用户交互。
“窗口”并非自计算机诞生之初就存在。然而它是如此有说服力的概念,以至于一经引入便成为主导的 UI 概念。终端(terminal)这一比“窗口”早十多年出现的概念,最终被“窗口”所吸纳:今天,大多数用户理解的“终端”就是出现在一个窗口里的东西。
当然,在 macOS 与 Windows(操作系统)中,“窗口”也并非完全相同的概念。凡是在两大操作系统间切换过的人,都体会过在两个相似但不完全一致的概念间工作的挫败感。表面相似而未完全对齐,正是开发在两套操作系统上都能优雅运行的软件之所以困难的原因之一:所需的深度适配远超最初的想象。
因此,概念构成了架构团队运作的环境的一部分。有些概念存在于相关系统(如操作系统)中,需要在设计中予以容纳;有些概念潜伏在你们的产品经理与界面设计师的头脑中,需要被提炼并理解;最后,随着架构工作推进,还会涌现出新概念。
可信赖性(Dependability)
可信赖性(Dependability)是一个总括性标签,涵盖一组相关的质量属性,用以判断用户是否可以信赖产品按预期运行。这些属性包括可靠性、韧性/弹性、性能与可扩展性[3]。
不同产品对可信赖性的期望水平不同。所有产品当然都应该可信赖,但例如一个移动应用的最低可信赖性通常低于身份与访问管理(IAM)系统:前者的失败通常仅在短时间内影响单个用户,而后者的失败可能一次性影响成千上万、甚至数百万用户,持续数小时。显然,这两种极端需要不同程度的关注与投入。
严格来说,可信赖性是实现层面的属性——而非架构本身。一个可被信赖的架构是实现可信赖性的前提,但不足以保证它。与安全等类似质量属性一样,落实现实中的可信赖性是一道“最薄弱环节”难题:无论在架构、设计还是实现层面,只要某处存在薄弱点,都会削弱整个系统。因此,我们不能宣称某个架构就保证某一水平的可信赖性,但它无疑是必要条件。
尽管如此,系统的架构对其可信赖性拥有实质影响。例如,显式考虑冗余与故障切换的架构,会让更可信赖的设计成为可能,而其他架构则未必如此。一个实现完全可以比其架构所允许的更不可靠,但要做到比架构所允许的更可靠却相当困难。
举一个有趣的例子:让客户端在面对服务端故障时保持韧性。客户端必须能检测到这些故障;还必须能判断何时重试请求、何时暂停一段时间以免让故障恶化。
在一种天真(naïve)的客户端架构中,对服务的请求会散落在整个代码库。一方面,这似乎合情合理:诸如 HTTP 之类的客户端通信库往往是客户端平台的基础能力。允许每个方法独立发起请求实现简单,也最省协调成本。
但另一方面,这种做法会削弱客户端集中掌握某个服务状态的能力。缺乏协调时,客户端不同部分可能会同时向该服务猛发请求与重试——最终彼此掣肘。要提升韧性,客户端需要一种结构来:跟踪同一服务的所有请求,跨这些请求统计失败率,并协调地暂停与重试。如果架构层面不处理这一关注点,客户端实现就无望在这方面改进其可信赖性。
如上述例子所示,可信赖性通常需要在架构层面给予容纳与支撑。否则,任何实现都将难以达到可信赖性目标。把这些关注点写入架构并不等于必然成功——具体设计可能没有利用这些支撑。架构团队的职责,是把这些能力提供出来。
与架构相关的关键需求(Architecturally Significant Requirements)
可信赖性(Dependability)只是更一般意义上的与架构相关的关键需求的一种[4]。与架构相关的关键需求,是指那些必须在架构层面加以应对、而不能仅靠具体设计或实现来处理的需求。这个略带自指的定义在实践中并不总是好用。
大多数非功能性需求都属于这一类。非功能性需求约束系统的性能与规模;最低吞吐、最高延迟等,都是常见的例子。读者或许会注意到“非功能性”这一标签有些名不副实:凡是无法满足所谓非功能性需求的产品,按定义都是“不具功能”的;因此本文更倾向使用更通用的称呼——“与架构相关的关键需求”。
其他类型的需求也可能具有与架构相关的重要性。一个有用的识别准则是:对每条需求都问一句——**一旦这条需求发生变化,需要多少重做(rework)?**当答案是“相当多”时,这条需求就与架构密切相关。
许多用户体验需求并非与架构相关的关键需求。比如要求在什么情况下触发一条通知。只要系统的架构支持检测这些情况并向用户派发通知,那么改变触发条件集合——甚至改变通知的展示方式——固然是变更,但不构成架构层面的重大变更。
而有些数据模型的变化则会产生深远影响。尤其要警惕带有“恰好一个”或“永不改变”字样的关系。举例:某条需求规定系统必须为每个用户存储一个地址。架构团队可能会因只需要一个地址而据此设计。这确实简单——而简单很重要——但该假设也会深深嵌入系统。
在这样的系统里,一旦需要为每个用户存储多个地址(比如收货与账单分离),就会引发一次破坏性变更。所有使用“恰好一个地址”的地方都得审计,以分辨该用收货还是账单地址。于是,一条需求的变化就需要大规模返工——这正体现了它的架构重要性。
注意到这一点后,架构师或许会改为维持一对多的“用户—地址”关系。每个地址都可带上与账户关系的标注:收货、账单,或未来的其他关系。一方面,这个模型更复杂(但并不夸张);另一方面,它弹性更大,能以最小实现代价容纳后续对需求的显著变更。
在“地址”这个例子中,我们的优势在于“需要存储地址”这条需求是显式给出的——这自然会触发我们追问:“是否永远只有一个地址?若改变会怎样? ”
而识别那些重要但未言明的需求要难得多。此时,架构团队可能需要依赖领域经验来判断需求里没说的部分。比如未给出延迟指标,是因为不适用,还是因为行业/竞品对类似产品已有默认基线、只是未写明?
为发掘这类隐含的、与架构相关的关键需求,架构团队可考虑制定一份指引(如核对清单),供自己与产品经理使用。对每一批新需求,逐项核对:依赖是否覆盖?法律/合规约束如何?哪些需求可能会变、怎么变?这些问题都能促成讨论,帮助发现未言明但与架构相关的关键需求。
产品家族(Product Families)
很少有软件产品是“独生子”。相反,大多数都有兄弟姐妹,甚至有“继亲”“表亲”——或近或远——乃至“叔伯姑姨”等等。
这些关系以多种方式体现。开启一个新产品时,几乎总意味着无需、有时也无机会从零开始。即便是“全新”产品,也常会在不同程度上继承既有代码和架构。这些牵连或有益,或有害,取决于如何运用。
要最大化这些关系的价值,架构团队必须知情并且有意地管理它们。毕竟,这些关系构成了产品被开发的环境——而“与环境的关系”正是我们对架构的核心定义的一部分。
下文给出一份不完全分类,帮助团队识别这些关系的性质并据此思考。
一个产品,多个平台(One Product, Multiple Platforms)
如今,应用运行于多平台已很常见:移动端(iOS/Android)、桌面端(macOS/Windows)。面向浏览器有时能缓解,但不同浏览器仍存在差异,移动与桌面也各不相同。
服务端同理。部分服务只运行在单一云厂商上,更多则跨多云(含私有云/公有云组合),以扩大覆盖、控制成本等。
在让同一系统跨平台运行时,架构团队必须慎重决定跨平台与平台特定要素之间的分界线。这往往是该系统架构影响最大的抉择之一,也常因无“唯一正确答案”而争议最大。
典型策略是:识别驱动核心概念的关键逻辑,并尽可能让它在多平台间共享。这部分逻辑通常复杂,因此拥有一份经过充分测试的实现更有利;并且从用户视角看,能保证核心行为一致。
当然,现实里常受既有代码库限制,需要复用而非替换;就算从零开始,也得考虑团队技能结构。通常需要与工程同伴协同决策。
另一方面,某些部分必然是平台特定的,尤其是用户体验层,因为使用平台原生控件的好处,往往胜过跨平台 UI 方案的代价。尽管自研跨平台 UI 库很有诱惑力,但它们几乎不可避免地与原生行为存在偏差,常令用户不适。
这些因素使得应用“核心”与各平台“边缘”之间的边界成为重中之重;定义并围绕此边界组织要素,理所当然是架构团队的核心职责。
熟悉 MVC 的读者会发现,这正对应“模型(核心) —视图/控制器(边缘) ”的分离。尽管实践中 MVC 未必总能把模型分得足够干净,但若彻底贯彻,MVC 是个熟悉可用的思路。
产品线(Product Lines)
上一节讨论的是在不同交付平台上提供同一组核心功能。而产品线则是在同一主题下提供多种变体,通常定价不同:低配更便宜(甚至免费),高配收费更高。
支持产品线有两种基本方式:
- 多份可交付物:为每个变体产出独立包。通常软件主体相同,高配版本额外包含一些组件以启用高级功能。若你计划在商店中同时售卖“标准版”和“专业版”,从商店与用户视角看它们是独立应用,那就各自打包。额外好处是:专业版功能不在标准版中,自然不会因缺陷/破解而被“白嫖”。
- 单份可交付物 + 许可证控制:用户下载同一应用,可在应用内通过购买/授权“就地升级”。这在移动平台很常见,体验连贯。但缺点是多种变体不能并存于同一设备上——用户设备上始终只有一个版本。
两种方式并不互斥。具体选择常由市场反馈驱动,且在不同平台上可能不同。此类场景下,架构团队应避免一味推动某单一方式,而应设计出具备弹性的架构以兼容并行尝试。
一个基本架构做法是:把可用功能的“知识”集中到一个系统要素中,通过接口明确其可动态变化(运行时可变)。接口既提供查询 API,又提供通知机制,以便其他要素监听授权状态变更。
注意,这既适用于应用内购买,也可用于打包期的静态决策。如果功能在打包时被“编进/剔除”,从接口看似乎有些“杀鸡用牛刀”——没编进来的功能运行时永远不会出现。但其他要素不需要知道这一差异,接口屏蔽了真实的动态程度;更关键的是,它允许在同一应用内对不同功能混用两种策略。
产品套件(Product Suites)
从“近亲”走向“远亲”,我们来到产品套件。同一产品线通常以不同价位解决同一类问题;而套件是一组产品,以相似方式解决不同但相关的问题。套件希望通过这种相似性,促使客户扩展购买(买“整套”而非单品);对客户而言,它承诺用相似的使用方式解决两类相关问题。
为多平台部署架构一个产品时,把产品划分为核心与边缘通常已足够:核心跨平台运行(常需跨平台技术),边缘则因各平台而定制。
而面向套件增加了与套件相关的新轴。通常需要在套件内对齐的行为包括:
- 认证与访问控制:在套件中一款应用登录后,用户期望在同一设备上的其他应用也处于已登录状态;
- 数据访问:对存储数据的连接与访问体验应在套件中共享;
- 用户体验行为:用户不希望在应用间切换时重新学习;相似性是套件的核心价值;
- 历史与推荐:越来越多的应用会记录用户操作,显式呈现为历史、或隐式用于推荐;若提供,应当在套件内聚合;
- 跨应用流程:套件的价值之一在于相关问题的联动解决,因此通常提供跨应用跳转/协作。
与之前的“核心—边缘”类似,套件维度与之正交。因此,多平台的套件架构把我们从两部分(核心/边缘)带到四部分,如下表所示:
表 2.1 多平台套件架构:从两部分到四部分
| 套件维度(Suite) | 产品维度(Product) | |
|---|---|---|
| 核心(Core) | 套件核心(Suite Core) | 产品核心(Product Core) |
| 边缘(Edge) | 套件边缘(Suite Edges) | 产品边缘(Product Edges) |
即便如表 2.1,也仍过于简化。由于核心是单体(每产品一个),它们彼此以及与各自的边缘只需集成一次。而边缘按平台分裂,于是需要管理一整套核心—边缘、边缘—边缘的集成。在多平台产品套件中,这种“组合数学”强烈推动我们保持边缘接口简单。
因此,套件内各产品的架构团队必须与套件层与其他产品的同伴紧密协作。架构团队始终需要考虑系统组件与环境的关系;而产品套件正定义了这样一种共享的环境。
跨平台平台(Cross-Platform Platforms)
至此,我们默认产品面向多个目标平台开发,且多从客户端平台切入;很多讨论同样适用于面向云平台的服务。
面向多平台时,架构团队还有一个选项:瞄准一个中间平台,由其在不同环境间提供一致性。多年来,Java、AIR、Unity、Electron等技术都在扮演这种角色,成功程度不一。
是否采用中间平台没有放之四海的答案。底层平台的稳定性与前景差异巨大;它们给出的保障(或缺乏保障)也相去甚远。采用中间层,本质上是在用一组风险换另一组风险:有的赌赢了,有的则不然。
这些问题提醒我们:系统与环境的关系是双向的。我们容易记得系统会被其环境影响/约束;大多数架构团队也清楚目标平台能做什么/不能做什么。
但也可以反向:让系统选择与环境的关系。如果团队不想面向少数几个目标平台分别设计,就不要选那样的环境;转而采用跨平台中介,只面向单一环境。相反,若单一环境的限制过强,则可直接面向多平台。成本与风险各异,但这些策略并存,因为各自适配不同系统。
最后,注意环境选择不必二选一。在客户端设备上,往往确实近似二选一;很少有团队只在部分目标系统上叠加跨平台中介。
而服务端更像是“拼装”环境:部分云厂商提供共通 API(底层实现不同),事实上的跨平台标准由此产生;也可以在不“全盘押注”跨平台方案的情况下,按需挑选跨平台供应商来提供特定能力(如数据库、机器学习库)。
总之,平台整合——也就是系统与环境之间的关系——常是架构中最复杂的议题之一。此处尤需充分讨论、紧密协同与一致愿景。
构建平台(Building Platforms)
有时,我们所构建的产品就是平台。与其他软件产品不同,平台的区别在于:它要同时吸引两个或更多相互独立的客户群体。例如,操作系统既卖给在设备上运行它的终端用户,也必须吸引为其编写应用的开发者。
这些客户的差异不仅在于“角色”不同。操作系统的终端用户包括用于日常任务的“普通”用户,以及负责管理已安装应用、安全设置等的管理员。二者的确是不同的角色,但最终,操作系统仍是面向管理员与用户来营销与销售——他们才是做出购买决策的客户。
而开发者在软件平台中扮演关键且与众不同的角色。作为一门生意,平台押注于生态系统的建立:用户与开发者之间形成正向反馈回路,推动平台的使用,并因此为平台方带来收入。为了启动这一过程,开发者至关重要——以至于平台方有时会补贴/付费吸引他们支持平台。
这种与开发者的紧密连接意味着:架构对平台的影响更甚于对非平台类产品。终端用户归根到底关心的是产品是否解决了某个功能性需求——例如帮助他们写书、做预算、管理项目进度。良好的架构确实有助于任何产品把这些功能做好,但架构终究与用户体验相隔一层。
相对地,开发者直接与产品的架构交互。从某种意义上说,平台是一种“未完成的产品” :一套等待被拼装为多种形态的积木。这些积木是否形状合适、是否易于拼接——正是架构团队通过确立系统的组件与关系所产出的结果。从这个层面看,平台即其架构。
有时,从一开始就很清楚你在构建一个平台。操作系统按定义就是平台。然而,许多产品也会逐步演化为平台。比如,某应用以文字处理器起步;后来加入宏(macro)支持——以新的方式把内部“积木”连接起来;再后来支持更复杂的插件——它们有自己的 UI、存储、通信与计算需求。这条路径在成功应用上常常看似难以避免。
架构团队可以以“每个产品都是平台,或终将成为平台”的视角来为成功预作筹划。在这种心态下,系统的架构——那套“积木集合”本身——就变成了特性,而不仅仅是实现细节。一旦产品演化为平台,这项投入就会清晰回报。
即使产品并未演化到支持插件等,这种做法也有助于产品自身的演进。一个优秀的“积木”,应当尽量少依赖其当前关系;相反,它应当随时准备通过新的关系与其他组件连接,且改动成本低。我们越能把这些特性嵌入系统设计,越容易重组与扩展这些“积木”以交付新的产品功能。
标准(Standards)
标准以足够的抽象度规定某项技术的形式与功能,从而允许多种实现;同时又以足够的具体性来实现互操作性。
标准在软件领域无处不在:它们定义了我们编写软件所用的编程语言,定义了服务之间通信的协议,定义了保障通信安全的公钥密码学,等等。
范围广泛的正式标准通常由多家技术提供方在某个协调组织的框架下协作制定。例如,国际标准化组织(ISO)即为此而生。ISO 及类似机构提供了制定与产出标准所需的流程、框架与基础设施。
与 ISO 等机构定义的正式标准制定流程相对,另一些标准以非正式方式产生。许多代码库、产品与组织都有“内部标准”。它们有时不过是项目早期“一开始就是这么做的”;直到新同事尝试以不同方式做事时,人们才意识到那其实已经成了“标准”。
例如,许多软件公司对特定编程语言有编码标准。这在 C++ 等语言上尤其常见:语言本身过于复杂,几乎所有人都倾向于限制其实际使用到一个更简化的子集。这些限制有时被称为“指南”或“风格规范”,但其本质与标准并无二致。
介于正式标准与内部规范之间的,是事实标准(de facto) ——它们有广泛采用与多种实现,但没有标准组织的正式背书。是否缺乏正式背书,并不总是采用该标准的障碍;而事实标准的地位常常是走向正式采用的踏脚石。
在某些情况下,使用特定标准是硬性要求。如果你的产品就是为了对某项能力提供一个 HTTP API,那么采用 HTTP 作为标准通信协议就是理所当然。
在其他情境下,上下文可能建议采用某些标准。比如,你的产品提供的是网络可达的服务。也许并非绝对要求使用 HTTP API,但 HTTP 在各个层面——客户端实现、服务端实现、开发者熟悉度——的普遍存在都应被纳入考量。
当标准与架构契合时,你可以借助标准来强化架构并加速工作。例如,HTTP 标准内含一种架构风格:定义了客户端与服务端的行为,以及它们通过请求/响应通信的方式。如果你的架构匹配这种风格,那么采用 HTTP 将有助于强化客户端与服务端组件的角色与期望,以及它们之间的通信。许多架构师熟悉 HTTP,他们能把这方面的知识与经验带入你的系统。
当然,标准也必须适配问题本身。HTTP 非常适合某些网络服务,但并非适合所有场景——这正是替代方案存在的原因。作为架构团队成员,当某个设计问题存在基于标准、开箱即用的解法时,你应当感到欣慰;但验证该标准是否适用也是职责所在——包括在事实标准并不适用时明确反对。
分层标准(Layering Standards)
由于 HTTP 足够抽象,许多产品会采用其特定用法:也就是固化其所使用的子集,而不是允许对 HTTP 的任何合法使用。举例说,用 HTTP 在服务器上创建资源至少有两种常见模式:对“资源自身的 URL”做 POST,或对“新资源应当所属的容器 URL”做 POST。二者并非对错之分,只是不同的做法。
当某个标准给予的自由度超过所需时,架构团队可能希望收敛这些选项。以上述 HTTP 为例,团队可能要求只使用其中一种创建方式,避免系统里一半用法 A、一半用法 B所带来的复杂度。
一种策略是:在正式标准之上再分层一个内部标准。在本例中,团队可以为系统定义一份内部 HTTP API 标准,例如规定“一律通过向父容器做 POST 来创建资源”。该规则与通用 HTTP 完全一致,但更具体,从而消除了本可出现的无谓差异。
总结(Summary)
架构工作总是在由上下文、历史与未言明假设共同塑造的环境中展开。作为架构师,如何容纳、应对并利用这种环境,和如何设计系统一样,都是工作的重要组成部分。
架构团队应先在系统所体现的概念、其应达到的可信赖性以及其他与架构相关的关键需求上达成一致。这些要点若不刻意追问,往往不会被写明;但若不予以应对,系统就难以成功。
没有哪个产品是“孤岛”。相关产品会进一步塑造上下文:与同一产品家族/产品线/产品套件中的其他产品对齐,通常会约束设计空间。这些产品所运行的平台——以及它们自身是否是平台——也是架构必须处理的关键考量。
你的系统的需求、行业与架构风格也可能要求或建议采用相应的技术标准。一套有效的软件架构实践,应当理解其运作所处上下文的多重面向,并用这些认知来指导架构工作的开展。