Go
文件、JSON、正则与时间
使用 os、filepath、encoding/json、regexp 与 time 安全处理跨平台路径、结构化数据和时间。
发布于 2026年7月23日
文件、JSON、正则与时间
文件、JSON、正则和时间看似是标准库常识,却集中体现外部输入的不可信、平台差异和契约演进。路径不能手工拼接,JSON 不能静默吞掉所有未知字段,正则不能替代解析器,业务时间也不能混用本地时区和 UTC。
一、学习目标
- 使用 filepath 构造本地路径并限制写入范围
- 以流式和整文件方式安全读写
- 设计稳定 JSON DTO 与严格解码边界
- 正确编译、复用正则并限制输入
- 使用 UTC、Location 与注入时钟处理时间
二、路径与文件边界
本地文件路径使用 path/filepath,URL 路径使用 path,两者不要混用:
dataPath := filepath.Join(configDir, "studytasks", "tasks.json")
clean := filepath.Clean(dataPath)
来自用户的相对路径可能包含 ..、绝对路径或符号链接。若程序只允许在数据目录内写入,必须明确验证最终目标,而不是只做字符串前缀比较。创建目录时选择最小必要权限,并理解 Windows 权限模型与 Unix mode 不完全等价。
三、读取与写入
小配置可用 os.ReadFile,大型或流式数据使用 os.Open 与缓冲读取。对不可信输入设置大小限制,例如先检查文件信息并通过 io.LimitReader 限制读取量。
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read task file %q: %w", path, err)
}
写入时先写同目录临时文件、同步并关闭,再按平台策略替换目标。临时文件与目标处在同一文件系统,才能最大化 rename 的原子性;仍要说明 Windows 替换差异和单写者约束。
四、JSON 契约
持久化使用独立 DTO:
type taskDTO struct {
ID string `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
CreatedAt time.Time `json:"createdAt"`
}
面对配置或严格 API,可使用 json.Decoder 的 DisallowUnknownFields,并检查输入后没有额外 JSON 值。对长期存储应加入 schema 版本并提供迁移路径。不要直接序列化内部未稳定结构后承诺永远兼容。
数字进入 any 时默认解码为 float64;需要保持大整数应使用明确结构或 UseNumber。
五、正则与边界
Go 的 regexp 使用 RE2 语法,保证线性时间,不支持回溯引用等特性。固定模式应在初始化时编译:
var taskIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{2,39}$`)
MustCompile 适合开发者控制的常量;用户提供的模式应使用 Compile 返回错误。正则适合格式筛选,不适合解析嵌套语言、完整 URL 或 SQL。即使算法线性,也要限制输入大小和结果数量。
六、时间、时区与时钟
持久化和协议通常使用 UTC:
now := time.Now().UTC()
encoded := now.Format(time.RFC3339Nano)
面向用户展示时再加载 time.Location 转换。time.Time 可能携带单调时钟部分,序列化后会丢失;测量进程内耗时可用 time.Since(start),业务比较则使用明确 UTC 时间。
测试不要依赖 time.Sleep 等真实时间推进。注入 func() time.Time 或小型 Clock 接口,固定边界时刻和时区转换。
七、从知识点到工程契约
本篇的示例最终要进入可维护的 Go 包,而不是停留在 main 中的一次性片段。先把目标写成调用者可以观察的契约:输入是否允许零值或 nil,返回值是否是快照,错误能否通过 errors.Is/As 分类,函数是否启动 goroutine、取得资源或修改共享状态。然后再选择结构体、接口、函数值或泛型;抽象形式必须服务于契约,而不是反过来决定需求。
可以用以下顺序把知识点落到工程代码:
- 在独立小函数中写出最小成功路径,并让
go test能直接调用。 - 加入一个与“用字符串拼接本地路径或把 filepath 用于 URL”相关的失败样例,确认失败可观察且不会留下半完成状态。
- 把文件、网络、时间、环境或并发等外部因素改成显式依赖,测试使用临时目录、固定时钟或本地服务。
- 运行 gofmt、vet 和相关测试;涉及共享状态时追加
-race,涉及解析器时追加有上限的 fuzz。 - 最后再评估 API 是否需要导出。只在同一模块内部使用的能力保留在
internal,避免过早形成公共兼容负担。
审查代码时至少回答四个问题:谁拥有数据,谁允许修改,失败由谁处理,工作由谁停止。Go 的垃圾回收只解决不可达内存回收,不会替你关闭文件、取消请求、等待 goroutine 或恢复被覆盖的数据。只要其中一个问题没有答案,就先缩小函数或包的边界。
本篇最重要的能力是“使用 filepath 构造本地路径并限制写入范围”。不要用注释替代可执行约束:能由类型表达的就交给类型,能由构造或验证表达的就返回错误,能由测试观察的就保存回归用例。示例扩展到 StudyTasks 时,还要保持领域包不导入命令行、文件和 HTTP 细节。
八、验证策略与复盘
验证分为静态、动态和故障三层。静态层检查格式、模块图和分析器;动态层用正常输入证明结果;故障层主动制造取消、权限、损坏数据、超时或竞态。一次测试通过只能说明执行过的路径符合断言,不能证明所有输入都安全,因此需要让每条关键契约至少对应一个成功用例和一个反例。
建议保存下面的复盘记录:
| 项目 | 需要记录的证据 |
|---|---|
| 版本 | go version、模块与 toolchain 指令 |
| 输入 | 最小正常值、零值、边界值和非法值 |
| 状态 | 调用前后数据、资源和 goroutine 的所有者 |
| 输出 | 返回值、错误链、stdout/stderr 与日志字段 |
| 失败 | 第一个失败点、清理动作和可恢复状态 |
| 工具 | 实际运行的 test、race、vet、benchmark 或 build 命令 |
完成验证后,用另一份干净临时目录重跑,不读取开发机的用户配置、缓存数据或真实网络。若测试只能按特定顺序成功,就说明状态隔离仍不完整。若为了让测试通过必须长时间 sleep,应改用 channel、WaitGroup、context 或可注入时钟表达确定的同步条件。
本篇可以用以下目标做验收:以流式和整文件方式安全读写;设计稳定 JSON DTO 与严格解码边界;正确编译、复用正则并限制输入。把它们逐项转成命令输出或断言,而不是写成“人工看起来正确”。当实现与预期不符时,先保存最小失败样例,再调整设计。
发布前再做一次反向审查:从调用方而不是实现内部出发,写出一个完全不知道具体类型和文件布局的使用示例;从故障点出发,假设进程在每个 I/O 之后被取消;从升级出发,假设下一版改变字段或默认值。若调用方必须知道未公开细节、故障会留下无法判断的状态,或升级只能覆盖旧数据,契约就还不完整。把这三个场景加入测试或文档,比继续增加抽象更有价值。
最后检查示例能否被复制到一份最小程序独立运行,所有导入、错误处理和清理是否完整。教学代码可以省略与主题无关的界面,却不能省略会改变正确性的 context、Close、边界检查或同步。对为了篇幅省略的部分要明确标注,不能让读者把伪代码误当成生产承诺。
九、StudyTasks 实践
实现 StudyTasks JSON DTO、严格解码和 UTC 时间字段。增加超大输入、未知字段、损坏 JSON、无效 TaskID、夏令时转换与数据目录越界测试;所有测试使用临时目录和固定时钟。
完成本节后,不要只保存代码或 SQL。请同时保存执行命令、关键输出和失败案例;学习笔记真正有价值的部分,是能够说明输入、状态变化、输出以及失败后的恢复方式。
十、常见错误
- 用字符串拼接本地路径或把 filepath 用于 URL
- 无限制读取用户控制的文件
- 直接把内部领域结构当永久 JSON Schema
- 为用户输入调用 regexp.MustCompile
- 持久化本地时间却没有时区或偏移信息
十一、练习与自测
- 实现只允许数据目录内相对路径的验证函数。
- 让 JSON 解码拒绝未知字段和第二个顶层值。
- 比较
time.Time的==与Equal,解释位置和单调时钟影响。 - 为固定正则写成功、失败和超长输入测试。
自测时应在干净的临时目录或临时数据库中重新执行,而不是依赖上一节遗留的状态。如果结果与预期不同,先记录实际输出,再缩小问题范围。
十二、官方资料
版本行为与二手文章不一致时,以本系列固定版本的官方文档、命令输出和可重复测试结果为准。
上一篇:迭代器、slices/maps 与数据流水线 下一篇:命令行、配置与结构化日志