高效的调试异常

阅读其他语言: 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() 帧:

在调试器的 '线程' 标签中选择 'parse()' 帧 在调试器的 '线程' 标签中选择 'parse()' 帧

看看我们现在有多少更多的信息: 我们看到了出现错误的文件名, 文件的完整内容,所有附近的变量, 当然还有导致错误的属性。 很明显,问题出在 在被称为 816.json 的文件中,因为它缺少 elevationFt 属性

现在,我们可以进行修复 问题。取决于我们的用例,我们可能只想修复数据, 或者修复程序处理错误的方式。 修复数据是直接的,所以让我们看看调试器 如何帮助我们处理错误。

修复原型

评估表达式 是一个很好的工具,用于制作原型变化,包括修复您的现有代码。这正是我们正在寻找的。 选择整个 parse() 方法体,然后转到 运行 (Run) | 调试操作 (Debugging Actions) | 对表达式求值 (Evaluate Expression) 。

Tip icon

如果你在打开 求值 (Evaluate) 对话框之前选择了代码, 选择的片段会被复制过去,所以你不需要手动输入。

当我们评估方法中的代码时,它会抛出一个异常,就像程序代码中那样:

在 'Evaluate' 对话框中评估相同的失败代码也会导致 NumberFormatException 在 'Evaluate' 对话框中评估相同的失败代码也会导致 NumberFormatException

我们稍微改变一下代码,让它在遇到缺失的数据时存储一个 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,而不是使应用程序崩溃:

在 'Evaluate' 对话框中点击 'Evaluate' 对修正后的代码,结果显示一个有效的返回值 在 'Evaluate' 对话框中点击 'Evaluate' 对修正后的代码,结果显示一个有效的返回值

我们不仅可以测试方法返回而不抛出异常,而且可以看到如何在控制台中看到错误文本:

控制台显示 'Failed to parse elevation for file: ./data/816.json' 控制台显示 'Failed to parse elevation for file: ./data/816.json'

重置帧

好的,我们修复了代码,这样错误在未来就不会发生了,但是我们可以撤销错误本身吗?事实上,我们可以! IntelliJ IDEA 的调试器允许我们弹出错误帧并重新执行该方法。

线程 (Threads) 标签页中点击 Reset Frame (重置帧):

在 'Threads' 标签页中点击 'Reset frame' 图标 在 'Threads' 标签页中点击 'Reset frame' 图标
Info icon

Reset Frame (重置帧) 只会弹出帧堆栈的内部状态。 在我们的例子中,这个方法是纯的,这意味着它不改变局部作用域之外的任何东西,所以这不是问题。 否则,如果对全局应用程序状态做了更改, 这些更改不会被撤销。 当使用类似 求值 (Evaluate) 和 Reset Frame (重置帧) 的特性时,请记住这一点

修复并热重新载入

在撤销错误并假装它从未发生后,我们也可以将修复提交给运行时。 由于执行点当前在我们要更改的方法之外,且我们没有更改该方法的签名,我们可以使用重新加载已更改的类 (Reload Changed Classes)选项,这个选项我已经在之前的帖子中提到过。

首先,将求值 (Evaluate) 对话框中的正确代码复制到编辑器中。 你可以通过浏览历史(⌥↓ / Alt↓)来找到以前评估过的代码。 在替换了 Airports 类中的代码后,你可以将它加载到正在运行的 JVM 中。为此,请选择 运行 (Run) | 调试操作 (Debugging Actions) | 重新加载已更改的类 (Reload Changed Classes)。

一个气球出现确认修复已经进入运行的应用程序 一个气球出现确认修复已经进入运行的应用程序

一个气球出现,确认修复已经进入了正在运行的应用程序。

Tip icon

如果你正在使用当前的 IntelliJ IDEA (2024.3 EAP1) 的 EAP 版本, 尝试使用新的热重载按钮,它直接出现在编辑器中:

一个弹出式对话框出现在编辑器的右上角,提示重新加载文件 一个弹出式对话框出现在编辑器的右上角,提示重新加载文件

未捕获异常过滤

如果我们现在恢复应用程序,它将再次在同一异常处被挂起。为什么会这样呢?

我们修改了代码以捕获 NumberFormatException 。 这个修复防止了应用程序崩溃,但并不能阻止抛出异常。 所以,每次抛出异常时,断点仍然会触发,即使这个异常最终会被捕获。

让我们告诉 IntelliJ IDEA,我们只希望在发生 未捕获的异常时才挂起应用程序。 为此,右键单击在字形边缘的断点,并清除 捕获异常 (Caught exception) 复选框:

'Exception breakpoint' 对话框中 '已捕获的异常' 未选中的复选框,该对话框在点击字形边缘的断点图标时打开 'Exception breakpoint' 对话框中 '已捕获的异常' 未选中的复选框,该对话框在点击字形边缘的断点图标时打开
Tip icon

异常断点图标只有在应用程序在异常处挂起时才会出现在字形边缘。 或者,您可以通过 运行 (Run) | 查看断点 (View Breakpoints) 来配置断点。

有了这个设置,除非发生 未处理的 NumberFormatException ,否则应用程序将无中断地运行。

恢复并享受

我们现在可以恢复应用程序:

调试器工具栏中的‘恢复程序’按钮 调试器工具栏中的‘恢复程序’按钮

它运行得非常顺利,在控制台通知我们缺少数据。 注意看文件816.json是在错误列表上, 证明我们真的处理了这个文件,而不是简单地跳过它。

控制台显示应用程序输出以及错误列表 控制台显示应用程序输出以及错误列表

实际上,816.json有两个条目 - 一个来自于我们的表达式评估实验, 另一个由程序本身放在那里,我们已经纠正了它。

总结

在这篇文章中,我们学习了如何有效地调试 Java 异常,通过收集额外的上下文 指出了故障的原因。然后,利用调试器的功能, 我们能够撤销错误,将调试会话变为测试解决方案的沙盒, 并在崩溃的中途向运行时提供修复, 所有这些都无需重新启动应用程序。

尽管这些技术基础,但在某些情况下可以非常强大,为您节省大量时间。 在即将推出的文章中,我们将讨论更多的调试技巧和技巧,敬请期待!

all posts ->