导入文件不能只检查读到了字符串

商品导入接收一个输入流,期望得到 UTF-8 文本。成功读取一个小文件,并不能证明导入器已经正确处理资源与输入边界:网络流可能每次只返回一个字节,文件可能带字节顺序标记,读取可能在中途失败,用户也可能提交超过内存预算的内容。方法名称中的 copy、toString、readFile,并没有统一规定谁负责关闭、允许读多少或失败后保留什么。

实验固定 Commons IO 2.22.0,源码提交为 c14acc16f73e44a75b2062b17aacb26c4feda746。Java 8 与 Java 21 分别运行完整的 Chapter25Test.java,有界复制实现见 BoundedIo.java。项目和命令见 复跑说明。本篇只使用受控字节数组与临时文件,不把本机文件测试当成网络超时验证。

短读是一次进展,不是文件结束

InputStream.read(buffer) 返回正数,只表示这次读到了这些字节;返回值小于缓冲区长度并不表示 EOF。结束信号是 -1。测试中的 ShortInput 每次最多提供一个字节,内容为 UTF-8 的“商品”,总计六字节。IOUtils.copy 必须持续读取,最终输出完整文本并返回 6。

若循环写成“本次读满缓冲区才继续”,第一个字节之后就会停止。这个错误可能在 ByteArrayInputStream 的普通输入中被掩盖,因为它经常一次填满请求范围;刻意限制每次读取量,才能让测试命中真实的流契约。测试 double 的职责是改变输入节奏,不改变最终数据。

IOUtils.copyLarge 的固定源码以 read 返回 -1 作为循环结束条件,每轮只写实际读到的长度。普通 copy 的 int 返回值还存在计数范围限制,大输入需要查看 copyLarge 的 long 结果;返回类型变大只解决数量表示,不提供输入大小限制。

第一项可迁移判断是将单次读取进展与整体完成分开。短读、空输入、完整输入、读取异常分别是不同状态。代码要由 EOF 或明确协议长度决定完成条件,不能从某次缓冲区未填满推导传输结束。

流所有权必须落到具体重载

IOUtils 的流到流复制一般不关闭输入与输出,调用方仍负责资源生命周期。实验用带 closed 标志的输入流证明:copy 返回后 closed 仍为 false,显式 close 后才变为 true。官方 IOUtils 文档也将这个责任留给调用者。拥有文件、套接字或压缩包装流的代码,应在同一个明确边界完成关闭。

FileUtils 的两个相近方法却不能互换。copyInputStreamToFile 会关闭传入的输入流;copyToFile(InputStream, File) 不关闭输入流。测试分别执行它们,断言前者 closed=true、后者 closed=false,同时读取目标文件核对真实内容。仅凭两个名称都包含 copy,不足以判断它们是否可以接受由外层管理的流。

调用入口 输入资源责任 本组验证
IOUtils.copy(input, output) 调用方关闭 copy 后输入仍打开
FileUtils.copyInputStreamToFile 方法关闭输入 copy 后输入已关闭
FileUtils.copyToFile 调用方关闭输入 copy 后输入仍打开
BOMInputStream.close() 关闭被包装流 try-with-resources 后底层已关闭

区别来自 FileUtils 固定实现。拥有输入时,入口用资源管理包住 source,再调用复制逻辑。借用输入时,入口只管理自身创建的输出流。应用代码也适合采用这种结构:创建或明确接管资源的一方负责关闭,借用型函数保留调用方后续使用权。

try-with-resources 解决的是关闭时机,不会自动回滚已经写入的目标文件。如果复制到一半失败,目标文件可能已经包含部分内容。导入结果需要全成或全败时,应写入私有临时位置,校验通过后再发布;不能因为异常被传播就认定目标未变化。

BOM 与字符集是两层处理

UTF-8 BOM 的字节序列为 EF BB BF。直接使用 UTF-8 将包含 BOM 的字节数组转成 String,开头会包含 U+FEFF;如果第一列是 sku,实际表头可能成为带隐藏前缀的另一个字符串。字符集解码正确,不代表业务字段已经去掉可选标记。

实验构造 BOM 加上 sku,先证明普通解码得到的字符串以 U+FEFF 开头,再通过 BOMInputStream 检测并排除 BOM,随后明确使用 UTF-8 解码,结果才是 sku。这里没有通过 trim 碰运气删除字符,也没有把所有不可见字符统一认定为空白。

BOMInputStream 源码会读取足够判断所配置 BOM 的前缀字节,再根据 include 选项决定是否把标记继续交给消费者。默认配置只检测 UTF-8 BOM,不能据此声称自动识别任意文本编码。若协议还允许 UTF-16,必须同时决定允许哪些 BOM、BOM 与声明字符集冲突时是否拒绝,以及后续用哪个解码器。

错误字节同样有独立策略。测试将非法 UTF-8 序列交给配置 REPORT 的 CharsetDecoder,要求抛出 CharacterCodingException。默认替换字符可能适合展示不完整日志,却可能掩盖商品编号损坏。严格导入应明确选择拒绝,而不是在某个字符串构造器的默认行为下无声改变输入。

全量读取之前先定义预算

IOUtils.toString 与 FileUtils.readFileToString 的结果是完整字符串。即使内部使用缓冲区读取,也仍然需要为最终全部内容保留内存。将“分块读取”写成“不会全量占用内存”是把实现过程与最终结果混淆。内存峰值还可能包含中间字节、字符缓冲区和业务对象,不能仅用文件字节数估计完整导入成本。

