到流程的这个阶段,我们已经拥有准确而全面的系统描述,对齐了指导性愿景,在概念层面提出了若干变更并选定其一推进。现在——终于——是时候进入详细设计了。注意,这里“设计(design) ”同时适用于架构的设计(有时称为“architecting”)以及依照现有架构进行的系统设计。你是在做前者、后者,还是二者兼而有之,取决于所做的变更,但设计这一活动本身并无区别。
设计是一种智力性的问题求解活动。它以问题陈述为输入——在软件开发中即需求——并产出一个程序规格,据此实现后即可解决问题。从问题跃迁到解法,有时仿佛带着一点“魔法”。
确实,有些架构师把设计当作黑箱技艺:他们手握设计挑战消失数天或数周,回来时携带一个完整成型的方案。我不怀疑少数天才能做到,但我不推荐这种方式。开放、结构化的设计方法往往能得到更好的结果,并且——至关重要的是——有助于传授设计技能,从而打造有效且有韧性的团队,而不是依赖某个个体的“特异功能”。
人类天生是问题求解者。面对问题,我们愿意试验新想法、尝试新事物,其中一些会奏效!凭借对历史成功解法的记忆,我们常能为下一个问题找出一个解或至少开端。如果大多数软件产品都足够简单,这也许已经足够。
然而本书所讨论的产品往往极其复杂:可能由数百个离散元素、百万级代码构成,并由数百/上千人协作开发;它们还常常试图解决全新的问题,因为产品开发的本性就是追求新颖与差异化。这类问题不能仅靠“回想过去曾做过什么”就能立刻得到解。
在管理设计流程时,认识到我们可以将其结构化为三步很有帮助:把大问题分解为小问题,逐个解决小问题,再将这些解组合起来解决更大的挑战。提供这样的结构,对架构设计的可管理性至关重要。
架构如何加速设计(How Architecture Accelerates Design)
设计是创造性活动:去意志性地让某个新事物出现。它需要技能与知识,也需要想象力与韧性。在一开始,一切皆有可能。
这种“无限可能”的空间当然也令人畏惧。我们的工作是产出一个设计来满足一组需求。穷尽无限多的选项显然做不完,更别说有截止期。
显然,没有工程师会真的考虑无限集合。各工程学科都会把设计选项约束在一份“菜单”上。比如土木工程师要建桥,不会从发明新型桥梁开始;他们会在梁桥、桁架桥、悬臂桥、拱桥等基本类型中选择。
这份固定菜单并不让桥梁设计变得容易。不同桥型在跨径、刚度、材料、施工方法等方面各不相同。若有一种桥在所有维度都全面占优,工程就简单了,但现实并非如此;多种桥型并存,正是因为需求千差万别。
在选型时,工程师还会受外部约束限制:比如峡谷的宽度与深度可能允许某些设计、排除另一些。工程师也许希望使用别的方案,但峡谷不会改变,设计必须适应它。
因此,设计一座桥,是在既定选项约束之内,创造一座独一无二(从未建过、也不会再建)的桥。这种**“边界中的创造”**就是工程的根本挑战——无论造桥还是写软件。
在软件中,我们常约束更少。软件可塑性强,常让人感觉似乎任何做法都可以。某种程度上确实如此:软件工程师很少遇到像造桥那样刚性的外部约束。
对此,工程师常见的两种反应是:
其一,沿用熟悉之道。比如要构建能并发处理大量 API 请求的服务,熟悉事件驱动的工程师可能就选它:满足需求又驾轻就熟,无需学习新方法,有助于赶上截止期。
其二,反其道行之——尝鲜。另一位同样熟悉事件驱动的人,可能听说多线程更“潮”,于是尝试新设计。冒险带来风险,也可能带来收益:也许性能更优;团队也学到新技能、保持投入。下次遇到类似问题,团队就同时具备事件驱动与多线程的经验。
遗憾的是,这种灵活性在大系统里反而可能削弱完整性。当这些选择在局部、孤立地做出且缺乏治理时,很容易得到这样一个系统:某个 API 用事件驱动,另一个 API 用多线程。
单看每个点子都能工作;合起来却让复杂度大幅增加而收益不等比:两套 API 难以复用代码;开发者在两种截然不同的规则间切换,负担更重;由于不同故障模式,缺陷修复几乎要翻倍;两者扩展性特征也不同,性能优化更难。
几乎是悖论般地,过多的灵活性会汲取系统的能量:在设计时增加了要做的选择,在实现时增加了要做的工作。
这正是**“作为约束的架构”登场之处。软件没有“不可改变的峡谷与材料”来限定方案,因此要保持系统完整性,我们必须自我施加约束**。这些约束——以治理系统设计的原则表达出来——就是系统的架构。
好的架构通过“剪枝”来加速设计。需要处理并发 API 请求?若架构明确规定采用某种方式,就可免去选型时间;还能创建实现层面的复用机会,并让人力、测试、扩展工作都围绕单一、统一的设计展开。
设计如何倒逼架构演化(How Design Forces Architectural Evolution)
这并不意味着每次设计都必须从现成菜单里挑。否则我们至今还在过原木梁桥。当既有选项不能满足需求时,新方法就有必要。
架构团队的大量职责在于为已知且可理解的问题设定约束;但另一面责任是识别当现有解不够好、需要创新的时候。开发新解并把它纳入架构是关键工作,应当尽早识别并投入足够关注。
我们有时会想把探索新问题混入常规工作流,这很冒险。应当承认不确定性,安排原型与研究,在计划中为其预留人员与时间。这需要前期耐心与投入,但当结果被一致且成功地在项目中采用时,这些投入会得到回报。
另一些时候,是外部选项变化——如同钢材出现改变了桥梁建造。我们应留意这些更优做法。同样,架构团队必须防止在系统各处随意尝鲜,否则会破坏完整性。
这里的要点不是压制一切试验。适量的试验很重要:它能带来新更好的方法,也能让优秀人才保持兴趣。要点在于:把试验纳入管理。
做法是把试验从常规产品开发周期中抽离出来:
- 一来,这能澄清投入——新点子的探索成本与直线生产的成本可以分开算;
- 二来,试验不背关键截止期的KPI,可允许更大胆;
- 三来,试验可以失败而不拖累发版计划——这点至关重要。若你在正确地做试验,一定会有失败。让生产工作依赖一个试验,会危及交付,也让团队在失败发生时难以承认。
回到API 并发的例子:事实证明,单纯的事件驱动或单纯的多线程都不一定最优。一个混合式方案可能更强:以事件驱动最大化单线程效能,再用多线程突破单线程上限。
因此,一个追求可扩展并发架构的系统,合理地可以组合使用二者。换言之,此类系统的架构约束可以规定:API 实现必须采用“事件驱动 + 多线程”的混合模型,而非二择一。
这当然是个复杂的选择,也不一定普遍适用:某些系统的规模需求不足以证明这份复杂度;或受语言/框架等限制,只支持其中一种。不管怎样,这个例子很好地说明了架构团队需要做出的权衡、团队需要了解可选项,以及哪些设计问题应当由架构给出答案。
分解(Decomposition)
除非你要解决的是很简单的设计挑战(当然这种情况也会发生),设计的第一步就是分解。面对庞大而复杂的问题,我们把它拆开。若拆得好,每一部分仍需要进一步工作,但对应的将是更小、更可控的问题。并且我们可以递归地这么做,直到得到一组我们知道如何求解的问题。
事实上,从本书一开始我们就一直在做分解,只是没有给它贴上标签。描述即便是中等规模系统的架构也是一个庞大而复杂的问题,因此我们立刻把它拆成若干块:把系统描述为在某个环境中运行的一组组件与关系。组件、关系、环境——这就是软件架构中的分解。
当我们把变更流程拆成若干阶段时,做的也是同样的动作:记录当前系统、与愿景对齐、提出变更提案、为特定变更做设计。要把这些事一次性完成几乎不可能,所以我们把它们拆成独立阶段。每个阶段都有各自的挑战;仅仅描述系统现状就已经很难,因为要捕获并表述大量信息。分解再次成为我们应对复杂性的基础技术。
因此,在设计过程中应用分解,并不是给技能库又新加了一招。不过在此阶段我们有时会遇到额外挑战:在考虑既有系统的架构时,它被分解成组件、关系与环境的方式已先验给定。而当我们在设计一个新系统,或对既有架构做重大修改时,还要额外面对选择合适分解方式的挑战。此时,架构师不能指望有人把“正确的分解”端到面前;相反,需要知道好的分解应具备什么特征,并能据此提出并评估若干可能的分解方案。
一般来说,分解应当追求简单。别忘了,我们之所以分解,是因为问题已经难到无法一把梭。目标是把它拆成更易掌控的部分。但如果我们记不清这些部分是什么、做什么、如何关联,那就没改善,甚至更糟。
好的分解首先通过引入一个可管理数量的要素来保持简单。若数量太少,分解没有带来增益;我们还是得进一步把这些要素再拆来设计它们。若数量太多,又引入了一个新问题——管理它们之间的关系。
好的分解还应当定义能够抽象细节的要素。分解之所以有效,根源在于:它把一个问题划分成多个更小、可独立求解的子问题。如果每个要素抽象得不够,就无法达到这个目标。我们希望把问题拆成能隔离开各自难题的要素。
软件设计有趣之处在于:这些挑战没有简单一刀切的答案。比如我们不能说“每次分解都应该把问题拆成六块”。“六”并不坏——足以形成一定的划分,又不至于数量过载——但脱离具体问题,无法断言它就合适。
组合(Composition)
仅有分解不足以构建一个可工作的系统。我们可以把问题拆成许多片并逐一解之,但如果不把碎片装回去,就没有一个能工作的系统。必须把这些部分重新组合为一个内聚整体,才能实现设计。
某种意义上,这句话显而易见:任何阶段下,若一种分解无法再拼回去解决更大的问题,那它就是无效的。因此,当我们做分解时,也在预想这些部分将如何重组为解法。这个层面上,分解与组合是同一枚硬币的两面。
尽管如此,组合本身也带来挑战与机会。此处,简单与高效至关重要。如果我们以一种使“拼回去”变得盘根错节的方式来分解问题,就会造出一个复杂解:难以组合、难以做对、难以维护。各要素间的关系应当尽可能直截了当。
糟糕的拆分还可能导致低效的交互。例如,逻辑组件(执行处理)与数据组件(存储记录/内容)之间的关系,常被设计成一次处理一条记录。这让简单场景保持简单,但在需要批量规模处理时就不灵了。要想在规模上高效,二者之间的关系就不应仅仅是逐条,而应支持批次或数据流,以便实现高效的组合。
正如该例所示,组合高度依赖各要素的接口。你是否将问题拆成两个由独立服务承担的部分?如果是,你就在二者之间建立了跨进程、甚至跨机器的边界。那种(相对)高延迟的交互能接受吗,还是会太慢?若过慢,也许需要把分解改为同一服务内的库或类,从而把组件间延迟降低好几个数量级。
标准化也有助于组合。当系统含有许多组件时,通常要花大量时间把它们连线到一起——无论是函数调用、网络请求还是消息传递。若系统采用的是一个异构的机制集合,就得额外花大量精力做机制间的翻译。而系统若能标准化一个“最小且足够”的机制集合,并将这一约束应用到每个组件的设计上,就能减少或消除这些阻抗不匹配。
组合与平台(Composition and Platforms)
当我们在设计某个具体能力时,可以规划一套面向该问题的分解与组合。若做得好,就能为新的组合奠定基础——这些组合并非最初的设计所计划或预料到的。
这正是代码库与复用直觉的来源。把问题拆成离散要素后,我们自然会问:它们还能用于何处?对接口做些小幅拓展或泛化,往往就能显著扩大其适用范围。
在设计过程中,我们也可以留意问题的再出现。也许系统中两个不同部分都需要一个文本处理要素。它们未必需要完全相同的函数集,但多半会高度重叠——尤其一旦涉及多语种的严格要求。这就创造了机会:做一个单一、共享的要素,再把它组合回系统的各个部分。
大多数情况下,上述讨论假定我们在构建一个应用,或一个应用家族。可以把“应用”理解为预先组合好的一组组件,用来提供构成该应用的那组能力。从这个角度看,“应用”与“平台”的区别在于:平台把组装权交给用户/开发者。也就是说,平台通过分解来设计,但其目标是产出一组可被组合的组件,使其能够以设计者未预料(当然也包括已预料)的方式进行组合。
预见未被预见之事很有挑战,也是平台开发之所以困难的很大一部分原因。平台设计者显然不可能枚举所有组合;组合爆炸会让这事变得不可行。成功的平台通过更强调以标准来实现组合来应对:它们必须把标准当作约束——否则积木拼不起来;同时还要提供足够灵活性——否则无法支持有趣的组合。因此,平台设计的相当一部分精力,都会聚焦在一个问题上:如何打造既可组合又足够丰富的接口。
增量主义(Incrementalism)
可以把设计过程类比为树遍历:树上每个节点是一个较大问题,可被分解成若干子节点并依次访问;叶子节点是无需再分解、可以直接解决的小问题。设计可以选择广度优先或深度优先,但无论哪种,当所有节点都被访问时过程才算完成。
当然,如此线性推进会让软件开发非常缓慢。当单人完成一个小项目时,这也许可接受甚至必要;但大多数产品开发都希望更快推进并在过程中交付中间成果。
通过增量工作可以获得中间成果。其核心思想是:在把系统重新装配并让其可运行之前,先把部分问题分解并解决,而非全部。后续的增量可以回到树上的任意节点继续展开。
增量式设计有多方面好处。首先,它能缓解焦虑:长时间看不到“跑起来的东西”既无趣也挫伤积极性。我做个人项目时就会强烈偏好迭代式,仅此一项就足够;每个阶段“活起来”的满足感,会驱动你去挑战下一个增量。
其次,在目标终点不清晰或未知时,增量主义尤为有用。也许你只清楚部分问题,或你知道所有问题但暂时解不了其中一些。那就把已理解的部分单独拉出来先跑通,再利用其反馈来指导后续。
一个有趣的副作用是:后续的一些增量有时会发现根本不需要。它们在抽象讨论中看起来合逻辑或必要,但当前几步跑起来后,可能会发现已有部分已经足够,继续投入的收益不值其成本。
你可以利用这一点来避免范围之争。团队里有人主张一次性做满,也有人主张最小化;与其空谈范围,不如先就增量计划达成一致,然后在每个增量交付后复盘。拿着结果,双方更容易收敛。
并行性(Parallelism)
增量是在时间维度上组织工作;并行是在人员维度上组织工作。除个别独自完成的小项目外,大多数系统由团队开发。个人之间越独立,工作就能越高效地推进。
幸运的是,并行与分解是相辅相成的[6]。若每个子问题定义得好,它就可以作为独立工作包分配给团队的不同部分。其效果如何取决于分解质量:越可分离、接口越清晰,并行就越奏效。
通常,在系统分解的上层更容易实现并行。以云端产品为例,Web 应用与后端服务的分离,天然适合由不同团队负责。额外好处是,这两块常需要不同技能/技术,团队也可以围绕这些需求来组织。
要让并行划算,独立完成的工作量必须大于为协调接口与连接所付出的开销。当分解从应用/服务一路往下走到类与方法层级,这个比例会迅速变小;在单个类的层面上,为分解而引入并行往往不值得。
有趣的是,并行所带来的讨论与协同也能用来反向评估设计。比如,某次分解把产品划成三个服务——A、B、C——并各配一支团队。若A 与 C之间几乎不需协调,A 与 B之间亦然,这是分解良好的信号:服务接口清晰,团队能低开销并行推进。
反之,若B 与 C两队频繁沟通、几乎天天开会处理问题、且两者的接口天天在变,那就明示:B/C 之间的分解不奏效!这类纸面设计上不明显的问题,会在团队行为中强烈显形。应据此回看并修正设计。
组织结构(Organizational Structure)
软件行业里许多人都熟悉康威定律(Conway’s law)[7],其内容是:
设计系统的组织……在本质上会产出与其沟通结构相对应的系统设计。
通常,人们将这一观察理解为:组织结构会影响到软件设计。经典案例是:系统被分解为 n 个要素,是因为组织被划分为 n 个团队——而且嘛,每个团队总得有点事做,不是吗?
这一定律固然准确,但康威本人也指出,它事实上是“设计组织结构的一个标准”。这一认识把它从为糟糕设计开脱的借口,转变为有用的工具。你是否希望降低系统中不同要素之间的耦合——比如为了让它们能被独立复用?那么就把它们分别交给两个不同团队。如果这两个团队之间合作不多,效果会更好。
反过来,某个设计里可能包含一个复杂组件,需要投入相当多精力来实现,但又必须保持统一的接口与行为。把它进一步拆分并把工作分摊出去或许很诱人,但这样不可避免会破坏结果的内聚性。在这种情况下,更好的做法是扩充单一团队的编制、给该团队更多时间,或二者兼施。
关键在于:把组织结构当作工具来使用。先用分解与软件设计过程来确定产品应当如何组织,然后相应地调整组织结构。你最终会得到一个复制了组织结构的系统——而这正是你想要的结果。
开放地工作(Work in the Open)
设计过程需要反馈,而反馈需要沟通。我们向他人讲述自己的工作,并寻求能促成共同理解的回应。当你与相关方(stakeholders)沟通时,你会持续获得多元观点的信息流。没有两个人会用完全相同的视角、知识与背景去审视系统。
在这些对话中,我们对自身设计的理解不可避免地会演进。根据所听到的内容,你可能意识到自己的表述并不清晰,从而促使你找到更好的说法;也可能鲜有回应,说明你需要加大沟通投入。一次成功的对话会带来改变——或在你的沟通上,或在你的设计上,抑或二者。越早开始这些对话,越好。
因此,你应当尽量在开放中工作。所谓“开放中工作”,是指变更提案及其他记录你工作的工件,应该尽可能对更多人可见。
在开放中工作,会带来两件重要的事情。其一,你可以避免在流程后期突然抛出一个他人既不了解也不同意的设计。我知道,把一份完整而有说服力的愿景端给别人很诱人,但你觉得的“完美收官”,对他们却可能是既成事实。即便工作本身很出色,你的相关方也可能为错失参与机会而不满。
我们最该倾听的是那些能够揭示新问题的提问与评论。没有哪个设计是完美的,每个设计都可以更好。当你贴近一个设计、尤其是亲手做的设计时,想要看见它的瑕疵与改进点会很难。
开放中工作意味着你更早分享,也更早收到反馈。注意,目标并不是让每位评审都了解每一次改动,更不是要他们审阅每一版草稿。有些读者——特别是投入较少的——会等到工作更完整时再看,这没关系。
如果你习惯开放中工作,那么投入且好奇的人——不论是对某个具体议题还是对整体——都会知道他们可以参与,也会感到受欢迎。他们会在新草稿发布时主动找到它们,并投入时间审阅。对架构师而言,早期且踊跃的评审是一份礼物。
开放中工作带来的第二件大事是:它能降低你对既有成果的路径依赖。我们常对第一个方案产生很强的依恋,但很少有人第一想法就无可改进。你在封闭环境里捧着这个想法独自打磨越久,就越容易**“卡住”**,不再探索备选。
自律的架构团队会在流程早期就刻意产出多个概念路径,以避免这种情况。这确有帮助,但最终还是要在备选之间做选择。与同行讨论利弊、帮助评估/质疑/共创,有什么比这更好的方式呢?以我的经验,很少有设计在经受审视后不变得更好的。
越早分享并获取反馈,你就越不容易被第一个想法“困住”,也能越早得到改进工作所需的意见。当你最终完成一份设计时,身边已经有一群对提案理解深入的审阅者。
当然,这并不意味着每条反馈都是正确的。每条评论都值得认真对待,但你也会听到不少不采纳的意见——只要理由正当,这完全没问题。毕竟,送来的点子里总会有坏点子。你的目标不是取悦评审,而是打造优秀的设计。
但别忘了,你也在追求对话与共识。从这个角度看,每条反馈都在传达信息。这条评论是否基于对你文稿的误解?误解当然可能在评审一方,但也可能提示你的表达还不够清晰。若能修正,就能替后续读者省去麻烦。
有些反馈可能提出了完全合理但你不会采纳的替代方案。秉持对话精神,它们同样值得回应。不要陷入辩护的冲动——这些备选不是威胁。只需解释你做决定的准则与依据。同时把解释记录下来,以便下一个读者再度提出同样问题时有所参考。
我的经验是,学会开放中工作对一些人并不容易。如果你把作为架构师的能力完全等同于你个人设计的质量,那么所有反馈都会让人如临大敌;越是早期、半成品的想法,越是如此。每条评论都像批评,你会希望自己有机会在别人看到“错误”之前就修掉。
然而,这并不是应当对待设计过程的方式。架构设计与产品开发的其他部分一样,是一项团队协作。架构师的工作是利用一切可用资源(包括同侪与相关方的输入)交付合适的设计。这不可避免地意味着要采纳他人的洞见与反馈。这么做会让设计更强,因此这种审慎的批评应当被欢迎。
如果你对此感到挣扎(或看到别人如此),请记住:你不是你的作品。我们时常容易忘记这一区分。人们往往把自我认同强烈绑定在自己创造的东西上,进而把对作品的赞美/批评与对自我的赞美/批评混为一谈。开放中工作不会让这件事更容易,但它会把问题摆到台前。而我们越能把自我与作品分离,就越能客观地改进它,作品也会因此更好。
放弃(Giving Up)
并非所有设计都能成功落地。若某个设计无法在原始变更提案设定的参数/约束内实现,就应该放弃,并将工作退回到更早的阶段。利用在设计阶段获得的见解去重审该提案以及先前被否决的替代方案。凭借新信息,你可以重新评估,若有必要,就改走另一条路径。
放弃并重来并不容易——前面提到,人们会对自己投入过心力的设计产生情感依附。这又会引发关于是否要重启早先备选的冗长拉扯。这类争论把项目层面(如进度)与工程层面(如可行性)的问题混在一起,既难以解决,也很难产出好结果。
你可以通过让“退回变更提案阶段”成为自动动作来避免泥潭。比如,设定一条规则:一旦交付预估延期超过若干周,就必须执行此步。暂停当前设计工作,把团队注意力拉回概念阶段。询问是否出现新的替代提案应纳入评估;然后决定换挡,或——也可能发生——再次承诺沿用原方案。
放弃一个设计看起来会扰动项目,几乎肯定会带来延迟。请克制住“硬顶着为了保住排期”的冲动。与其让问题发酵,几乎总是短而可预测的延迟更好;发酵后的问题通常会引发更大麻烦与更长延迟。而且,如果你已经建立了有效的提案评审与择优流程,此刻重新评估并不会花太久。
完成(Done)
一旦设计完成,就不应再改动它。当然,之后可以由新的变更提案来取而代之。不过,这个新提案必须在系统当前状态的语境下评估,充分考虑已经完成的设计工作。
项目应当让每一次额外变更都经受与最初设计同等严谨度的评审与批准。目标不是用繁琐流程减少变更,而是要让变更与原设计获得同等级别的严格审视。这套流程是轻量还是重量,由项目自行决定。
这点对架构师来说有时很难!随着实现推进,你可能会想到更新、更好的做法。把这些“闪亮的可能性”按下,坚持既定方案并不容易。然而,能掌握这项能力的团队更能按时交付。有纪律地做决定并坚持之,是团队成熟度的体现。
总结(Summary)
设计是变更的第三阶段,也是我们把概念层变更的细节落到实处的时刻。简单的变更可以直接着手,但即便是最简单的变更,架构师也应当通过开放地工作来寻求反馈。
对于更复杂的变更,可以采用图 5.1所示的设计流程路径。
图 5.1 设计流程的路径示意。
若设计复杂,应将其分解为更小的问题;必要时递归分解,形成一棵设计树,直到每个子设计都可单独掌控。
根据变更性质,子设计可以增量式推进:完成一个再做下一个。增量方法有助于展示进展、获取早期反馈。有时,后续增量会被发现并非必需,或可以推迟;被推迟的项应回到架构待办中。
若分解方式允许且团队有产能,这些子设计也可并行展开。并行工作应与组织边界相协调,同时也受子设计间耦合影响。找到平衡点可能需要调整你的设计或调整组织。
工作应在开放环境中进行,以便尽早且频繁获得反馈。子设计完成后,必须校验其组合是否满足原始设计需求。一旦完成,设计即告结束。后续任何变更都应作为新的变更提案,从流程起点重新开始。
贯穿整个流程,团队都应牢记:并非所有设计都会完成。有些会因不值得而放弃;有些会因不可行而中止。健壮的变更流程力求尽早识别这些情形,而不是执着于把每个设计都做出来。