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 行对照结果。先看 history、result 和 partChars:

  • history:检查过程中是否提前出现成功,不能只看最后一个状态。
  • result:ready 表示引用路径存在,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);
    }
}
相关推荐
const_qiu2 分钟前
第四章 06:4.6 统一入口 —— analyze() 能力收束
java·人工智能
她的男孩28 分钟前
JAR 全对仍 checksum mismatch:Forge Admin 把 Flyway 从 filesystem 改成 classpath
java·数据库·spring boot
鬼手点金29 分钟前
如何选择模型
java·前端·人工智能·git·深度学习
古法老登瞎折腾36 分钟前
大数据智能运维实战(三):实现第一个运维 MCP 工具——查询节点与组件状态
java·大数据·运维·人工智能·spring ai·mcp
小蒜学长37 分钟前
基于数据挖掘的京东商城商品推荐系统设计与实现(代码+数据库+LW)
java·数据库·spring boot·后端·推荐系统·个性化推荐
IT研究室1 小时前
最新计算机毕业设计选题推荐-基于spring boot的医院体检管理系统-网站-文档指导-Java-springboot
java·spring boot·课程设计
有同事要进步1 小时前
Leecode第68题:x的平方根
java·数据结构·算法
摇滚侠1 小时前
使用 VSCode 开发 Java 经验分享
java·经验分享·vscode
吹什么轩1 小时前
linux系统复习:进程优先级与O(1)调度
java·linux·算法
古法老登瞎折腾1 小时前
大数据智能运维实战(六):任务一直在等——排查调度队列与执行资源
java·大数据·运维·spring ai·mcp