qinghuan
← 回到知识探索

后端现场 / 动手实验

解压内容已经读出来,为什么最后一步仍会失败?

给真实 GZIP 数据制造三种尾部损坏,比较读到 EOF、只读预期长度和提前关闭,区分拿到内容与完成校验。

展开本文目录
  1. 下载完整练习包
  2. 拿到最后一个内容字节,仍可能没有完成最后一次读取
  3. 尾部还带着校验值与原始长度记录
  4. 三个坏样本都先返回原文,然后在继续读取时失败
  5. close 释放资源,不会替你把剩余输入全部读完
  6. 运行完整脚本,保存六行矩阵与四份压缩样本
  7. 先定义何时可以把暂存内容升级为正式结果
  8. CRC 能帮助发现意外损坏,不能证明来源可信
  9. 记住这三点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
  • 能读懂 Java 字节数组、输入流和 IOException。
  • 知道 read 返回正数表示本次取得字节,-1 表示读到结尾。
  • 完整 JDK 17 或更新版本;只使用标准库,不需要联网下载压缩样本。

导入一份压缩表格时,标题和三行内容已经全部显示出来,程序却在继续读取时报告“压缩尾部损坏”。如果内容都能显示,为什么还不能宣布导入完成?流式解压允许先交出内容,而某些检查要到后面的边界才有足够信息执行。

本篇在内存中生成一个真实 GZIP 成员,只改动尾部,再交给 JDK 的 GZIPInputStream。六组对照分别记录拿到多少字节、是否与原文完全相同、有没有读到 -1,以及读取和关闭各自的错误。

把例子带到自己的电脑上

下载完整练习包

需要 JDK 17 或更新版本,无第三方依赖;制作时实测 openjdk 17.0.20.1 2026-08-18 LTS。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • GzipTrailerBoundary.java:完整示例
  • README.md:环境、范围与独立练习
  • observed.stdout.txt:制作时的实际输出
  • verification.json:源码与产物验证记录
  • SHA256SUMS.txt:文件内容摘要

在这个文件夹打开 PowerShell,逐行运行

java --version
java --source 17 GzipTrailerBoundary.java

解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。真实 JDK 17 读取自制单成员 GZIP,核对固定损坏样本的返回字节与尾部检查;不验证任意文件、来源真实性或线上导入。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

拿到最后一个内容字节,仍可能没有完成最后一次读取

小样本原文为一行标题和三行虚构标签,共三十二个 UTF-8 字节。循环每次最多请求七个字节,把已经返回的内容加入内存缓冲。某次调用把最后几个原文字节交出来,并不意味着这次调用也一定已经确认压缩流结束。

完整读取分支继续调用 read,直到看到 -1 或遇到异常。另一个分支在累计三十二字节时主动停止。两者都可能拿到与原文完全相同的内容,但只有第一种尝试走完库提供的结束读取路径。

因此报告分开保存 equals_expected_payload 与 saw_eof。这两个布尔值回答不同问题:前者比较已返回内容,后者表示是否实际观察到结束信号。不能用“数量正好够了”替换对流结束和错误结果的检查。

尾部还带着校验值与原始长度记录

本例由 GZIPOutputStream 生成一个成员,关闭压缩输出后才取出完整字节。这个成员的最后八个字节包含 CRC32 和 ISIZE。CRC32 根据未压缩内容计算,ISIZE 记录原始长度对二的三十二次方取模后的值。

脚本复制出三种损坏样本:翻转 CRC 字段的一个位、翻转 ISIZE 字段的一个位,以及移除最后四个字节。压缩内容部分保持原样,所以可以专门观察“内容能够解出来,尾部仍不符合”的边界。

这里用“总长度减八”和“总长度减四”定位,是因为样本由我们生成且只有一个成员。它不是通用 GZIP 编辑器,也没有处理任意拼接成员、可选头字段或外部文件结构。把这种位置计算拿到别的格式前,要先重新核对布局。

三个坏样本都先返回原文,然后在继续读取时失败

