《Java 编程入门》13.3 异常转换与用户可见错误

从 CommandParser 解析失败和 exportLoans 的 IOException 出发,讲清技术异常、领域异常与用户可见错误的三层分工;再把 NumberFormatException 包装成 InvalidCommandException、在 CLI 边界统一映射 ErrorCode 与退出码,给出真实输出,并列出丢失 cause、重复记日志、吞异常等边界。

本节目标:能判断在哪个层次把底层异常转换成领域异常,知道怎么保留 cause、怎么设计错误码和退出码,并让 CLI 只向用户展示可读的一行错误,把栈追踪留给日志。
适用版本:JDK 25 LTS(Temurin 25.0.5);java.util.logging 与退出码行为在 JDK 21 上一致。

上一节留下的问题

loanBatch.write 和 close 会抛 IOException,CommandParser 里的 Integer.parseInt 会抛 NumberFormatException,日期解析会抛 DateTimeParseException。这些异常的共同点是:消息面向开发者,不面向用户。

NumberFormatException 的消息是:

For input string: "二〇〇七"

IOException 的消息可能是文件路径、权限、磁盘状态。把它们直接打印给 CLI 用户,轻则看不懂,重则泄露内部路径。反过来,如果每一层都把它包装成「操作失败」,日志里又找不到根因。

这一节要建立一条清晰的转换链:底层技术异常 → 领域异常(带错误码和 cause)→ CLI 边界输出用户可见消息并记录日志。

三层错误模型

把错误分成三层,每层只关心自己的语言:

层次典型类型关心什么谁负责转换
技术层IOException、NumberFormatException、DateTimeParseException哪个操作失败、根因是什么靠近边界的适配代码
领域层LibraryException 及子类业务规则为什么不允许服务层、命令解析器
用户层错误码 + 一行消息 + 退出码用户能做什么CLI 入口

技术异常不应该直接出现在用户界面,领域异常也不应该携带「请检查网络」这种面向具体界面的措辞。LibraryException 的 userMessage() 只拼接错误码和领域消息,真正的展示格式留给 CLI。

转换时保留 cause

InvalidCommandException 已经接受一个 Throwable cause:

public final class InvalidCommandException extends LibraryException {
    public InvalidCommandException(String command, Throwable cause) {
        super(ErrorCode.INVALID_COMMAND, "无法解析命令: " + command, cause);
    }
}

super(..., cause) 把原始异常挂到 getCause() 上。没有这一步,栈追踪会在转换点断掉,日志里只剩「无法解析命令」,看不到到底是数字格式错了还是数组越界。

CommandParser 的转换点是 NumberFormatException。以 add 命令的年份字段为例:

try {
    require(parts, 5, line);
    int year = Integer.parseInt(parts[4]);
    return new Command.Add(parts[1], parts[2], parts[3], year);
} catch (NumberFormatException e) {
    throw new InvalidCommandException(line, e);
}

本机 JDK 25 下,add ... 二〇〇七 的 cause 是:

NumberFormatException: For input string: "二〇〇七"

用户看到的是「无法解析命令」,开发者从 cause 里能看到具体是年份不是数字。两层信息都保留,各取所需。

同样,exportLoans 的 IOException 如果在 CLI 层处理,应该包装成领域异常:

try {
    service.exportLoans(out);
} catch (IOException e) {
    throw new LibraryException(ErrorCode.INTERNAL, "导出借阅报表失败", e);
}

注意错误码用了 INTERNAL,因为导出失败通常是环境问题,不是用户输入错误。消息里不要拼文件路径,路径留给日志。

CLI 边界:一次捕获,统一输出

LibraryApp.run 是用户与程序的边界。它只做三件事:

  1. 解析并执行命令。
  2. 捕获 LibraryException,打印 userMessage(),返回对应退出码。
  3. 捕获其他 RuntimeException,记录日志,打印通用内部错误。
public static int run(String line, LibraryService service,
                      PrintStream out, PrintStream err) {
    try {
        return dispatch(CommandParser.parse(line), service, out, err);
    } catch (LibraryException e) {
        err.println(e.userMessage());
        if (e.getCause() != null) {
            LOG.log(Level.FINE, "命令失败: " + line, e.getCause());
        }
        return e.errorCode().exitCode();
    } catch (RuntimeException e) {
        LOG.log(Level.SEVERE, "未预期的异常", e);
        err.println("[E_INTERNAL] 内部错误,请查看日志");
        return ErrorCode.INTERNAL.exitCode();
    }
}

