qinghuan
← 回到知识探索

后端现场 / 动手实验

PrintWriter 没抛异常,输出就一定成功了吗?

用真实 JDK 17 包装一个可控 Writer,让写入失败、刷新失败和自动刷新分别留下证据,找出调用方应该检查的错误通道。

展开本文目录
  1. 下载完整练习包
  2. 先确定错误从哪里交给调用方
  3. 用同一个故障点,对比直接写与包装后写
  4. checkError 会推进刷新,不能当作纯读取
  5. 字符串里有换行,不代表这个调用会自动刷新
  6. 运行七组对照,先核对小表再阅读实现
  7. 把错误检查写进成功条件,而不是随手记一条日志
  8. 资源释放与输出验收承担不同职责
  9. 记住这三点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
  • 能读懂 Java 类、继承、try/finally 和 IOException。
  • 已安装完整 JDK 17 或更新版本,能运行 java --version。
  • 知道 write 是写入、flush 是把缓冲内容向下一层推进,无需准备真实故障磁盘。

一个导出任务走到了最后一行,没有进入异常分支,于是页面提示“导出成功”。但如果中间使用 PrintWriter,只看代码有没有抛出 IOException,可能漏掉已经被记录在对象内部的输出错误。成功条件需要符合所选 API 的错误报告方式。

本篇让同一个内存 Writer 在指定位置抛出普通 IOException,分别直接调用它和交给真实 PrintWriter。七组对照会生成小型 CSV 与 JSON,把“方法返回了”“接收了字符”“发生了刷新”“错误标记是什么”分开记录。

把例子带到自己的电脑上

下载完整练习包

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

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

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

java --version
java --source 17 PrintWriterBoundary.java

解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。真实 JDK 17 PrintWriter 与可控内存 Writer 故障;矩阵在关闭前采样,未单独验收关闭阶段,不代表实际磁盘、网络或断电持久性。

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

先确定错误从哪里交给调用方

Writer 的写入与刷新接口允许通过 IOException 报告 I/O 失败。PrintWriter 则提供另一种约定:其输出方法不向调用方抛出这类异常,调用方可以用 checkError 查询错误状态。包装之后,不能直接沿用只等待异常的成功判定。

这不意味着所有 Writer 都会吞掉异常,也不意味着 PrintWriter 永远不会报任何错误。部分构造器可能在打开文件时抛出异常;错误参数和格式等问题还可能通过其他异常表现出来。本实验只研究对象创建之后的普通 I/O 故障。

负责输出的一层需要同时负责结果判断。如果工具方法内部创建 PrintWriter,外层又只收到一个“已执行”状态,两层之间就可能无人检查错误标记。先画出创建、写入、检查和关闭由谁负责,再决定成功状态应该从哪里返回。

用同一个故障点,对比直接写与包装后写

ProbeWriter 是本篇自己编写的测试替身。正常时,它把字符加入 StringBuilder;开启 failWrite 后,它在接收任何字符之前抛出 IOException。这样不用拔设备、填满磁盘或访问系统目录,也能精确知道失败发生在哪一步。

直接调用 ProbeWriter.write,得到 IOException,接收字符数为零。把同一类故障放到 PrintWriter 下面,write 却正常返回;随后 checkError 为 true,接收字符数仍为零。两个分支的输出目标都失败了,区别在于错误怎样被带回来。

这是可控的内存故障注入,外层 PrintWriter 使用的是实际 JDK 类。它验证包装边界和调用方的判断方式,没有证明某块真实磁盘、网络连接或权限故障已经复现。脚本也没有覆盖 InterruptedIOException 的特殊处理分支。

测试替身没有额外缓冲,接收字符数就是它已加入字符串的长度。真实项目若再包一层 BufferedWriter,错误出现时间还可能后移。增加缓冲不一定改变最终失败的性质,却会改变哪一个调用首次碰到故障,因此应把完整包装链纳入项目验证。

checkError 会推进刷新,不能当作纯读取