有效样本累计返回三十二字节,内容比较相同,最后读到 -1,没有读取错误。CRC 错误和 ISIZE 错误也先返回相同的三十二字节,但随后抛出 ZipException。移除了长度字段的样本则在后续读取中抛出 EOFException。

看到错误并不否定前面某些 read 已经成功返回过字节。它否定的是把这份成员当作已经通过完整读取检查的输入。如果业务已经在每次收到一行时直接写入正式结果,后来的失败就会要求你处理先前产生的效果。

脚本只把返回字节暂存在 ByteArrayOutputStream,方便在异常之后继续检查本次实际返回了什么。它没有将坏样本导入数据库,也没有把这些字节发布为成功结果。故障报告中的内容相同,是观察证据,不是接收坏输入的建议。

close 释放资源,不会替你把剩余输入全部读完

对同一个 CRC 损坏样本,改为累计三十二字节就停,再调用 close。制作时读操作没有报错,关闭也没有报错,但 saw_eof 仍为 false。只读前七个字节就关闭,结果同样没有错误,内容当然也尚未收齐。

这说明在本次 JDK17 路径中,提前关闭没有触发我们希望观察的尾部检查。把读取放进 try-with-resources,可以组织资源释放;它不会自动把“只读取一部分”的业务策略改成“读取到结束并验证”。

阅读固定版本源码能解释结果:read 在解压阶段返回结束时调用 readTrailer,校对累计 CRC 和长度;close 走释放资源路径。本篇把源码解释与实跑结果对应起来,但不把所有语言的解压库都说成拥有完全相同的关闭语义。

运行完整脚本,保存六行矩阵与四份压缩样本

把下方代码保存为 GzipTrailerBoundary.java,或从练习包解压同名文件,在该目录执行命令。程序先生成有效样本,再复制和修改字节,因此不依赖互联网上可能改变的下载内容,也不读取你的私人压缩文件。

gzip-output 中会留下 report.json、matrix.csv 和四份 gz 样本。下次运行会更新同名教学文件;也可以传入另一个输出目录。程序只生成三十二字节原文的微型输入,便于逐行查看结果,不能据此估算大文件解压速度。

制作时显式使用 Microsoft OpenJDK 17.0.20.1,六组断言全部通过,JSON 中记录了版本。完整内容先返回、随后校验失败的时序取决于本例输入与读取路径;换库或换读取封装后,应重新保留自己的结果。

矩阵同时记录读取错误与关闭错误,是为了避免只保留最后一步结果。三个坏样本关闭都没有报错,但读取阶段已经失败;这个后续的正常关闭不能把前面的失败改成成功。分析一份报告时,需要保留操作顺序和各阶段结论,不能只取最后一列。

展开收起代码 · 小实验java · 113 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · java
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;

public class GzipTrailerBoundary {
    record Row(String name, String policy, int bytesReturned, boolean expectedPayload,
               boolean sawEof, String readError, String closeError) {
        String json() {
            return String.format(Locale.ROOT,
                "{\"case\":\"%s\",\"policy\":\"%s\",\"bytes_returned\":%d,"
                + "\"equals_expected_payload\":%s,\"saw_eof\":%s,"
                + "\"read_error\":\"%s\",\"close_error\":\"%s\"}",
                name, policy, bytesReturned, expectedPayload, sawEof, readError, closeError);
        }
        String csv() {
            return String.join(",", name, policy, Integer.toString(bytesReturned),
                Boolean.toString(expectedPayload), Boolean.toString(sawEof), readError, closeError);
        }
    }

    static void require(boolean condition, String message) {
        if (!condition) throw new AssertionError(message);
    }

    static byte[] compress(byte[] payload) throws IOException {
        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        try (GZIPOutputStream gzip = new GZIPOutputStream(bytes)) { gzip.write(payload); }
        return bytes.toByteArray(); // Read after close has finished the GZIP member.
    }

