Java 文件导出:写完了,为什么还不能标记成功?

导出方法没有漏写 catch,最后也把任务改成了失败,为什么还要检查它?

因为最终是 FAILED,不代表过程中没有报过 SUCCESS

下面是一个小程序的实际输出:三条虚构订单写进了真实临时文件,随后人为注入关闭异常。提前标成功的版本,状态走成了这样:

text 复制代码
RUNNING > SUCCESS > FAILED

暂存文件里有完整的 60 个字符,最终结果引用却是空的。问题不在于文件一定没生成,而在于成功状态写早了。

本文只看这个顺序问题:写入、关闭资源、准备正式结果,哪一步之后才能更新成功。末尾有完整代码,不需要数据库、Spring 或网盘文件。

先排除一个直白的错误

如果成功状态写在 finally 里,失败就可能被覆盖。完整示例中的这两段足够说明问题:

java 复制代码
} catch (IOException e) {
    task.fail(e);
} finally {
    if (mode == Mode.FINALLY_SUCCESS) {
        task.success(target); // 故意保留的错误写法。
    }
}

模拟写入失败后,实际轨迹是:

text 复制代码
RUNNING > FAILED > SUCCESS

这版甚至返回了一个并不存在的正式文件路径。这个错误改掉不难,后面那种"看起来已经改好了"的写法更值得检查。

把成功移进 try,也可能早了一步

try-with-resources 会自动关闭资源,但不是在执行最后一条 write() 时就关闭。

下面这段摘自示例的 EARLY_SUCCESS 分支:

java 复制代码
try (Writer out = openWriter(part, scenario)) {
    writeCsv(out);
    task.events.add("WRITTEN");
    if (mode == Mode.EARLY_SUCCESS) {
        task.success(target); // 这里执行时,close 还没调用。
    }
}

执行顺序是:写数据、更新成功、退出代码块时关闭资源。关闭阶段也可能抛异常;带资源的 try 对应的 catch 在资源关闭之后执行。Oracle 对关闭顺序的说明

在这个例子里,catch 接到关闭异常后会清空结果引用,再把任务改成 FAILED。所以最终状态是失败,历史记录里却已经出现过一次成功。

如果你的系统只在这个方法返回后读取状态,本例不能证明前端一定看到了那次成功。这里记录的是单线程内的状态变更;并发读取是否会暴露它,需要针对实际系统再测。

还有一件事:关闭成功也不是本例的最后一步。暂存文件还要移到正式结果路径,移动失败,同样不能返回成功。

成功分支放在哪儿

这个小程序先写 orders.part,关闭后再移成 orders.csv。只有正式结果准备好,才把路径交给调用方。

修复逻辑如下。这里去掉了演示用的模式判断,保留实际执行的位置:

java 复制代码
try {
    try (Writer out = openWriter(part, scenario)) {
        writeCsv(out);
        task.events.add("WRITTEN");
    }
    task.events.add("CLOSED");

    Files.move(part, target);
    task.events.add("MOVED");

    task.success(target);
} catch (IOException e) {
    task.fail(e);
}

注意外层 try 包住了关闭和移动操作。写入、关闭、移动中的任何一步抛出 IOException,都会进入失败分支,不会再执行这里的 success()

不要把它简化成一句"把成功更新挪到 try 后面"。如果 catch 处理完异常后还能继续走到那个成功更新,仍然会报错状态。

失败处理也不只是写一个状态值。本例的处理是:

java 复制代码
void fail(IOException e) {
    error = e;
    result = null;
    status = "FAILED";
    history.add(status);
    events.add(status);
}

result = null 表示不提供成功结果引用,不表示磁盘上什么都没有。失败后的暂存文件可能是半成品,也可能已写完整,不能拿它的存在与否替代任务是否成功的判断。

五种情况,实际跑一遍

测试使用 UTF-8、固定的 \n 换行和三条虚构订单。写入与关闭异常由 Writer 包装器控制;文件写入和移动是真实操作。任务状态只存在内存里。