另一组先接受 hello 的五个字符,只在 flush 时抛出异常。调用 checkError 之前,刷新次数为零;调用之后变成一,错误标记为 true。这个错误是在检查所触发的刷新中发现的,不能简单写成“之前的 write 肯定没有成功返回”。

因此记录里同时保留 flushes_before_check 与 flushes_after_check。假如只看最终刷新次数,就可能把检查本身做的工作错算到 write 或自动刷新上。这种观察动作会改变对象状态,设计实验时应先记录观察之前的值。

健康对照同样接受五个字符,checkError 也执行一次刷新,但返回 false。它帮助我们确认 true 来自指定故障,而不是每次查询都会报错。false 表示检查时没有报告该类错误,并不替你确认内容符合业务要求。

字符串里有换行,不代表这个调用会自动刷新

把 autoFlush 设为 true,再分别调用 write("hello\n")、println("hello") 和 printf("%s", "hello")。在 checkError 之前,三组的刷新次数分别是零、一、一。触发条件与调用的方法有关,不能只盯着输出文字里有没有换行。

特别看第三组:printf 的格式里没有换行,仍发生自动刷新。第一组虽然明确写入换行字符,却没有触发同样的动作。PrintWriter 文档把 println、printf 与 format 列为自动刷新的触发方法,本实验实际对照前两种和普通 write。

println 使用当前平台的行分隔符。制作环境是 Windows,因此它接收了七个字符;write 中显式的单个换行只增加一个字符。跨平台验收应核对触发次数和错误状态,不应把这两个字符数硬写成所有系统的共同结果。

运行七组对照,先核对小表再阅读实现

把下方完整代码保存为 PrintWriterBoundary.java,或从练习包解压同名文件,在所在目录执行给出的命令。源码启动方式不需要先手工运行 javac,但需要包含编译能力的完整 JDK。先用 java --version 确认你实际调用的版本。

程序在当前工作目录的 printwriter-output 中写出 report.json 与 matrix.csv,重复运行会更新同名教学结果。报告文件由 Files.writeString 写出,让报告自身的写入错误继续向外传播,避免用未经检查的 PrintWriter 证明 PrintWriter 没有出错。

制作时显式调用 Microsoft OpenJDK 17.0.20.1,七组断言通过并打印 PASS。JSON 记录本次 java.version;你在其他环境运行时应保留自己的版本和结果。输出目录也可作为唯一命令行参数指定。

直接调用 Writer 的那一行没有 checkError 通道,所以报告填不适用,而不是 false。零个已接收字符、一次刷新和一个错误标记也不能互相替代;读表时应沿同一行检查全部字段,避免从某一个看似正常的值推出整次操作成功。

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

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

小实验 · java
import java.io.IOException;
import java.io.PrintWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

public class PrintWriterBoundary {
    // Deliberate in-memory fault injection, not a real disk failure.
    static final class ProbeWriter extends Writer {
        final boolean failWrite;
        final boolean failFlush;
        final StringBuilder accepted = new StringBuilder();
        int flushCalls;
        boolean closed;

        ProbeWriter(boolean failWrite, boolean failFlush) {
            this.failWrite = failWrite;
            this.failFlush = failFlush;
        }
        @Override public void write(char[] chars, int offset, int length) throws IOException {
            if (closed) throw new IOException("closed");
            if (failWrite) throw new IOException("injected write failure");
            accepted.append(chars, offset, length);
        }
        @Override public void flush() throws IOException {
            if (closed) throw new IOException("closed");
            flushCalls++;
            if (failFlush) throw new IOException("injected flush failure");
        }
        @Override public void close() throws IOException {
            if (!closed) {
                try { flush(); } finally { closed = true; }
            }
        }
    }

