Skip to content

第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 来记录异常和发布相关的事件。