本节目标:能判断在哪个层次把底层异常转换成领域异常,知道怎么保留 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 是用户与程序的边界。它只做三件事:
- 解析并执行命令。
- 捕获
LibraryException,打印userMessage(),返回对应退出码。 - 捕获其他
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 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。