两个 catch 的分工很关键:

  • LibraryException 是已知的领域失败,消息可以直接给用户,cause 只记在 FINE 级别。
  • 其他 RuntimeException 是未预期的问题(空指针、下标越界、状态错误),用户只看到通用提示,完整栈进 SEVERE 日志。

dispatch 负责把命令映射到服务方法。下面是精简到两个分支的版本,其余命令同理:

private static int dispatch(Command command, LibraryService service,
                            PrintStream out, PrintStream err) {
    switch (command) {
        case Command.Add add -> {
            Result<Book> result = service.registerBook(
                    new Book(add.isbn(), add.title(), add.author(), add.year()));
            if (result instanceof Result.Ok<Book> ok) {
                out.println("已添加: " + ok.value().title());
                return 0;
            }
            Result.Err<Book> failure = (Result.Err<Book>) result;
            err.println("[" + ErrorCode.DUPLICATE_ISBN.code() + "] "
                    + failure.message());
            return ErrorCode.DUPLICATE_ISBN.exitCode();
        }
        case Command.Borrow borrow -> {
            Loan loan = service.borrow(borrow.isbn(), borrow.memberId());
            out.println("借出成功: " + loan.isbn() + " -> " + loan.memberId()
                    + ",应还日期 " + loan.dueOn());
            return 0;
        }
        default -> {
            out.println("命令已解析: " + command);
            return 0;
        }
    }
}

Add 分支展示了 Result 和异常的分工:ISBN 重复是预期内的输入问题,用 Result.Err 返回,CLI 打印错误码并返回 3;借阅失败是规则拒绝,由 LibraryException 抛出,统一 catch 处理。

main 只在退出码非 0 时调用 System.exit,避免影响测试:

public static void main(String[] args) {
    int code = run(String.join(" ", args), createService(), System.out, System.err);
    if (code != 0) {
        System.exit(code);
    }
}

退出码约定:

退出码含义例子
0成功添加、借出、查询正常
1内部错误未预期异常、配置缺失
2输入或查找失败命令格式错、书不存在
3业务规则拒绝重复 ISBN、书已借出、达到上限

脚本和自动化测试可以靠退出码判断结果,不必解析中文消息。消息可以改、可以本地化,错误码要保持稳定。

在 library 里落地

CommandParser 的完整骨架:

public final class CommandParser {
    private CommandParser() {
    }

    public static Command parse(String line) {
        String trimmed = line.trim();
        if (trimmed.isEmpty()) {
            throw new InvalidCommandException(line,
                    new IllegalArgumentException("空命令"));
        }
        String[] parts = trimmed.split("\\s+");
        try {
            return switch (parts[0]) {
                case "add" -> {
                    require(parts, 5, line);
                    yield new Command.Add(parts[1], parts[2], parts[3],
                            Integer.parseInt(parts[4]));
                }
                case "list" -> new Command.ListBooks();
                case "borrow" -> {
                    require(parts, 3, line);
                    yield new Command.Borrow(parts[1], parts[2]);
                }
                case "return" -> {
                    require(parts, 2, line);
                    yield new Command.Return(parts[1]);
                }
                case "search" -> {
                    require(parts, 2, line);
                    yield new Command.Search(parts[1]);
                }
                case "quit" -> new Command.Quit();
                default -> throw new InvalidCommandException(line,
                        new IllegalArgumentException("未知命令: " + parts[0]));
            };
        } catch (NumberFormatException e) {
            throw new InvalidCommandException(line, e);
        }
    }

    private static void require(String[] parts, int expected, String line) {
        if (parts.length < expected) {
            throw new InvalidCommandException(line,
                    new IllegalArgumentException("参数不足,需要 " + expected + " 段"));
        }
    }
}

这里有两类转换:

  • require 直接抛 InvalidCommandException,因为参数不足本身就是命令层的错误。
  • NumberFormatException 被 catch 后包装,保留了原始 cause。
  • 未知命令和空命令也走 InvalidCommandException,错误码统一是 E_INVALID_COMMAND。