修复版在本机 JDK 21.0.11 上的结果如下。编译使用 --release 17,但没有另在 JDK 17 运行时上执行。

场景 状态轨迹 成功结果引用 本次文件与异常情况
NORMAL:正常导出 RUNNING → SUCCESS 正式路径 文件完整,内容逐字匹配
WRITE_FAIL:写入中途失败 RUNNING → FAILED 暂存内容 31 字符;写入异常
CLOSE_FAIL:关闭失败 RUNNING → FAILED 暂存内容 60 字符;关闭异常
BOTH_FAIL:写入和关闭都失败 RUNNING → FAILED 暂存内容 31 字符;写入主异常,关闭异常被保留
MOVE_FAIL:移动失败 RUNNING → FAILED 暂存内容 60 字符;本机报 NoSuchFileException

两个细节单独说一下。

写入失败不是只把一个布尔值改成失败。包装器先把表头和第一条订单刷新到真实文件,再在第二次写入前抛异常。因此测试能检查:只有 31 字符的暂存半成品,不应被当成完整结果返回。

关闭失败则是在底层文件正常关闭后,由包装器主动抛异常,避免为了演示而泄漏句柄。这也解释了为什么该场景文件内容完整。它验证的是收到关闭异常后的控制流程,不是在模拟真实磁盘损坏,也不代表所有关闭失败都会留下完整文件。

移动失败没有模拟返回值:程序真的调用了 Files.move,只是目标父目录不存在。具体异常文案可能因系统而异,检查重点是任务失败、不返回成功结果,并留下原始异常。

两个异常,别只留下最后一个

同时启用写入和关闭故障时,实际检查到:

text 复制代码
主异常:injected write failure
suppressed[0]:injected close failure

try-with-resources 在这种情况下保留写入异常为主异常,将关闭异常放进 suppressed。可以用 getSuppressed() 取出它们。Oracle 对 suppressed 异常的说明

本例保存的是异常对象,不是仅保存 e.getMessage()。要是只留一句"导出失败",后面很难分清失败发生在写入还是关闭阶段。

自己运行时,先看这三列

把末尾源码保存为 ExportStateDemo.java,执行:

shell 复制代码
javac -encoding UTF-8 --release 17 ExportStateDemo.java
java ExportStateDemo

程序会遍历三种写法、五种场景,共输出 15 行对照结果。先看 historyresultpartChars

  • history:检查过程中是否提前出现成功,不能只看最后一个状态。
  • resultready 表示引用路径存在,missing 表示返回了不存在的路径,none 表示没有结果引用。正常用例另有完整内容校验,不能只凭 ready 判断内容正确。
  • partChars:保留下来的暂存文件字符数;- 表示此时没有暂存文件。

源码中的 verifyFixed() 会检查修复版的状态轨迹、正常文件内容、关键事件顺序和失败结果引用。不需要加 -ea,检查失败会直接抛出 AssertionError

每次运行都使用新临时目录,最后会打印位置,里面的测试文件保留供核查,不覆盖已有文件。

这份代码没有替你解决什么

这里的"准备正式结果",只指本地文件移动完成,没有 HTTP 下载接口或对象存储。

普通 Files.move 不能直接说成任何环境下的原子操作;非原子移动发生 I/O 异常时,文件状态需要另行核实。Java 17 Files.move 文档

正常关闭、移动成功,也不等于验证了掉电持久性。如果任务状态存在数据库,还会有"文件准备好,状态写库失败"或进程中途退出的情况,需要恢复和核对机制。并发、重试、下载权限与性能都不在这次测试范围内。

回到自己的导出代码,可以先查五个位置:

  1. SUCCESS 写入时,资源是否已经正常关闭?
  2. 成功结果是否已经准备好,还是还指向暂存文件?
  3. 异常被 catch 后,会不会继续执行成功更新?
  4. 失败返回值里,有没有残留的"成功文件路径"?
  5. 测试有没有断言状态变更顺序,以及保留的原始异常?

