18 Boundary Anatomy

系统的架构由一组软件组件,以及分隔这些组件的边界共同定义。这些边界有多种不同的形式。在本章中,我们将介绍其中最常见的几种。

Boundary Crossing

在运行时,一次边界跨越无非就是边界一侧的函数调用另一侧的函数,并传递一些数据,而打造合适的边界穿越关键在于管理源代码依赖。

为什么是源代码?因为当一个源代码模块发生变更时,其他源代码模块可能也需要修改、重新编译,进而重新部署。针对这类变更进行管控、构建防护墙,正是边界的核心意义所在。

The Dreaded Monolith

在所有架构边界中,最简单、最常见的一类并没有严格的物理形态。它仅仅是在同一个处理器、同一个地址空间内,对函数和数据进行有规范的分离。在前一章节中,我把这种方式称为源码级解耦模式。

从部署角度看,这最终只是一个单独的可执行文件—— 也就是常说的单体应用。这个文件可以是静态链接的 C/C++ 程序、打包成可执行 JAR 的一系列 Java 类文件、或是集成在单个 .exe 中的 .NET 二进制程序,等等。

单体应用在部署时看不到明显的边界,但这并不代表边界不存在、也不代表它没有意义。即便所有代码最终静态链接成一个可执行文件,能够对各个组件独立开发、独立管理,最后再统一组装,其价值依然是巨大的。

这类架构几乎总是依靠某种动态多态来管理内部依赖关系。这也是近几十年来面向对象开发变得如此重要的原因之一。如果没有面向对象,或没有等效的多态机制,架构师就只能退而求其次,使用危险的函数指针来实现适当的解耦。大多数架构师都认为大量使用函数指针风险过高,因此不得不放弃任何形式的组件拆分。
最简单的一种边界穿越,就是低层客户端调用高层服务的一次普通函数调用。运行时依赖与编译时依赖方向一致,都指向更高层的组件。
在图 18.1 中,控制流从左向右跨越边界:客户端调用服务中的函数 f (),并传递一个数据实例。标记 仅代表一个数据结构,数据可以通过函数参数传递,也可以通过其他更复杂的方式传递。注意:数据结构的定义位于边界的被调用方一侧。
图 18.1 控制流从低层跨越边界到高层
当高层客户端需要调用低层服务时,就需要使用动态多态,让依赖方向与控制流方向反向。此时,运行时依赖与编译时依赖方向相反。
在图 18.2 中,控制流依旧从左向右跨越边界:高层客户端通过 Service 接口调用低层实现类 ServiceImpl 中的 f () 方法。但请注意:所有依赖都从右向左跨越边界,指向更高层的组件。同时,数据结构的定义位于边界的调用方一侧。
图 18.2 逆控制流方向跨越边界
即便在一个单体、静态链接的可执行文件中,这种规范的组件划分也能极大地帮助项目的开发、测试与部署。不同团队可以在各自组件上独立工作,互不干扰;高层组件也能保持独立,不依赖于低层实现细节。
单体应用内部组件之间的通信非常快速、开销极低,通常只是简单的函数调用。因此,基于源码级解耦边界的通信可以设计得非常频繁细碎。
由于单体应用的部署通常需要编译和静态链接,这类系统中的组件一般以源代码形式交付。

Deployment Components

架构边界最简单的物理表现形式,是动态链接库—— 比如 .NET 的 DLL、Java 的 JAR 包、Ruby 的 Gem,或是 UNIX 的共享库。这类部署方式不涉及编译,组件以二进制(或等效的可部署形式)交付,这就是部署级解耦模式。部署操作本身,只是把这些可部署单元以某种便捷形式(比如 WAR 包,甚至直接一个目录)归集到一起。
除了这一点差异,部署级组件和单体应用本质上完全一致:
所有函数通常都运行在同一个处理器、同一个地址空间内;
组件拆分、依赖管理的策略也完全相同。
和单体应用一样,跨部署组件边界的通信也只是普通的函数调用,因此开销极低。唯一的额外成本是动态链接或运行时加载的一次性开销,但这类边界上的通信依然可以设计得非常频繁细碎。

Threads

单体应用与部署级组件都可以使用线程。线程既不是架构边界也不是部署单元,他只是一种组织执行时序与执行顺序的方式。
线程可以完全局限在单个组件内部,也可以跨越多个组件运行。

Local Processes

本地进程是一种更强的物理架构边界。本地进程通常通过命令行或等效的系统调用创建。它们运行在同一个处理器,或多核系统中的同一组处理器上,但拥有相互独立的地址空间。内存保护机制通常会阻止进程间直接共享内存,尽管有时也会使用共享内存分区。
本地进程之间最常见的通信方式是使用套接字(socket),或是其他操作系统提供的通信机制,例如消息邮箱、消息队列等。
每个本地进程既可以是一个静态链接的单体应用,也可以由多个动态链接的部署组件构成。在前一种情况下,多个单体进程内部可能包含相同的编译、链接后的组件;在后一种情况下,它们可以共享相同的动态链接部署组件。
你可以把本地进程看作一种超级组件:进程内部包含多个低层组件,这些组件通过动态多态管理彼此的依赖。
本地进程之间的隔离策略,与单体应用和二进制组件一致:跨越边界的源代码依赖方向保持统一,永远指向更高层级的组件。
对本地进程而言,这意味着:高层进程的源代码中,绝不应该出现低层进程的名称、物理地址或注册查找键。要记住架构目标:低层进程应当作为高层进程的插件存在。
跨越本地进程边界的通信,涉及系统调用、数据编组与解码,以及进程间上下文切换,因此开销属于中等水平。这类通信应当谨慎控制,避免过于频繁细碎。

Services

服务是一种进程,通常通过命令行或等效的系统调用启动。服务不依赖于自身所在的物理位置:两个相互通信的服务,可以运行在同一物理处理器或多核环境中,也可以不在。服务默认所有通信都通过网络进行。
与函数调用相比,跨越服务边界的通信速度非常慢,往返耗时可能从几十毫秒到几秒不等。必须尽可能避免频繁细碎的交互,这种层级的通信必须应对较高的延迟。
除此之外,服务遵循的规则与本地进程完全相同:
低层服务应当作为插件 “接入” 高层服务
高层服务的源代码中,绝不允许包含任何低层服务的具体物理信息(如 URI)

Conclusion

除纯单体应用外,大多数系统都会采用不止一种边界策略。一个使用服务边界的系统,可能同时包含若干本地进程边界;事实上,一个服务往往只是一组交互的本地进程的外观层(facade)。而无论是服务还是本地进程,其内部几乎必然是:要么是由源码级组件构成的单体,要么是一组动态链接的部署级组件。
这意味着,系统中的边界通常是两类边界的混合体:一类是可高频交互的本地边界,另一类是需要重点关注延迟问题的跨节点边界。
注 1:静态多态(例如泛型或模板)有时也可作为单体系统中依赖管理的有效手段(尤其在 C++ 这类语言中)。但泛型带来的解耦效果,无法像动态多态那样,避免因变更导致的重新编译与重新部署。注 2:不过在部署级解耦的场景下,静态多态并非可行方案。

精华

架构就是画边界,目的是解耦,防止变更传染
边界从弱到强:单体内部->部署组件->本地进程->服务
越弱越快,越简单;越强越慢,越复杂
所有边界都遵守同一个原则:依赖指向核心,低层是插件
真实的系统是多层边界嵌套
架构师的功力:用最轻的边界解决问题,不乱拆分布式

posted @ 2026-03-20 14:48  cyusouyiku  阅读(4)  评论(0)    收藏  举报