10.05 重构与模块边界:保持本地 IM 工具的外部行为
返回第十卷 · 前置:10.04 测试替身 · 项目:01.12 本地 CLI
10.05 重构与模块边界:保持本地 IM 工具的外部行为
DeepTutor 原稿经技术与教学审阅后的静态课程。所有程序、命令、测试输出和改造步骤均为教学示例或待学习者完成的任务;本次没有运行 Go、测试、站点构建或 IM 服务。
先明确这一次改变什么
01.12 为虚构 IM 本地历史提出 check/list/export 三个命令,01.09 给出领域规则、文件适配器和命令入口的职责,10.01–10.04 已讲清需求预期、测试与可控依赖。现在假设接手一个教学用单文件旧版:它能处理版本 1 的 history-c-a.json,但 main.go 同时解析参数、打开文件、解码、校验、打印和导出。下一次修改也许要支持新文件版本,维护者不敢碰它。
本章的任务是让代码更容易理解和修改,同时保持现有用户可观察合同。这个单文件版本是假想的重构起点,不表示课程仓库已经构建了该程序。所有身份 c-a/u-a/m-a 均为虚构数据;本地文件的成功不表示服务端受理、设备送达或已读。
| 第一次阅读 | 第二次阅读 | 个人实践 |
|---|---|---|
| 行为合同、变化原因、最小改造 | 包边界、信息隐藏、命名与评审 | 在独立目录逐步实现,保留前后证据 |
一、什么叫“行为保持”
Martin Fowler 将重构描述为改变内部结构、保持可观察行为的一连串小步骤。这里的“可观察”必须先说清是谁观察:终端用户看命令和输出,调用脚本看退出码与输出通道,读取文件的程序看 JSON 格式,后续维护者看公开的 Go API。若改名使 -file 消失,尽管 Go 测试里一个函数仍返回相同值,CLI 用户的行为已经改变。
本课程现有版本 1 合同应先列成表:
| 外部输入或状态 | 保持的结果 | 观察位置 |
|---|---|---|
check -file 给合法 v1 文件 | 版本、会话、消息数;退出 0 | stdout、退出码 |
list 给合法 v1 文件 | 保持文件原有消息顺序,只显示 ID 和时间 | stdout,不泄漏正文 |
未知字段、缺失或 null 的 messages、重复 ID | 拒绝整份输入;退出 3 | stderr、无部分 stdout |
| 输入文件不存在 | I/O 错误;退出 4 | stderr、退出码 |
导出目标已存在且未给 -replace | 不覆盖旧目标;退出 4 | 目标前后字节、退出码 |
| 显式帮助、无命令、无效 flag | 分别遵守已有 0 或 2 的约定 | 帮助通道和退出码 |
上表只列关键项目,完整命令、资源上限、文件版本和覆盖风险见01.12。这里有两个不同的上限:10.02 的 6 字节是单条正文的教学规则;01.12 的 -max-bytes 默认 1 MiB,限制整份输入文件。重构不增加新命令,也不把 6 字节教学规则改为 9,不声称导出是崩溃原子操作。改变上限是需求变更;改正原来错误的上限判断是修复缺陷。它们都应有独立的需求、测试和提交,不能在一个“整理代码”差异里悄悄发生。
“不变”也不等于每个内部变量、函数调用次数或 JSON 空格都必须逐字一样。需要保留的是明确承诺的行为。例如 v1 字段与消息顺序是合同,编码器内部局部变量不是;若下游脚本依赖精确输出格式,就应把该格式写入合同并核对。面对原来未记录的行为,先做观察记录,再和产品规则核对,不能把一次偶然输出自动升级为永久约定。
二、先按变化原因找到混在一起的职责
想象旧版 run 先读 os.Args,再打开路径、限制 1 MiB、严格解码 JSON、检查会话和消息,最后根据命令打印或写目标。它的问题并非“函数超过多少行”,而是不同理由引起的变化都要改同一处。错误分类也容易在某次调整中被跨层覆盖。
| 变化原因 | 应归属的知识 | 可能受影响的边界 |
|---|---|---|
| “正文为空或超过上限”规则调整 | history 的领域校验 | 所有读取和写入入口 |
| v1 JSON 字段、严格解码或读取上限调整 | internal/historyfile | 文件兼容和输入错误分类 |
-file 的优先级、-out 必填 | cmd/imhistory | CLI 用法与退出 2 |
list 默认展示哪些字段 | 命令的展示层 | stdout 格式和敏感字段 |
-replace 的写入与关闭责任 | 文件适配器及命令组合 | 目标前后状态和退出 4 |
内聚是相关规则放在能共同解释的地方;耦合是一个位置变化要迫使多少别的位置跟着改。01.09 已说明包、模块和导入路径;这里从“谁拥有这条知识”判断拆分位置。把长函数切成 step1、step2、step3 三个只转交同一组参数的函数,行数变短,却没有减少理解成本。
还有一种反例:为了未来可能出现的数据库、网络、缓存,先定义一个含 Open/Save/List/Delete/Flush/Close 的大接口,再给本地 JSON 写一套框架。当前工具只需有限能力,这些抽象会让初学者更难找到真实规则。先让实际变化揭示边界,再增加必要抽象。
三、重构之前建立独立的行为证据
10.01 的决策表提供 want,10.02 的测试检查错误与前后状态,10.04 的替身帮助固定时间或注入失败。将它们合成一个重构前的行为矩阵。这里列的是学习者应写出的预期,不是本项目已运行的结果:
| 案例 | 预期 | 前后证据 |
|---|---|---|
single、正文 你好、上限 6 | 等于上限合法 | 校验返回、保存次数 |
single、正文 你好呀、上限 6 | 超限拒绝 | errors.Is、旧消息原值 |
| 未知会话、空正文、上限 0 | BAD_KIND 优先 | 返回原因、不触碰存储 |
v1 messages:[] | 合法空历史 | check 数量 0 |
缺失或 null 的 messages | 输入历史无效,退出 3 | stderr、stdout 为空 |
目标已有字节 OLD | 无 -replace 拒绝,退出 4 | 目标仍为 OLD |
list 有两条消息 | 按文件次序输出 ID 与时间 | 不出现完整正文 |
边界错误的顺序也属于行为:先检查会话种类,再检查上限和正文。若重构时把长度检查移到更外层,三个条件同时无效的案例可能从 BAD_KIND 变成 BAD_LIMIT。只验证“有错误”不足以保护这个承诺。
对尚未写成规则的旧行为,可以先写特征测试(characterization test)记录实际观察,例如旧版遇到第二个 JSON 值输出什么;再判断它是应该保留的契约还是要修复的缺陷。特征测试回答“现在发生了什么”,业务决策表回答“应该发生什么”。两者冲突时先明确产品规则,并把修复与重构分开。
证据层次也不能合并:纯函数测试保护字节与优先级;注入 MessageSaver 的测试保护服务与保存边界;t.TempDir() 中的真实文件案例保护解码、路径、排他创建与关闭结果。这里不运行任何测试,学习者应在自己的实现中记录代码版本、命令、预期、实际退出码、两条输出流和文件前后字节。覆盖率高仍可能漏掉关键 6 字节边界。
四、按一步一种变化改造
下面是一个假想的单文件练习旧版片段,用于演示把 10.02 的正文规则移出入口;它不是 01.12 已实现的命令,也不为 v1 文件增加 kind 字段。旧版在入口处重复实现正文规则:
// 假想旧版入口片段;bodyLimit 是消息正文上限,不是文件 -max-bytes。
if kind != "single" && kind != "group" {
return invalidHistory("BAD_KIND")
}
if bodyLimit <= 0 {
return invalidHistory("BAD_LIMIT")
}
if body == "" {
return invalidHistory("EMPTY")
}
if len(body) > bodyLimit {
return invalidHistory("TOO_LONG")
}第一步把同一判断顺序提取为 history.ValidateMessage(kind, body, maxBytes),它返回 10.02 已定义的 (bool, string)。命令层仍负责把不合法原因包装成 ErrInvalidHistory 类别并映射退出 3:
ok, reason := history.ValidateMessage(kind, body, bodyLimit)
if !ok {
return invalidHistory(reason)
}这段代码只是调用处的替换;history.ValidateMessage 的具体实现见10.02。invalidHistory 是假想旧版的示意包装函数,不是标准库 API;若旧版已经承诺错误身份和退出映射,移动代码时必须保留。若提取后同时把 BAD_KIND、BAD_LIMIT 顺序交换,就已经改变可观察结果。对于 01.12 的课程设计,领域校验本来就在 history;本段只说明怎样从更早的单文件练习走到那个边界。
下一步才将版本 1 JSON、字节上限、未知字段和尾随值检查集中到 internal/historyfile.Load。移动代码时保持“整份验证成功才返回历史”的合同,再让 check/list/export 复用同一个读取入口。第三步把展示留在命令层,确保 list 的原顺序、默认不输出正文、失败时没有部分 stdout。第四步检查导出仍先完整验证再创建目标,默认使用排他创建,并保留关闭失败的错误。
每一步先读前后差异,再做与该边界相关的个人验证;不合预期就回退这一小步。步骤可以分别提交,提交说明应指出哪一段责任移动、保留了哪些外部合同。若一个函数提取后只多了一层转发而没有更清晰的责任边界,可以撤销该提取。
五、Go 包边界与信息隐藏
目标的依赖方向仍是:
cmd/imhistory ──→ internal/historyfile ──→ history
└────────────────────────────────→ historyhistory 决定消息字段、会话内唯一性和正文规则;它不应为了判断 6 字节正文而导入 os、flag 或 encoding/json。historyfile 知道如何在 v1 JSON 与领域数据之间转换,如何限制读取和归类格式错误;命令入口决定子命令、配置优先级、输出流及退出码。若让 history 反过来导入 historyfile,就会出现循环依赖,也使纯规则必须了解文件格式。
信息隐藏是在公开边界上只暴露调用者需要的能力和保证。10.04 的 MessageSaver 是消费方需要的一种能力;不必让文件适配器把所有内部方法都公开。internal 限制 Go 导入范围,能表达“仅模块内部用”,但不是安全沙箱,也不保护本机文件免受其他进程访问。
保持不变量时尤其要注意共享数据。若一个 History.Messages 切片直接返回给调用者,调用者可能修改底层数组,使“消息 ID 唯一、原文不变”的约定在不经过校验方法时失效。是否复制取决于所有权合同:返回独立快照需复制切片;若元素中还有 []byte、map 等可变字段,可能需要逐层复制。若明确返回只读视图,也要让接口和调用者约定其生命周期;Go 类型本身不会强制切片只读。01.04、01.05 的切片共享与 map 引用知识在这里成为模块边界问题。
公开 API 本身也是合同。包内私有 parseV1 改名通常不影响外部调用者;导出的 Load 改名、移包或变更参数,会影响依赖它的 Go 代码。CLI 的 -file、退出码和 JSON v1 字段更是外部界面,不能因为“内部重构”就顺手改掉。若确要调整,另立兼容或迁移任务。
六、命名、注释与错误语义
命名让读者在调用点就看见业务单位和责任。maxBytes 指字节上限,不能简写成让人误以为字符数的 maxLen;ErrInvalidHistory 表示输入历史无效,不能与“文件打不开”混为一谈。Go 包名宜简短、准确、避免 util 这类难以说明拥有哪种规则的容器,也避免 history.HistoryServiceHistory 式重复。
注释重点解释为什么这样约束,例如“先验证整份文件再输出,防止下游误读部分列表”“默认排他创建,避免无意覆盖”;不必把 count++ 翻译成“计数加一”。导出函数的文档还应交代输入、错误与资源责任。代码迁移后要更新已经失真的注释;错误注释比没有注释更容易误导后续改动。
错误的身份和分类应随着重构保留。historyfile.Load 可用 %w 包装 ErrInvalidHistory,命令层用 errors.Is 映射退出 3;打开、读取、写入或关闭的 I/O 错误映射退出 4。不能把包装改成只拼接文字,使 errors.Is 失效;也不能把文件不存在误归成“JSON 损坏”。诊断保留操作上下文和记录位置,默认不打印正文、原始 JSON 或真实用户资料。内部结构更清楚,是为了更容易守住这条对用户可见的边界。
七、反例、取舍与代码审查
| 看似合理的改法 | 隐藏变化 | 应怎样处理 |
|---|---|---|
| 一次重写全部 CLI | 错误码、文件顺序、覆盖规则同时改变 | 分小步,每步按行为矩阵复核 |
| 把所有代码抽成接口 | 无关方法传遍各包,理解路径更长 | 由具体消费方定义小接口 |
| 把 JSON 标签放进纯领域类型 | 文件版本知识渗入消息规则 | 在适配器定义文件 DTO 并转换 |
| “顺便”把上限 6 改 9 | 需求改变混入结构整理 | 单列需求变更与边界测试 |
| 用 fake 证明导出安全 | fake 不会遇到路径、关闭和部分写入 | 补真实文件边界案例 |
| 只看覆盖率和测试绿灯 | 漏掉错误优先级与既有目标不变 | 核对独立需求表和前后状态 |
一次评审可以问五个具体问题:这次差异只改变哪项内部责任?哪些用户可见合同明确保持?哪些用例在真实文件边界上观察过?是否改了公开包 API 或文件格式?若失败,能否只撤销本步而不丢失其他修改?评审记录应把“理论预期”和“实际运行证据”分栏。下一章 10.06 再完整讨论差异、冲突、提交与合并。
抽象也有成本。若只有一个稳定的本地文件实现,增加多层接口可能让读者更难追踪错误;若确有多个调用方和变化方向,小接口能减少扩散。当前目标是让本地 v1 工具易于改动,不应提前宣称未来 v2 已兼容、性能变快或崩溃后自动恢复。
八、个人迁移任务:先守旧合同,再引入新能力
在独立练习目录中按四轮推进,不把课程静态示例当真实执行记录:
- 基线:写下当前 v1 格式、命令、退出码、两条输出流、文件前后状态的决策表;选 6 字节边界、缺失
messages、目标已存在等高风险例子。 - 领域边界:从旧命令提取或复用
ValidateMessage,保留错误优先级和ErrInvalidHistory分类;记录前后差异和个人测试结果。 - 文件边界:集中 v1 解码、上限、重复 ID 与导出写入;在
t.TempDir中核对真实文件,不从替身推断持久性。 - 命令与后续功能:核对
check/list/export输出和退出码。重构完成并单独留证后,才提出 v2 或 9 字节需求,并为新行为写独立验收。
分层练习:先回答,再核对反馈
1. 重构首先要保持什么?
保持已定义的外部可观察行为;先列出谁观察命令、错误、文件或公开 Go API。
2. 把 6 字节上限改 9 是重构吗?
不是。合法输入集合变化了,这是需求变更。
3. 修复旧版拒绝恰好 6 字节的 bug 算纯重构吗?
不算。它改变了错误结果;应以需求和测试单独记录修复。
4. 为何先列 `stdout`、`stderr` 和退出码?
脚本和用户能直接观察它们;函数返回正确但通道或退出码改变,CLI 合同仍被破坏。
5. 内部局部变量改名一定要逐字保持吗?
不必,只要它不属于公开 API,且行为不变。
6. `messages:[]` 与缺失字段在 v1 中一样吗?
不一样。前者表示合法空数组,后者应按输入历史无效拒绝。
7. 为什么多重无效输入要测错误优先级?
条件检查顺序改变会改变用户看到的具体拒绝原因。
8. 特征测试的预期从哪里来?
来自对旧版的观察;它用于记录现状,还要与独立需求核对是否应保留。
9. 只在 `run` 中抽出十个 `stepN` 函数就更好吗?
不一定。若函数只传递同一堆参数,变化原因和责任仍未分开。
10. 纯 `history` 包为什么不导入 `flag`?
消息规则不应依赖命令行解析;这会把外部界面知识带进领域边界。
11. `internal` 是否能阻止本机其他进程读取文件?
不能。它限制 Go 包的导入范围,不提供操作系统权限隔离。
12. 为什么 `historyfile` 要依赖 `history` 而非反向?
文件适配器用领域规则解释记录;领域规则不必知道 JSON 或路径。
13. 导出方法改名可以无条件视作内部整理吗?
若它是公开 API,外部 Go 调用者会受影响,需要兼容评估。
14. 返回内部切片后调用者改了元素,说明什么?
模块的可变状态泄漏了;需要明确所有权,并按需要复制切片及其可变元素。
15. 什么时候注释比直接命名更有用?
解释约束缘由、失败承诺和非显然取舍时;重复代码表面动作的注释帮助有限。
16. 为什么错误包装用 `%w`?
保留可由 errors.Is 判断的身份,让命令层能稳定映射退出类别。
17. 文件打开失败能归为 JSON 内容无效吗?
不能。前者是 I/O 类别,后者才是输入内容类别;课程 CLI 分别映射退出 4 与 3。
18. fake 保存成功能证明 `-replace` 安全覆盖吗?
不能。路径、排他创建、写入和关闭需要真实文件边界证据。
19. 为什么保留 `list` 的原消息顺序?
01.12 已把文件顺序定义为输出合同;改成按时间排序会改变可观察结果。
20. 一个步骤失败时怎样限制返工范围?
每步只改一种责任,核对差异和对应行为,可单独撤销或修正该步。
21. 为 v2 预先建立庞大插件框架总是更易维护吗?
不是。先观察真实变化方向;无使用者的大接口增加成本和误用空间。
22. 重构后还有哪些事实不能宣称?
不能宣称已经实现 v2、崩溃原子导出、线上持久化或消息送达;这些都需要新的设计与证据。
来源与下一步
- Martin Fowler:Refactoring:行为保持与小步改造的定义及目录。
- A Philosophy of Software Design 作者说明:复杂度与模块设计的后续阅读入口。
- Effective Go 与 Go Code Review Comments:Go 命名、包和注释风格的官方参考。
下一章10.06 代码审查与合并将把这里的一次小步改造放进评审和合并流程。进入前应能展示一张前后行为矩阵,并解释哪一项真实文件证据仍需个人执行。