软件工程中的耦合与解耦方式
引子
复用与解耦,是推动软件工程技术发展的两大思想溯源。复用可以极大提升软件开发效率,使得软件开发可以从 70% 甚至 90% 起步;而解耦可以大幅提升软件的可维护性和可修改性,降低长期维护成本。
谈到解耦,就不能不先谈耦合。耦合,是指两个软件组件之间有相互影响的或强或弱的关联关系。软件组件的范围涉及:函数、类、库、框架、模块、微服务、操作系统、硬件体系结构。在实际谈到耦合与解耦时,组件通常指的是模块与微服务。
本文对组件间的耦合和解耦方式做个小小的梳理,以备后用。
耦合
共享数据库读写耦合
多个组件共享访问同一个数据库,且都具有读写能力。数据读写可能会失控、混乱而无法追溯。
“直捅心脏式”的耦合。如果某天应用出了线上故障,“元气大伤”,可能都不知道是从哪里来的刀子。一般常见于创业初期快速上线满足业务需求的单体应用。
耦合程度:很重。
共享数据库只读耦合
多个组件共享访问同一个数据库,只有一个组件具备写权限,其它只具备只读权限。虽然数据写可控,但读不可控,如果有访问组件有瞬时高并发读请求,可能会影响所有访问该数据库的组件的稳定性。
不会“直捅心脏”,但会在某个时候对“应用心脏”突然来上一重拳。
耦合程度:重。
共享缓存读写耦合
多个组件共享访问同一个 redis 缓存实例,都具备读写权限。通常必须隔离各个组件所使用的名字空间,避免 key 命名重叠写覆盖而导致数据混乱。如果某个组件的读写把 redis 打挂了,那么所有组件都会受到影响。因此,应用应当对 redis 具有降级策略,避免核心功能过度依赖 redis。
耦合程度:较重。
共享数据耦合
同一个微服务内,多个模块共享同一个内存缓存,或者都能对同一个库表进行读写。
耦合程度:略重。
RPC 耦合
多个组件通过 RPC 调用来通信和交换信息。RPC 调用方对被调用方有依赖关系。如果被调用方不够稳定,而调用方又没有降级策略,就可能会产生级联稳定性影响。通过 HTTP 通信同样有此问题。
要解决RPC的稳定性级联影响,通常采用降级策略,对应用中的弱依赖进行降级,使用线程池隔离和异常捕获,避免受弱依赖不稳定的影响。
耦合程度:普通。
API 数据耦合
组件有自己独享的数据,其它组件不能读写为其它组件专享的数据,只能通过 API 来访问该组件的公开数据。你不需要知道我的内部实现细节,只要传给我需要的信息,我就能为你提供服务。
API 是模块与微服务内部的相对理想的耦合方式之一。
耦合程度:较轻。
消息耦合
多个组件通过消息队列来传递信息,彼此没有耦合。实际上是转移了耦合关系,彼此都依赖消息队列。消息队列需要高可用,避免单点故障。这种常成为“生产者消费者模式”,采用“订阅—推送—拉取”的方式实现。
消息耦合是微服务间的相对理想的耦合方式之一。
耦合程度:较轻。
运行环境耦合
多个组件运行于同一个 JVM 实例内。如果 JVM 挂了,那么所有组件都无法正常工作。同样,多个组件运行于同一个操作系统环境下或容器下,都有运行环境耦合,共享和竞争 CPU 、内存、外存和网络带宽资源。不同性质的服务避免放在同一台机器上,比如数据库服务器与应用服务器不适合放在同一台机器上。
耦合程度:较轻。
解耦
解耦并不是完全去除组件的关联,而是使组件关联的影响可控:当某个组件变化时,其它组件不需要作较大的变更;当某个组件失败时,不会影响到其它组件的运行和整体的运行。
从数据和变化影响来看,解耦应当做到:
- 数据专享。不同的组件仅仅只关注、使用自己专享的数据,公开可以公开的数据;不能直接读或写别的组件的专享数据,别的组件也不能读或写这个组件的专享数据。
- 变化与不确定性解耦。组件的变更和运行不应因为外部的变化或不确定性或失败而受到影响。
常见解耦方式
常见解耦方式有:
- API:组件通过 API 来公开自己的服务,屏蔽内部的复杂性。API 是的最广泛的解耦形式,函数、类、库、框架、模块、微服务、操作系统、硬件体系都可以提供自身的 API。
- 消息中间件: 组件通过消息中间件来传递和消费消息,共同协作完成一个任务。一般用于模块、微服务。消息中间件在底层是依赖协议通信的。
- 协议通信:跨 JVM 或跨机器的组件之间通过协议来传递和处理数据。常见的跨进程协议通信有 HTTP、Restful、RPC。
- 插件架构/扩展点机制:用于同一系统基础上的不同业务或功能之间的解耦。设计合理的插件/扩展点接口,限制插件/扩展点的访问和影响范围,从而能通过插件/扩展点增强系统功能同时又能避免系统受到不稳定插件的影响。
- 适配器架构:通过定义适配器接口来适配和响应不同的终端请求。
- IOC/接口/钩子:用于基础模块或框架与业务模块之间的解耦。如果基础模块需要业务模块的某种实现,可以定义接口/钩子,依赖这个接口/钩子,然后让业务模块传入接口/钩子的实现,实现IOC(控制反转)。即基础模块依赖接口,而不是依赖接口的具体实现。
- 设计模式:通过组件职责的分配,在代码级别的解耦实现,可以用于实现函数、类、基本组件的解耦和交互。常用的有策略模式、模板方法模式、组合模式、装饰模式、适配器、代理。
- 异步操作:使用异步操作将子流程执行从主流程中分离出去,使得主子流程可以并行进行。
- 线程池隔离:通过将任务或流程提交给线程池执行,从而分离主子流程、 任务执行隔离。
- 异常捕获:通常用于串行流程的解耦,使得整体流程不会因为某个次要流程的失败而失败。
部署解耦
场景:
- 应用的部署环境标准化,与所在操作系统的环境无关。
- 应用的伸缩容能够配置化和自动化执行。
方式:
- 虚拟化(虚拟机、容器)
- K8S
领域解耦
场景:
- 不同领域有各自复杂的领域模型,必须保持独立性,同时有领域间通信需求。比如资产、入侵、风险、镜像等是不同的领域。
方式:
- 领域模型(限界上下文、根对象)
- 微服务(适合功能集不大的独立性较强的代码集)
- 复制应用代码(原有领域代码很难从原来应用中抽离,去除不必要的依赖)
微服务解耦
场景:
- 即使是同一个领域,也会有多个微服务。比如入侵领域也有检测、响应与阻断等微服务。
- 微服务之间需要解耦。
方式:
- 适配器架构
- 消息传递
- API
- 协议通信
业务/模块解耦
场景:
- 同一个微服务中,有不同的子模块,子模块之间有相互关联,又有独立性,需要解耦。比如检测模块行为依赖于检测配置模块里的配置,检测模块的数据依赖主机模块的变更。
方式:
- API
- 消息传递
业务与基础框架解耦
场景:
- 业务要使用基础框架的基本功能。
- 业务要向基础框架注入自定义功能或实现。比如应用要系统启动前加载一些自定义动态模块,或在 JVM 退出前做一些资源释放操作。
- 业务代码不能写在框架里。
方法:
- IOC
- API
- 接口与钩子
业务扩展解耦
场景:
- 在现有功能基础上添加扩展/定制功能,不同的扩展业务不能相互影响。
方法:
- 插件架构/扩展点机制
- 接口与设计模式
- 异常捕获
数据处理解耦
场景:
- 一份数据,多种业务处理。比如同一份登录事件要做不同的算法检测处理。或者同一份主机上线卸载事件要做不同业务处理。
方式:
- 消息广播
- 线程池隔离加异常捕获
共享数据空间解耦
场景:
- 不同的业务需要共享使用同一个 Redis 实例。不同业务的数据不能相互影响。
方式:
- 名字空间(使用名字空间隔离不同业务所使用的数据域)。
流量解耦
场景:
- 线上的全部流量需要分配到不同的服务器,使之均衡分布或者分流到合适的服务器来处理。
方式:
- 分流
- 负载均衡
流程解耦
场景:
- 主流程与子流程分离,主流程的执行不依赖于子流程的执行完成。比如应用启动时的必要资源加载,或者异步操作。
方法:
- 异步操作
- 线程池隔离加异常捕获
任务解耦
场景:
- 系统会创建不同类别或同样类别的任务,任务的耗时长短不一,任务执行相互独立。比如导出任务。
方法:
- 线程池隔离加异常捕获
解耦的利弊
益处
- 适合敏捷开发,并行开发;
- 功能集的分离,单个应用或微服务的可维护性好;
- 更容易提升单个应用或微服务的性能和可伸缩性;
- 提升整体可用性。
不利
- 可能会引入整体架构和技术复杂度提升,比如需要分布式技术,微服务依赖治理;
- 版本依赖与迭代需要整体控制,需要紧密团队协作才能确保完整应用的正常运行;
- 需要良好的 DEVOPS 支持,自动化构建、打包、测试、部署、监控;
- 数据和消息传输和交互可能会更加复杂。
对于简单的应用,只需要引用模块级别及之下的解耦方式;对于复杂的应用,需要应用领域解耦和微服务解耦,但微服务不宜拆分过多。