本实验的 BoundedIo.copy 保留一个固定缓冲区,逐次累计实际读到的字节。每轮最多请求剩余额度加一个字节,用额外字节识别“恰好达到预算”与“还有更多内容”。发现超限后,在写入超额字节之前抛出 IOException。输入六字节、预算五字节、每次短读一字节时,输出恰好五字节并失败;输入恰好五字节则成功。

这不是截断接口。成功必须表示完整输入已到 EOF,并且实际字节数没有超过预算;静默复制前五字节并返回成功,会让调用方把残缺文件当成完整导入。零预算也有明确定义:空输入可成功,任何额外字节都失败。

读取预算按字节计,文本长度预算按解码后的字符或业务单元计,两者不能替代。UTF-8 的“商品”是两个汉字、六个字节;同样数量的字符可能占用不同字节数。上传层限制原始字节,解析层限制记录和字段,业务层限制实体数量,分别保护不同的资源消耗。

第二项可迁移判断是把预算放在资源真正增加的位置。文件头中的 size、HTTP 声明长度和压缩条目声明大小都可以用于提前拒绝,但不能替代实际读取累计。计数必须发生在输出之前,并检查剩余额度,避免先写入再发现超限。

异常传播不等于原子成功

读取失败测试构造一个会在读取中抛出 IOException 的输入。BoundedIo 传播失败,外层 try-with-resources 仍关闭输入;测试没有把已收到的前缀当成成功返回值。这里区分了数据完整性与资源释放:失败后资源应该关闭,但数据是否需要删除还由拥有目标的上层决定。

异常可能发生在 read、write 或 close 三个阶段。当前实验直接注入 read 失败,没有穷举磁盘满、文件系统故障和所有关闭异常。文章的实测结论限定在这条路径,生产导入器需要根据实际输出介质补充失败清理检查。捕获异常后返回空字符串,会同时丢失错误位置与已经完成多少工作的事实,不适合作为严格导入策略。

时间预算在每次读前、读后检查 System.nanoTime 的经过时间,达到预算就失败。它能限制循环继续执行,却不能强制终止一个永不返回的底层 read。网络流仍需要传输层超时或独立取消机制;不能将局部循环的时间检查描述为完整的请求截止时间保障。

标准库可以承担哪些工作

有界复制还需要区分输入消耗与输出消耗。输出流可能是 ByteArrayOutputStream,缓冲区虽固定,目标仍会不断增长;也可能是压缩输出,最终文件字节数小于传入字节数。预算名称应说明限制的是复制边界看到的原始字节,不能借此推算任意下游包装器的内存或文件大小。

测试通过短读让五字节前缀确实写出,再在第六字节失败。普通流一次返回六字节时,同一个实现可能在写出这块之前就失败,输出前缀因读取分块不同而不同。这不违反契约,因为失败结果从未承诺保留某个长度的可用前缀。调用方若依赖失败文件长度进行断点续传,就需要另行设计分块确认协议。

输入流不应由多个线程并发交给同一次复制逻辑。预算计数只属于当前调用,不能协调其他读者或写者;调用方必须确保资源所有权和并发访问规则一致。本组测试使用单线程受控流,没有验证共享套接字或并发文件写入。

Java 8 的 Files.newInputStream、Files.newOutputStream 与 try-with-resources 已能明确管理文件流,Files.copy 也能完成常见复制。Commons IO 的收益是提供统一的复制、BOM 等组合入口,并减少重复实现;它不替应用决定字符集、输入信任、覆盖策略和预算。需求只有一次本地文件复制时,标准库通常已足够。

使用工具类时仍应保留可观测的输入与输出契约。复制函数可以返回已完成字节数,调用方可以记录文件标识和错误类别,但不应为排查问题把完整商品内容或凭据写进日志。日志需要的是哪一批、哪个阶段、什么限制触发,不能把数据副本当成唯一诊断手段。

完整示例保持 Java 8,未使用较新 JDK 的 InputStream.transferTo 或 Files.readString。升级到现代 API 可以改变代码入口,却不会取消这些所有权与预算问题;迁移测试应保留相同短读、失败和超限输入,而不是只验证新方法能编译。

结果与练习

五组测试覆盖短读、BOM 与严格解码、实际字节预算、FileUtils 关闭差异和读取异常。JDK 8 与 JDK 21 的原始 Surefire 报告随实验保存;没有运行真实网络、磁盘故障或内存压测。观察到完整字节与明确异常,比仅检查函数没有抛出异常更能证明复制契约。

手算题:两个 UTF-8 汉字的预算设为五字节,复制是否应该返回前五字节?应当失败;前缀不是合法完整导入结果。改动练习是在输出流的第三次 write 注入异常,并由拥有临时文件的代码删除失败产物,同时断言输入已关闭和最终发布路径不存在。

判断关键词 可迁移模式 具体选择
短读 进展与完成分开 以 EOF 或协议长度判断完成
包装流、复制重载 所有权决定关闭 明确借用与接管入口
超大输入 按实际消耗执行业务预算 写前累计,超限失败而非截断成功
BOM、坏字节 标记处理与解码分层 明确编码、标记范围和错误策略

可独立复跑的 Maven 项目见 下载 25–30 实验包,包含固定依赖配置、完整源码与 Maven Wrapper。

系列起点:可复现基线。