高效的调试异常
阅读其他语言: English Español 한국어 Português
在您对 Java 的学习过程中,需要学习的最早的概念之一是异常。这个词定义了程序执行过程中的一个意外情况,例如网络故障或意外的文件结束。 或者,正如 Oracle 文档 所说:
Exception 类及其子类是一种 Throwable 形式,表示一个合理的应用程序可能希望捕获的条件。
如果您的程序配备了一个 “计划 B” 来有效处理这些情况, 他将继续顺利运行,无论这些情况是否发生。 否则,程序可能会意外崩溃或进入一个错误的状态。
当程序因为异常而失败时,您需要调试它。 编程语言通过提供一个 堆栈跟踪 来方便调试与异常相关的错误 —— 一个特殊的消息,指向导致失败的代码路径。 这些信息非常有用,有时候就足够了; 然而,有些情况下,我们可能会从额外的细节和技巧中受益。
在本文中,我们将通过一个案例研究,着重讨论在 JSON 解析期间发生异常的调试。 通过这样做,我们将超越仅仅查看堆栈跟踪,并发现使用调试器的好处。
示例应用程序
这个使用案例的 示例应用程序 将是一个小型的 Java 程序,它解析一组包含世界各地机场数据的 JSON 文件。这些文件包括 一些细节,如机场的 IATA 编码,国家,纬度和经度。以下是一个条目的示例:
{
"iso_country": "AR",
"iata_code": "MDQ",
"longitude_deg": "-57.5733",
"latitude_deg": "-37.9342",
"elevation_ft": "72",
"name": "Ástor Piazzola International Airport",
"municipality": "Mar del Plata",
"iso_region": "AR-B"
}
该程序非常直观。它遍历一组文件,读取并解析每一个文件,过滤掉机场对象,以避免输入限制,如 “country=AR”,然后打印出匹配的机场列表:
public class Airports {
static Path input = Paths.get("./data");
static Gson gson = new Gson();
static {
System.setOut(new PrintStream(System.out, true, StandardCharsets.UTF_8));
}
public static void main(String[] args) throws IOException {
List<Predicate<Airport>> filters = new ArrayList<>();
for (String arg : args) {
if (arg.startsWith("country=")) {
filters.add(airport -> airport.isoCountry.equals(arg.substring("country=".length()))) ;
} else if (arg.startsWith("region=")) {
filters.add(airport -> airport.isoRegion.equals(arg.substring("region=".length()))) ;
} else if (arg.startsWith("municipality=")) {
filters.add(airport -> airport.municipality.equals(arg.substring("municipality=".length()))) ;
}
}
try (Stream<Path> files = Files.list(input)) {
Stream<Airport> airports = files.map(Airports::parse);
for (Predicate<Airport> f : filters) {
airports = airports.filter(f);
}
airports.forEach(System.out::println);
}
}
static Airport parse(Path path) {
try {
JsonObject root = gson.fromJson(Files.readString(path), JsonObject.class);
String name = root.get("name").getAsString();
String isoCountry = root.get("isoCountry").getAsString();
String iataCode = root.get("iataCode").getAsString();
String longitudeDeg = root.get("longitudeDeg").getAsString();
String latitudeDeg = root.get("latitudeDeg").getAsString();
String municipality = root.get("municipality").getAsString();
String isoRegion = root.get("isoRegion").getAsString();
Integer elevationFt = Integer.parseInt(root.get("elevationFt").getAsString());
return new Airport(name, isoCountry, iataCode, longitudeDeg, latitudeDeg, elevationFt, municipality, isoRegion);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
record Airport(String name, String isoCountry, String iataCode, String longitudeDeg, String latitudeDeg, Integer elevationFt, String municipality, String isoRegion) { }
}
问题
当我们运行程序时,它会因为一个 NumberFormatException
失败。
这就是我们获得的堆栈跟踪:
Exception in thread "main" java.lang.NumberFormatException: For input string: ""
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:672)
at java.base/java.lang.Integer.parseInt(Integer.java:778)
at dev.flounder.Airports.parse(Airports.java:53)
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
at dev.flounder.Airports.main(Airports.java:39)
它指向的是 Airports.java
文件的第53行。
通过查看这一行,我们可以知道在将其中一个机场的 elevationFt
属性转换为数字时出现了问题。
异常消息告诉我们,这是因为在相应的文件中,这个属性是空的。
堆栈跟踪不够用吗?
虽然上面的堆栈跟踪已经给我们提供了很多信息,但在几个方面它是有限的。 它指向了失败的代码行,但它告诉我们哪个数据文件是原因吗? 如果我们想更仔细地检查文件或纠正数据,那么文件的名字将非常有用。 遗憾的是,当应用程序崩溃时,这些运行时的细节就丢失了。
然而,有一种方法可以在应用程序终止之前将其挂起,这样就可以访问堆栈跟踪和日志中没有捕获的上下文。 此外,当一个应用程序被挂起时,你可以进行原型设计,测试, 甚至在应用程序还在运行时加载修复。
在异常处挂起
首先,让我们开始设置一个异常断点。 你可以在断点对话框 或通过在堆栈跟踪中点击 Create Breakpoint 来做到这一点:
这种类型的断点
会在应用程序即将抛出异常时挂起应用程序。
目前,我们对 NumberFormatException
感兴趣。
那么,我们在程序即将抛出异常时挂起了应用程序。 错误已经发生,但应用程序还没有崩溃。 我们正好赶上,所以让我们看看这给我们带来了什么。
在 线程 (Threads) 标签页中,转到 parse()
帧:
看看我们现在有多少更多的信息:
我们看到了出现错误的文件名,
文件的完整内容,所有附近的变量,
当然还有导致错误的属性。
很明显,问题出在
在被称为 816.json
的文件中,因为它缺少 elevationFt
属性
现在,我们可以进行修复 问题。取决于我们的用例,我们可能只想修复数据, 或者修复程序处理错误的方式。 修复数据是直接的,所以让我们看看调试器 如何帮助我们处理错误。
修复原型
评估表达式
是一个很好的工具,用于制作原型变化,包括修复您的现有代码。这正是我们正在寻找的。
选择整个
parse()
方法体,然后转到
运行 (Run) | 调试操作 (Debugging Actions) | 对表达式求值 (Evaluate Expression) 。
如果你在打开 求值 (Evaluate) 对话框之前选择了代码, 选择的片段会被复制过去,所以你不需要手动输入。
当我们评估方法中的代码时,它会抛出一个异常,就像程序代码中那样:
我们稍微改变一下代码,让它在遇到缺失的数据时存储一个 null
。另外,让我们添加一个打印语句将错误输出到标准错误流中:
try {
JsonObject root = gson.fromJson(Files.readString(path), JsonObject.class);
String name = root.get("name").getAsString();
String isoCountry = root.get("isoCountry").getAsString();
String iataCode = root.get("iataCode").getAsString();
String longitudeDeg = root.get("longitudeDeg").getAsString();
String latitudeDeg = root.get("latitudeDeg").getAsString();
String municipality = root.get("municipality").getAsString();
String isoRegion = root.get("isoRegion").getAsString();
Integer elevationFt;
try {
elevationFt = Integer.parseInt(root.get("elevationFt").getAsString());
} catch (NumberFormatException e) {
elevationFt = null;
System.err.println("Failed to parse elevation for file: " + path);
}
return new Airport(name, isoCountry, iataCode, longitudeDeg, latitudeDeg, elevationFt, municipality, isoRegion);
} catch (IOException e) {
throw new RuntimeException(e);
}
通过在 求值 (Evaluate) 对话框中运行调整后的代码,
我们可以验证这段代码确实如预期那样处理了错误。
现在,它将字段设置为 null
,而不是使应用程序崩溃:
我们不仅可以测试方法返回而不抛出异常,而且可以看到如何在控制台中看到错误文本:
重置帧
好的,我们修复了代码,这样错误在未来就不会发生了,但是我们可以撤销错误本身吗?事实上,我们可以! IntelliJ IDEA 的调试器允许我们弹出错误帧并重新执行该方法。
在 线程 (Threads) 标签页中点击 Reset Frame (重置帧):
Reset Frame (重置帧) 只会弹出帧堆栈的内部状态。 在我们的例子中,这个方法是纯的,这意味着它不改变局部作用域之外的任何东西,所以这不是问题。 否则,如果对全局应用程序状态做了更改, 这些更改不会被撤销。 当使用类似 求值 (Evaluate) 和 Reset Frame (重置帧) 的特性时,请记住这一点
修复并热重新载入
在撤销错误并假装它从未发生后,我们也可以将修复提交给运行时。 由于执行点当前在我们要更改的方法之外,且我们没有更改该方法的签名,我们可以使用重新加载已更改的类 (Reload Changed Classes)选项,这个选项我已经在之前的帖子中提到过。
首先,将求值 (Evaluate) 对话框中的正确代码复制到编辑器中。
你可以通过浏览历史(⌥↓ / Alt↓)来找到以前评估过的代码。
在替换了 Airports
类中的代码后,你可以将它加载到正在运行的 JVM 中。为此,请选择 运行 (Run) | 调试操作 (Debugging Actions) | 重新加载已更改的类 (Reload Changed Classes)。
一个气球出现,确认修复已经进入了正在运行的应用程序。
如果你正在使用当前的 IntelliJ IDEA (2024.3 EAP1) 的 EAP 版本, 尝试使用新的热重载按钮,它直接出现在编辑器中:
未捕获异常过滤
如果我们现在恢复应用程序,它将再次在同一异常处被挂起。为什么会这样呢?
我们修改了代码以捕获 NumberFormatException
。
这个修复防止了应用程序崩溃,但并不能阻止抛出异常。
所以,每次抛出异常时,断点仍然会触发,即使这个异常最终会被捕获。
让我们告诉 IntelliJ IDEA,我们只希望在发生 未捕获的异常时才挂起应用程序。 为此,右键单击在字形边缘的断点,并清除 捕获异常 (Caught exception) 复选框:
异常断点图标只有在应用程序在异常处挂起时才会出现在字形边缘。 或者,您可以通过 运行 (Run) | 查看断点 (View Breakpoints) 来配置断点。
有了这个设置,除非发生
未处理的 NumberFormatException
,否则应用程序将无中断地运行。
恢复并享受
我们现在可以恢复应用程序:
它运行得非常顺利,在控制台通知我们缺少数据。
注意看文件816.json
是在错误列表上,
证明我们真的处理了这个文件,而不是简单地跳过它。
实际上,816.json
有两个条目 - 一个来自于我们的表达式评估实验,
另一个由程序本身放在那里,我们已经纠正了它。
总结
在这篇文章中,我们学习了如何有效地调试 Java 异常,通过收集额外的上下文 指出了故障的原因。然后,利用调试器的功能, 我们能够撤销错误,将调试会话变为测试解决方案的沙盒, 并在崩溃的中途向运行时提供修复, 所有这些都无需重新启动应用程序。
尽管这些技术基础,但在某些情况下可以非常强大,为您节省大量时间。 在即将推出的文章中,我们将讨论更多的调试技巧和技巧,敬请期待!