    static Row inspect(String name, byte[] encoded, byte[] payload, String policy, int limit)
            throws IOException {
        GZIPInputStream gzip = new GZIPInputStream(new ByteArrayInputStream(encoded));
        ByteArrayOutputStream returned = new ByteArrayOutputStream();
        byte[] buffer = new byte[7];
        boolean eof = false;
        String readError = "none";
        String closeError = "none";
        try {
            while (policy.equals("to_eof") || returned.size() < limit) {
                int wanted = policy.equals("to_eof") ? buffer.length
                    : Math.min(buffer.length, limit - returned.size());
                int count = gzip.read(buffer, 0, wanted);
                if (count == -1) { eof = true; break; }
                returned.write(buffer, 0, count);
            }
        } catch (IOException expected) {
            readError = expected.getClass().getSimpleName();
        } finally {
            try { gzip.close(); }
            catch (IOException failure) { closeError = failure.getClass().getSimpleName(); }
        }
        return new Row(name, policy, returned.size(), Arrays.equals(returned.toByteArray(), payload),
            eof, readError, closeError);
    }

    public static void main(String[] args) throws Exception {
        Path output = args.length == 0 ? Path.of("gzip-output") : Path.of(args[0]);
        byte[] payload = "id,label\n1,alpha\n2,beta\n3,gamma\n".getBytes(StandardCharsets.UTF_8);
        byte[] valid = compress(payload);
        byte[] badCrc = valid.clone();
        badCrc[badCrc.length - 8] ^= 1; // Single member: first byte of its CRC32 trailer field.
        byte[] badSize = valid.clone();
        badSize[badSize.length - 4] ^= 1; // First byte of its ISIZE field.
        byte[] truncated = Arrays.copyOf(valid, valid.length - 4);
        List<Row> rows = List.of(
            inspect("valid_full", valid, payload, "to_eof", 0),
            inspect("bad_crc_full", badCrc, payload, "to_eof", 0),
            inspect("bad_size_full", badSize, payload, "to_eof", 0),
            inspect("truncated_full", truncated, payload, "to_eof", 0),
            inspect("bad_crc_expected_length", badCrc, payload, "stop_at_limit", payload.length),
            inspect("bad_crc_prefix_only", badCrc, payload, "stop_at_limit", 7)
        );
        Row good = rows.get(0);
        require(good.expectedPayload && good.sawEof && good.readError.equals("none"), "valid fixture");
        for (int i = 1; i <= 3; i++) {
            Row row = rows.get(i);
            require(row.expectedPayload && !row.sawEof, row.name + ": payload must precede the error");
            require(row.readError.equals(i == 3 ? "EOFException" : "ZipException"), row.name);
        }
        Row exact = rows.get(4);
        require(exact.expectedPayload && !exact.sawEof && exact.readError.equals("none"), "exact stop");
        Row prefix = rows.get(5);
        require(prefix.bytesReturned == 7 && !prefix.expectedPayload && !prefix.sawEof
            && prefix.readError.equals("none"), "prefix stop");
        require(rows.stream().allMatch(row -> row.closeError.equals("none")), "close results");
        Files.createDirectories(output);
        Files.write(output.resolve("valid.gz"), valid);
        Files.write(output.resolve("bad-crc.gz"), badCrc);
        Files.write(output.resolve("bad-size.gz"), badSize);
        Files.write(output.resolve("truncated.gz"), truncated);
        String version = System.getProperty("java.version");
        String json = "{\n  \"java_version\":\"" + version + "\",\n  \"payload_bytes\":"
            + payload.length + ",\n  \"fixture\":\"single_gzip_member\",\n  \"rows\":[\n    "
            + String.join(",\n    ", rows.stream().map(Row::json).toList()) + "\n  ]\n}\n";
        String csv = "case,policy,bytes_returned,equals_expected_payload,saw_eof,read_error,close_error\n"
            + String.join("\n", rows.stream().map(Row::csv).toList()) + "\n";
        Files.writeString(output.resolve("report.json"), json, StandardCharsets.UTF_8);
        Files.writeString(output.resolve("matrix.csv"), csv, StandardCharsets.UTF_8);
        System.out.println("Java " + version + "; 6 cases; payload bytes=" + payload.length);
        System.out.println("full read: valid=EOF; bad CRC/size=ZipException; truncated=EOFException");
        System.out.println("bad CRC: stop at expected length or prefix + close reports no error");
        System.out.println("PASS; " + output);
    }
}

