15 What Is Architecture?
架构这个词,总会让人联想到权威和神秘感,他让我们想到那些举足轻重的决策和深厚的技术功底,软件架构位于技术成就的顶峰。一提起软件架构师,我们就会想到大佬们
可究竟什么是软件架构?软件架构师是做什么的?又是什么时候开展工作?
首先软件架构师本身就是程序员,并且始终都是程序员,千万别相信那种鬼话:说架构师要脱离代码,只专注高层问题,但是根本就不是这样!软件架构师是最顶尖的程序员,他们依然会承担编程任务,同时带领团队走向能最大化开发效率的设计方向。架构师写的代码可能不如普通程序员多,但是他们不会脱离编程。因为如果不亲身感受自己给程序员带来的问题,他们就无法做好本职工作。
软件系统的架构,就是构建者赋予系统的整体结构,这个结构体现为:把系统拆分成组件,组织这些组件的关系以及设计组件之间的通信方式。
设计这种结构的目的是为了让软件系统更易于开发,部署,运行和维护。
支撑这一目标的核心策略是:尽可能晚地锁定决策,尽可能长久地保持选择开放。
这句话可能让你意外。你或许以为软件架构的目标是让系统正确运行。当然,我们希望系统运行正常,架构也必须把这一点作为最高优先级之一。
但是,系统架构对 “系统能否正常运行” 的影响其实很小。世上有很多架构糟糕透顶的系统,运行得依然很顺畅。它们的问题不在运行本身,而在部署、维护和持续开发上。
这并不是说架构对系统行为毫无支撑作用。架构当然至关重要,但这种作用是被动、辅助性的,而非主动、决定性的。架构几乎无法为系统的行为保留多少可选项。
架构的首要目的,是支撑系统的整个生命周期。好的架构让系统易于理解、易于开发、易于维护、易于部署。它的终极目标是:最小化系统的全生命周期成本,并最大化程序员的生产力。
Development
一个难以开发的软件系统,注定无法拥有长久且健康的生命周期。因此,系统架构应当让开发团队能够轻松地进行开发。
不同的团队结构,意味着不同的架构决策。一方面,一个由五名开发者组成的小团队,可以高效协作开发一个单体系统,无需划分清晰的组件或接口。事实上,在开发初期,这样的团队很可能会觉得严格的架构约束反而是一种障碍。这大概也是为什么很多系统缺乏良好架构的原因:项目起步时团队规模很小,不想被上层架构束缚,于是一开始就没有做架构设计。
另一方面,如果一个系统由五个不同团队共同开发,每个团队各有七名开发者,那么必须把系统拆分成定义明确、接口稳定可靠的组件,否则项目根本无法推进。在不考虑其他因素的情况下,这个系统的架构最终大概率会演变成五个组件—— 每个团队负责一个。
这种 “按团队划分组件” 的架构,对于系统的部署、运行和维护而言,很可能并非最优方案。尽管如此,如果团队只受开发进度驱动,最终还是会自然而然地走向这种架构
Deployment
为了效率,一个软件系统必须是可部署的,部署的成本越高,这个系统作用越小,因此软件架构的设计目标之一就是应当让系统可以一键轻松部署。
不幸的是,部署策略在一开始的开发种可能想的很少,这种架构策略可能导致系统很容易被开发,难部署。
比如,在系统开发初期,开发者可能会决定采用 “微服务架构”。他们会发现这种方式让系统开发变得十分轻松,因为组件边界清晰、接口也相对稳定。可等到真正要部署系统时,他们可能会发现微服务的数量多到令人望而生畏;配置服务之间的连接关系、设置服务的启动顺序,也可能成为大量错误的来源。
如果架构师能在早期就考虑到部署问题,他们或许会选择更少的服务、采用服务与进程内组件混合的模式,并使用更一体化的方式来管理组件间的交互
Operation
相比于对开发、部署和维护的影响,架构对系统运行效率的影响往往没那么显著。几乎所有运行层面的问题,都可以通过增加硬件资源来解决,而不需要大幅改动软件架构。
事实上,我们一再看到这种情况:那些架构不够高效的软件系统,往往只要多加几台服务器、扩容存储,就能跑得很顺畅。硬件便宜、人力昂贵这个现实意味着,不利于运行的架构,其代价远低于不利于开发、部署和维护的架构。
这并不是说,专门为运行性能优化的架构不值得追求 —— 当然值得。只是成本的天平,更倾向于向开发、部署和维护倾斜。
话虽如此,架构在系统运行中还扮演着另一个角色:好的软件架构,会清晰地体现出系统的运行逻辑与需求。
或许更准确的说法是:系统架构应当让系统的运行方式对开发者一目了然。架构应当直观展现系统行为。架构需要把系统的用例、功能和必需行为,提升为核心、显眼的 “地标”,让开发者一眼就能看清。这会简化对系统的理解,从而极大地助力开发与维护工作。
Maintenance
在软件系统的所有环节中,维护成本是最高的。源源不断的新功能需求,以及无法避免的缺陷修复与问题改正,会消耗海量的人力资源。
维护的主要成本来自代码探查与变更风险。所谓代码探查,就是在现有代码中反复深挖,寻找添加新功能或修复缺陷的最佳位置与最佳策略所付出的成本。而在进行这类修改时,无意间引入新 bug 的风险始终存在,这又进一步增加了风险成本。
一套经过精心设计的架构,能极大降低这些成本。通过将系统拆分为多个组件,并通过稳定的接口将组件彼此隔离,可以清晰地为未来功能扩展指明方向,同时大幅降低意外破坏原有功能的风险
Keeping Options Open
正如我们在前一章中所述,软件有两类价值:行为价值与结构价值。其中结构价值更为重要,因为正是这种价值,让软件称得上 “软” 件。
我们发明软件,是因为需要一种能够快速、轻松地改变机器行为的方式。而这种灵活性,高度依赖于系统的形态、组件的划分方式,以及组件之间的互联方式。
让软件保持 “柔软可变” 的方法,就是尽可能推迟决策,尽可能长久地保留更多选择余地。
我们需要保留的是什么选择?是那些无关紧要的细节。
所有软件系统都可以拆分为两大核心部分:策略与细节。策略部分,承载了所有业务规则与流程,是系统真正价值的所在。细节,则是为了让人员、其他系统和开发者能够与策略交互而必需的部分,但完全不影响策略本身的行为。这包括 IO 设备、数据库、Web 系统、服务器、框架、通信协议等等。
架构师的目标,是为系统设计一种结构:将策略视为系统最核心的部分,同时让细节与策略无关。这样一来,关于这些细节的决策就可以被不断推迟、延后。
举几个例子:
在开发早期不必选定数据库系统,因为高层策略并不关心用哪种数据库。如果架构设计得当,高层策略甚至不会在意数据库是关系型、分布式、层次型,还是普通的文本文件。
在开发早期不必选定 Web 服务器,因为高层策略不需要知道自己是通过 Web 提供服务的。如果高层策略对 HTML、AJAX、JSP、JSF 等一堆 Web 技术名词一无所知,那么你完全可以等到项目后期再决定使用哪种 Web 体系,甚至可以先不确定系统是否要基于 Web 实现。
在开发早期不必采用 REST 风格,因为高层策略应该对外界接口无感知。同样,也不必过早选用微服务框架或 SOA 框架,高层策略本就不该关心这些。
在开发早期不必引入依赖注入框架,因为高层策略不关心依赖是如何被解决的。
我想你已经明白核心思想了。如果你能在不绑定周边细节的前提下完成高层策略开发,就可以把这些细节决策推迟很久。而你做决策等待的时间越长,手握的信息就越充分,决策也就越正确。
这也会给你留出尝试不同方案的空间。如果一部分高层策略已经可以运行,并且与数据库无关,你就可以试着把它连接到多种不同数据库,验证适用性与性能。对 Web 系统、Web 框架,甚至 Web 本身,也是同理。
你保留选择的时间越长,能做的实验就越多,能尝试的方案就越丰富。等到不得不做决策的那一刻,你拥有的信息也最充足。
那如果这些决策已经被别人定好了呢?如果公司已经确定要用某款数据库、某台 Web 服务器或某个框架怎么办?一名优秀的架构师会假装这些决策尚未确定,并设计系统结构,让这些决定依然可以尽可能久地被推迟或变更。
优秀的架构师,会尽可能推迟不去做那些不必急于敲定的决策。
Device Independence
我们可以通过一个例子来理解这种思想。让我们回到 20 世纪 60 年代,那时候计算机还很初级,大多数程序员是数学家或其他领域的工程师,而且其中三分之一甚至更多是女性。
那时候我们犯了很多错误。当然,在当时我们并不知道那是错的 —— 我们怎么可能知道呢?
其中一个错误,就是把代码直接和 I/O 设备绑定在一起。如果我们需要在打印机上输出内容,就直接编写控制打印机的 I/O 指令。代码是与设备强相关的。
举个例子,当年我在 PDP-8 上编写电传打字机打印程序时,使用的机器指令大致是这样的:
PRTCHR, 0
TSF
JMP .-1
TLS
JMP I PRTCHR
PRTCHR 是一个在电传打印机上打印单个字符的子程序。开头的 0 用来保存返回地址(别问为什么)。TSF 指令会在打印机就绪时跳过下一条指令;如果打印机忙,程序就会卡在 TSF 和 JMP .-1 之间循环等待。当设备准备好后,TSF 会跳过到 TLS 指令,把 A 寄存器中的字符发送给打印机,最后通过 JMP I PRTCHR 返回到调用者。
起初这种方式运行得很好。如果需要从读卡机读取卡片,我们就写直接操作读卡机的代码;如果需要打孔,就直接操作打孔机。程序运行得非常完美。我们怎么会想到这是个错误呢?
但是,大量的穿孔卡片非常难以管理。它们会丢失、损坏、被折叠、打乱或掉落。单张卡片可能遗失,也可能被误插入多余卡片。于是数据完整性成了严重问题。
磁带成了解决方案。我们可以把卡片映像存到磁带上。如果你掉落了一盘磁带,记录不会乱掉;你不会不小心弄丢某条记录,也不会因为传递磁带而凭空多出一条空白记录。磁带更安全,读写更快,也非常容易备份。
可问题是,我们所有的软件都是为操作读卡机和打孔机编写的。要改用磁带,这些程序必须全部重写 —— 这是一项巨大的工程。
到了 60 年代末,我们终于吸取了教训,并由此发明了设备无关性。当时的操作系统把 I/O 设备抽象成处理 “类卡片” 单位记录的软件函数。程序只调用操作系统提供的抽象单位记录设备服务,操作员可以告诉操作系统:这些抽象接口应该连接到读卡机、磁带,还是其他任何单位记录设备。这样一来,同一个程序既可以读写卡片,也可以读写磁带,而不需要做任何修改。
Junk Mail
20 世纪 60 年代末,我在一家为客户制作垃圾直邮广告的公司工作。客户会把存有客户姓名、地址等单位记录的磁带发给我们,我们再编写程序,打印出精美的个性化广告。
你肯定见过这种东西:
您好,马丁先生:恭喜您!我们从威奇伍德巷的所有住户中选中了您,来参与我们仅此一次的超棒活动……
客户会寄来成卷的印刷好的信函模板,上面除了姓名、地址以及其他需要我们填充的内容之外,文字都已印好。我们编写程序,从磁带上提取姓名、地址和其他信息,并精准打印在模板的对应位置上。
这些信函模板一卷就重达 500 磅,包含数千封信。客户一次会寄来数百卷,我们需要逐卷打印。
最初,我们用一台 IBM 360 计算机自带的行式打印机打印,每个班次能印几千封信。可问题是,这台昂贵的机器被长时间占用。在那个年代,IBM 360 的月租金高达数万美元。
于是我们让操作系统把输出指向磁带,而不是直接连行打。我们的程序完全不受影响,因为它们本来就是基于操作系统的 I/O 抽象编写的。
IBM 360 只需要 10 分钟左右就能写满一盘磁带,足够打印好几卷信函模板。这些磁带被拿到机房外,装到连接离线打印机的磁带机上。我们有五台这样的打印机,全天 24 小时、每周七天不停运转,每周能打印数十万封垃圾邮件。
设备无关性的价值是巨大的!我们编写程序时,完全不需要知道、也不需要关心最终用的是什么设备。可以先用连在电脑上的行式打印机测试程序,之后再让操作系统把内容 “打印” 到磁带上,批量印出数十万份模板。
我们的程序有清晰的结构:策略与细节分离。策略部分,是姓名、地址记录的排版逻辑;细节部分,是具体使用哪种输出设备。关于最终使用哪种设备的决策,我们一直推迟到最后才确定。
开放–封闭原则(OCP)就此诞生 —— 尽管当时还没有被正式命名。
Physical Addressing
20 世纪 70 年代初,我为本地卡车司机工会开发一套大型会计系统。当时我们用一个 25MB 的磁盘驱动器存储代理商(Agent)、雇主(Employer)和会员(Member)的记录 —— 不同类型的记录大小不同,所以我们把磁盘前几个柱面(cylinder)格式化得刚好能容纳单条代理商记录,接下来几个柱面适配雇主记录,最后几个柱面则对应会员记录。
我们写的软件完全 “吃透” 了这台磁盘的底层结构:代码里硬编码了磁盘有 200 个柱面、10 个磁头(head),每个柱面的每个磁头对应几十个扇区(sector);还硬写了哪些柱面存代理商数据、哪些存雇主和会员数据 —— 这些细节全嵌在代码里,改都没法轻易改。
我们在磁盘上建了索引来快速查找各类记录,索引又存在另一组专门格式化的柱面里:代理商索引包含代理商 ID,以及对应记录的柱面号、磁头号、扇区号;雇主和会员的索引结构也类似。更甚的是,会员记录还在磁盘上做成了双向链表 —— 每条会员记录里都存着上一条和下一条记录的柱面 / 磁头 / 扇区地址。
试想一下:如果我们要升级磁盘驱动器(比如磁头更多、柱面更多,或每个柱面的扇区数更多)会怎样?我们得写专用程序把旧磁盘的数据读出来,转换所有柱面 / 磁头 / 扇区地址后写入新磁盘;还得把代码里到处都是的硬编码全改掉 —— 而这些硬编码渗透在所有业务逻辑里,改起来简直是灾难。
后来,一位经验更丰富的程序员加入了团队。他看到我们的代码后脸色煞白,惊愕地盯着我们,仿佛我们是外星来的。随后他温和地建议我们把地址方式改成相对地址。
这位更有经验的同事提出:把整个磁盘看作一个由扇区组成的巨型线性数组,每个扇区用连续的整数编号(相对地址)标识;然后写一个小转换程序,让它专门处理磁盘的物理结构,实时把相对地址转换成对应的柱面 / 磁头 / 扇区号。
万幸的是,我们采纳了他的建议。我们重构了系统的高层业务逻辑,让它完全不感知磁盘的物理结构—— 这一下子把 “用什么结构的磁盘” 这个决策,和应用程序的核心逻辑彻底解耦了。
Conclusion
本章中的这两个小故事,看似是小场景下的案例,实则体现了架构师在大型系统设计中遵循的核心原则:优秀的架构师会刻意将 “细节” 与 “核心策略” 剥离开,并让二者彻底解耦 —— 核心策略既不感知细节的存在,也不以任何方式依赖细节。优秀的架构师在设计核心策略时,会尽可能把关于细节的决策推迟、延后,能拖多久就拖多久。

浙公网安备 33010602011771号