    record Row(String name, String writeOutcome, Boolean checkError,
               int acceptedChars, int flushesBeforeCheck, int flushesAfterCheck) {
        String json() {
            return String.format(Locale.ROOT,
                "{\"case\":\"%s\",\"write_outcome\":\"%s\",\"check_error\":%s,"
                + "\"accepted_chars\":%d,\"flushes_before_check\":%d,\"flushes_after_check\":%d}",
                name, writeOutcome, checkError, acceptedChars,
                flushesBeforeCheck, flushesAfterCheck);
        }
        String csv() {
            return String.join(",", name, writeOutcome,
                checkError == null ? "not_applicable" : checkError.toString(),
                Integer.toString(acceptedChars), Integer.toString(flushesBeforeCheck),
                Integer.toString(flushesAfterCheck));
        }
    }

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

    static Row directFailure() throws IOException {
        ProbeWriter sink = new ProbeWriter(true, false);
        String outcome = "returned";
        try {
            try { sink.write("hello"); }
            catch (IOException expected) { outcome = expected.getClass().getSimpleName(); }
            require(outcome.equals("IOException"), "direct Writer must report the injected failure");
            return new Row("direct_write_failure", outcome, null, sink.accepted.length(),
                sink.flushCalls, sink.flushCalls);
        } finally { sink.close(); }
    }

    static Row wrapped(String name, boolean failWrite, boolean failFlush,
                       boolean autoFlush, String operation, int expectedBefore,
                       boolean expectedError) {
        ProbeWriter sink = new ProbeWriter(failWrite, failFlush);
        PrintWriter writer = new PrintWriter(sink, autoFlush);
        try {
            // None of these PrintWriter calls declares IOException.
            switch (operation) {
                case "write" -> writer.write("hello");
                case "newline" -> writer.write("hello\n");
                case "println" -> writer.println("hello");
                case "printf" -> writer.printf("%s", "hello");
                default -> throw new IllegalArgumentException(operation);
            }
            int before = sink.flushCalls;
            boolean error = writer.checkError(); // This call also flushes an open writer.
            int after = sink.flushCalls;
            require(before == expectedBefore, name + ": unexpected automatic flush");
            require(after == before + 1, name + ": checkError must perform one flush here");
            require(error == expectedError, name + ": unexpected error flag");
            require(failWrite ? sink.accepted.length() == 0 : sink.accepted.length() > 0,
                name + ": unexpected accepted characters");
            return new Row(name, "returned", error, sink.accepted.length(), before, after);
        } finally { writer.close(); }
    }

    public static void main(String[] args) throws Exception {
        Path output = args.length == 0 ? Path.of("printwriter-output") : Path.of(args[0]);
        List<Row> rows = new ArrayList<>();
        rows.add(directFailure());
        rows.add(wrapped("wrapped_write_failure", true, false, false, "write", 0, true));
        rows.add(wrapped("flush_failure_on_check", false, true, false, "write", 0, true));
        rows.add(wrapped("healthy_control", false, false, false, "write", 0, false));
        rows.add(wrapped("auto_write_newline", false, false, true, "newline", 0, false));
        rows.add(wrapped("auto_println", false, false, true, "println", 1, false));
        rows.add(wrapped("auto_printf_no_newline", false, false, true, "printf", 1, false));
        Files.createDirectories(output);
        String version = System.getProperty("java.version");
        String json = "{\n  \"java_version\":\"" + version
            + "\",\n  \"fault_model\":\"in_memory_ordinary_IOException\",\n  \"rows\":[\n    "
            + String.join(",\n    ", rows.stream().map(Row::json).toList()) + "\n  ]\n}\n";
        String csv = "case,write_outcome,check_error,accepted_chars,flushes_before_check,flushes_after_check\n"
            + String.join("\n", rows.stream().map(Row::csv).toList()) + "\n";
        // Report creation deliberately uses an API that propagates IOException.
        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 + "; 7 cases; " + output);
        System.out.println("direct: IOException; wrapped: returned + checkError=true");
        System.out.println("autoFlush before check: write(newline)=0; println=1; printf(no newline)=1");
        System.out.println("PASS");
    }
}

