Skill v1.0.0
currentAutomated scan100/100name: module-design description: Use whenever writing code, writing tests, designing architecture or modules, refactoring, or reviewing code, to enforce module boundaries, public interfaces, dependency direction, cohesion, coupling, information hiding, and black-box tests that use only public interfaces. user-invocation: false metadata: version: "1.0.0"
Module Design
设计各个模块时, 必须遵循以下规范. 这些规范源自经过实证的软件工程原则, 不可违反.
注意, 例如bindgen, grpc等工具自动生成的模块, 保持其原样, 不受规范约束.
1. 模块定义
以 Rust 为例:
- 一个
xxx.rs文件是一个模块. 模块下面还可以有子模块, 即xxx.rs+xxx/目录,
xxx/ 目录下是 xxx 的子模块(子模块也是模块)
- 单独一个函数、一个子程序、一个例程、或者一个类(struct/trait/enum), 都不是模块
- Crate 不是模块, 它是多个模块的集合
- Python 的包和模块都是模块,等于 Rust 的模块和子模块
模块是类之上的更大组织粒度——一个模块内含多个相互关联的函数、struct、trait、enum 等项, 是对相关功能的聚合容器.
1.1 模块设计
模块的行为由测试用例定义. 每个模块必须声明模块名、职责、公开操作、主要数据实体和测试用例:
module: authresponsibilities: user authentication and token managementpublic operations: login, logout, refresh_token, validate_tokendata entities: User, Token, Sessiontests: test_login_success, test_login_invalid_password, test_logout, test_token_refresh, test_token_expiry
2. 模块约束(不能怎样)
2.1 大小约束
枚举三个维度, 任一超阈值则必须拆分:
| 维度 | 含义 | 阈值 | |
|---|---|---|---|
| 职责 | 用一句话能说清的独立职责, 需用"和/与"连接即为多个 | >3 | |
| 公开操作 | 模块对外暴露的 pub 项(fn, struct, enum, trait, type, const 等) | >14 | |
| 数据实体 | 模块内定义的 struct/enum/type | >7 | |
| 测试用例 | 模块对应的测试用例总数 | >35 |
2.2 内聚约束
- 禁止巧合内聚: 不得创建
utils、misc、helpers、common等以"杂项"为名
的模块——这种模块内部操作之间没有任何可辨识的关系, 是最差的内聚形式
- 禁止逻辑内聚: 不得在模块中通过控制标志(flag/enum 参数)选择执行不同的操作——
不同操作应拆为独立模块
- 禁止将无关项混入同一模块: 如果模块中半数函数操作半数数据, 另外半数函数操作
另外半数数据, 则实际上有两个模块伪装成一个, 必须拆分
2.3 耦合约束
- 禁止循环依赖: 模块间的
use依赖不得形成环路. 环路使所有被圈入的模块变成
一个"超级模块", 任何一个发布都必须构建全部. 打破环路的方法:
- 提取共同依赖到新模块
- 通过 trait / interface 反转依赖方向(DIP), trait 放在使用方模块或单独模块而非实现方模块
- 禁止语义耦合: 不得利用另一个模块的内部实现知识:
- 不得因"知道"对方会自动初始化而跳过初始化调用
- 不得因"知道"对方只用部分字段而只部分构造数据
- 不得假设对方内部的全局/静态数据已被自己修改
- 语义耦合的危险在于: 更改被依赖模块的代码会以编译器完全无法检测的方式破坏调用方
- 禁止依赖不稳定模块: 模块的依赖方向必须指向更稳定的模块(被更多模块依赖的模块).
- 不得让被广泛依赖的稳定模块依赖不稳定的模块
2.4 不得暴露实现细节
- 模块的公开接口不得暴露内部数据结构的表示方式——使用 newtype 模式或封装
- 不得在公开接口中暴露仅内部使用的函数或类型
- 不得将私有实现细节放入模块接口声明
3. 模块必须达到的标准
3.1 功能内聚
模块必须达到功能内聚——模块内的所有项围绕一个单一的、明确的职责组织. 判断标准:
- 能用一句话描述模块做什么(而不是"做X和Y和Z")
- 模块名就是对其职责的精确描述(如
parser、config、auth) - 移除模块中任何一个项都会使模块的职责不完整
实证数据: 高内聚模块无故障率 50%, 低内聚模块仅 18%; 内聚-耦合比率最差的模块错误率高 7 倍, 修复成本高 20 倍.
3.2 无环依赖
模块依赖图必须是有向无环图(DAG). 每次新增或修改 use 依赖时必须验证不引入环路.
3.3 依赖方向正确
依赖必须指向更稳定的方向. 模块的"稳定性"由传入依赖数(Ca)和传出依赖数(Ce)决定:
I = Ce / (Ca + Ce), 取值 [0, 1], I 越小越稳定- 依赖 I 值比自己低的模块
3.4 稳定模块必须抽象
被大量模块依赖的稳定模块必须以抽象(trait)为主, 而非具体实现. 这样即使模块难以改变 (因大量传入依赖), 仍可通过扩展适应新需求. 具体原则:
- 稳定模块(I 接近 0)应暴露 trait 和类型定义, 具体实现放在依赖它的不稳定模块中
- 不稳定模块(I 接近 1)可以包含具体实现, 因为它们易于改变
- 禁止出现"痛苦区"模块: 高度具体(A 接近 0)且大量传入依赖(I 接近 0)——这种模块
改变极其痛苦且无法通过扩展应对
3.5 共同闭合
因同一需求变化而必须同时修改的项, 必须放在同一个模块中(CCP). 这确保变化集中, 减少每个发布周期中受影响的模块数量. 一个模块不应有多个变化原因.
3.6 共同复用
总是一起被复用的项必须放在同一个模块中(CRP). 不一起复用的项不得放在同一模块. 对模块的依赖就是对模块内所有内容的依赖——模块中存在不被一起使用的项, 会导致无关项的 变化强制依赖方升级和重新验证.
4. 模块接口设计
4.1 最小化公开接口
模块的公开接口必须是最小必要集. 判断标准:
- 不确定是否应该 pub 时, 就不 pub
- 每增加一个 pub 项都要问: "这个项与模块的抽象一致吗? 去掉它模块职责是否完整?"
- 通过组合少量精确定义的接口提供功能, 而非暴露大量细粒度操作
4.2 接口一致性
模块的公开接口必须呈现一致的抽象层次:
- 所有 pub 项必须围绕同一个职责, 不得混入不同层次的关注点
- 不得在模块接口中添加与现有抽象不一致的公共成员——每次添加时验证一致性
- 成对提供服务: 有创建就有销毁, 有打开就有关闭, 有添加就有移除
4.3 接口程序化而非语义化
模块接口必须尽可能通过类型系统和编译器强制约束, 而非依赖调用方的语义理解:
- 用类型(如 newtype、enum)代替裸基本类型, 让编译器捕获误用
- 用必须调用的方法代替可选的初始化步骤(消除"忘记调用 init"的可能)
- 将语义接口元素(调用方必须知道的隐式规则)转化为程序化接口元素(编译器可强制的约束)
4.4 信息隐藏
设计模块接口就是问"这个模块需要隐藏什么?". 两类秘密:
- 隐藏复杂性: 复杂数据结构、文件格式、算法细节——使调用方的大脑不需要处理
- 隐藏变化源: 当变化发生时影响被局部化——业务规则、硬件依赖、数据格式等
可能变化的区域必须被隐藏在接口之后:
- 业务规则
- 硬件或外部软件依赖
- 输入输出格式
- 状态变量(用访问函数而非直接暴露变量)
- 数据大小约束(用命名常量而非硬编码)
4.5 抽象接口放在使用方
模块间的 trait (rust) / interface (go, java) 应放在使用它的模块中, 而 非实现它的模块中.
这使得依赖方向从"使用方→实现方"反转为"使用方→抽象接口, 实现方→抽象接口", 打破直接依赖环路, 并让抽象接口精确反映使用方的需求.
5. 模块层次结构设计
深模块: 简洁接口隐藏强大功能(如 Unix I/O: open/read/write/close 封装文件系统与io设备等多个复杂层次) 浅模块: 接口复杂度 ≈ 实现复杂度, 没有抽象价值
深模块优于浅模块
5.1 设计原则
- 模块层级与目录层级必须对应:同层级模块放在同目录下,子模块放在子目录下
- 例如:xxx.rs 模块的子模块放在 xxx/yyy.rs
- 每个模块层级必须改变抽象, 而非仅转发调用
- 必须按信息隐藏原则而不是按操作的执行顺序拆分模块
- 如果信息隐藏原则造成模块过大,必须拆分成子模块,而不是拆成同层级的浅模块
5.2 判断标准
若去掉某模块层级后调用方几乎不需要多了解实现细节, 则该层级必须去掉. 例如 pass-through 反例:
// handler.rs — 该模块仅转发, 未改变抽象, 参数和返回值与下层完全一致pub fn handle_user_query(id: u64) -> User { service::find_user(id) }// service.rs — 该模块仅转发, 未改变抽象, 参数和返回值与下层完全一致pub fn find_user(id: u64) -> User { repo::get_user(id) }// repo.rs — 真正有意义的逻辑pub fn get_user(id: u64) -> User {let row = db.query("SELECT ...", &[id]);User::from_row(row) // 这里改变了抽象: SQL Row → domain User}// handler 和 service 模块没有改变抽象, 应去掉, 直接用 repo 模块// **注意**: 这是模块层级的判断标准, 不适用于函数调用等其他场景的层级判断
6. 模块测试规范
6.1 测试定义行为
测试模块是模块行为的唯一定义来源, 测试用例必须完整覆盖模块所有行为. 模块能做什么、 不能做什么、边界在哪里, 都由测试用例规定. 没有测试覆盖的行为可认为不存在.
- 测试必须先于实现编写(TDD)——测试定义了模块应该做什么, 实现只是让测试通过
- 测试用例即行为契约:
- 正常路径定义"能做什么": 所有公共函数的输入输出、所有公共类型的构造与转换、
所有公共 trait 的行为
- 错误路径定义"不能做什么": 非法输入、前置条件不满足、资源不可用
- 边界条件定义"做到什么程度": 极值、空值、溢出、并发
- 不变量(invariant)定义"始终成立的约束": 无论什么路径都不得违反
- 测试用例必须从模块的外部可观察行为出发设计, 而非基于内部实现细节
- 必须先同步更新测试以定义新的行为, 再变更模块的公开接口——不得出现测试与接口不一致
- 禁止后向兼容测试: 删除即消失, 禁止测试被删除的旧行为, 即使只是验证它会失败
6.2 测试模块结构
- 每个模块
xxx.rs对应同目录下的xxx_tests.rs, 测试用例数 >35 个则拆为xxx_<子职责>_tests.rs - 测试模块禁止调用或依赖任何被测模块的内部实现,最多只允许调用被测模块的模块级公开接口
- 被测模块内部不得包含任何测试代码——所有测试代码必须在
_tests.rs文件中 - 测试模块与被测模块必须是两个独立的模块, 通过被测模块的模块级公开接口交互, 实现模块级黑盒测试
- 模块级测试必须放在被测模块的同目录下, 禁止放在被测模块的子目录下
- 进程级测试属于集成测试, 必须端到端覆盖所有用户场景, 只能调用进程外部接口, 而不是代码级接口
- 集成测试必须放在tests目录而不是src目录下, 与模块级测试区分开
6.3 测试用例命名
- 格式:
test_<操作>_<场景>, 如test_login_invalid_password - 操作名对应被测的公开操作, 场景名描述具体行为或条件
- 禁止模糊命名:
test_function_1、test_edge_case、test_ok均不合格