再成功的软件系统也难免走向复杂。用户数量的增加将给系统的可能性、可伸缩性、性能表现施加前所未有地压力。新功能不断加入,补丁越来越多,让软件越来越笨重。拓展系统地任务可能压得开发团队喘不过气。如果不保持警惕,软件系统最终会成为其自身成功的受害者。
当然,复杂性刚刚露出狰狞面目时,我们还是有办法控制的,比如通过需求变更和代码裁剪精简系统,将大件拆分成容易分析和管理的小件,还可以从细节中抽身出来,从更抽象的层面重新思考软件。
我们曾说过,架构是由结构组成的,而结构又是由元素和关系组成地。本章将学习使用这些基本构件创建有意义的模型,帮助我们分析、构思、推演我们的设计。
8.1 推演架构
无论何时,人的大脑都只能保留有限的信息。多年来,软件开发人员已经掌握了一些技巧突破大脑的局限性。有一个技巧是与他人合作,用大规模并行工作解决问题。还有一个技巧是建立抽象概念来压缩表示大量信息。协作和抽象是我们思考、分析、理解架构的基本方法。
抽象让我们关注特定的细节。例如,在面向对象程序设计里,类的接口描述了公共方法,但不涉及如何实现这些方法,这些细节可以等具体实现接口时再处理。暂时忽略令人分心的细节,我们才能专心思考接口与外界的交互。
当然,如果不能分享,再完美的抽象也会失去意义。分享的办法就时建立抽象的架构模型。建立架构模型并不仅仅时画线条和方框那么简单,它需要严肃认真的思考。架构模型与草图不同,模型是对架构的精确描述,能让他人更容易理解和推演架构。
优秀的架构模型有诸多优点:
构建设计词汇。字词传神,至关重要。怡当的元素名称可以更好地表达意义和目的。我们建立的每个模型都拓展了软件系统的词汇。这些词汇将在日常讨论中使用,它们会融入我们编写的代码,影响我们看世界的方式。
引导我们关注重要的细节。在软件开发中,细节决定一切。虽然细节都很重要,但是我们并不希望(也没有能力〉同时考虑所有细节。模型隐藏了部分细节,让我们可以在特定的时刻专心思考特定问题。
帮助推演质量属性和其他系统特性。模型可以帮助大家思考和描述系统行为。良好的架构模型还可以用来在动手开发之前检验系统设计。当然,为了建立精确的模型,我们还需要做实验和创建原型,但磨刀不误砍柴工,这样做总比开发完系统才发现有问题划算。
展示架构师的构思。所有开发人员都应该理解为什么选择当前的设计。良好的模型能展示结构背后的构思。理解架构师构思的人越多,就越有可能在系统开发过程中维持设计的完整性和一致性。
模型来自我们对世界的认知以及我们为表达设计意图所做的努力。所有模型都有概念和规则,规则描述了概念的使用方式。正确运用这些规则,才能让模型与我们的认知保持一致。
如果我不建立模型,直接写代码会怎么样?
多花一个小时设计架构,可以节省一个月的编程时间。我们知道,在设计阶段修改架构缺陷要比开发、测试、验收、发布后再修改容易得多。在白板上发现、修复问题比在数千行代码里做快得多。
当然,写代码也是了解系统及设计的绝佳方式,应该尽早开始写代码。除此之外,在探索架构时还要做实验和创建原型。借助模型思考不能代替动手实践,不能只用图表和框图来分析预测系统的行为。
你做架构设计也好,不做架构设计(让它在编写代码的过程中浮现)也好,最终总会得到一个架构。在你决定跳过架构建模,直接编写代码之前,一定考虑清楚:你打算何时设计,以及你能承受多少返工量。记住:多花一个小时设计架构,可以节省一个月的编程时间。
8.2 设计元模型
架构的元模型定义了模型中使用的概念和使用规则。元模型好比是设计的语法,它规范思考方式,并且设定了讨论架构的词汇。
定义元模型可以更容易地向大家描述架构、设定期望,并推演我们创建的模型。创建元模型,首先要定义概念,也就是架构中的元素和关系。定义概念后,再建立这些概念的使用规则。
8.2.1 分离新概念
概念分离(concept individuation)是我们学习新概念的认知过程。每当从架构中分离出新概念,我们就加深了对模型及其反映的世界的理解。
我们从小就能自然而然地分离概念。当你蹒跚学步时,你第一次见到门。观察大人开门后,你猜测转动把手可以把门打开。这时你就将门与墙的概念分离开了。有一天你试着转动把手开门,结果门却纹丝不动。于是你又将上锁的门与没上锁的门的概念分离开了。你的思维模型就是这样逐步建立起来的。
分离概念的过程称为好奇心循环(见下图),它适合用于建立任何模型,无论是现实世界的模型,还是软件的系统架构。
这个循环总是从提问开始。提问是检验的手段。检验的结构要么是在模型中找到答案,腰门是发现认知还存在偏差。如果找到了答案,就强化了现有的模型。如果有偏差,就要修正模型(分离概念)。
假设利益相关方很看重可用性,而我们需要控制成本,在这种情况下,我们可能户itiwen:
-
提问:哪些组件不可用造成的损失最大?
-
检验:当前的模型无法回答这个问题。我们知道有哪些组件,但不清楚组件不可用造成的损失。
-
分离新概念:引入损失的概念。
-
建立新模型:在元模型中用颜色表示损失:白色代表没有损失,黑色代表损失严重。颜色越深,损失越大。
-
检验:解决了!现在清楚了,Foo组件不可用造成的损失最大。
修改元模型,增加损失的概念后,我们创建了一个新模型,从而可以推演成本与其他质量属性之间的关系。
从提问到解决会花一些时间,而且这里也有风险,比如我们的知识和经验不足以分离新概念,也就无法正确地设计系统。为了降低这种风险,我们可以选用现成的元模型(如架构模式)。
8.2.2 选择架构模式作为基础
所有的设计都是在已有设计基础上的重新设计和调整创新。使用现成的架构模式是对这条原则的最佳应用。架构模式包含了针对特定问题的元模型。选择了合适的架构模式,你就有了现成的元模式。
大多数系统设计都至少运用了一种架构模式,有些还用了两种。即使有了这些架构模式,仍然有许多细节要补充和完善。而当我们向元模型添加新概念时,我们有可能会破坏已有的架构模式。
8.2.3 保持一致性
同时使用多个元模型 (例如将多个架构模式合并)可能会出现不一致的情况。例如,两个元模型都定义了 worker 元素,但这两个 worker 的职责和使用规则完全不同。这种不一致性会破坏系统架构。为了保持一致性,应该对相似的概念进行合并,对同名不同义的概念进行更名,对使用规则做出相应调整。
向元模型添加新概念时务必附上使用规则。规则描述了元素和关系在系统中的交互方式。它们必须反映实际情况。例如,许多编程语言都有严格的类型系统。如果使用强类型语言实现管道过滤器系统 ,那么元模型应包含相关类型的规则。如果选择的语言不强调类型,那么就需要定义消息描述和协议 头。
规则还描述了我们在架构上引入的概念约束 (conceptual constraint)。概念约束能够提升某种质量属性。例如,在分层模式 (见第 72 节)中,层中元素只允许调用同一层或下一层的元素。这条规则可以提升可维护性。违反这条规则,系统将变成一锅粥。
建立规则的方式与建立其他概念的方式相同。先提问,然后检验模型,再通过修改或添加规则更新模型。如此循环,直到模型能解决问题。
8.2.4 取好名称
给元素命名很重要,但绝非易事。命名也是一种设计,值得严肃对待。
Arlo Belshee 曾在一篇文章中将命名过程分成七个阶段。他认为命名反映了我们对设计的理解程度。随着理解的加深,命名也将逐步完善。Belshee 提出的七个阶段如下:
阶段1:空白
没有名称。我们对系统及其背景情况还不了解,无法为元素提炼名称。
阶段2:凑合
名称不能准确反映元素的含义。我们只是刚刚有了一些大致的想法。
阶段3:沾边
名称至少反映了元素某一方面的功能
阶段 4:反映功能
名称直接描述了元素的所有功能。
阶段5:反映角色
名称充分地反映了元素在架构中的角色。
阶段 6:反映意图
名称不仅能反映元素的功能,还能反映其目的。这说明我们既了解元素要做什么,也理解元素为什么存在。
阶段7:领域抽象
名称超越了单个元素本身,成为一个新的抽象概念。元模型里的新概念就是这样诞生的。
名称反映了你对架构概念的理解程度。如果名称只能凑合用,那说明你还没有完全理解这个概念。
8.3 让模型融入代码
模型反映架构,它可以用来推演架构。但模型也有缺陷,它很容易与代码脱节。稍不小心,包含在模型中的想法就无法在代码中体现出来。那样的话,有关质量属性的思考和推演就会付诸东流,多么可惜呀!
别担心!大部分架构模型是可以直接融入代码的,稍后会讲解这方面的技巧。这样做有很多好处,当架构在代码中不言而喻时,我们就能很容易地维护设计的完整性,同时提升既定的质量属性。将模型融于代码还能降低架构漂移的可能性(因为模型和代码几乎是联动的)。这样做还减少了对文档的依赖,因为设计己经融入系统之中。
不幸的是,George Fairbanks 在《恰如其分的软件架构》一书中指出,我们不可能在代码中直接实现架构元模型中的所有概念。尽管如此,我们还是可以设法减小模型与代码之间的偏差 (model-code gap)。
为了缩小模型与代码之间的偏差,Fairbanks 提出了“架构显见的编码风格”(Carchitecturally evident coding style)。通过这种方法,我们将模型及其使用规则,还有设计背后的逻辑等都融入到代码之中。这样做可以尽可能地减小偏差,让代码充分体现架构模型。
8.3.1 统一使用架构词汇
在抽象的架构变成具体代码的过程中,术语失配(trminology mismatch)是造成混滑的常见原因。架构讨论的是层、服务、过滤器,而代码实现的是包、类、方法。要将模型融入代码,最简单的方法就是统一使用架构词汇。
如果使用的是分层模式,那么就应该称代码包为层。如果采用了管道过滤器模式,那公就应该用管道和过滤器给类命名。
将领域模型(domain model)融入到代码中是减小模型与代码之间偏差的另种方法。按照领域概念进行建模和编码,是面向对象编程的常见做法。许多框架都假设(或鼓励》把领域模型作为整体实现的一部分。以这种方式进行领域建模是领域驱动设计 (domain-driven design)的核心原则。同样,事件驱动架构模式(event-based pttern)和响应式架构模式 (reactive pattern)在很大程度上也依赖对领域工作流的事件模型的理解。
8.3.2 组织代码,突显架构
除了命名,代码的组织形式也会极大地影响系统架构。下图展示了一个合理地分层组织代码包的例子。
组织代码的方式不止一种,比如还可以按照功能模块来组织代码。这种方式要求完成一项功能所需的所有类都包含在单个包里。功能包外部的类将无法访问业务逻辑或数据访问类。
组织代码,使之与设计的模块结构相匹配应该成为标准做法。毕竞提升质量属性靠的是代码,而不是面在白板上的模式。如果开发的系统体现不出模式,模式实际上就不存在。如果模式没有落地,就无法提升既定的质量属性。Simon Brown 在 《Sofiware Architecture for Developers》一书中列举了不少这方面的例子。
我们至少应该做到将代码组织到包里,使之与架构元素对应。如果想做得更好,还要确保关系也能贯彻落实,从而杜绝代码与架构不一致的情况。
8.3.3 贯彻落实元素关系
大多数开发团队的问题在于他们只靠纪律和警惕性维护架构的完整性,仅仅这样做是不够的。为了在代码中落实架构,还应该使用其他方法。一旦设计决策在代码中得到了贯彻落实,想要违反或改变就不太可能了(至少是极其困难的)。
我们能够贯彻落实架构的程度与使用的结构类型、编程语言、操作环境等因素有关。
模块结构
模块结构在代码中最容易看到,但通常最难贯彻落实。在大多数现代编程语言里,可以通过限制对特定模块的访问贯彻落实 “允许使用”关系。如果这么做不奏效,还可以将模块作为库进行分发。
假如无法确保关系被贯彻落实,至少还可以监控它们,比如使用静态分析工具识别违反使用、允许使用等关系地方。在有些编程语言里,可以创造性地使用类型来呈现可见且易于监控的元素之间的关系。
组件连接器结构
贯彻落实组件连接器模型的一种方法是将系统设计为在违反架构约定时就终止运行。《Object-Oriented Software Construction》一书定义了这种“契约式设计“(design by contract)方法。该方法在代码中设置各种前置条件、后置条件和不变量,并在运行时进行检查。如果开发人员违反契约,应用就会抛出错误,终止运行。契约可以在多种抽象粒度上工作,包括对象、服务、跨线程的进程。
贯彻落实组件连接器模型的另一种方法是防止不应连接的组件之间的连接。比如,要求组件之问进行身份验证,这是数据访问层连接数据源时的常见做法。
微服务架构的迅速普及多少是因为它使领域模型在运行时可见且可落实。在模块结构中贯彻落实 “允许使用”关系可能有难度。如果将这些模块转换为组件,我们就可以在运行时贯彻落实交互规则。
分配结构
在代码中表达分配模型背后的意图曾经是一个难题。由于平台即服务(platform as a service, Paas)、容器技术(如 docker)、基础设施即代码(infastructure as code, TaC)和分布式版本控制系统等技术的出现,现在有可能在代码中描述和贯彻落实分配模型了。
将代码视为基础设施,为静态分析创造了机会。利用云平台的自动化构建和部署管道,我们可以将自动架构检查引入部署过程。大多数平合即服务(Paas)产品都可以测试硬件分配限制。我们还可以使用配置和自动化来贯彻落实硬件伸缩和平台配置。
与物理硬件和传统虚拟机相比,容器是轻量级的、可丢弃的。使用容器,可以采用简单且易于贯彻落实的分配模式,例如每个容器安装一个进程。
分布式版本控制与基于 Web 的工具(如 Github)相结合,可以轻松地将特定的架构组件分配给某个团队开发,同时保持开放的协作氛围。Fork 和 Pull 上游仓库虽然会受限制,但不会阻止协作。
8.3.4 添加代码注释
在代码中贯彻落实模型,能做的就只有以上这些了。我们可以贯彻落实设计决策,但代码的结构不会告诉我们为什么要这么做决策。我们可以通过良好的命名和对已知模式的恰当使用,为代码注入更多的逻辑性,但如果还想更进一步,就得靠代码注释了。
代码注释可以有多种形式,比如描述逻辑和理由,或者给出已有设计文档的的链接。即使异常消息也可以包含注释。最好能简要介绍错误背后的设计原理,避免采用一般性的错误描述。例如,写”未知错误“就不如写”ASSUMPTION_VIOLATED: 须提供文档 ID 进行验证“有效。
8.3.5 用代码生成模型
我们还可以用代码自动生成系统模型。如果选择了合适的编程语言、技术、模式,就可以使用模型来自动验证落实情況并监控设计演变。
所有现代面向对象编程语言都可以生成 UML 类图和包图。大多数编程语言都有依赖分析工具。使用这些工具生成的模型可以分析模块结构。
组件连接器结构较难自动生成。要生成组件连接器结构必须添加分析工具以便观察运行时模型。然后就可以用记录的数据生成模型并分析架构的贯彻落实情况。
结束语
本章依旧是硬核方法论。介绍了什么是模型,以及如何描述模型。