先把这些具体位置查清,再决定是否需要改动更大的任务管理逻辑。

------ IT枫斗者枫哥

完整代码

保存为 ExportStateDemo.java。下面是本次实际运行的完整源码。

java 复制代码
import java.io.IOException;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

/** 教学示例:真实临时文件 + 人为注入的写入/关闭异常,不是生产事故记录。 */
public class ExportStateDemo {
    static final String FIRST = "order_id,amount\nDEMO-001,12.50\n";
    static final String REST = "DEMO-002,30.00\nDEMO-003,8.80\n";

    enum Mode { FINALLY_SUCCESS, EARLY_SUCCESS, FIXED }

    enum Scenario {
        NORMAL(false, false), WRITE_FAIL(true, false),
        CLOSE_FAIL(false, true), BOTH_FAIL(true, true),
        MOVE_FAIL(false, false);

        final boolean failWrite;
        final boolean failClose;

        Scenario(boolean failWrite, boolean failClose) {
            this.failWrite = failWrite;
            this.failClose = failClose;
        }
    }

    // 这里只记录单次调用的状态,不模拟数据库、并发或重试。
    static class Task {
        String status = "RUNNING";
        Path result;
        IOException error;
        final List<String> history = new ArrayList<>(List.of("RUNNING"));
        final List<String> events = new ArrayList<>();

        void success(Path path) {
            result = path;
            status = "SUCCESS";
            history.add(status);
            events.add(status);
            // 不抹掉旧异常,便于观察错误版出现"有异常却成功"的矛盾。
        }

        void fail(IOException e) {
            error = e; // 保留异常对象,suppressed 信息也仍在其中。
            result = null;
            status = "FAILED";
            history.add(status);
            events.add(status);
        }
    }

    static Task export(Path dir, Scenario scenario, Mode mode) throws IOException {
        // 调用者为每次测试传入新目录;创建失败直接终止测试,不覆盖旧产物。
        Files.createDirectory(dir);
        Path part = dir.resolve("orders.part");
        Path target = scenario == Scenario.MOVE_FAIL
                ? dir.resolve("missing/orders.csv") : dir.resolve("orders.csv");
        Task task = new Task();
        try {
            try (Writer out = openWriter(part, scenario)) {
                writeCsv(out);
                task.events.add("WRITTEN");
                if (mode == Mode.EARLY_SUCCESS) {
                    task.success(target); // 错误:close 和 move 都还没完成。
                }
            }
            task.events.add("CLOSED");
            // 不替换已有文件;MOVE_FAIL 的目标父目录确实不存在。
            Files.move(part, target);
            task.events.add("MOVED");
            if (mode == Mode.FIXED) {
                task.success(target); // 只有这条成功分支已完成全部前置操作。
            }
        } catch (IOException e) {
            task.fail(e);
        } finally {
            if (mode == Mode.FINALLY_SUCCESS) {
                task.success(target); // 错误:失败状态也会被无条件覆盖。
            }
        }
        return task;
    }

    static void writeCsv(Writer out) throws IOException {
        out.write(FIRST);
        out.write(REST);
    }

