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);
    }
}
相关推荐
Java_2017_csdn1 小时前
OFC、ERP、OMS、WMS、TMS 对比-详解
java
EatFan8 小时前
Spring Boot 4 落地观察:从 yudao-cloud、matecloud、JPower 看国产脚手架的升级路线与迁移清单
java·spring boot·后端·spring cloud·微服务·后端开发·jdk 21
Maiko Star9 小时前
* LangChain 提示词模板详解:ChatPromptTemplate 的使用与高级特性
java·人工智能·langchain
现任明教教主~11 小时前
Thinkphp站群蜘蛛池SaaS系统YanyvSEO含多用户/积分/六大引擎计费/易支付对接
java·开发语言·spring
一条破秋裤11 小时前
Linux 线程创建:pthread_create 与基本回收
java·linux·运维
布吉岛的石头12 小时前
Java 程序员第 49 阶段5:BERT 预训练目标 MLM+NSP 的工程含义
java·人工智能·深度学习·bert·transformer
摇滚侠12 小时前
《On Java 中文版 基础卷》阅读笔记 对象无处不在 03
java·笔记·python
二十雨辰13 小时前
[学成在线]-01项目背景
java
弈栈录14 小时前
Java AI 应用的异步化与高并发设计
java·后端·架构
落魄实习生14 小时前
Agent Scope Java 2.x 系列【2】 ReActAgent
java·开发语言