Appearance
第30讲:使用Sentry处理异常
异常是 Java 应用中处理错误的标准方式。在捕获异常时,通常的做法是以日志的方式记录下来,可以使用第 29 课时介绍的日志聚合技术栈来处理异常。
但是异常中包含了很多与代码相关的信息,尤其是异常的堆栈信息,对错误调试很有帮助。如果只是把这些异常消息当成普通的日志消息,则没办法将其充分利用,更好的做法应该是对异常进行特殊的处理,也就是本课时会主要讲解的内容。
Java 中的异常
在 Java 开发中,总是免不了与异常打交道,异常表示的是错误的情况。
1.异常的类型
Java 中的异常可以分成 3 类,分别是检查异常(Checked Exception)、非检查异常(Unchecked Exception)和错误(Error)。这三种异常都是 Throwable 的子类型,类层次结构如下图所示。
只有 Throwable 类或者其子类的实例,才能被 Java 虚拟机以错误来抛出,或是作为 throw 语句的对象。同样的,只有 Throwable 类及其子类才能作为 catch 子句的类型。
Error 类表示的是严重的系统错误,应用程序不应该试图去捕获和处理这样的错误。这样的错误通常表示异常的情况,应该由虚拟机来处理,只能终止程序的执行。常见的错误包括表示内存不足的 OutOfMemoryError、表示类链接错误的 LinkageError 及其子类、表示 IO 错误的 IOError等。Error 类型是保留给虚拟机使用的,应用程序不应该创建自定义的 Error 类的子类。
与 Error 相对应的 Exception 类,表示的是程序可以捕获和处理的异常情况,异常可分成检查异常和非检查异常两类。这两者的区别在于,检查异常的使用由编译器来检查,而非检查异常则不需要。Throwable 类及其子类,如果不是 Error 或 RuntimeException 及它们的子类型,则被视为检查异常,否则是非检查异常。当检查异常出现在方法声明的 throws 子句中时,该方法的调用者必须对声明的异常进行处理,要么使用 catch 子句来捕获并处理该异常,要么把异常往上传递。非检查异常则没有这样的限制,可以自由地抛出和捕获。
2.异常对象
Throwable 对象在创建时有两个基本的参数,其中一个是 String 类型的 message,表示概要性的描述;另外一个是 Throwable 类型的 cause,表示导致当前 Throwable 对象产生的原因。由于每个 Throwable 对象都可以有自己的原因。多个 Throwable 对象可以通过这种关系串联起来,形成异常链。
在出现异常时,异常对象是获取错误信息的最重要的渠道。因此,异常类中应该包含足够多的信息来描述错误出现时的情况。这一点与日志记录是相似的,其目的都是为了方便开发人员查找错误的根源。异常类除了必须继承自 Throwable 类之外,与其他的 Java 类并没有区别。异常类也可以添加不同的属性和方法。
下面代码中的 OrderNotFoundException 类表示找不到指定标识符对应的订单,其构造参数orderId 表示订单的标识符。当 OrderNotFoundException 异常被抛出时,可以利用异常消息中包含的订单标识符快速查找问题。
java
public class OrderNotFoundException extends Exception {
private final String orderId;
public OrderNotFoundException(final String orderId) {
super(String.format("Order %s not found", orderId));
this.orderId = orderId;
}
public String getOrderId() {
return this.orderId;
}
}
3.异常翻译
当一个方法中使用 throws 子句声明了它所能抛出的异常之后,这些异常就成了这个方法的公开 API 的一部分。从抽象的层次来说,一个方法抛出的异常的抽象层次,应该与该方法的抽象层次互相匹配。举例来说,服务层的方法在实现中需要调用数据访问层的代码,数据访问层的代码可能抛出相应的异常,服务层的方法需要捕获这些异常,并翻译成服务层所对应的异常。这就需要用到上面提到的异常链。
在下面的代码中,MyDataAccess 类的 getData 方法会抛出 DataAccessException 异常。在 MyService 类的 service 方法中,DataAccessException 异常被捕获,并翻译成服务层的MyServiceException 异常。DataAccessException 异常对象则作为 MyServiceException 异常的原因。
java
@Service
public class MyService {
@Autowired
MyDataAccess dataAccess;
public void service() throws MyServiceException {
try {
this.dataAccess.getData();
} catch (final DataAccessException e) {
throw new MyServiceException("Failed to get data", e);
}
}
}
通过异常翻译,既可以保证异常的抽象层次,又可以保留错误产生的追踪信息。
4.检查异常与非检查异常
在设计 Java 的 API 时,一个常见的讨论是检查异常和非检查异常应该在什么时候使用。关于这一点,社区中有很多不同的观点,不同的开发团队也可能选择不同的策略。检查异常的特点在于它们的使用由编译器来强制保证。如果不使用 try-catch 来捕获异常,或是重新抛出异常,代码无法通过编译。
检查异常的这种特点,如果应用得当,会是代码调用者的一大助力,可以让调用者清楚地了解到可能出现的错误情况,并加以处理。而如果使用不当,则会让调用者感觉到很困扰。设计不好的检查异常可能会产生不好的使用模式。
一种常见的情况是,调用者除了忽略异常之外,没有别的处理方法。一个典型的例子是 Java 标准库中的 java.net.URLEncoder 类的 encode 方法。这个方法的一个参数是进行编码的字符集名称,而这个方法会抛出检查异常 UnsupportedEncodingException,也是因为可能找不到指定的字符集。实际上,绝大多数情况下都会使用 UTF-8 作为字符集,而 UTF-8 属于必须支持的字符集,因此这个 UnsupportedEncodingException 不可能出现。
对于这样的情况,一种做法是对原始方法进行封装,去掉检查异常,如下面的代码所示。另外一种做法是把原来的方法拆分成两个方法,其中一个方法用来判断是否可以进行编码,另外一个方法进行编码,但是不抛出检查异常。
java
public static String encode(final String input) {
try {
return URLEncoder.encode(input, "UTF-8");
} catch (final UnsupportedEncodingException ignored) {
// 不会出现的情况
}
return input;
}
使用检查异常的目的是希望调用者可以从错误中恢复。比如,当从文件中读取系统的配置时,如果出现 IOException,则可以使用默认配置。非检查异常用来表示程序运行中的错误。由于非检查异常并不强制进行处理,在使用时会方便一些。比如,Integer.parseInt 会抛出非检查异常NumberFormatException。如果输入的字符串来自内部,则可以忽略对该异常的处理;如果来自外部的用户输入,则需要处理该异常。由于非检查异常的这种灵活性,一般的观点是认为应该优先使用非检查异常。在 Kotlin 中,所有的异常都是非检查的。检查异常的另外一个劣势在于不能与 Java 流 API 一同使用。
5.异常处理的原则
首先是不要忽略异常。使用 try-catch 捕获异常之后,不做任何处理是一种危险的做法,会造成意想不到的问题。如果确定异常不可能产生,应该在 catch 子句中添加注释来说明忽略该异常的理由,如上面代码中对 UnsupportedEncodingException 异常的处理。另外一种更加安全的做法是添加日志记录,至少可以保留异常的相关信息。
另外一个原则是避免多长的异常链。当异常链过长时,在输出堆栈信息或记录日志时,会占用过多的空间。在很多时候,异常链的根本原因就已经足够了。只需要通过 Throwable 的 getCause方法来遍历异常链,并找到作为根的 Throwable 对象即可。更简单的做法是使用 Guava 中Throwables 类的 getRootCause 方法。
使用 Sentry
Sentry 是开源的记录应用错误的服务。对于应用来说,既可以使用 Sentry.io 提供的在线服务,也可以在自己的服务器上部署。Sentry 提供了容器镜像,在 Kubernetes 上运行部署也很简单。
1.配置
如果使用 Sentry.io 提供的在线服务,在使用之前,首先需要注册账号和创建新的项目。在项目的配置界面中,可以找到客户端秘钥(DSN)。这个秘钥是 Sentry SDK 发送数据到服务器所必需的。复制该 DSN 的值,并以系统属性 sentry.dsn 或环境变量 SENTRY_DSN 传递给应用。
除了 DSN 之外,Sentry 还提供了很多配置项。这些配置项可以添加在 CLASSPATH 上的sentry.properties 文件中,也可以通过 Java 系统属性或环境变量来传递。所有的系统属性都以 sentry. 开头,而环境变量都以 SENTRY_ 开头。下表给出了常用的配置项。当需要提供配置项 environment 的值时,可以使用系统属性 sentry.environment 或环境变量SENTRY_ENVIRONMENT。
配置项 | 说明 |
---|---|
release | 应用的版本号 |
environment | 当前的运行环境,如开发、测试、交付准备或生产环境 |
servername | 服务器主机名 |
tags | 附加的标签名值对,以 tag1:value1,tag2:value2 的形式传递 |
extra | 附加的数据,以 key1:value1,key2:value2 的形式传递 |
stacktrace.app.packages | 异常堆栈信息中,属于应用代码的包的名称 |
stacktrace.hidecommon | 异常堆栈信息中,隐藏异常链中非应用代码的帧 |
uncaught.handler.enabled | 处理并发送未捕获的异常 |
Sentry 的大部分配置项都与运行环境有关,因此不适合放在 sentry.properties 文件中,而是在运行时由底层平台来提供。
在使用 Sentry 之前,需要在 Java 应用的 main 方法中调用 Sentry.init 方法来进行初始化。
2.集成日志实现
Sentry 提供了对日志实现框架的集成。在记录日志时,通常都会记录下相关的异常对象。通过与 Sentry 的集成,日志事件中包含的异常会被自动发送到 Sentry。以 Log4j 2 来说,只需要配置使用 Sentry 的日志输出源即可,如下面的代码所示。
xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration status="warn">
<appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/>
</Console>
<Sentry name="Sentry"/>
</appenders>
<loggers>
<root level="INFO">
<appender-ref ref="Console"/>
<appender-ref ref="Sentry" level="WARN"/>
</root>
</loggers>
</configuration>
3.手动记录事件
除了与日志实现集成之外,还可以通过 Sentry 的 Java 客户端来手动记录事件。使用Sentry.capture 方法可以记录不同类型的事件,包括 String、Throwable 和 Sentry 的 Event对象。Event 是 Sentry 提供的事件 POJO 类,包含了事件所具备的属性,可以从 EventBuilder 构建器中创建。
在下面的代码中,通过 EventBuilder 创建出新的 Event 对象,并由 Sentry.capture 方法来记录。
java
Sentry.capture(new EventBuilder()
.withMessage("Test message")
.withLevel(Level.WARNING)
.withTag("tag1", "value1")
.withExtra("key1", "value1")
);
除了直接使用 Event 对象之外,事件中包含的数据,还可以来自当前的上下文。默认情况下,Sentry 使用 ThreadLocal 来保存上下文对象,与当前的线程关联起来,这一点与日志实现中的MDC 是相同的。在发送事件到 Sentry 时,事件会自动包含当前上下文中的内容。
在下面的代码中,通过 Sentry.getContext 方法可以获取到当前的上下文对象,并对其中的数据进行修改。
java
Sentry.getContext().setUser(
new UserBuilder().setUsername("test").setEmail("test@test.com").build()
);
Sentry.getContext().addTag("tag1", "value1");
Sentry.getContext().addExtra("key1", "value1");
Sentry.capture("A new message");
Sentry 还支持收集一种名为面包屑(Breadcrumb)的数据,表示一些相关的事件。面包屑中可以包含下表中的属性。
属性 | 说明 |
---|---|
message | 事件的消息 |
data | 以哈希表表示的元数据 |
category | 事件的类别 |
level | 事件的严重性级别 |
type | 事件的类型 |
在下面的代码中,通过上下文对象的 recordBreadcrumb 方法可以记录 Breadcrumb 对象。
java
Sentry.getContext().recordBreadcrumb(
new BreadcrumbBuilder()
.setMessage("Event 123")
.setData(ImmutableMap.of("key1", "value1"))
.setCategory("test")
.setLevel(Breadcrumb.Level.INFO)
.setType(Type.DEFAULT)
.build());
Sentry.capture("A message with breadcrumb");
4.用户界面
捕获的事件可以通过 Sentry 的界面来查看,下图给出了 Sentry 中查看问题列表的界面。
对于每个问题,可以查看详细信息,如下图所示。
Sentry 界面所提供的功能很强大,可以帮助开发人员快速获取相关信息。
总结
通过记录 Java 应用运行中产生的异常,可以方便开发人员查找问题的根源。通过本课时的学习,你可以掌握 Java 中使用异常的基本知识和相关实践细节,包括检查异常和非检查异常的使用和异常处理的原则等,还可以了解到如何使用 Sentry 来记录异常和发布相关的事件。