| name | devflow-design |
| description | 在规格确认后、写代码前做软件设计时使用;也在设计评审被打回、或实现中发现模块边界/接口契约/错误处理需要重新设计时使用。涵盖模块划分、接口契约、错误模型、数据所有权、方案取舍与测试设计。 |
DevFlow 设计
总览
设计回答的问题:用什么结构来满足规格,让代码做对的同时也值得长期持有。 一份好设计的检验标准:
- 拿着它,不看代码就能写出测试(接口契约完整);
- 实现者不需要再做任何"发明"(结构决策已闭合);
- 每个结构决策都能回答「为什么不是更简单的方案」(复杂度有理由)。
设计分两级(团队开发流程要求):
| 级别 | 工件 | 何时需要 | 模板 |
|---|
| 组件级设计 | 组件根下 features/<id>/component-design-draft.md → ship 时 promote 到组件根下 docs/component-design.md(或团队覆盖路径) | 工作项影响组件边界:对外接口 / 组件依赖 / 状态机 / 组件职责变化,或组件设计基线缺失、过期 | references/devflow-component-design-template.md |
| 工作项级设计 | 组件根下 features/<id>/design.md(或团队覆盖路径) | 每个工作项(微小修改可按 using-devflow 裁剪) | references/devflow-ar-design-template.md |
硬性顺序:影响组件边界时,必须先修订组件设计草稿并经评审与模块架构师确认,再写工作项设计;工作项设计只能引用组件基线(功能编号、接口契约、软件单元),不得重新定义组件级架构。组件根下 docs/component-design.md(或团队覆盖的组件设计基线)不存在而工作项触及组件边界 → 先补建组件设计,不要在工作项设计里"顺便"定义组件架构。
设计的第一律:简单性。 满足当前规格的最少结构就是好结构。每多一层间接、一个抽象、一个配置项,都要付出理解、测试和演进的复利成本。本文所有原则最终都服务于这一条。
工作流
- 读 spec 与组件基线:先读 plan.md 头部记录的组件根与工件根,或按
using-devflow 重新解析;读该组件根下已确认的 spec.md 和 docs/component-design.md(存在时,或团队覆盖路径),列出本变更触碰的既有模块与新增职责。
- 判定设计级别:按 spec 的接口候选契约与影响面判断是否触及组件边界;触及 → 先按组件模板修订
component-design-draft.md,确认后再继续。
- 划分模块职责(见下文 §职责与边界)。
- 设计接口契约与错误模型(见 §接口契约、§错误模型)。
- 记录方案取舍:有真实可选方案时写 2-3 个选项的对比;只有一个合理方案时写明其他方案为什么不成立(见 §方案取舍)。
- 写测试设计:把 spec 的每条验收标准映射成测试用例表(见 §测试设计)。
- 更新追溯:在组件根下
features/<id>/traceability.md(或团队覆盖路径)填入每条需求对应的组件设计章节 / 工作项设计章节 / 测试设计用例列。
- 自检(文末清单)通过后只表示作者侧设计产物就绪,下一步必须进入 R2 门禁:派发
devflow-review 按 design rubric 做独立评审并落盘记录(必经节点);评审 verdict 通过后,attended 模式再把评审记录与 verdict 呈人确认,并更新 plan.md 门禁表。R2 门禁未通过(含 attended 下未确认)前不进入实现。
实现中发现设计有误:停下、回来改 design.md(必要时回到组件设计)、重新评审确认,不在代码里悄悄偏离。
职责与边界
一句话职责测试
每个模块(文件/类/组件)的职责必须能用一句不含「和」「以及」的话说清。说不清,或者句子里有两个动词短语,就是两个职责。
❌ ConfigManager:负责加载配置、校验配置、监听配置变化,以及把变化通知给订阅者
✅ ConfigStore:持有当前生效配置,提供原子读取
✅ ConfigLoader:从存储读取并校验配置块
✅ ConfigNotifier:把配置变化分发给订阅者
是否真的要拆成三个文件取决于规模——小就先放一个文件里,但内部结构按职责组织,这样将来拆分是搬运而不是手术。
按变化理由划分,而不是按技术层次
判断两段代码该不该放一起:它们是否因同一个理由而变化。协议格式变化时要改的代码放一起;业务规则变化时要改的代码放一起。反例是「所有回调放 callbacks.c、所有结构体放 types.h」这类按形态分类——一次行为变更要横跨所有文件(霰弹式修改)。
耦合的可操作判断
「低耦合」不是感觉,按下面检查:
| 检查 | 坏信号 | 动作 |
|---|
| 依赖方向 | 底层模块 include 上层头文件;两模块互相 include | 依赖必须单向:上层依赖下层、具体依赖抽象。互相依赖 → 提取第三方共同依赖或用回调/事件反转 |
| 知识泄漏 | 调用方需要知道被调方的内部状态/调用顺序才能正确使用("先调 init 再调 open,但 reset 之后要重新 init") | 把时序约束收进模块内部,或用状态机显式拒绝非法顺序 |
| 数据泥团 | 三四个参数总是结伴出现在多个签名里 | 提取成结构体,给这组数据一个名字 |
| 特性依恋 | 一个函数大量读写另一个模块的数据,却几乎不碰自己模块的 | 函数搬到数据所在的模块 |
| 扇出过大 | 一个模块 include / 调用 7-8 个以上其他模块 | 它在做协调器还是上帝模块?拆出子职责 |
| 实现泄漏 | 公共头文件暴露内部结构体字段、私有函数、实现用的宏 | 头文件只放契约;内部细节进 .c / detail 命名空间 |
内聚的可操作判断
模块内聚的检验:随机删掉模块里的一个函数,其余函数是否大概率也要跟着改?是 → 内聚好。模块里有一半函数和另一半函数互不引用、不共享数据 → 那是两个模块住在一个文件里。
抽象纪律
抽象必须由真实的重复或真实的变化轴支撑,不由想象支撑。
- Rule of three:第三个真实用例出现前,重复通常比错误的抽象便宜。错误抽象一旦被依赖,纠正成本远高于消除重复。
- 单实现接口是负债:只有一个实现的 interface/抽象基类,在没有第二个真实实现(不含测试 mock 的伪需求)或明确的稳定契约要求前,就是纯开销。直接用具体类型。
- 可配置性不是免费的:每个"以后可能要改"的配置项/策略钩子/插件点,现在就要文档、测试和维护。spec 里没有的变化轴不要预留。
typedef struct {
int (*open)(void *ctx);
int (*write)(void *ctx, const log_entry_t *e);
int (*flush)(void *ctx);
int (*close)(void *ctx);
} log_backend_ops_t;
int log_register_backend(const log_backend_ops_t *ops, void *ctx);
int log_file_open(const char *path);
int log_file_write(const log_entry_t *e);
什么时候间接层值得引入:跨越所有权边界(隔离第三方库、硬件、协议栈,让它们可替换可仿真);隔离真实的不稳定源(spec 明确说协议版本会变);切断循环依赖。
SOLID 翻译表
SOLID 不是新增流程,也不是为了制造抽象。它是设计与重构时识别变化理由、依赖方向和契约稳定性的速查语言;每条都必须落回 DevFlow 的可检查问题。
| 原则 | DevFlow 判据 | 常见坏信号 | 默认动作 |
|---|
| SRP | 一个模块只有一个变化理由 | 职责句里出现“和/以及”;一次需求变更横跨无关职责 | 拆职责;规模还小时至少按职责组织内部结构 |
| OCP | 真实变化轴有稳定扩展点 | 每加一种类型要改多处 switch / if 链和调用方 | 先确认变化轴真实,再提取表驱动、策略或多态 |
| LSP | 替换实现不削弱接口契约 | 子实现改变错误语义、前置条件或失败后状态保证 | 收紧契约,拆接口;不成立时取消继承/抽象 |
| ISP | 调用方只依赖自己使用的契约 | 公共头文件暴露大而全接口、内部字段、私有宏 | 拆小接口;隐藏内部字段与实现细节 |
| DIP | 高层策略不依赖底层细节 | 上层知道硬件、协议、存储或第三方库调用细节 | 在真实边界引入端口/适配层;拒绝无第二用例的单实现接口 |
接口契约
接口契约描述可观察行为,不是函数名列表。每个对外接口(公共头文件函数、服务操作、协议消息)写全六项:
- 输入与前置条件:参数含义、单位、合法范围、NULL 语义、调用上下文限制(可否在中断里调)
- 输出与后置条件:返回值、出参、成功后系统状态的变化
- 错误语义:每个错误码什么条件下返回、出错后系统状态如何(见 §错误模型)
- 副作用:写了什么状态、发了什么事件、持有了什么资源
- 并发与时序(如适用):线程安全性、可重入性、阻塞行为、超时
- 兼容性(modify/remove 时):旧调用方迁移策略、错误码集变化、废弃计划
int mode_set(int mode);
int mode_set(mode_t mode);
接口设计的取向:让误用难以编译通过、让正确用法成为唯一明显写法。用枚举不用魔法 int;语义不同的量用不同类型(duration_ms_t 而不是裸 uint32_t);需要配对调用的资源返回句柄并提供成对 API。
错误模型
错误处理是设计决策,不是实现时的临场发挥。设计阶段定三件事:
1. 错误分类——不同类别的处理策略不同:
| 类别 | 例子 | 策略 |
|---|
| 调用方编程错误 | 传 NULL、非法枚举、违反调用顺序 | 校验并返回明确错误码(或按项目约定 assert);不进入降级逻辑 |
| 可预期的运行时失败 | 资源暂不可用、队列满、超时、外部输入非法 | 返回错误码,调用方有明确的恢复/退避路径 |
| 环境/硬件故障 | 存储损坏、外设无响应 | 进入设计好的降级模式,上报诊断事件 |
| 不可恢复的内部矛盾 | 状态机进入"不可能"状态 | 按项目故障策略(安全状态/复位/记录后受控终止) |
2. 传播策略:错误在哪一层被翻译、哪一层被处理。底层错误码原样穿透到顶层是泄漏(调用方被迫了解三层之下的细节);每层都包一遍是噪音。默认:在模块边界翻译一次("flash 写失败" → "配置保存失败"),中间层只透传。
3. 失败路径的状态保证:每个可失败操作明确——失败后已发生的副作用是回滚、保留还是半完成?接口契约里写清。「出错后状态未定义」在评审中按 critical 处理。
数据所有权与生命周期
每块跨边界的数据(缓冲区、句柄、回调上下文)在设计里明确三个问题:谁分配、谁释放、指针在调用返回后是否仍可用。
- 默认取向:谁分配谁释放;跨边界传递用复制或显式转移所有权(并在契约里写明)。
- 回调注册类接口必须写明:注销后是否还可能被回调一次(in-flight callback)、ctx 指针的生命周期由谁保证。
- 长生命周期模块持有外部传入指针 = 红色信号,改为复制或在契约中写明调用方必须保证的存活期。
方案取舍
只在真实存在多个合理方案时写选项对比,每个方案至少回答:改动范围、复杂度、对既有调用方的兼容性、失败时回滚成本、长期维护影响。然后给出推荐和理由——列完选项不推荐等于把设计工作推给评审者。
只有一个合理方案时,写一段「为什么不是 X」:X 是评审者最可能问的替代方案(通常是"更简单的做法"或"更通用的做法")。这不是形式——它强迫你检验自己是否真的考虑过更简单的路径。
伪选项是常见造假:三个方案其实是同一方案的不同措辞,或者两个陪跑方案明显荒谬。评审会按风险信号处理。
测试设计
设计文档必须含测试设计章节——这是第一层规格通向第二层 TDD 的桥。把 spec 的每条验收标准映射成用例。canonical 测试设计表只有一张:工作项设计模板第 6.1 的 Case ID 汇总表(或等价表),它是 devflow-tdd 细化 plan 的唯一入口;第 6.2+ 子表只能展开步骤、mock、风险覆盖,不得引入无法回指到第 6.1 的新用例。
| Case ID | 覆盖需求 | 场景(Given/When/Then 摘要) | 层级 | 预期结果 |
|---|
| TC-001 | FR-001 | SAFE 下 SetMode(NORMAL) → 切换+事件 | unit | 返回 OK;周期内 ModeChanged=NORMAL |
| TC-002 | FR-001 | SetMode(非法值) → 拒绝 | unit | ERR_INVALID_ARG;状态与事件无变化 |
| TC-003 | NFR-001 | 1000 次切换延迟测量 | integration | p95 ≤ 5ms(QAS 阈值) |
规则:
- 每条 FR/IFR 至少一个正向 + 一个异常/边界用例;每条 NFR 的 QAS Response Measure 对应一个可量化用例
modify 需求必须有回归用例(旧行为中要保留的部分);remove 必须有删除后语义用例
- 写明每个用例的层级(unit / integration / simulation)与 mock 边界:只 mock 硬件、外部组件、慢速依赖;内部纯逻辑不 mock
- Case ID 必须稳定(
TC-xxx),并能双向追溯:spec Acceptance → Case ID → plan 任务;组件级测试项如需引用,先映射到工作项级 TC-xxx
- 写不出用例的需求 = 规格不可测试 → 回
devflow-specify
这张表就是 devflow-tdd 的任务来源:实现时逐用例 RED→GREEN→REFACTOR。
风险信号
- 工作项触及对外接口/依赖/状态机,却没有组件设计修订(在工作项设计里"顺便"改了组件架构)
- 设计文档里只有结构图和文件清单,没有接口契约和错误语义(实现者仍然要猜)
- 「错误处理:返回错误码」一笔带过(哪些错误码?出错后状态?谁恢复?)
- 出现"以后可能需要"作为某个抽象层/配置项的唯一理由
- 单实现接口、单子类继承、只被调用一次的"通用工具"
- 方案对比是同一方案的三种措辞
- 测试设计只有正向路径,或某条验收标准没有对应用例
- 改了对外接口语义却没有兼容性章节
自检清单
支撑参考
| 文件 | 用途 |
|---|
references/devflow-ar-design-template.md | 工作项级设计(design.md)模板,含「高质量设计增补」章节 |
references/devflow-component-design-template.md | 组件级设计模板,含「高质量设计增补」章节 |