第一章:揭示一个应用程序的模糊之处
软件开发人员有各种责任,其中大部分取决于他们对所使用的代码的理解程度。软件开发人员花费大部分时间分析代码,以找出如何纠正问题、实现新功能甚至学习新技术的方法。时间宝贵,因此开发人员需要高效的调查技术以提高生产力。本书的主题是学习如何高效地理解您的代码。
注意:相较于编写代码实现新功能或纠正错误,软件开发人员通常花费更多时间理解软件的工作原理。
通常情况下,软件开发人员将调试一词用于任何调查技术;然而,这只是用于检查以代码形式实现的逻辑的众多工具之一。虽然调试应该意味着"发现问题并解决它们",开发人员使用它来表示分析代码工作的不同目的:
- 学习新的框架
- 找到问题的根本原因
- 理解现有的逻辑以扩展其具备新功能的能力
怎样更容易地理解你的app?
首先,了解代码调查是什么以及开发人员如何进行调查是非常重要的。在接下来的部分中,我们将探讨几种常见的情况,您可以在这些情况下应用本书中所学的技术。
我将代码调查定义为分析软件能力的具体行为的过程。您可能会想,“为什么给出如此泛泛的定义?调查的目的是什么?”在软件开发的早期阶段,查找代码的唯一目的是发现和纠正软件错误(即bug)。这就是为什么许多开发人员仍然将这些技术称为调试的原因。看看debug一词的构成方式:
de-bug = 去除bug,消除错误
在今天的许多情况下,我们仍然会调试应用程序以找到和纠正错误。但与软件开发的早期相比,如今的应用程序更加复杂。在许多情况下,开发人员发现自己需要调查特定软件能力的工作原理,仅仅是为了学习特定的技术或库。调试不再仅仅是找到一个特定问题,而且还涉及正确理解其行为(图1.1;也可以参考mng.bz/M012)。
我们为什么要分析应用程序中的代码呢?
- 为了找到一个特定的问题
- 为了理解特定软件能力的工作原理,以便我们可以增强它
- 为了学习特定的技术或库
许多开发人员也出于兴趣而进行代码调查,因为探索代码工作原理是有趣的。有时候这也可能变得令人沮丧,但没有什么能比找到问题的根本原因或最终理解事物如何工作的感觉更好了(图1.2)。
有各种调查技术可以应用于研究软件的行为方式。正如我们稍后在本章中将讨论的那样,开发人员(尤其是初学者)常常错误地将调试与使用调试器工具等同起来。调试器是一种软件程序,您可以使用它来阅读和更轻松地理解应用程序的源代码,通常是通过在特定指令上暂停执行并逐步运行代码来实现的。这是一种常见的调查软件行为的方式(通常是开发人员学习的第一种方式)。但这不是您唯一可以使用的技术,并且它并不能在每种情况下都对您有所帮助。我们将在第2章和第3章中讨论使用调试器的标准和更高级的方法。图1.3展示了您将在本书中学到的各种调查技术。
当开发人员解决一个bug时,他们在理解特定功能上花费了大部分时间。最终他们所做的改变有时将问题简化为一行代码,比如缺少条件、缺少指令或错误使用操作符。开发人员花费大部分时间的不是编写代码,而是理解应用程序的工作原理。
在某些情况下,仅仅阅读代码就足以理解它,但阅读代码并不像阅读一本书那样简单。当我们阅读代码时,我们不会阅读从上到下以逻辑顺序编写的漂亮的短段落。相反,我们从一个方法跳到另一个方法,从一个文件跳到另一个文件;有时我们感觉自己在一个广阔的迷宫中前进并迷失了方向(关于这个主题,我推荐Felienne Hermans的优秀书籍《程序员的大脑》[Manning,2021])。
在许多情况下,源代码的编写方式并不容易阅读。是的,我知道你在想什么:它应该是易读的。我同意你的看法。如今,我们学习了许多代码设计的模式和原则,以及如何避免代码异味,但让我们诚实一点:在许多情况下,开发人员仍然没有正确地使用这些原则。此外,遗留应用程序通常不遵循这些原则,只是因为在编写这些功能时,这些原则多年前并不存在。但您仍然需要能够调查这样的代码。
看看代码清单1.1。假设您在尝试查找您正在开发的应用程序中问题的根本原因时,找到了这段代码。这段代码肯定需要重构。但在进行重构之前,您需要理解它在做什么。我知道有些开发人员可以读过这段代码并立即理解它的功能,但我不是其中之一。
为了更容易理解清单1.1中的逻辑,我使用调试器——一种允许我在特定行上暂停执行并手动运行每条指令的工具,同时观察数据如何变化——逐行分析代码,观察它如何处理给定的输入(正如我们将在第2章中讨论的)。通过一些经验和一些技巧(我们将在第2章和第3章中讨论),你会发现通过多次解析这段代码,它计算了给定输入之间的最大值。这段代码是附带本书的项目da-ch1-ex1的一部分。
public int m(int f, int g) {
try {
int[] far = new int[f];
far[g] = 1;
return f;
} catch(NegativeArraySizeException e) {
f = -f;
g = -g;
return (-m(f, g) == -f) ? -g : -f;
} catch(IndexOutOfBoundsException e) {
return (m(g, 0) == 0) ? f : g;
}
}
有些情况下,您无法浏览代码,或者浏览代码变得更具挑战性。如今,大多数应用程序依赖于诸如库或框架之类的依赖项。在大多数情况下,即使您可以访问源代码(当您使用开源依赖项时),跟踪定义框架逻辑的源代码仍然很困难。有时候,您甚至不知道从哪里开始。在这种情况下,您必须使用不同的技术来理解应用程序。例如,您可以使用性能分析工具(正如您将在第6章到第9章中学到的)来确定在决定从哪里开始调查之前执行的代码。
其他情况下,您将没有机会运行应用程序。在某些情况下,您需要调查导致应用程序崩溃的问题。如果遇到问题并停止的应用程序是一个生产服务,您需要尽快使其恢复可用。因此,您需要收集细节并使用它们来识别问题,并改进应用程序以避免将来发生相同的问题。这种在应用程序崩溃后依赖于收集数据的调查被称为事后调查。对于这种情况,您可以使用日志、堆转储或线程转储——这些是我们将在第10章和第11章中讨论的故障排除工具。
使用调查技术的典型场景
让我们讨论一些常见的使用代码调查方法的场景。我们必须查看一些来自实际应用程序的典型案例,并对它们进行分析,以强调本书主题的重要性:
- 理解为什么特定的代码段或软件功能提供的结果与预期不同
- 学习应用程序所使用的依赖技术的工作原理
- 识别导致性能问题(如应用程序运行缓慢)的原因
- 查找应用程序突然停止的根本原因
针对每个呈现的案例,您将找到一个或多个有助于调查应用程序逻辑的技术。随后,我们将深入探讨这些技术,并通过示例演示如何使用它们。
揭开意外输出的神秘面纱
当某个逻辑的输出结果与预期不符时,这是最常见的需要分析代码的情况之一。这听起来可能很简单,但解决起来并不一定容易。
首先,让我们定义输出。对于一个应用程序来说,输出可能有很多定义。输出可以是应用程序控制台中的一些文本,也可以是数据库中的某些记录更改。我们可以将应用程序发送到不同系统的HTTP请求或作为对客户端请求的HTTP响应发送的某些数据视为输出。
定义:任何执行逻辑的结果,可能导致数据更改、信息交换或对其他组件或系统执行操作,都是一个输出。
当某个应用程序的特定部分没有达到预期的执行结果时,我们如何进行调查?我们可以根据预期的输出选择适当的技术来进行调查。让我们看一些示例。
场景一:简单案例
假设一个应用程序应该将一些记录插入到数据库中。然而,该应用程序只添加了部分记录。也就是说,您预期在数据库中找到的数据应该比应用程序实际生成的数据更多。
分析这个问题的最简单方法是使用调试器工具来跟踪代码的执行并理解其工作原理(图1.4)。您将在第2章和第3章中了解调试器的主要功能。调试器可以在您选择的特定代码行上添加断点,以暂停应用程序的执行,然后允许您手动继续执行。您可以逐步运行代码指令,以便查看变量的值如何变化,并即时评估表达式。
这个场景是最简单的,通过学习如何正确使用所有相关的调试器功能,您可以迅速找到解决这类问题的方法。不幸的是,其他情况更加复杂,调试器工具并不总能足够解开难题并找到问题的原因。
提示:在许多情况下,单一的调查技术并不足以理解应用程序的行为。您需要结合多种方法来更快地理解更复杂的行为。
场景二:我应该从哪里开始调试?
有时候,您可能无法使用调试器,因为您不知道应该调试什么。假设您的应用程序是一个复杂的服务,包含许多行代码。您正在调查一个问题,即应用程序没有将预期的记录存储到数据库中。这绝对是一个输出问题,但在定义您的应用程序的成千上万行代码中,您不知道哪个部分实现了您需要修复的功能。
我记得有位同事曾经调查过这样的问题。他因为不知道从哪里开始而感到压力倍增,他大声说道:“我希望调试器有一种方式,可以在应用程序的所有行上都设置断点,这样你就可以看到它实际使用了哪些代码。”
我同事的说法很有趣,但调试器中有这样的功能并不是解决办法。我们有其他方法来解决这个问题。通过使用性能分析器,您很可能可以缩小可以添加断点的行的范围。
性能分析器是一种工具,可以在应用程序运行时识别出执行的代码(图1.5)。这对于我们的情况是一个很好的选择,因为它会给您一个关于从哪里开始调试的思路。我们将在第6章到第9章讨论如何使用性能分析器,在那里您将了解到您不仅可以观察代码的执行情况,还有其他选择。
场景三:多线程应用程序
在处理通过多个线程实现的逻辑或多线程架构时,情况变得更加复杂。在许多这样的情况下,使用调试器并不是一个选择,因为多线程架构往往对干扰非常敏感。
换句话说,当您使用调试器时,应用程序的行为会发生变化。开发人员将这种特性称为海森堡执行或海森巴格(图1.6)。这个名称来自于二十世纪的物理学家沃纳·海森堡,他制定了不确定性原理,该原理指出,一旦您干涉一个粒子,它的行为就会发生变化,因此您无法同时准确预测其速度和位置(plato.stanford.edu/entries/qt-…
对于多线程功能,我们有各种各样的情况。这就是我认为这些场景最难测试的原因。有时,使用分析器是一个不错的选择,但即使是分析器也可能干扰应用程序的执行,因此可能也不起作用。另一种选择是在应用程序中使用日志记录(我们将在第5章中讨论)。对于某些问题,您可以找到一种方法将线程数量减少到一个,以便您可以使用调试器进行调查。
场景4:向给定服务发送错误的调用
您可能需要调查这样一种情况:应用程序与另一个系统组件或外部系统的交互不正确。假设您的应用程序向另一个应用程序发送HTTP请求。第二个应用程序的维护者通知您,HTTP请求的格式不正确(可能缺少标题或请求体包含错误数据)。图1.7以可视化的方式展示了这种情况。
这是一个错误输出的情况。您可以如何处理呢?首先,您需要确定哪部分代码发送了请求。如果您已经知道,您可以使用调试器来调查应用程序如何创建请求,并确定出了什么问题。如果您需要找到哪部分应用程序发送了请求,您可能需要使用分析器,正如您将在第6至9章中学到的那样。您可以使用分析器确定在执行过程中的某个特定时间点哪些代码在起作用。
在处理像这种复杂案例时,我经常使用一个诀窍,即当某种原因导致我无法直接识别应用程序从何处发送请求时:我用一个存根(stub)替换掉另一个应用程序(即我的应用程序错误地发送请求的那个应用程序)。存根是一个我可以控制的虚拟应用程序,可以帮助我确定问题所在。例如,为了确定哪部分代码发送了请求,我可以让我的存根阻止请求,以便我的应用程序无限期地等待响应。然后,我只需使用分析器确定哪些代码被存根所阻塞。图1.8展示了存根的使用方式。将该图与图1.7进行对比,以了解存根是如何替代真实应用程序的。
学习某些技术
使用调查技术分析代码的另一个用途是学习某些技术的工作原理。一些开发人员开玩笑说,花费6小时进行调试可以节省5分钟阅读文档的时间。虽然阅读文档在学习新技术时也很重要,但有些技术过于复杂,单靠阅读书籍或规范是无法完全掌握的。我经常建议我的学生深入研究特定的框架或库,以便更好地理解它。
提示:对于你学习的任何技术(框架或库),花一些时间回顾你编写的代码。始终尝试深入了解并调试框架的代码。
我将以我最喜欢的技术Spring Security为例。乍一看,Spring Security可能看起来很简单。它只是实现身份验证和授权,对吗?事实上,确实如此,直到你发现了将这两个功能配置到你的应用程序中的各种方式。如果你将它们搞错了,可能会遇到麻烦。当事情不起作用时,你必须处理不正常的情况,而处理不正常情况的最佳选择是调查Spring Security的代码。
比任何其他东西都重要的是,调试帮助我理解了Spring Security。为了帮助他人,我将我的经验和知识写入了一本名为《Spring Security in Action》(Manning,2020)的书中。在这本书中,我提供了70多个项目供你重新创建、运行,也可以进行调试。我邀请你调试阅读过的所有技术书籍中提供的示例,以学习各种技术。
第二个我主要通过调试学习的技术示例是Hibernate。Hibernate是一个高级框架,用于实现应用程序与SQL数据库的交互能力。Hibernate是Java世界中最知名、最常用的框架之一,因此对于任何Java开发人员来说,它都是必学的。
学习Hibernate的基础知识很容易,你可以通过阅读书籍来实现。但在实际工作中,使用Hibernate(如何使用和在何处使用)包含的内容远远超出了基础知识。对我来说,如果不深入挖掘Hibernate的代码,我绝对不会像今天这样深入了解这个框架。
我给你的建议很简单:对于你学习的任何技术(框架或库),花一些时间回顾你编写的代码。始终尝试深入了解并调试框架的代码。这将使你成为一个更好的开发人员。
澄清应用程序运行缓慢问题
应用程序中不时会出现性能问题,就像其他问题一样,在解决问题之前,您需要进行调查研究。学习正确使用不同的调试技术来识别性能问题的原因非常重要。
根据我的经验,应用程序中最常见的性能问题与应用程序的响应速度有关。然而,即使大多数开发人员认为缓慢和性能问题是相同的,实际上并非如此。缓慢问题(即应用程序对给定触发器的响应速度较慢的情况)只是性能问题的一种。
例如,我曾经需要调试一个消耗移动设备电池过快的移动应用程序。我有一个使用蓝牙连接到外部设备的 Android 应用程序,使用了一个库。由于某种原因,该库在不关闭的情况下创建了大量线程。这些保持打开状态且没有实际用途的线程被称为僵尸线程,通常会导致性能和内存问题。而且,它们通常很难进行调查。
然而,电池过快消耗的问题也是应用程序的性能问题。应用程序在通过网络传输数据时使用过多的网络带宽也是性能问题的另一个很好的例子。
让我们专注于最常遇到的缓慢问题。许多开发人员对缓慢问题感到害怕。通常,这并不是因为这些问题很难识别,而是因为它们很难解决。使用性能分析工具找到性能问题的原因通常是一项容易的工作,正如您将在第 6 至 9 章中了解到的那样。除了识别哪些代码在执行,如 1.2.1 节所讨论的,性能分析工具还会显示应用程序在每个指令上所花费的时间(图 1.9)。
在许多情况下,缓慢问题是由于 I/O 调用引起的,例如从文件或数据库中读取或写入数据,或在网络上发送数据。因此,开发人员通常会根据经验来寻找问题的原因。如果您知道受影响的能力是什么,您可以将重点放在执行该能力的 I/O 调用上。这种方法还有助于缩小问题的范围,但通常仍需要工具来确定其确切位置。
理解应用程序崩溃
有时应用程序由于各种原因完全停止响应。这类问题通常被认为比其他问题更具挑战性。在许多情况下,应用程序崩溃只会在特定条件下发生,因此您无法在本地环境中重现(有意使问题发生)这些问题。
每次调查问题时,您应该首先尝试在可以研究问题的环境中重现它。这种方法使您的调查更具灵活性,并帮助您确认解决方案。然而,我们并不总是幸运地能够重现一个问题。而应用程序崩溃通常不容易重现。
我们可以将应用程序崩溃的情况分为两种主要类型:
- 应用程序完全停止。
- 应用程序仍在运行,但无法响应请求。
当应用程序完全停止时,通常是因为遇到无法恢复的错误。最常见的情况是内存错误导致这种行为。对于Java应用程序而言,当堆内存填满并且应用程序无法继续工作时,会出现OutOfMemoryError错误。
为了调查堆内存问题,我们使用堆转储(heap dumps),它们提供了在特定时间点堆内存包含的快照。您可以配置Java进程,在出现OutOfMemoryError错误并导致应用程序崩溃时自动生成这样的快照。
堆转储是强大的工具,可以提供有关应用程序如何内部处理数据的详细信息。我们将在第11章中更详细地讨论如何使用它们。但让我们先简单看一个示例。
代码清单1.2展示了一个小的代码片段,它使用一个名为Product的类的实例来填充内存。您可以在本书提供的da-ch1-ex2项目中找到此应用程序。该应用程序不断将Product实例添加到列表中,从而导致预期的OutOfMemoryError错误消息。
public class Main {
private static List<Product> products = ❶
new ArrayList<>();
public static void main(String[] args) {
while (true) {
products.add( ❷
new Product(UUID.randomUUID().toString())); ❸
}
}
}
❶ We declare a list that stores references of Product objects.
❷ We continuously add Product instances to the list until the heap memory completely fills.
❸ Each Product instance has a String attribute. We use a unique random identifier as its value.
图1.10显示了为此应用程序的一次执行创建的堆转储。您可以很容易地看到Product和String实例填充了大部分堆内存。堆转储就像是内存的地图。它提供了许多细节,包括实例之间的关系和值。例如,即使您不看代码,仍然可以根据实例数量的接近程度注意到Product实例和String实例之间的联系。如果这些方面看起来复杂,不要担心。在第11章中,我们将详细讨论您在使用堆转储方面需要了解的一切。
如果应用程序仍在运行但停止响应请求,那么线程转储是分析发生情况的最佳工具。图1.11展示了线程转储的示例以及该工具提供的一些详细信息。在第10章中,我们将讨论生成和分析线程转储以进行代码调查。