Go 语言里没碰过io/ioutil的开发者应该很少。早期写 Go 项目,读文件用ioutil.ReadFile,读整个流用ioutil.ReadAll,列目录用ioutil.ReadDir,几乎成了标准动作。但很多人并不知道,这个包从 Go 1.16 开始就被官方逐步标记为 Deprecated,大部分功能迁到了io和os。为什么一个赫赫有名的包会被官方“拆掉”?它的源码结构到底是怎么样的?这对我们平时写代码、做代码评审有什么实际影响?这篇文章就从源码层面把io/ioutil从头到尾拆一遍,讲讲它的实现细节、历史包袱,以及迁移到新 API 时真正容易踩的坑。无论你是正在维护老项目,还是准备在新代码里彻底告别ioutil,这篇文章应该都能帮你少走一些弯路。
1. 为什么还在分析一个被标记 Deprecated 的包
先回答一个最实际的问题:一个已经被官方嫌弃的包,还有什么好分析的?
我的判断是:恰恰因为它被 Deprecated,才更值得花时间看源码。io/ioutil本质上不是一个复杂的包,它的绝大多数函数都是对io和os底层能力的再封装。看它的源码,等于看 Go 标准库早期对“API 边界”的一次探索:哪些能力属于流式 IO,哪些能力属于文件系统操作,哪些能力属于系统进程边界。搞清楚了这些划分逻辑,你才真正理解 Go 1.16 之后为什么把ReadAll挪到io,把ReadFile、WriteFile、ReadDir、TempFile、TempDir挪到os。
这套迁移不是简单的“换个位置”,而是对职责边界的重新梳理。很多老代码在迁移时只机械地改了函数名,却忽略了返回值类型、错误语义、排序保证这些细节差异,结果在测试环境和线上环境来回踩坑。源码分析能帮你把这些差异全部找出来。
另外,源码里藏着的很多细节,在官方文档里是看不到的。比如TempFile对 pattern 中*的处理方式,WriteFile在写失败时留下的半截文件,ReadDir对目录项排序的保证,以及Discard在 HTTP 连接复用里的妙用。这些都是只有读过源码才会注意到的点。
1.1 io/ioutil 的定位与历史包袱
io/ioutil出现在 Go 非常早期的版本里。它的定位很简单:提供一些“很方便但不太好归类”的函数。比如ReadAll,它本质上是把任意io.Reader的内容全部读进[]byte,逻辑上属于io包;ReadFile和WriteFile则是典型的文件系统操作,应该属于os包;TempFile和TempDir更是直接依赖操作系统的临时目录语义。但在早期,Go 团队倾向于把所有“高层的、面向应用开发者的便捷函数”集中在ioutil里,避免io和os两个包过度膨胀。
这个设计在 Go 1.0 时代是合理的。但到了 Go 1.16 前后,标准库内部已经发展出io/fs这一整套文件系统抽象,os包也有了更成熟的目录读取接口os.ReadDir,继续把文件操作函数放在ioutil里就显得很尴尬。一方面是语义不够清晰,另一方面是很多人在实际使用时会误以为ioutil是“纯内存操作”,忽略了它底层会打开文件句柄、执行系统调用。
于是 Go 团队在 Go 1.16 的 release note 里明确宣布:ioutil包内的函数逐一被取代,新代码不要再用。
不过事情并没有这么简单。标准库为了兼容性,并没有直接删掉ioutil,而是让ioutil里的函数变成一层薄薄的转发外壳。所以我们分析ioutil源码,实际上是在分析一个“历史 API 层”和“现代 API 层”之间的映射关系。
1.2 先建立 io、os、ioutil 的边界感
分析io/ioutil之前,建议先把三个包的边界理清楚。io包关注的是流式读写抽象,围绕Reader、Writer、ReaderAt、WriterAt这些接口展开,不关心数据来自文件还是网络还是内存。os包关注的是操作系统资源,包括文件句柄、进程、信号、环境变量、路径操作。ioutil则是一个早期的“工具人”,把一些既像 IO 又像 OS 的操作揉在一起。
| 包名 | 核心职责 | 典型抽象 |
|---|---|---|
| io | 流式读写、组合 | Reader / Writer / ReadWriter / Closer |
| os | 操作系统资源访问 | File / Process / Signal / Env |
| ioutil | 早期便捷函数集合 | ReadAll / ReadFile / ReadDir / TempFile |
之所以强调这个边界,是因为迁移时很多人会把ioutil.ReadFile直接改成io.ReadFile,然后发现编译不过。原因很简单:ReadFile根本不归io管,它归os。只有ReadAll、NopCloser、Discard这些还在io的职责范围内。边界感建立起来之后,后面看每个函数的源码,你就会很清楚“它为什么被放在这里”。
2. 核心函数逐个源码拆解
接下来进入正题。我按函数逐个拆,每个函数都会带上关键源码结构、演进过程、以及实际使用中的注意点。
2.1 ReadAll:一切读取的最终落脚点
ioutil.ReadAll应该是整个包里被调用得最频繁的函数。在 Go 1.16 之前,它的实现大致是这样的思路:
func ReadAll(r io.Reader) ([]byte, error) { return readAll(r, bytes.MinRead) } func readAll(r io.Reader, capacity int64) (b []byte, err error) { buf := bytes.NewBuffer(make([]byte, 0, capacity)) if _, err = buf.ReadFrom(r); err != nil { return nil, err } return buf.Bytes(), nil }这里的bytes.MinRead是 512。也就是说,早期实现会先用一个 512 字节的bytes.Buffer来接数据,然后依赖ReadFrom内部的动态扩容机制把整个流读完。bytes.Buffer.ReadFrom内部会做倍增扩容,并且会尝试利用io.ReaderFrom接口的优化路径,如果目标 Reader 是*bytes.Buffer或*os.File这类实现了ReaderFrom的类型,可以直接批量迁移数据,减少拷贝次数。
Go 1.16 之后,ioutil.ReadAll变成了对io.ReadAll的一行转发,而新的io.ReadAll实现更聪明。它对常见的*bytes.Reader和*strings.Reader做了特殊处理,直接读取底层数据切片,绕开了一层循环 Read;对于其他 Reader,则从 512 字节的切片开始,不断按需扩容,直到读到 EOF。
func ReadAll(r Reader) ([]byte, error) { if r, ok := r.(*strings.Reader); ok { // 直接返回底层数据 } if r, ok := r.(*bytes.Reader); ok { // 直接返回底层数据 } b := make([]byte, 0, 512) for { if len(b) == cap(b) { b = append(b, 0)[:len(b)] } n, err := r.Read(b[len(b):cap(b)]) b = b[:len(b)+n] if err != nil { if err == EOF { err = nil } return b, err } } }这里最值得注意的就是 EOF 处理:Read返回的err == EOF会被转换成nil,这意味着读到了文件结尾不是错误,而是一种正常终止条件。很多初学者在手动写读取循环时容易忽略这一点,结果在读到空文件时得到[0x0]和EOF两个返回值,反而把错误吞掉了。
实战中的建议:如果只是读取一个不超过几十 MB 的流,ioutil.ReadAll或io.ReadAll完全够用。但如果你要从网络响应里读一个可能很大的 body,最好配合io.LimitReader做一个上限保护,否则一个异常响应可能直接把内存打爆。
2.2 ReadFile / WriteFile:封装背后的异常语义
ioutil.ReadFile的逻辑更直白。Go 1.16 之前,它的内部实现等价于打开文件、读取全部内容、再关闭文件。Go 1.16 之后,它就是一行转发给os.ReadFile。
但源码里真正值得展开的是WriteFile。它的核心实现:
func WriteFile(filename string, data []byte, perm os.FileMode) error { f, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) if err != nil { return err } _, err = f.Write(data) if err1 := f.Close(); err == nil { err = err1 } return err }这段代码有四个隐藏语义,不读源码很难发现。
第一,文件打开模式是O_WRONLY|O_CREATE|O_TRUNC。如果文件已存在,会先截断成空文件再写入。如果你只是想在文件末尾追加内容,千万不要用WriteFile,它会把你原有数据清空。
第二,权限位perm不是最终落盘权限。它会被进程的umask过滤掉。比如你传0666,但系统umask是022,最终文件权限会是0644。这是一个让很多人困惑的点,因为从代码上看你明明指定了权限,结果却不一致。
第三,WriteFile不调用f.Sync()。数据写入文件句柄的缓冲区后,不一定立刻落盘。如果进程在写完后崩溃,数据可能丢失。对配置类、缓存类文件问题不大,但如果你在写数据库 WAL 或重要的消息记录,必须在WriteFile之后单独调用f.Sync(),或者改用自己控制更细粒度流程的方式。
第四,如果写入过程中发生错误,文件不会自动删除,残留的可能是一段截断后的半截文件。因为这个封装不会为你做回滚。日常开发中,我见过不少服务因为WriteFile写了一半磁盘满了,重启后加载一个不完整的配置直接 panic。更稳妥的做法是先写临时文件,再rename覆盖目标文件。
2.3 ReadDir:目录读取的排序真相
ioutil.ReadDir的早期实现比很多人想象中复杂。它不是简单的os.Open+Readdirnames,而是直接读取底层目录流,解析每个目录项的d_name、d_type、d_reclen,然后组装成os.FileInfo。
这个过程在不同平台上有完全不同的实现。Linux 上要用getdents,BSD 系统上要用getdirentries,Windows 上要用另一套 API。这也是ioutil.ReadDir历史实现中平台相关性最强的一个函数。
更重要的是,早期实现里有一段排序逻辑:
sort.Sort(byName(list))这意味着ioutil.ReadDir返回的文件列表,在历史上一直被保证是按文件名排序的。很多老项目在断言目录内容顺序时,悄悄依赖了这个保证。
Go 1.16 之后的实现变成了这样:先调用os.ReadDir,拿到[]DirEntry,再逐个调用Info()转成[]FileInfo。
os.ReadDir的文档明确承诺:返回结果按文件名排序。所以新路径下,ioutil.ReadDir依然保持排序语义。
但这里有一个陷阱:os.ReadDir返回的是[]os.DirEntry,ioutil.ReadDir返回的是[]fs.FileInfo。如果你直接把旧代码里的ioutil.ReadDir改成os.ReadDir,后续调用entry.Mode()、entry.ModTime()等FileInfo方法都会编译失败。必须通过entry.Info()补充获取完整信息。这个类型差异我在后面专门写一节,因为它是最常见的迁移事故现场。
另一个容易忽略的差异是:os.File.ReadDir如果分批读取,返回顺序并不保证稳定,只有os.ReadDir这个函数级封装才保证排序。如果你在代码里直接操作*os.File的ReadDir方法,一定要自己排一遍序,不然测试环境复现不出来,生产环境偶尔出问题。
2.4 TempFile / TempDir:临时文件的坑比想象中多
ioutil.TempFile的源码同样在 Go 1.16 后变成了os.CreateTemp的转发外壳。它的核心逻辑是一个带重试的循环:用随机字符串生成候选文件名,用O_CREATE|O_EXCL原子创建文件,如果文件名冲突则换一个随机名重试。
有一点很多人不知道:pattern参数中的最后一个*是特殊占位符。只有当 pattern 里包含*时,*才会被替换为一串随机数。例如pattern = "log-*.txt",可能生成log-123456.txt;但如果pattern = "log.txt",最终生成的文件名会是log.txt123456,而不是log.txt。
这个行为在源码里是由prefixAndSuffix函数决定的。当时我第一次遇到这个现象时也很困惑:我明明指定了文件名后缀.txt,为什么生成的文件变成了log.txt123456?后来翻源码才明白,*是随机数的锚点,没有*就等价于pattern + random。
TempDir类似,区别是它创建的是目录,权限固定为0700,并且通过Mkdir保证原子性。TempFile创建的临时文件权限固定是0600,拿到手之后如果你需要其他权限,要自己Chmod。
临时文件还有一个老生常谈的问题:用完一定要清理。不要以为在/tmp里就万事大吉了。我在实际操作中见过因为进程没退出、临时文件堆积导致整个节点磁盘写满的案例,后面会在故障排查部分展开讲。
2.5 Discard 与 NopCloser:容易被忽略的两个工具
Discard的源码极短:
type discard struct{} func (discard) Write(p []byte) (int, error) { return len(p), nil } var Discard io.Writer = discard{}它的作用就是无条件接受所有写入,并返回“全部写入成功”。看起来毫无用处,但在网络编程里非常实用。因为 HTTP 客户端如果只关闭resp.Body而不读取内容,底层连接可能会被标记为不可复用。正确的做法是先io.Copy(io.Discard, resp.Body)把残留数据读掉,再resp.Body.Close(),这样连接可以还给连接池继续用。
NopCloser也很简单:
func NopCloser(r io.Reader) io.ReadCloser { return nopCloser{r} } type nopCloser struct { io.Reader } func (nopCloser) Close() error { return nil }它把一个io.Reader包装成io.ReadCloser,Close()永远返回nil。适用的场景是:你的业务函数需要接收一个io.ReadCloser参数,但实际数据源是bytes.Reader或strings.Reader,没有也不需要真正的关闭语义。用NopCloser包一层,就能满足接口要求,又不会引发副作用。
这两个工具在迁移时对应的是io.Discard和io.NopCloser,用法完全一致,只是位置变了。
3. ioutil 的隐形边界与那些年踩过的坑
这一部分聊几个真正影响线上稳定性的细节。很多问题不是ioutil本身有 bug,而是因为我们对它的封装语义缺少敬畏。
3.1 大文件不能无脑 ReadAll
ReadAll的复杂度是 O(n) 时间、O(n) 内存,而且内存是动态翻倍增长的。如果你试图用它读一个 10GB 的日志文件,内存峰值可能接近文件大小的两倍,因为在扩容过程中,旧切片和新切片有一个短暂的共存期,GC 还没回收旧内存,新内存又分配出来了。这个时候如果你的服务还在处理其他请求,很容易直接 OOM。
正确的思路是:大文件一律走流式处理,用bufio.Scanner逐行读,或者用io.Copy写入目标文件。如果你确实需要一次性读取全部内容,至少先用os.Stat看一眼文件大小,做一个阈值判断,超过阈值就走流式分支。
我在实际项目里还踩过一个更隐蔽的坑:从 HTTP 接口读响应体时用了ioutil.ReadAll,而响应体大小完全由对方控制。对方如果返回一个几 GB 的数据流,我的进程直接被打挂。之后我给所有外部请求都加上了io.LimitReader包装,限制最大读取量,并且设置了响应体大小上限。不要信任任何上游输入,这是读完ReadAll源码之后最该记住的一句话。
3.2 临时文件的清理与权限陷阱
TempFile和TempDir返回一个随机位置的文件或目录,自动清理的责任完全在用户。很多人写完临时文件后忘了defer os.Remove,进程长时间运行后/tmp下全是垃圾文件。
清理时机也很讲究,不一定要在函数退出时立刻删。我有一个习惯:如果临时文件只用于单个函数内部的中间计算,那就只在一个函数内使用,退出前无条件删除;如果临时文件需要跨函数传递,那就在外层函数里用defer统一清理,并且把清理逻辑放在显眼位置,避免后续维护的人不知道这个文件是临时产物。
权限方面,TempFile的0600权限在很多场景下可能不够。比如你生成了一个临时 socket 文件或 Unix 域套接字,其他进程需要连接它,如果权限是0600,其他用户根本连不上。这时候就需要主动Chmod或Chown,但改权限又引入了新的安全风险。我的建议是:尽量在一个专用临时目录里操作,不要直接使用系统全局/tmp。
3.3 目录读取展开的语义差异
ioutil.ReadDir返回的是[]fs.FileInfo,每个FileInfo包含了Name()、Size()、Mode()、ModTime()、IsDir()等完整信息。它的缺点是:获取这些信息需要为每个目录项做一次lstat系统调用。如果目录里有数万个文件,这个成本非常可观。
os.ReadDir返回的是[]os.DirEntry,DirEntry只保证告诉你“文件名”和“类型”,Info()方法会在第一时间返回FileInfo。如果只是需要判断子目录数量、读取文件名列表,直接使用DirEntry能省掉大量系统调用。
所以在做目录遍历时,新代码应该优先选择os.ReadDir+DirEntry,把Info()的调用推迟到真正需要文件大小和修改时间的场景。这也是 Go 标准库后来引入io/fs.DirEntry的重要动机。而如果你还在维护老代码,依赖ioutil.ReadDir时,心里要清楚:这个函数返回的完整FileInfo是有代价的,不是白送的。
3.4 错误处理不当的句柄泄漏
ReadFile和WriteFile这种封装函数会替你处理文件句柄的打开和关闭,整体风险可控。但很多开发者在读源码后,喜欢模仿这种模式自己写中间层,这时候最容易出现的问题就是:打开文件后忘记defer Close,或者在有多个 return 分支时漏掉了关闭。
相比之下,TempFile更危险。它返回的是一个已经打开的文件句柄,如果你在创建临时文件后没有及时Close,文件会一直占着句柄。而且在 Windows 上,一个被打开的临时文件无法被删除,甚至程序自身想清理都会失败。我建议把TempFile的使用模式固定成下面这样:
f, err := os.CreateTemp("", "example-*.tmp") if err != nil { return err } defer func() { f.Close() os.Remove(f.Name()) }()这样无论后面的逻辑怎么走,最终都会关闭并清理临时文件。如果服务异常退出,最坏也只会残留一个文件在磁盘上,而不会把句柄一直占住不放。
4. 从 ioutil 迁移到 io / os 新 API 的实操清单
如果项目已经切换到 Go 1.16+,迁移ioutil是一件性价比很高的事。但你得按清单来,避免踩到前面说的那些坑。
4.1 新旧 API 替代映射表
先给出完整的对应关系。这张表我建议直接贴在项目文档里,方便团队迁移时对照。
| 旧 ioutil API | 新 API | 返回值类型变化 |
|---|---|---|
| ioutil.ReadAll(r) | io.ReadAll(r) | 无变化 |
| ioutil.ReadFile(path) | os.ReadFile(path) | 无变化 |
| ioutil.WriteFile(path, data, perm) | os.WriteFile(path, data, perm) | 无变化 |
| ioutil.ReadDir(dir) | os.ReadDir(dir) | []fs.FileInfo 变为 []os.DirEntry |
| ioutil.TempFile(dir, pattern) | os.CreateTemp(dir, pattern) | 无变化,返回 *os.File |
| ioutil.TempDir(dir, pattern) | os.MkdirTemp(dir, pattern) | 无变化,返回 string |
| ioutil.NopCloser(r) | io.NopCloser(r) | 无变化 |
| ioutil.Discard | io.Discard | 无变化 |
这里最显眼的变化就是ReadDir的返回值。其他函数几乎一一对应,迁移成本很低,但ReadDir需要额外的适配代码。
4.2 迁移示例与行为差异
对于ReadFile、WriteFile、ReadAll,迁移基本就是改 import 和函数名。以前你可能要同时 importio/ioutil和os,迁移后只保留os或io。
// before import ( "io/ioutil" ) data, err := ioutil.ReadFile("config.yml") if err != nil { log.Fatal(err) } // after import ( "os" ) data, err := os.ReadFile("config.yml") if err != nil { log.Fatal(err) }ReadDir的迁移稍微复杂一点。旧代码可能是这样的:
files, err := ioutil.ReadDir("/var/log") for _, f := range files { fmt.Println(f.Name(), f.Mode(), f.ModTime()) }迁到os.ReadDir后,f的类型变成了os.DirEntry,f.Mode()和f.ModTime()这行代码立刻会编译失败。正确改法是:
entries, err := os.ReadDir("/var/log") for _, entry := range entries { info, err := entry.Info() if err != nil { // 处理异常 continue } fmt.Println(entry.Name(), info.Mode(), info.ModTime()) }注意:DirEntry.Info()是有可能返回错误的,尤其是当一个目录项在遍历过程中被删除或者权限发生变化时。所以不能省略错误处理。这也是迁移ReadDir时最容易留下隐患的地方。
迁移过程中建议跑一遍go vet,新版工具会明确提示ioutil中哪些函数已被废弃,并建议替换成什么。如果你想在团队层面强制推行,可以引入 lint 规则,直接把io/ioutil加到禁用列表里,从源头阻止新代码继续使用这个包。
4.3 什么时候可以继续用 ioutil
虽然ioutil已经被官方标记为 Deprecated,但老项目里它不一定需要立刻消失。如果项目还在兼容 Go 1.15 及以下版本,那只能继续用ioutil,因为新 API 根本不存在于旧版本里。
另外有一种情况:项目里大量代码依赖ioutil.ReadDir返回[]fs.FileInfo的语义,而且这些代码遍布在几十个模块里。这种时候大规模替换的收益并不高,反而容易因为类型差异引入新的 bug。更好的策略是逐步替换:新写的代码直接用新 API,老代码只在自己正好要改动的模块里顺手迁移。
我个人不太赞同“为了迁移而迁移”。标准库的废弃标记是提醒你不要在新代码里使用,不是逼迫你把所有历史代码一夜之间改完。关键在于,团队里每个人都要知道这些 API 的来源和边界,看到ioutil时心里有一个“这是一个旧封装层”的认知,这就够了。
5. 实战故障排查:ioutil 相关的典型现场
最后分享几个我在实际项目中遇到过的,和ioutil相关的真实故障。这里不涉及具体公司和项目,只讲现象和解法。
5.1 高并发下 ReadFile 打爆内存
曾经有个批处理服务,每小时并发读取几十个数据文件,每个文件大约 200MB,使用ioutil.ReadFile处理。平时没问题,但在业务高峰期,上游同时推送了两次数据,并发文件数量翻倍,进程内存突然飙升,最终 OOM。
用 pprof 分析后,内存主要被bytes.Buffer和底层读取循环占据。ReadFile本身没有流式处理的可能,它就是把整个文件读进内存。解决方案是把处理逻辑改成边读边算:按行读取,逐行处理,用一个bufio.Scanner把单行数据丢给下游。改造后峰值内存从几百 MB 降到了几十 MB。
这个案例给我的教训是:标准库封装虽然方便,但它不会替你考虑业务的数据规模。所有“一次读取全部”的函数,都要先估算数据量再决定是否使用。
5.2 读目录不排序引发的可复现性问题
另一个项目里,测试代码用ioutil.ReadDir读一个产物目录,断言文件顺序。有一段时间测试偶尔失败,失败时文件顺序和期望不一样。第一反应是测试代码写错了,但后来发现是有人在重构时把ioutil.ReadDir改成了os.File.ReadDir(-1),以为两者等价。
实际上,os.ReadDir函数会排序,但os.File.ReadDir方法按系统目录流返回顺序给出数据,不同文件系统、不同目录项删除历史,都会影响顺序。解决办法很简单:在读取后统一sort.Slice,或者重新改成os.ReadDir。
这类问题最难排查的地方在于:它在绝大多数机器上稳定复现不了,只在特定目录、特定文件系统状态下才会出现。以后看到“偶发顺序不一致”这种问题,优先检查是不是用了未排序的目录读取原语。
5.3 临时文件堆积拖垮节点磁盘
还有一次是线上服务突然报“no space left on device”,查了一圈发现/tmp被占满。定位后找到一堆命名类似cache.log1234567890的文件,数量上万。
最后发现是某个定时任务误把 pattern 写成了cache.log,而不是cache-*.log。由于 pattern 里没有*,TempFile把所有文件都生成了cache.log + 随机字符串,看起来毫无规则,而且代码里也没有清理逻辑,文件越积越多。
经验总结有两点:第一,使用os.CreateTemp/ioutil.TempFile时,pattern 一定要刻意包含*作为随机占位符,这样生成的文件名可读性才正常;第二,创建临时文件的那一刻,就应该顺手写好defer os.Remove,不要想着“后面再清理”,后面大概率会忘。
5.4 迁移 ReadDir 后遇到的类型暗礁
有一次团队做标准库迁移,有人把ioutil.ReadDir直接改成了os.ReadDir,然后编译报错,原因是后续代码用了FileInfo的Mode()、ModTime()。这时候一般开发者会想当然地这么改:
entries, err := os.ReadDir(dir) for _, entry := range entries { info, _ := entry.Info() fmt.Println(info.Mode()) }直接把Info()错误忽略掉。这种代码在大部分情况下能跑,但一旦某个目录项在遍历期间被删除,Info()返回错误,程序拿到一个空的FileInfo,后续读取Mode()可能返回零值而不是真实权限,逻辑判断就会出错。
所以这里一定要养成习惯:凡是调用DirEntry.Info(),必须处理错误。哪怕只是log.Printf,也好过静默忽略。
6. 写在最后:一点个人体会
我读io/ioutil源码最大的收获,不是记住了某个函数内部怎么写,而是理解了标准库演进的一条主线:职责归位。早期标准库为了易用性,把很多函数堆在一个包里,随着 Go 生态逐渐成熟,官方开始慢慢梳理边界,把 IO 抽象的归io,把文件系统操作的归os,把文件系统接口定义归io/fs。
如果你也在维护一个不断长大的项目,这个思路同样适用:当一个公共包开始出现“什么都往里塞”的迹象时,就是时候考虑分层和拆分边界了。ioutil的源码本身只是几百行代码,但它背后承载的 API 设计教训,比这几百行代码值钱得多。
至于迁移这件事,我的态度一直比较务实:新代码坚决不再用ioutil,老代码不强求一天改完,但每次路过顺手改一处,慢慢地历史包袱就没了。如果真的遇到ReadDir这类返回值类型差异比较大的迁移,记得先写测试把目录顺序、文件元数据这些行为钉死,再动手改。这样改完心里才有底。最后再分享一个小经验:阅读标准库源码时,多配合历史版本的提交信息一起看,尤其是每个函数“为什么从 A 包挪到 B 包”的理由,往往比函数本身的实现更值得研究。