1.5 架构适应度函数
在架构师确定了组件之间的关系并用代码实现其设计之后,应该如何保证其他的实现者会遵从设计呢?更宽泛地说,如果不亲手实现的话,架构师应该如何保证他们设立的设计原则成为现实呢?
这些问题都可以归类为架构治理,适用于对软件开发的任何有组织的监管。因为本书主要讲的是架构结构,所以我们会在很多地方介绍如何通过适应度函数自动化设计和质量原则。
软件开发随着时间缓慢地演变,以适应特定的工程实践。在早期的软件开发中,经常用制造业来类比软件实践,无论是大型的(例如瀑布开发过程)还是小型的(项目的集成实践)。在20世纪90年代早期,Kent Beck和C3项目[1]上的其他工程师发起了对软件开发工程实践的重新思考,他们叫它极限编程(XP),详述了增量反馈和自动化对软件开发生产效率的关键作用。在21世纪初,软件的开发和运维碰撞出了同样的思维火花,催生了DevOps这个新角色,同时自动化了很多曾经需要手工运行的工作。就像以前一样,自动化让团队前进得更快了,因为团队成员不用再担心什么东西突然悄无声息地出现问题。因此,自动化和反馈成为高效软件开发的信条。2
让我们回想致使自动化有了突破性进展的环境和场景。在持续集成(CI)出现之前,绝大部分软件项目都要经历漫长的集成阶段。每个开发人员被认为需要在一定程度上独立于其他人工作,然后在最后的集成阶段汇总所有的代码。这种实践的余音还萦绕在版本控制工具里,强制分支从而阻止持续集成。不出意外,项目规模和集成之痛这两者之间存在强关联。通过开创持续集成,XP团队用事实证明了快速、持续的反馈所具有的价值。
DevOps的变革有一段相似的历程。随着Linux和其他开源软件对企业来说变得“足够好”,加上可以程序化定义(终于有了)虚拟机的工具的降临,运维人员意识到他们可以自动化机器定义和其他很多重复工作。
在这两种场景里,得益于技术的发展和洞察力,昂贵人力进行的重复性工作被自动化所取代,这也正是当前在大多数组织里架构治理的现状。举例来说,如果架构师选择了一种特定的架构风格或者通信媒介,如何确保开发人员可以正确地实施呢?如果手动去保证,架构师需要组织代码审查或者召开架构审查委员会来评估治理状态。然而,就如计算机里的手动配置一样,重要的细节很容易被浮于表面的审查所忽略。
使用适应度函数
在2017年出版的Building Evolutionary Architectures(O'Reilly)一书中,作者(Neal Ford、Rebecca Parsons和Patrick Kua)定义了架构适应度函数:它是能够对于某个(或一组)架构特征进行客观的完整性评估的任何机制。
任何机制
架构师可以使用多种不同的工具来实现适应度函数,本书中将会演示非常多的例子。例如,有专门用来测试架构结构的测试库,架构师可以使用监控设备测试运维性架构的特征(例如性能或可伸缩性),使用混沌工程框架测试可靠性和弹性。
客观的完整性评估
自动化治理的一个关键点便是架构特征的客观定义。举例来说,架构师不能说我想要一个“高性能”的网站,而必须提供一个可以被测试、监控或者其他适应度函数来测定的目标值。
架构师必须警惕那些复合的架构特征,它们本身不能被客观地度量,却由其他可以度量的东西组成。例如,“敏捷度”是不可度量的,但是如果把宽泛的“敏捷度”拆解开,它的目标是无论是在生态系统还是领域中,团队都能够快速响应、大胆应变。从而架构师可以找到组成敏捷度的可度量特征:可部署性、可测试性、周期等。通常,缺乏度量某个架构特征的能力是因为这个定义本身过于模糊。如果架构师致力于寻求可度量属性,那么他们就可以自动化适应度函数应用程序。
某个(或一组)架构特征
这里的特征描述的是适应度函数的两种类型:
原子的
这类适应度函数在隔离条件下验证单一架构特征。例如,在单个代码库里检查组件周期的适应度函数在这个范围内是原子的。
整体的
整体适应度函数验证一组架构特征。架构特征复杂化的一个征兆是它们有时和其他架构特征协同出现。例如,如果架构师想要增强安全性,则很可能会影响性能。类似地,可伸缩性和弹性可能是互相矛盾的——支持大量并发用户可能会使得突发流量更加难以处理。整体适应度函数运用一组连锁的架构特征来确保其综合效应不会给架构带来负面影响。
架构师利用适应度函数来保护架构特征不被改变。在敏捷软件开发的世界里,程序员编写单元、功能和用户验收测试,以此来验证领域设计的不同维度。然而,直到现在,没有类似的机制来验证设计的架构特征部分。事实上,区分适应度函数和单元测试给架构师提供了很好的界定范围的方式。适应度函数验证架构特征,而不是领域标准;单元测试恰恰相反。因此,架构师可以通过问题“执行这个测试需要任何领域知识吗?”来决定使用哪种测试,如果答案是需要,则使用单元、功能和用户验收测试;如果不需要,则使用适应度函数。
例如,当架构师说到弹性时,指的是应用程序承受用户激增的能力。注意,架构师不需要知道任何领域细节,因此,弹性是一个架构考量,在适应度函数的范畴内。与此相反,如果架构师想要验证一个邮寄地址的各个有效部分,这就是传统测试的范畴了。当然,这种区分不是绝对的,一些适应度函数会触及领域,反之亦然,但是其目标的差异性提供了一个很好的思维方式来区分它们。
为了使概念不那么抽象,下面举几个例子。
架构师常见的目标之一就是在代码库中保持良好的内部结构完整性。然而,黑暗势力在各种平台上和架构师的美好意图作对。例如,在任何流行的Java或 .NET开发环境中编写代码时,一旦程序员使用了任何还没引入的类,IDE(集成开发环境)就会善解人意地冒出一个提示框,询问是否需要自动引入这个引用。这个场景太常见了,以至于大部分程序员养成了顺手确认自动引入以便让提示框快点消失的习惯。
然而,在组件间随意地引用类或组件给模块化带来了灾难性的问题。例如,图1-1展示了一个架构师想极力避免的典型的破坏性反模式。
图1-1:组件之间的循环依赖
在这个反模式中,每个组件都依赖其他组件。这样的组件网络会破坏模块化,因为开发者不能单独复用某一个组件,而是必须带上其他组件。如果其他组件也这样和另外的组件耦合,那么架构最终将杂糅成另一个反模式:大泥球(Big Ball of Mud,https://oreil.ly/usx7p)。除了一直在背后监视那些迫不及待敲回车的程序员以外,架构师该怎么管理这样的行为呢?代码审查可以起到一些作用,但是因为发生的太迟而鲜有成效。如果架构师允许开发团队在代码库里胡乱引用,到一周后的代码审查时,一些严重的破坏早已发生。
这个问题的解决办法就是编写一个适应度函数,来阻止组件循环,如示例1-1所示。
示例1-1:用于检测组件循环的适应度函数
上述代码中,架构师用度量工具JDepend(https://oreil.ly/ozzzk)来检查包与包之间的依赖。这个工具可以理解Java包结构,当存在循环依赖的时候,不让测试通过。架构师可以把这个测试加入项目的持续构建里,这样就不用担心这些手速惊人的程序员不经意引入循环依赖了。这是个很好的例子,证明适应度函数可以捍卫那些重要但不紧急的软件开发实践。对于架构师来说,这是一个重要的问题,而且几乎不会影响日常编码。
示例1-1是一个非常底层、以代码为中心的适应度函数。很多流行的代码整洁工具(例如SonarQube,https://www.sonarqube.org)实现了很多可供一键式使用的适应度函数。然而,与微服务架构一样,架构师可能也想验证架构的宏观结构。当设计如图1-2所示的分层架构时,架构师为了隔离关注点定义了不同的层。
图1-2:传统的分层架构
然而,架构师又该如何保证开发人员会遵从这样的分层呢?一些开发人员可能不理解模式的重要性,为了解决一些棘手的局部问题(比如性能),另外一些人可能会秉持“先斩后奏”的态度。允许实施者破坏架构的根基会伤害其长期健康。
ArchUnit(https://www.archunit.org)允许架构师通过适应度函数来解决这一问题,如示例1-2所示。
示例1-2:ArchUnit适应度函数治理分层
在示例1-2中,架构师定义所需的层之间的关系,然后用一个验证适应度函数来管理它。这使得架构师能够在图表及其他信息性工件以外建立架构原则,并且可以持续验证它们。
在.NET领域有一个类似的工具NetArchTest(https://oreil.ly/EMXpv),在其平台上可以运行类似的测试。示例1-3展示了一个C#中的分层校验。
示例1-3:用于检查层依赖的NetArchTest
这个领域中新工具持续出现,且愈加先进。随着本书很多解决方案用到适应度函数,我们将会持续关注这项技术。
确定适应度函数的客观结果是至关重要的。客观并不意味着静态。一些适应度函数会有和上下文无关的返回值,例如true/false或数值(如性能阈值)。然而,其他适应度函数(被视为动态的)的返回值是由上下文决定的。例如,当度量可伸缩性(scalability)时,架构师既度量并发用户的总量,也度量单个用户的性能。正常情况下,在架构师设计的系统中,用户数量增加时,每个用户的性能会略微降低(但不是断崖式下跌)。因此,对于这些系统,架构师会考虑并发用户的总量来设计性能适应度函数。只要一个架构特征的度量是客观的,架构师就能测试它。
尽管大多数适应度函数应该是自动化并持续运行的,但还是存在一些必须要手动运行的情况。手动适应度函数需要人来进行验证。举例来说,对于含有敏感法律信息的系统,需要律师去审查关键部分的变化以保证其合法性,这就没办法自动化。大多数部署流水线支持手动阶段,允许团队安排手动适应度函数。理想情况下,它们最好尽可能频繁地运行——不运行的验证什么也验证不了。团队要么按需运行这些适应度函数(较少见),要么把它作为持续集成工作流的一部分(最常见)。想要尽可能从适应度函数这类校验中获益,就应该持续地运行适应度函数。
下面这个使用适应度函数来做企业级治理的例子告诉我们,持续性很重要。考虑这个场景:假如企业正在使用的某个开发框架或者库发现了一个零日漏洞(zero-day exploit),这时该怎么办?在大多数公司里,安全专家会把用到这个有问题的版本的地方翻个底朝天,确保它们都更新了。然而这个过程很少是自动化的,大都依赖于很多人工步骤。这不是一个抽象问题,一家主要的金融机构的Equifax,就因为上述这般流程导致了数据泄露事件。如前面描述的架构治理问题所述,人工环节容易出错还容易忽略细节。
Equifax数据泄露事件
2017年9月7日,Equifax(美国一家信用评分机构)发表公告称其发生了数据泄露。最终,这个问题被追溯到有人利用了Java生态中流行的Struts Web框架的一个漏洞(Apache Struts vCVE-2017-5638)。基金会发表了一项声明告知了这个漏洞并在2017年3月7日发布了补丁。美国国土安全部第二天就联系了Equifax和其他类似公司,警示它们有此问题。它们也在2017年3月15日进行了扫描,但并没有发现所有受到该影响的系统。因此,直到2017年7月29日这天,很多老系统也没有应用这个至关重要的补丁,此时Equifax的安全专家监测到了黑客行为,致使这次数据泄露事件发生。
想象有另外一个平行世界,在那里每个项目都运行着一个部署流水线,安全团队在每个团队的部署流水线上都有个“槽”,可以部署自己的适应度函数。大多数时间,这就是一些平常的安全检查,防止开发人员把密码存在数据库里,以及诸如此类的日常管理琐事。然而,一旦有零日漏洞出现,这个机制可以让安全团队在每个项目里插入一个测试来查验某个特定框架及其版本号。如果发现了有危险的版本,就会使构建失败并通知安全团队。团队配置流水线让其响应系统中的任何变化:代码、数据库模式、部署配置以及适应度函数。如此,企业便可以全面自动化重要的治理任务。
架构师可以从适应度函数获益良多,最重要的是他们有机会重新编写代码!架构师最常抱怨的就是他们几乎没时间写代码——但是适应度函数基本都是代码!架构师必须理解这个系统及其未来的演进才能够搭建可执行的架构规范。任何人可以在任何时间通过运行项目的构建来验证这个规范。同时这也结合了架构师的核心目标,即随着项目增长持续跟进项目代码。
架构师应该避免过度使用强大的适应度函数。他们不应该蜷缩在象牙塔里筹谋一个复杂到不可思议、环环相扣的适应度函数,那样只会让开发人员和团队感到沮丧和挫败。相反,适应度函数可以是架构师为软件项目构建重要而不紧急原则的可执行清单的一种方式。很多项目十万火急,允许忽略一些重要的原则。这通常是技术债的来源:“我们知道这样做不好,回头就改”——然后永不回头。通过把代码质量、架构和其他保护措施写成适应度函数代码,并持续运行,架构师可以构建一个开发人员无法跳过的质量清单。
几年前,Atul Gawande写了一本杰出的书——The Checklist Manifesto(Picador出版社),书中重点介绍了外科手术医生,飞行员等专业人士和其他领域的人员对清单的使用,清单(checklist)在他们的工作中非常重要(有时是法律要求)。这不是因为他们不了解自己的工作或者特别健忘,当专业人士一遍一遍地重复相同的任务时,很容易无意中跳过一步而不自知,清单正是用来防止此事发生的。适应度函数代表了架构师定义的重要原则清单,作为构建中的一部分用于保证开发人员不会无意或有意地忽略它们。
在本书中,当有机会演示如何让架构解决方案和最初设计保持一致时,我们就会使用适应度函数。