怎样运行:java --source 17 PrintWriterBoundary.java(在解压目录执行;需要完整 JDK 17 或更新版本,仅标准库,不要求 javac 在 PATH 中。) 2026-10-15 已用 openjdk 17.0.20.1 2026-08-18 LTS 执行这份代码并通过自带检查;下方是当次输出。真实 JDK 17 PrintWriter 与可控内存 Writer 故障;矩阵在关闭前采样,未单独验收关闭阶段,不代表实际磁盘、网络或断电持久性。

运行后对照结果
Java 17.0.20.1; 7 cases; printwriter-output
direct: IOException; wrapped: returned + checkError=true
autoFlush before check: write(newline)=0; println=1; printf(no newline)=1
PASS

把错误检查写进成功条件,而不是随手记一条日志

如果当前业务继续使用 PrintWriter,负责完成输出的一层应在约定的完成边界检查错误状态,并在为 true 时阻止“成功”结果继续传播。只是把 true 写到调试日志,然后仍向用户显示完成,并没有改变错误处理结果。

错误标记也不是原始异常详情。需要精确追踪哪次输出失败、保留具体 IOException 或交给现有异常链处理时,可以评估直接使用会传播异常的 Writer 接口,把字符串格式化与实际写出分开。选型应服务于调用方需要的失败信息。

不要直接在一个只调用 PrintWriter.write 的 try 块外添加 catch(IOException),就认为已经补上检查。该方法本身没有声明这个受检异常;若块里没有其他可能抛出它的操作,这样的 catch 还会导致编译错误。错误通道要按实际调用核对。

资源释放与输出验收承担不同职责

try-with-resources 可以保证按规则执行关闭动作,但不会替你定义“导出成功”。PrintWriter 的 close 也沿用自己的 I/O 错误报告方式。需要把关闭阶段纳入验收时,应明确最终错误检查位置,不能只因资源块正常离开就发出成功状态。

本脚本的矩阵在 finally 关闭对象之前取值,主要对照写入与刷新故障。刷新失败组的故障会持续到关闭时触发的再次刷新,但报告没有单独记录关闭阶段,也没有隔离一个仅在关闭时失败的场景。独立练习会要求补上这一边界,并区分关闭前后检查。

刷新也不等于断电后一定可恢复。Writer 文档明确区分把数据交给操作系统与写到物理设备;本篇使用内存目标,更没有进行持久性测试。导出内容正确、输出无报告错误、文件可重新读取和持久性要求,应按业务分别验收。

记住这三点

  • PrintWriter 的普通 I/O 错误需要按其错误状态通道检查,不能只等 write 抛出异常。
  • checkError 在对象未关闭时会刷新,观察前后计数才能辨认错误发生时机。
  • autoFlush 的触发取决于方法,write 中出现换行并不等同于调用 println。

别把例子用过头

  • 把 PrintWriter 的约定推广成所有 Writer 都不抛 IOException。
  • 先调用 checkError,再把新增的刷新误算成自动刷新。
  • 正常离开资源块或得到 false,就宣称业务内容、真实设备与持久性全部通过。

自己试一小步

如果故障只发生在关闭阶段,谁应该阻止输出任务报告成功?

  1. 复制脚本,增加一个只在 close 中抛普通 IOException 的测试替身。
  2. 先预测直接调用、PrintWriter 包装、关闭前检查与关闭后检查分别能观察到什么。
  3. 给每组记录增加关闭次数、关闭结果和最终错误标记,实际运行后对照预测。
  4. 设计一个明确拥有输出对象的工具方法,说明成功判定放在哪一层,并保留一次健康对照。

做到这些,就算完成

  • 新的故障点确实只在关闭阶段触发,不能沿用写入失败代替。
  • 区分方法是否返回、底层是否完成关闭和调用方是否报告成功。
  • 说明自定义替身的范围,不把它称为已复现真实磁盘故障。
  • 保留原始报告,不只交一条 PASS。

查原始资料

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