2、模块化设计理念:高内聚低耦合原则、模块接口定义、模块粒度划分
模块化设计,说白了就是“分而治之”。把一个复杂系统拆成若干个独立模块。每个模块负责自己的事,彼此之间少打交道。
听起来简单,对吧?但我在项目中见过太多次拆得乱七八糟的案例。有的模块内聚性极差,一个函数干三件事。有的模块之间耦合像麻花一样拧在一起,改一个地方要联动七八个文件。
这一章,我聊聊我的经验。高内聚低耦合、接口定义、粒度划分。这三个东西是模块化的基石。
2.1 高内聚低耦合原则
先记住一句话:模块内部尽量紧密,模块之间尽量松散。
- 高内聚:一个模块封装一组高度相关的职责。比如“订单模块”只负责订单创建、查询、状态变更;别把“支付”逻辑塞进去。
- 低耦合:模块间只通过明确接口通信。不要偷偷摸摸共享全局变量、不要直接访问另一个模块的内部数据。
我个人习惯用一个简单表格判断模块的内聚程度:
| 内聚类型 | 描述 | 是否推荐 |
|---|---|---|
| 功能内聚 | 模块内所有元素共同完成一个单一功能 | ✅ 最佳 |
| 顺序内聚 | 一个元素输出是另一个输入 | ✅ 可接受 |
| 通信内聚 | 共享相同数据 | ⚠️ 谨慎 |
| 逻辑内聚 | 把相似但无关的功能放在一起 | ❌ 避免 |
| 偶然内聚 | 完全无关的代码凑一锅 | ❌ 强烈避免 |
耦合也一样,我通常把它看作七层阶梯:
| 耦合类型 | 特征 | 后果 |
|---|---|---|
| 数据耦合 | 只通过参数传递简单数据 | 最理想,改动影响小 |
| 标记耦合 | 传递整个数据结构但只用一小部分 | 容易被误导,过度依赖 |
| 控制耦合 | 传递标志位控制对方逻辑 | 模块不再单纯,职责模糊 |
| 外部耦合 | 共享外部资源(如全局变量、文件) | 不好调试,并发容易出问题 |
| 公共耦合 | 多个模块读写同一全局数据区 | 改一处可能炸一片 |
| 内容耦合 | 一个模块直接使用另一个模块的内部数据 | 极难维护,基本等于没模块化 |
我在项目中遇到过一种经典的反模式:把日志和告警写在同一个函数里。日志是日志,告警是告警,两个完全不同的事,硬塞到一起。后来业务要求告警分级,日志又要换格式,改一个函数影响两条链路。拆开之后,清爽多了。
核心原则:一个模块只改变一个原因。这就是单一职责在模块维度的体现。
我的检查清单:
- 模块内部功能是否围绕同一个概念?
- 改动一个功能,会不会影响本模块其他功能?
- 另一个模块是否必须依赖本模块的内部实现?
但凡有一个回答是“是”,就该重构了。
2.2 模块接口定义
接口是模块间的契约。定好接口,你改你里面,我改我里面,互不干扰。
我建议接口定义要回答三个问题:
- 入参是什么? 类型、范围、是否可为空。
- 出参是什么? 成功返回什么,失败返回什么。
- 边界条件怎么处理? 超时、重试、并发安全?
举个例子,一个“任务调度”模块的接口定义:
// 调度模块接口声明(伪代码)
// 入参:任务ID, 调度时间戳(UTC, 毫秒)
// 出参:调度结果枚举 { SUCCESS, TASK_NOT_FOUND, SLOT_EXHAUSTED, INTERNAL_ERROR }
Status schedule_task(
TaskId id,
uint64_t scheduled_at_ms
);
你看,只有这三个参数,没有泄露任何内部细节。调度模块内部用数据库还是用队列,外部完全不关心。
我曾经在一个项目中接手过一套接口:入参传了一个JSON对象,里面包含了十几个字段。调用方根本不知道该传哪些。出参返回一个字符串“ok”或“fail”,失败原因无从得知。嗯,那段时间改bug改到怀疑人生。
接口设计三大坑:
- 坑1:参数太多、类型模糊。改用明确的结构体或强类型参数。
- 坑2:返回值过于简单,无法区分错误。至少返回错误码+错误信息。
- 坑3:接口不稳定,频繁变更。用版本号管理,比如
/v1/tasks、/v2/tasks。
接口定义还有一个容易被忽略的点:接口的稳定性。接口一旦发布给外部模块使用,就不应该随意删改。我习惯把接口分成“公开接口”和“内部接口”,公开接口必须经过评审,改动需要版本升级。
如果你用接口描述语言(IDL)比如 Protobuf、Thrift、OpenAPI,记得在 proto 文件或 yaml 文件里写清楚每个字段的含义。我曾经见过一个 proto 文件,字段名叫 extra,备注是空的。整整查了三天才搞明白它原来是做容灾备份用的。
2.3 模块粒度划分
最后一个问题:一个模块应该有多大?
没有标准答案。但我可以分享我的经验法则:
- 太细:每个模块只有几个函数,接口数量爆炸,模块间调用链比头发还多。管理成本高,调用开销大。
- 太粗:模块内部塞了几百个功能,没人说得清它到底做了什么。改一个地方,影响面难以评估。
我常用的划分方法叫“职责驱动划分”。你想想看,一个系统里有哪些自然边界?
| 划分依据 | 例子 |
|---|---|
| 业务领域 | 用户模块、订单模块、支付模块、库存模块 |
| 技术层次 | 数据访问层、业务逻辑层、展示层 |
| 非功能需求 | 日志模块、监控模块、配置模块 |
| 变化频率 | 核心稳定模块 + 易变业务模块 |
我个人建议优先按业务领域来切。因为技术层次的划分容易让一个模块同时服务多个业务,最后耦合越来越深。
举个例子,你要做一个“内容管理系统”。按领域切:
// 模块清单(按业务领域)
- 文章模块
- 用户模块
- 标签模块
- 评论模块
- 统计模块
每个模块内部再按技术层拆成子模块。这样拆下去,粒度就很自然了。
粒度检查标准:一个模块应该小到你可以用一句话说清它做什么,大到它不需要依赖太多其他模块才能完成自己的工作。
我曾经参与过一个网络库的设计。初期把“TCP连接管理”、“协议解析”、“消息路由”放到了同一个模块。结果想换个协议(比如从自定义协议切到HTTP/2),几乎要重写整个模块。后来我把协议解析拆成独立模块,只暴露一个接口parse(raw_bytes) -> Message。再换协议,只需要换这一个模块。
这就是粒度划分带来的好处。拆得巧,改得少。
要注意循环依赖。如果模块A依赖模块B,模块B又依赖模块A,说明它们该合并,或者你缺了一个公共抽象层。
好了,关于模块化的三个核心理念就先聊到这儿。高内聚低耦合是目标,接口定义是执行契约,粒度划分是尺寸拿捏。三件事都做好了,你的系统架构会非常干净,改一个模块不需要惊动所有人。