    static Writer openWriter(Path part, Scenario scenario) throws IOException {
        Writer real = Files.newBufferedWriter(part, StandardCharsets.UTF_8,
                StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
        return new FaultWriter(real, scenario);
    }

    static class FaultWriter extends Writer {
        private final Writer delegate;
        private final Scenario scenario;
        private int writes;

        FaultWriter(Writer delegate, Scenario scenario) {
            this.delegate = delegate;
            this.scenario = scenario;
        }

        @Override
        public void write(char[] chars, int offset, int length) throws IOException {
            if (++writes == 2 && scenario.failWrite) {
                // 先让第一条订单进入真实文件,再模拟第二批写入失败。
                delegate.flush();
                throw new IOException("injected write failure");
            }
            delegate.write(chars, offset, length);
        }

        @Override
        public void flush() throws IOException {
            delegate.flush();
        }

        @Override
        public void close() throws IOException {
            // 先释放真实句柄,再注入异常,不为了演示而泄漏文件句柄。
            delegate.close();
            if (scenario.failClose) {
                throw new IOException("injected close failure");
            }
        }
    }

    public static void main(String[] args) throws IOException {
        Path root = Files.createTempDirectory("export-state-demo-");
        System.out.println("mode | scenario | history | result | partChars | error | suppressed");
        for (Mode mode : Mode.values()) {
            for (Scenario scenario : Scenario.values()) {
                Path dir = root.resolve(mode + "-" + scenario);
                Task task = export(dir, scenario, mode);
                if (mode == Mode.FIXED) verifyFixed(task, scenario);
                String result = task.result == null ? "none"
                        : Files.exists(task.result) ? "ready" : "missing";
                Path part = dir.resolve("orders.part");
                String partChars = Files.exists(part)
                        ? String.valueOf(Files.readString(part, StandardCharsets.UTF_8).length()) : "-";
                String error = task.error == null ? "none"
                        : task.error.getClass().getSimpleName();
                String suppressed = task.error == null ? "none"
                        : Arrays.stream(task.error.getSuppressed())
                                .map(Throwable::getMessage).collect(Collectors.joining(","));
                if (suppressed.isEmpty()) suppressed = "none";
                System.out.printf("%s | %s | %s | %s | %s | %s | %s%n",
                        mode, scenario, String.join(">", task.history),
                        result, partChars, error, suppressed);
            }
        }
        System.out.println("PASS: FIXED self-checks for all 5 scenarios");
        System.out.println("Artifacts: " + root); // 留下隔离的测试文件,方便自己核对。
    }

    static void verifyFixed(Task task, Scenario scenario) throws IOException {
        boolean normal = scenario == Scenario.NORMAL;
        require(task.history.equals(normal ? List.of("RUNNING", "SUCCESS")
                : List.of("RUNNING", "FAILED")), "Unexpected state history: " + scenario);
        if (normal) {
            require(task.error == null && task.result != null, "Missing successful result");
            require(Files.readString(task.result, StandardCharsets.UTF_8)
                    .equals(FIRST + REST), "CSV content mismatch");
            require(task.events.equals(List.of("WRITTEN", "CLOSED", "MOVED", "SUCCESS")),
                    "Success happened before close/move");
        } else {
            require(task.result == null && task.error != null, "Failed export exposed a result");
            int expected = scenario == Scenario.BOTH_FAIL ? 1 : 0;
            require(task.error.getSuppressed().length == expected, "Suppressed exception lost");
        }
    }

    static void require(boolean condition, String message) {
        if (!condition) throw new AssertionError(message);
    }
}
相关推荐
陈皮波比茶1 小时前
java笔记-typora快捷键
java
蚂蚁雅嘿1 小时前
maven标签日常总结
java·maven
洋不写bug2 小时前
二叉树(二) 常见基础操作解析|结点数、树高、查找结点、判断完全二叉树
java·开发语言·数据结构·完全二叉树·二叉树结点数·树高·查找结点
菠萝加点糖2 小时前
Maven 坐标系统说明
java·maven
hm宋2 小时前
Canal 升级 jar 包操作文档(以升级 FastJSON 为例)
java·canal·jar
后台模板学习2 小时前
学习的心态高频面试题
java·数据库·学习
用户3126874877202 小时前
线程池到底怎么管理线程的?从 ThreadPoolExecutor 到拒绝策略全链路拆解
java
cpolar技术支持3 小时前
Kafka Streams 窗口统计怎么验收:本地跑订单流聚合,用 cpolar 给同事看只读结果页
java·docker·kafka·cpolar·kafka streams
爱读源码的大都督3 小时前
DeepSeek面试官问:生产RAG系统回答不准确,该如何定位和优化?这样回答,能让面试官当场给你Offer!
java·后端·python