寻找架构驱动力
人类自开始学会以智慧洗亮观察世界的双眼之后,就明白观察事物不能浅尝辄止停留在表面现象,而要去看透本质。通过本质规律去建模世界,才能以“一”推演万物。种种推演的过程,皆是要去寻找某种驱动力量作为分析或建构的起点。
例如,当我们要分析一个运动中的物体会形成如何的运动轨迹时,就需要寻找产生运动的力,包括初始的动力、重力、摩擦力以及其他可能干扰物体运动的力。有的力会推动者物体向前,例如初始动力以及与运动方向保持一致的作用力;有的力会阻碍物体的运动,如摩擦力或者空气阻力等。通过分析这些力的方向及度量,大致可以描绘出物体可能的运动轨迹。
软件系统的复杂度远远超过物体的运动模型(当然,从确定性角度讲,软件或许比物体的运动更简单),但其推演的过程却是相似的,因为一个软件系统并非完全独立的存在,而是处在一个更大的生态环境圈中,包括客户的需求与使用体验、上游依赖系统、下游依赖系统、硬件与网络环境、团队技能水平等诸多因素纵横交错,显式或隐式地对软件架构的走向施加影响。这些影响因素就相当于是影响“软件”这个物体运动的力量。架构师要做的工作就是要敏锐地从这些纷繁复杂如蛛网一般纠缠的力量中梳理出清晰的脉络。
所谓“力”,其实是一种隐喻。虽然观察软件系统的视角如万花筒一般缤纷多彩,然而若从“物理力学”的视角剖析架构,似乎更加准确直接。软件系统正如物体一般,在各种影响力之下不停变化(运动)。不同的影响因素会决定着架构师的设计决策,而这些决策之间又相互影响着,或者相吸,或者相斥,绝对不能孤立看待。于是,架构分析与设计就变成了对软件系统的影响力识别,这种设计的驱动力即我们所谓的RAID分析法。
RAID分析法
所谓RAID分析法,即识别软件系统的风险(Risk)、假设(Assumption)、问题(Issue)、依赖(Dependency),准确地说,就是:
- 评估风险
- 明确假设
- 分析问题
- 识别依赖
正如在《架构之美》中John Klein、David Weiss写道:
软件架构师的首要关注点不是系统的功能。……你关注的是需要满足的品质。品质关注点指明了功能必须以何种方式交付,才能被系统的利益相关人所接受,系统的结果包含这些人的既定利益。
这里所谓的“品质”,即我们常说的质量属性(Quality Attribute)。对于架构师而言,业务需求导致设计复杂度的增加仅仅是一种量的变化;而质量属性对设计的要求,则可能随着复杂度的增加而产生质变。以分布式系统为例,随着对消息队列、分布式存储、服务通信与集成的引入,在数据一致性、可靠性、安全、运维管理等诸多方面,产生的复杂度与单机系统不可同日而语,设计挑战与难度几乎与规模形成指数增长。
系统复杂度或许是没有限制的,而人力却有限。我们在开始软件系统的建构与设计时,难免有考虑不周到之处,若是没有掌握合理的设计方法而深陷浩瀚如沧海一般的各种需求中,牵扯到各个利益相关者的纠缠中,我们就可能会迷路、困惑,或者作出不适合当下场景的设计决策。
RAID分析在一定程度上可以帮助我们重拾正确的方向,尤其在处理质量属性方面,颇有奇效。
我的建议是将RAID分析以Workshop的形式开展,召集团队成员通过头脑风暴来完成。由于将所有软件系统可能面临的问题分为了RAID四类,从而明确了讨论的范围与类别,使得参与者能够以更加收敛更加清晰的思路参与进来。一个典型的RAID分析结果如下图所示:
在进行RAID分析之前,我们需要明确这四个概念之间的区别。
风险与问题
风险(Risk)与问题(Issue)常常被人混淆在一起,而二者在概念上却有其相关性。风险其实就是未来可能出现的问题。我们在软件设计的过程中,一直都在未来与现实中徘徊。满足现实,却又需要预测未来。然而,未来是不可预测的,所有的预测其实都是一种想象;我们夸夸其谈预测未来,其实不过是想象未来罢了。于是,现实与未来之间就开始了痛苦的拉锯战,我们既不能对未来做过多预测与判断,却又不能仅满足于现状,如何做到架构设计的恰如其分,在规避过度设计的同时,又能让我们的架构能够在未来需求发生变化时以最小的成本应对。我们真正要做到的是前瞻未来,评估风险就是让我们能够前瞻未来的瞭望镜(这世上并没有预测未来的魔法水晶球)。
分析现在存在的问题,评估未来风险,将是这场拉锯战的关键制高点。在判定优先级时,问题往往高于风险,需要在解决现有问题的前提上,考虑未来风险的应对方案。譬如说,系统目前存在的问题是性能堪忧,那么除了必要的调优手段外,我们可以通过提高系统的可伸缩性来改进性能。然而,要保证系统的可伸缩性,就需要保持服务的无状态,并在设计系统的各个分层时都需要支持水平扩展,则可能引入数据不一致以及系统欠稳定的风险。
假设
我们往往会忽略为系统给定假设(Assumption),而事实上,这种假设往往代表了关键的架构约束。
架构约束是一种非常重要的驱动力。Roy Fielding在其论文Architectural Styles and the Design of Network-based Software Architectures(《架构风格与基于网络的软件架构设计》)中如此勾勒出约束的重要性:
属性是由架构中的一组约束所导致的。约束往往是由在架构元素的某个方面应用软件工程原则来驱动的。例如,统一管道和过滤器(uniform pipe-and-filter)风格通过在其组件接口之上应用通用性原则——强迫组件实现单一的接口类型,从应用中获得了组件的可重用性和可配置性的品质。因此,架构约束是由通用性原则所驱动的“统一组件接口”,目的是获得两个想要得到的品质,当在架构中实现了这种风格时,这两个品质将成为可重用和可配置组件的架构属性。
我们在明确假设时,需要将这些约束甄别出来,以之作为架构设计的驱动力。例如,对于一个移动APP,我们明确假设:用户在断开网络连接时,能够正常地查阅个人信息与产品信息。这个假设就对软件架构提出约束,即在APP的客户端需要缓存数据信息,并在用户连接WIFI时,能够自动同步客户端数据到服务端。
某些假设则是系统功能性的重要约定,好似契约一般,需要在整个设计与实现阶段需要遵从。例如假设电商系统需要调用的推荐系统为第三方系统,那么在设计时就需要明确推荐系统公开的接口,系统之间如何集成,当推荐系统的服务发生变更时,客户方该如何应对。这些都会直接影响我们的设计决策。
依赖
在软件设计中,我们无时不刻不在与依赖作斗争。依赖本身是无善无恶的,关键在于我们该如何分解(内聚),如何协作(耦合),这就是我们需要遵循的高内聚低耦合设计原则。在架构层面,情况更显复杂,除了系统内部的依赖之外,还需要考虑系统外部上游与下游的依赖。尤其是跨越物理边界(可以视为一个进程)之间的通信,会直接影响到可靠性、性能、可伸缩性等诸多质量属性。
DDD的Context Map定义了九种Bounded Context之间的映射关系,其中包括防腐层、开放主机服务与发布语言表达的就是Bounded Context之间的集成关系。如果我们能够在架构之处识别出系统存在的依赖,再结合Cockburn提出的六边形架构对其进行更加直观的可视化,找出依赖途经的端口(Port)与适配器(Adapter),然后确定依赖之间的通信(集成)方式,几乎就可以得出整个软件系统应用逻辑架构与物理架构的雏形了。
下图将六边形架构与识别的依赖结合起来:
实施RAID分析的案例
在多个系统的架构设计或Inception阶段,我通过运用RAID分析法驱动系统的软件架构设计,效果颇佳,虽然在细节处还欠缺精细,但从大处着手,却可以帮助我们高屋建瓴地分析与架构整个系统。以下是针对某版本升级系统的RAID分析案例。
评估风险
通常而言,对风险的识别可以引导我们对系统质量属性的思考,利益相关者可
以充分表达对这些属性的担心,从而驱动我们去寻找解决方案。
稳定性
在这次RAID分析中,有利益相关者明确提出了对稳定性的担忧。系统的多个模块驻留在不同的节点中,部分模块还是以嵌入方式驻留在主控板上。由于业务需要,模块之间的通信相对频繁,主要的通信协议为Telnet与SSH。从旧有的系统表现来看,跨界点之间的通信在稳定性方面表现欠佳。基于这一问题,我们在后续的架构设计中对此进行了深入分析,除了保证通信实现自身的健壮性与异常处理之外,我们还决定在主控板一端设计粗粒度的接口,一次性地传递版本升级需要的信息,减少不必要的通信。
可扩展性
风险对扩展性的识别,帮助我们确立了一个架构原则,就是版本规格包的结构不应该影响到主控板的系统。这是因为主控板系统的版本升级受到的制约最多,我们不希望当产品发生变化时,影响整个版本管理系统。
性能
当需要升级的系统数量较多时,系统的版本升级过程会变得缓慢。而业务需求有要求了系统不能长期处于shutdown状态,否则会增加运营成本。因此,升级过程通常会选在凌晨,并且要求在较短时间内完成整个升级工作,故而性能可谓重中之重。
我们考虑采用并发方式为每个待升级系统进行升级。升级过程是一个独立的过程,却又牵涉到较为复杂的业务流程以及跨节点通信。由于部署限制,后台只能部署在一个JVM之上,通过启动多个并发线程来处理升级业务。执行升级时,需要加载配置文件到内存中,若同时启动的线程数过多,则可能导致OutOfMemory异常。这个风险的识别及时地为我们敲响了警钟。我们为此安排了技术Spike,以期找到合适的配置项,在性能与可靠性之间进行最优权衡。
明确假设
假设(Assumption)可以是关键的架构约束,也可以是系统功能性的约定。架构约束既可能是设计的阻力,也可以成为动力。经过讨论,我们基本上确定了两条最为重要的假设:
- 系统必须支持双向兼容。这个假设的提出,则要求我们在开发过程中,只要接口已经发布,就不能再修改接口。除修复缺陷外,我们不能删除旧有功能,只能增加新功能。即使旧有功能已被新功能取代,为保持兼容性,我们也不能删除,但可以将其置为@deprecated标注。
- 版本升级过程中,若前后操作具有依赖关系,则必须保证事务的一致性,要么全部成功,要么全部失败。事实上,这一条假设也是对质量属性“可靠性”的一个回应。
分析问题
整个RAID的识别都针对技术层面,而非管理层面。因此我们识别的问题也限
制在技术范围。
在我们识别出来的问题中,最致命的一个问题是关于模块NVUM的加载。NVUM是一个JAR包。它并非一个独立运行的系统,而是由管理系统动态加载。之所以选择动态加载,而非静态依赖,原因包括:
- NVUM由我们项目组维护,管理系统则属于另外一个项目,两边的版本计划完全不一致。网管系统为一个Client-Server系统,相对成熟,目前已被独立地部署到全球多个外场。若采用静态依赖,就需要我们将其纳入到网管系统中。但NVUM的版本更新更加频繁,外场不可能因为NVUM一个模块的调整,而付出频繁更新管理系统的代价。
- 管理系统负责监控外场各设备的运转状况。虽然系统的重启(耗时数十分钟)并不会影响设备的功能,但却可能在重启过程中,因为未能及时掌控设备状态,而导致无法及时发现问题。必须避免这种事故的发生。换言之,管理系统的重启代价太高,不能经常重启。
JAR包的动态加载可以通过URLClassLoader来实现,又或者选择OSGI。前者需要充分验证其稳定性,后者则过于重型,成本太高。另外,动态加载方式对于模块设计而言存在设计约束,即我们需要将NVUM分为interface和impl两个模块,且必须保证interface的稳定性。
另一个方案是采用脚本,例如选择能够运行在JVM上的Groovy脚本语言。我们只需要在Java中调用Groovy提供的GroovyShell,就能直接读取groovy脚本文件;然后调用run()方法即可执行脚本。
识别依赖
除了NVUM与管理系统,NVUM与主控板,主控板与其他设备之间的依赖外,牵涉到的依赖还有很多。有的属于输入依赖,有的则属于输出依赖。此外,还有版本制作工具等系统也会受到NVUM的影响。同时,NVUM还需要访问内建的文件系统,通过FTP读取诸多外部文件。通信则可能采用Telnet、SNMP、SSH等多种协议。
这些依赖的识别便于确定本系统对其他系统可能造成的影响,事先识别有利于我们及时做好沟通,同时还需要就一些架构约定以及接口定义达成一致意见。依赖的识别也有利于我们设计系统的物理架构,考虑系统的部署方式。