怎样运行:java --source 17 GzipTrailerBoundary.java(在解压目录执行;需要完整 JDK 17 或更新版本,仅标准库,不要求 javac 在 PATH 中。) 2026-10-15 已用 openjdk 17.0.20.1 2026-08-18 LTS 执行这份代码并通过自带检查;下方是当次输出。真实 JDK 17 读取自制单成员 GZIP,核对固定损坏样本的返回字节与尾部检查;不验证任意文件、来源真实性或线上导入。

运行后对照结果
Java 17.0.20.1; 6 cases; payload bytes=32
full read: valid=EOF; bad CRC/size=ZipException; truncated=EOFException
bad CRC: stop at expected length or prefix + close reports no error
PASS; gzip-output

先定义何时可以把暂存内容升级为正式结果

如果业务要求整份导入通过后才能生效,可以把解压读取和业务提交分开:读取阶段形成暂存结果,只有预期范围完整结束、没有相关异常、业务字段也通过校验,才进入正式提交。收到第一行或最后一行都不是自动提交信号。

这是一种流程设计建议,本脚本并未实现数据库事务、临时文件重命名或上传服务。真实系统选择哪种暂存方式,还取决于文件大小和撤销成本。不要为了验证大文件而无上限地把全部解压内容装进内存。

有些产品只需要预览前几行,提前停止可以是明确的功能。此时应把结果标为“部分预览,完整性尚未核验”,而非“导入成功”。关键是调用方知道自己选择了哪个读取边界,并让展示状态与证据一致。

CRC 能帮助发现意外损坏,不能证明来源可信

本篇损坏的是校验字段或长度字段,所以读取到相应位置后能识别不一致。CRC 适合发现许多意外差错,但它不是数字签名;可以改写内容的人也可能重新计算匹配的 CRC。通过这项检查不能证明文件来自某个可信作者。

ISIZE 也不是任意大文件的精确总长度证明,因为字段只保存取模后的值。业务若有最大展开大小,应在读取时自行计数并施加限制,不能等读完后只看尾部字段才决定是否超限。

本实验没有覆盖多成员拼接、尾随垃圾、特殊头字段或资源耗尽等情况。没有异常地读到 -1,能说明的是当前库和当前输入路径完成了所执行的检查;它不等于整个文件格式、安全策略与来源认证都已经通过。

记住这三点

  • 已经返回完整原文字节,与已经读到结束并完成尾部检查,是不同的观察。
  • 提前 close 负责资源释放;本次 JDK17 实验中它没有补做未到达的 GZIP 尾部检查。
  • 完整导入、部分预览、意外损坏检查和来源认证,应分别定义成功条件。

别把例子用过头

  • 累计到预期长度就停,并把关闭无异常视作完整校验通过。
  • 边解压边产生不可撤销业务效果,却没有定义后续校验失败怎么办。
  • 把 CRC 或 ISIZE 当作真实性证明、完整安全检查或大文件精确长度承诺。

自己试一小步

读取缓冲大小和停止规则改变后,你还能指出校验真正发生在哪里吗?

  1. 自拟至少五行新标签,其中包含中文,并在运行前计算 UTF-8 字节数。
  2. 把读取缓冲分别改为一字节、八字节和大于原文长度,保留三份矩阵。
  3. 新增一个在“还差一个原文字节”时停止的分支,先预测内容相等、EOF、读取错误和关闭错误四项结果。
  4. 为“只预览两行”和“正式导入整份”分别写一个成功条件,并说明暂存数据在失败后怎样处理。

做到这些,就算完成

  • 字符数与 UTF-8 字节数分别记录,不能把中文字符直接按一字节计算。
  • 每组都有健康样本与尾部损坏样本,错误类型来自实际运行。
  • 没有通过删除断言把异常行为隐藏起来。
  • 交付源码、运行版本与矩阵,并明确未实现的数据库或文件提交步骤。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。