CLI 运行结果(节选):

$ add 978-7-111-54742-6 EffectiveJava JoshuaBloch 2018
[E_DUPLICATE_ISBN] ISBN 已存在: 978-7-111-54742-6
  exit = 3

$ borrow 978-7-000-00000-0 M001
[E_BOOK_NOT_FOUND] 找不到 ISBN 为 978-7-000-00000-0 的图书
  exit = 2

$ borrow 978-7-111-54742-6 M001
借出成功: 978-7-111-54742-6 -> M001,应还日期 2026-10-27
  exit = 0

$ unknown-command
[E_INVALID_COMMAND] 无法解析命令: unknown-command
  exit = 2

本机 JDK 25 输出,$ 行是输入命令。可以看到用户只看到错误码和一行中文,没有栈追踪;栈被写进日志的 FINE 记录,需要时再查。

解析阶段的 cause 也能验证:

try {
    CommandParser.parse("add 978-7-111-62072-3 Java编程思想 BruceEckel 二〇〇七");
} catch (InvalidCommandException e) {
    System.out.println(e.getCause().getClass().getSimpleName()
            + ": " + e.getCause().getMessage());
}

输出:

NumberFormatException: For input string: "二〇〇七"

常见错误与边界

错误一:转换时丢掉 cause

catch (NumberFormatException e) {
    throw new InvalidCommandException(line, null);
}

这样写能编译,日志里却只剩「无法解析命令」,不知道是哪个字段、什么值导致的。转换的目的不是把异常换一个名字,而是在更高层语言里补充上下文,同时保留根因。构造器里一定要传 e。

错误二:每层都记日志再抛出

catch (IOException e) {
    LOG.log(Level.SEVERE, "导出失败", e);
    throw new LibraryException(ErrorCode.INTERNAL, "导出失败", e);
}

如果上层又记一次,同一个错误会在日志里出现多遍,真正的位置反而难找。约定是:只在能补充关键上下文或最终处理的那一层记录。 中间层只负责转换和传递。

错误三:用 catch Exception 吞掉所有失败

try {
    service.borrow(isbn, memberId);
} catch (Exception e) {
    System.out.println("操作失败");
}

这会把 NullPointerException、ClassCastException 这类编程错误也变成「操作失败」,掩盖真正的 bug。CLI 边界只应该把 LibraryException 当作用户错误;其他 RuntimeException 要单独走内部错误分支,并记录完整栈。

边界:转换不要发生在服务层

服务层不应该知道「CLI 要打印什么格式」。它抛 BookNotFoundException,消息是领域语言「找不到 ISBN 为 … 的书」;CLI 决定加不加错误码前缀、用中文还是英文、退出码是多少。把界面措辞写进服务层,会让同一套服务难以复用到 HTTP 接口或测试里。

边界:getMessage() 可能为 null

Throwable.getMessage() 允许返回 null。拼接用户消息时不要直接 e.getMessage().toLowerCase(),否则会在异常处理里再抛一个空指针。用 String.valueOf(e.getMessage()) 或提供一个默认文案。

小结

  • 错误分三层:技术异常、领域异常、用户可见错误;每层只用自己的语言。
  • 转换底层异常时用 super(message, cause) 保留根因,否则栈追踪会在转换点断开。
  • CLI 边界只捕获两类:LibraryException 输出 userMessage() 和对应退出码,其他 RuntimeException 记日志并返回内部错误。
  • 退出码 0/1/2/3 分别对应成功、内部错误、输入或查找失败、业务规则拒绝;错误码比消息更稳定。
  • Result 用于预期内的输入问题(如重复 ISBN),异常用于规则拒绝和未预期故障,两者在 dispatch 里各归其位。
  • 不要丢 cause、不要每层重复记日志、不要用 catch (Exception) 吞掉编程错误,也不要把界面措辞写进服务层。

到这里,library 的错误处理已经有了完整链路:领域规则抛 LibraryException,资源用 try-with-resources 关闭,CLI 把错误码翻译成用户能看懂的一行。下一章开始接触文件系统:用 Path、Files 和 NIO 把图书数据真正持久化下来,IOException 会在那里第一次成为日常。

阅读导航:上一节:13.2 try-with-resources 与关闭顺序 · 下一节:14.1 Path 与 Files 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发