“架构”这个词给人的直观感受就是充满了权力和神秘感,因此谈论架构总让人有一种正在进行责任重大的决策或者深度技术分析的感觉。而软件架构师的工作内容究竟是什么呢?
软件架构师自身需要是程序员,并且必须一直坚持做一线程序员! 软件架构师应该是能力最强的一群程序员,他们通常会在自身承接编程任务的同时,逐渐引导整个团队向一个能够最大化生产力的系统设计方向前进。也许软件架构师生产的代码量不是最多的,但是他们必须不停地承接编程任务。
软件系统的架构质量是由它的构建者所决定的,软件架构这项工作的实质就是规划如何将系统切分成组件,并安排好组件之间的排列关系,以及组件之间互相通信的方式。而设计软件架构的目的,就是为了在工作中更好地对这些组件进行研发、部署、运行以及维护。
如果想设计一个便于推进各项工作的系统,其策略就是要在设计中尽可能长时间地保留尽可能多的可选项。 一个软件系统的架构质量和该系统是否能正常工作的关系并不大,毕竟世界上有很多架构设计糟糕但是工作正常的软件系统。真正的麻烦往往并不会在我们运行软件的过程中出现,而是会出现在这个软件系统的开发、部署以及后续的补充开发中。
软件架构设计的主要目标是支撑软件系统的全生命周期,设计良好的架构可以让系统便于理解、易于修改、方便维护,并且能轻松部署。软件架构的终极目标就是最大化程序员的生产力,同时最小化系统的总运营成本:
(1) 开发:一个开发起来很困难的软件系统一般不太可能会有一个长久、健康的生命周期,所以 系统架构的作用就是要方便其他开发团队对它的开发 ;
(2) 部署:为了让开发成为有效的工作,软件系统就必须是可部署的,通常情况下,一个系统的部署成本越高,可用性就越低,因此, 实现一键式的轻松部署是设计软件架构的一个目标 ;
(3) 运行:软件架构对系统运行的影响远不及它对开发、部署和维护的影响。几乎任何运行问题都可以通过增加硬件的方式来解决,这避免了软件架构的重新设计,即使是这样,软件架构在整个系统运行的过程中还发挥着另外一个重要作用,那就是 一个设计良好的软件架构应该能明确反映该系统在运行时的需求。换句话说,设计良好的系统架构可以使开发人员对系统的运行过程一目了然,架构应该起到揭示系统运行过程的作用。 具体来说,就是该架构应该将系统中的用例、功能以及该系统的必备行为设置为对研发者可见的一级实体,简化它们对于系统的理解,这将为整个系统的开发与维护提供很大的帮助;
(4) 维护:在软件系统的所有方面中,维护所需的成本是最高的。满足永不停歇的新功能需求,以及修改导出不穷的系统缺陷这些工作将会占去绝大部分的人力资源。我们可以 通过精雕细琢的架构设计极大地降低这两项成本,通过将系统切分为组件,并使用稳定的接口将组件隔离,我们可以将未来新功能的添加方式明确出来,并大幅度地降低在修改过程中对系统其他部分造成伤害的可能性 ;
(5) 保持可选项:如前文所述,软件有行为价值和架构价值两种价值,而架构价值是保持软件“软”的原因,而让软件“软”的方法就是尽可能长时间地保留尽可能多的可选项。基本上,所有的软件系统都可以降解为策略与细节这两种主要元素,策略体现的是软件中所有的业务规则与操作过程,它是系统真正的价值所在,而细节则是指那些让操作该系统的人、其他系统以及程序员们与策略进行交互,但是又不会影响到策略本身的行为,包括I/O设备、数据库、Web系统、服务器、框架、交互协议等。这些“细节”,就是所谓的“可选项”。 软件架构师的目标是创建一种系统形态,该形态会以策略为最基本的元素,并让细节与策略脱离关系,以允许在具体决策过程中推迟或延迟与细节相关的内容。 例如,软件的高阶策略不关心数据库的类型、不需要考虑是不是以网页形式展示、不需要过早地考虑采用微服务框架还是SOA等,应在开发高层策略时有意地让自己摆脱具体细节的纠缠,将与具体实现相关的细节推迟或延后,因为越到后期,会有越多的信息来支撑这些细节的合理决策。 一个优秀的软件架构师应该致力于最大化可选项的数量 ;
(6) 设备无关性:最重要的例子就是早期在使用C语言编写程序时,需要根据硬件类型编写不同的程序,即兼容不同的硬件设备,这当然会使得程序的兼容性变得非常困难,因此好的架构设计应该是设备无关的;
如前文所述,一个设计良好的软件架构必须支持以下几点:
(1) 系统的用例与正常运行:一个系统的架构必须能支持其自身的设计意图,也即,如果某系统是一个购物车应用,那么该系统的架构就必须非常直观地支持这类应用可能会涉及到的所有用例。开发人员不需要在系统中查找系统所应有的行为,这些行为应该在系统顶层作为主要元素已经是明确可见的,这些元素会以类、函数或模块的形式在架构中占据明显位置,它们的名字也能够清晰地描述对应的功能;
(2) 系统的运行:系统架构应该支持系统运行的实际诉求,如100000 TPS、毫秒级大数据仓库查询等。系统架构应该能够在组件之间做一些适当的隔离,同时不强制规定组件之间的交互方式,这样系统就可以随时根据不断变化的运行需求来转换成各种运行时的线程、进程或服务模型;
(3) 系统的开发:一个由多个不同目标的团队协作开发的系统必须具有相应的软件架构,这样,这些团队才可以各自独立地完成工作,不会彼此干扰,这就需要恰当地将系统切分成为一系列隔离良好、可独立开发的组件,然后才能将这些组件分配给不同的团队,各自独立开发;
(4) 系统的部署:架构设计的目标是实现“立刻部署”或“一键部署”,一个设计良好的软件架构可以让系统在构建完成之后立刻就能部署,而不是依赖于成堆的脚本与配置文件,也不需要手动创建一堆“有严格要求”的目录与文件。这也可以通过正确地划分、隔离系统组件来实现,这其中也包括开发一些主组件,让它们将整个系统黏合在一起,正确地启动、连接并监控每个组件;
(5) 保留可选项:一个设计良好的架构应该通过保留可选项的方式,让系统在任何情况下都能方便地做出必要的变更;
从用例的角度来看,架构师的目标是让系统结构支持其所需要的所有用例。但是问题恰恰是我们无法预知全部的用例。好在架构师应该还是知道整个系统的基本设计意图的,所以架构师可以通过采用单一职责原则(SRP)和共同闭包原则(CCP),以及既定的系统设计意图来隔离那些变更原因不同的部分,集成变更原因相同的部分。
如果我们按照变更原因的不同对系统进行解耦,就可以持续地向系统内添加新的用例,而不会影响旧有的用例。如果不同面向之间的用例得到了良好的隔离,那么需要高吞吐量的用例就和需要低吞吐量的用例互相自然分开了。
一个设计良好的架构应该能允许一个系统从单体结构开始,以单一文件的形式部署,然后逐渐成长为一组相互独立的可部署单元,甚至是独立的服务或者微服务,最后还能随着情况的变化,允许系统逐渐回退到单体结构。并且,一个设计良好的架构在上述过程中还应能保护系统的大部分源码不受变更影响。对整个系统来说,解耦模式也应该是一个可选项,我们在进行大型部署时可以采用一种模式,而在进行小型部署时则可以采用另一种模式。
软件架构设计本身就是一门划分边界的艺术,边界的作用是将软件分割成各种元素,以便约束边界两侧之间的依赖关系。其中有一些边界是在项目初期——甚至在编写代码之前——就已经划分好,而其他的边界则是后来才划分的。在项目初期划分这些边界的目的是方便我们尽量将一些决策延后进行,并且确保未来这些决策不会对系统的核心业务逻辑产生干扰。
架构师们所追求的目标是最大限度地降低构建和维护一个系统所需的人力资源,而最消耗人力资源的是系统中存在的耦合——尤其是那种过早做出的、不成熟的决策所导致的耦合——即那些与系统的业务需求(也就是用例)无关的决策,例如要采用的框架、数据库、Web服务器、工具库、依赖注入等,在一个设计良好的系统架构中,这些细节性的决策都应该是辅助性的,可以被推迟的。一个设计良好的系统架构不应该依赖于这些细节,而应该尽可能地推迟这些细节性的决策,并致力于将这种推迟所产生的影响降到最低。
边界线应该画在那些不相关的事情中间,例如GUI与业务逻辑无关,所以两者之间应该有一条边界线,又例如数据库与GUI无关,这两者之间也应该有一条边界线,再例如数据库与业务逻辑无关,所以两者之间也应该有一条边界线。
如下图所示:
BusinessRules是通过DatabaseInterface来加载和保存数据的,而DatabaseAccess则负责实现该接口,并与实际的Database交互,很明显,作为业务层的BusinessRules和DatabaseInterface并不需要知识作为数据库层的DatabaseAccess和Database的具体实现,BusinessRules只需要知识DatabaseInterface有哪些接口即可,而具体的数据库查询和写入方法则由DatabaseAccess来实现。
于是进行组件划分时,则可以如下划分:
注意看,并不是BusinessRules组件依赖Database组件,而是Database组件依赖BusinessRules组件,这意味着Database组件不会对BusinessRules组件形成干扰,但Database组件却不能脱离BusinessRules组件单独存在,那么也就实现了业务逻辑对数据库的解耦,数据库可以是Oracle,也可以是MySQL、Couch等,只要Database组件内的DatabaseAccess实现了BusinessRules组件中的DatabaseInterface接口即可。
这也反向印证了上文所述:好的架构设计中,业务逻辑是与数据库无关的。
再来看GUI和业务逻辑,上文我们讲了一个很重要的概念,即“ I/O是无关紧要的 ”,这个原则可能一开始比较难以理解,毕竟我们经常从直觉上会以I/O的行为来定义系统的行为,以视频游戏为例,我们的主观体验是以界面反应为主的,这些反应来自屏幕、鼠标、按钮和声音等,但不要忘了,这些界面的背后存在一个模型——一套非常复杂的数据结构和函数,那才是游戏真正的核心驱动力。更重要的是,该模型并不一定非要有一个界面。就算该游戏不显示在屏幕上,其模型也应该可以完成所有的任务逻辑,处理所有的游戏事件。因此,界面对模型——也就是业务逻辑来说——一点都不重要。
因此,业务逻辑BusinessRules和GUI组件之间的关系如下所示:
与数据库一样,BusinessRules组件并不依赖GUI组件,而是GUI组件依赖BusinessRules组件,因此GUI可以是Web,也可以是一个小程序,或者是Windows桌面程序,只要其实现了BusinessRules的业务诉求即可。
类似BusinessRules组件与GUI组件、Database组件这样的关系,我们称为插件式架构,软件开发技术发展的历史就是一个如何想方设法方便地增加插件,从而构建一个可扩展、可维护的系统架构的故事。系统的核心业务逻辑必须和其他组件隔离,保持独立,而其他组件可以像插件一样,随时插拨。
当系统设计为插件式架构,就等于构建起一面变更无法逾越的防火墙,换句话说,只要GUI是以插件形式插入系统的业务逻辑中的,那么GUI这边所发生的变更就不会影响系统的业务逻辑。
所以,边界线也应该沿着系统的变更轴来画,也就是说,位于边界线两侧的组件应该以不同原因、不同速率变化着。这其实就是单一职责原则(SRP)的具体实现,SRP的作用就是告诉我们应该在哪里画边界线。
为了将软件架构中画边界线,我们需要先将系统分割成组件,其中一部分是系统的核心业务逻辑组件,而另一部分则是与核心业务逻辑无关但负责提供必要功能的插件,然后通过对源代码的修改,让这些非核心组件依赖于系统的核心业务逻辑组件。
而这也是一种对依赖反转原则(DIP)和稳定抽象原则(SAP)的具体应用,依赖箭头应该由底层具体实现细节指向高层抽象的方向。
一个系统的架构是由一系列软件组件以及它们之间的边界共同定义的,而这些边界有着多种不同的存在形式。
在运行时,跨边界调用是指边界线一侧的函数调用另一侧的函数,并同时传递数据的行为。构造合理的跨边界调用需要我们对源码中的依赖关系进行合理管控。因为当一个模块的源码发生变更时,其他模块的源码也可能会随之发生变更或重新编译,并需要重新部署。所谓划分边界,就是指在这些模块之间建立这种针对变更的防火墙。
最简单、最常见的架构边界通常并没有一个固定的物理形式,它们只是对同一个进程、同一个地址空间内的函数和数据进行了某种划分,即源码层次上的解耦模式。
但是从部署的角度来看,这一切到最后都产生了一个单独的可执行文件——这就是所谓的单体结构。这个文件可能是静态链接形成的C/C++项目,或是一个将一堆Java类绑定在一起的jar可执行文件,或是由一系列.NET二进制文件组成的.EXE文件等。
虽然这类系统的架构边界在部署过程中并不可见,但这并不意味着它们就不存在或者没有意义,因为即使最终的组件都被静态链接成了一个可执行文件,这些边界的划分对该系统各组件的独立开发也是非常有意义的。
因为这类架构一般都需要利用某种动态形式的多态来管理其内部的依赖关系,这也是为什么面向对象编程近几十年来逐渐成为一种重要编程范式的原因之一。
最简单的跨边界调用形式,是由低层客户端来调用高层服务函数,这种依赖关系在运行时和编译时会保持指向一致,都是由低层组件指向高层组件,如下所示:
上图中控制流跨越边界的方向是从左向右的,Client调用了Service上的函数f(),并向它传递了一个Data实例。这里的标记是指Data是一个数据结构。Data实例的具体传递方法可以是函数的调用参数,也可以是其他更复杂的传递方式,Data的定义位于边界的被调用方一侧。
但当高层组件中的客户端需要调用低层组件中的服务时,我们就需要运用动态形式的多态来反转依赖关系了。在这种情况下,系统在运行时的依赖关系与编译时的依赖关系就是相反的,以下图为例:
控制流跨边界的方向与之前是一样的,都是从左至右的。这里的高层组件Client通过Service接口调用了低层组件ServiceImpl上的函数f()。但依赖关系是从右向左跨越边界的,方向由低层组件指向高层组件。这种情况下,数据结构的定义位于调用方一侧。
即使是在一个单体部署、静态链接的可执行文件中,这种自律式的组件划分仍然可以极大地帮助整个项目的开发、测试与部署,使不同的团队可以独立开发不同的组件,不会互相干扰。高层组件与低层细节之间也可以得到良好的隔离,独立演进。在单体结构中,组件之间的交互一般情况下都只是普通的函数调用,迅速而廉价,这就意味着这种跨源码层次解耦边界的通信会很频繁。由于单体结构的部署需要编译所有源码,并且进行静态链接,这就意味着这些系统中的组件一般都会以源码形式交付。
系统架构中最常见的物理边界形式是动态链接库,这种形式包括.Net的DLL、Java的jar文件、Ruby Gem以及UNIX的共享库等。这种类型的组件在部署时不需要重新编译,因为它们都是以二进制形式或其他等价的可部署形式交付的。这里采用的就是部署层次上的解耦模式,部署这种类型的项目,就是将其所有可部署的单元打包成一个便于操作的文件格式,例如WAR文件,甚至可以只是一个目录(或者文件夹)。
除这一点以外,这种按部署层次解耦的组件与单体结构几乎一模一样的,其所有的函数仍然处于同一个进程、同一个地址空间中,管理组件划分依赖关系的策略也基本上是一致的。
与单体结构类似,按部署层次解耦的组件之间的跨边界调用也只是普通的函数调用,成本很低,虽然动态链接或运行时加载的过程本身可能会有一个一次性的调用成本,但它们之间的跨边界通信调用依然会很频繁。
单体结构和按部署层次划分的组件都可以采用线程模型,当然,线程既不属于架构边界,也不属于部署单元,它们仅仅是一种管理并调度程序执行的方式,一个线程既可以被包含在单一组件中,也可以横跨多个组件。
系统架构还有一个更明显的物理边界形式,那就是本地进程。本地进程一般是由命令行启动或其他等价的系统调用产生的。本地进程往往运行于单个处理器或多核系统的同一处理器上,但它们拥有各自不同的地址空间。一般来说,现有的内存保护机制会使这些进程无法共享其内存,但它们通常可以用某种独立的内存区域来实现共享。
最常见的情况是,这些本地进程会用socket来实现彼此的通信,当然,它们也可以通过一些操作系统提供的方式来通信,例如共享邮件或消息队列。
每个本地进程都既可以是一个静态链接的单体结构,也可以是一个由动态链接组件组成的程序。在前一种情况下,若干个单体过程会被链接到同一个组件中。而在后一种情况下,这些单体过程可以共享同一个动态链接的可部署组件。
我们在这里可以将本地进程看成某种超级组件,该进程由一系列较低层次的组件组成,我们将通过动态形式的多态来管理它们之间的依赖关系。另外,本地进程之间的隔离策略也与单体结构、二进制组件基本相同,其源码中的依赖关系跨越架构边界的方向是一致的,始终指向更高层次的组件。
对本地进程来说,这就意味着高层进程的源码中不应该包含低层次进程的名字、物理内存地址或是注册表建名,该系统架构的设计目标是让低层进程成为高层进程的一个插件。
本地进程之间的跨边界通信需要用到系统调用、数据的编码和解码,以及进程间的上下文切换,成本相对来说会更高一些,所以这里需要谨慎地控制通信的次数。
系统架构中最强的边界形式就是服务,一个服务就是一个进程,它们通常由命令行环境或其他等价的系统调用来产生。服务并不依赖于具体的运行位置,两个互相通信的服务既可以处于单一物理处理器或多核系统的同一组处理器上,也可以彼此处于不同的处理器上。服务会始终假设它们之间的通信将全部通过网络进行。
服务之间的跨边界通信相对于函数调用来说,速度是非常缓慢的,其往返时间可以从几十毫秒到几秒不等。因此我们在划分架构边界时,一定要尽可能地控制通信次数,在这个层次上通信必须能够适应高延时的情况。
除此之外,我们可以在服务层次上使用与本地进程相同的规则。也就是让较低层次服务成为较高层次服务的“插件”。为此,我们要确保高层服务的源码中没有包含任何与低层服务相关的物理信息。
除单体结构以外,大部分系统都会同时采用多种边界划分策略,一个按照服务层次划分边界的系统也可能会在某一部分采用本地进程的边界划分模式。事实上,服务经常不过就是一系列互相作用的本地进程的某种外在形式。无论是服务还是本地进程,它们几乎肯定都是由一个或多个源码组件组成的单体结构,或者一组动态链接的可部署组件。这也意味着一个系统中通常会同时包含高通信量、低延迟的本地架构边界和低通信量、高延迟的服务边界。