深入 Ruby 19:文件、IO 与路径的资源边界
返回字符串之后,文件还开着吗
下面两个函数都能读出相同内容,却把资源责任交给了不同的地方:
1 | |
第一个函数创建流,block 结束时关闭流;第二个函数接收调用者已经拥有的流,读取结束后仍由调用者决定是否关闭。把第二个函数也写成“读完就 close”,会破坏调用者接着读取其他记录的计划。IO 接口设计首先要回答谁拥有资源,才有可能正确处理异常与关闭。
前置是第 03 篇的编码和第 18 篇的异常传播。完整实验在 examples/ruby/labs/19/run.rb,使用临时目录、普通本地文件和 CRuby 3.4.11。文件系统的掉电恢复和多进程写入协调没有被这组实验覆盖。
block 把生命周期限制在调用范围
保留流对象引用,可以在异常之后检查是否真的关闭:
1 | |
这个断言检查的是 block 打开形式的保证:消费过程抛出异常后,File.open 的资源作用域仍完成关闭。外层捕获只负责验证,不负责补做 close。如果实验先在捕获分支手动关闭,再断言关闭,就无法证明 API 自身履行了约定。
Ruby 对象可能被垃圾回收,文件对象也存在相应资源释放行为,但释放时间不该成为程序正确性的前提。文件描述符是有限资源;大量短生命周期对象尚未触发垃圾回收时,操作系统资源就可能先耗尽。block 写法把释放时间与一次调用完成绑定,避免把资源压力交给内存回收时机。IO 文档 列出流的模式、位置和关闭状态。
若资源确实要跨越一次调用保存,例如返回一个供调用者持续读取的文件对象,API 就应明确转移所有权。返回仍打开的流并不天然错误,含糊地返回才会使两边都关闭或两边都不关闭。枚举器也可能延迟实际读取;创建枚举器所在的 block 结束以后,流已经关闭,不能期待延迟消费自动延长文件生命周期。
字节读取与字符解释是两个步骤
read(2) 推进的是流位置。实验选择 ASCII 字节,避免把“每次读取两个字节”误读为“每次读取两个中文字符”。中文字符在 UTF-8 下通常占多个字节,二进制块读取可能在字符中间切开。若块边界直接被当作完整字符串边界,后续解码会遇到被截断的字符。
文本处理可以通过行读取表达记录边界,但行本身也需要限制。一个没有换行的巨大输入依旧是一行;把 each_line 称为“天然限制内存”会隐藏最长行的成本。Taskbook 后续采用读取上限加一字节的方式,对教学输入设置总量限制,然后再解析。大型流式格式应另行定义分块、解码器状态和记录上限。
force_encoding 只改变字符串携带的编码解释,不会把错误字节转换成正确字符。因此实验故意写入 0xFF,再要求 UTF-8 到 UTF-16LE 的转码:
1 | |
这是转换失败的证据,不是说所有读取非法 UTF-8 的操作都立即抛异常。只标记外部编码而不发生转码时,程序可能得到带非法字节的字符串,随后 valid_encoding? 返回假。输入边界需要在选定编码后检查有效性;“能读出来”不足以说明文本可以被安全解析。
用替代字符修补非法字节也是一种策略,但会修改原始数据。任务 ID、签名、配置键和其他身份字段通常不能静默替换,否则两份不同输入可能变成同一个值。需要容错展示的日志与必须精确识别的业务数据,应使用不同的处理政策。
打开模式决定什么时候破坏旧内容
'w' 打开现有文件时会截断旧内容。即使随后校验失败、一字节都没有写入,旧文件也可能已经变空。先校验再打开输出路径,是一个具体的顺序约束,无法靠 ensure 恢复被截断的数据。
'a' 允许追加,适合追加式记录,却不能自动把多次 Ruby 写入合并成一个不可分割业务事务。'r+' 允许读写,但共享游标以及覆盖长度都需要调用方处理。'b' 指定二进制处理方式,避免平台文本转换介入原始字节协议。File 文档 的模式表适合在修改写入方式时逐项对照。
如果要求“目标存在就失败”,不能先 File.exist? 再 File.write。检查结束到写入之间,另一个进程可能创建目标。应把判定交给打开动作:
1 | |
这限定了创建条件,但不保证整个内容要么全写要么不写。创建后写到一半失败,仍可能留下不完整的新文件。第 27 篇的 CLI 将“拒绝覆盖”作为明确合同,同时保留这个失败边界,不把它描述成事务输出。
rename 改变可见名称,fsync 处理另一个问题
需要替换旧报告时,可以先在目标所在目录完成候选文件,再进行重命名:
1 | |
本地实验观察到目标内容变为新值,候选名称消失。同目录使示例避开了跨文件系统移动这个额外条件。示例的固定候选名称仅存在于独占临时目录;真实共享目录应使用不会互相冲突的临时名称。
flush 将 Ruby 层缓冲提交给底层,fsync 请求同步文件。目录项改变、存储设备缓存、文件系统和操作系统规则又是另一层。只有文件同步动作,不能凭空证明断电后新名称与新数据都按期望恢复。需要严格耐久性时,应依据目标平台制定文件与目录的同步流程,再进行故障注入。
原子可见性与写入竞争同样不同。两个进程分别产生完整候选文件再重命名,即使读者从未看到半份内容,也可能出现后一个覆盖前一个的更新丢失。锁、版本比较或持久存储事务解决的是写入者协调;“先写临时文件”本身不解决这个问题。
路径解析不构成访问授权
File.join 拼接路径,File.expand_path 可以整理相对部分,但都不自动判断访问者是否有权读取目标。把用户提供的 ../ 拼进工作目录仍可能得到外部路径。符号链接也让“字符串看起来在目录下”和“实际访问对象在目录下”产生差异。
本篇实验的目录与文件名由程序创建,避免引入不可信路径。Taskbook CLI 接受本地用户显式提供的路径;它不是面对远端请求的文件浏览服务。后续安全章节才为受限目录定义访问规则,不能把这里的方便函数直接复用成远端文件访问接口。
路径还受当前工作目录影响。库内资源定位宜从 __dir__ 出发,CLI 的用户输入路径则通常按调用者工作目录解释。这两种基准服务不同目的。悄悄在库里 Dir.chdir 会改变整个进程的相对路径解释,尤其不适合共享进程中的组件。
EOF、空文本和未完成记录
读取结果还需要区分空数据与结束状态。带长度的读取到达流末尾时可能返回 nil,不带长度的整段读取则可以得到空字符串;逐行读取的 gets 在末尾返回 nil,readline 则用异常表达末尾。这些接口差异会影响循环停止条件,不能只凭方法名称相似就互换。
空字符串在 Ruby 中为真,因此把读取结果直接当作真值条件,需要先确认接口用什么值表示结束。一个没有内容的合法文件可以表示空输入,却不一定表示合法任务集合。是否接受空内容,是格式层与领域层的决定;文件系统只提供字节,无法知道应用想表达空数组还是缺失配置。
管道与普通文件的等待方式也不同。普通文件的末尾可以由当前长度判断,管道在写端仍打开时可能继续等待。测试通过 Open3 传入 stdin_data 后,输入端关闭才能让读取方知道数据结束。如果调用者忘记关闭写端,解析函数看起来像“卡死”,根因可能是输入协议从未发送结束信号。
异常关闭测试最好在多个位置施加故障:打开前、第一次读取后、解析后和输出前。打开前失败时没有流可关闭,打开后失败才需要验证资源释放。不要在所有失败分支都盲目 close 一个可能尚未赋值的变量,进而用新的 NoMethodError 掩盖原来的文件错误。block API 正好把创建成功之后的所有权范围表达出来。
运行与练习
1 | |
脚本检查部分读取、异常关闭、非法编码转换和同目录替换;evidence/19/run.txt 记录实际输出。成功只说明这些本地场景成立,不能替代并发写入或掉电测试。
练习一:把 ASCII 文件换成包含中文的 UTF-8 文本,对比 read(2)、each_char 与 each_line 的边界,给每种读取方式写断言。练习二:在写完候选文件、尚未重命名时主动抛异常,断言旧目标保持原内容,并设计清理候选文件的所有权规则。
系列导航
导读 · 上一篇:18:异常、ensure 与 throw/catch · 下一篇:20:require、load 与 autoload · 完整源码包
