Java中的Exception:checked、unchecked与设计方法

1. 总体介绍

Exception是Java用于表明某件事未按预期发生的机制。许多项目对exception的使用不一致。有的地方到处是throws Exception声明。有的地方则在空的catch块中吞掉错误。

在本文中,你将了解checked异常和unchecked异常的区别。你还会知道何时应该使用哪种类型,以及如何为应用程序设计自己的异常体系。文章最后介绍如何使用JUnit 5和AssertJ来测试异常行为。

2. Throwable的继承体系

所有通过throw抛出的东西都继承自java.lang.Throwable。简化的继承树如下:

Throwable
├── Error                      (unchecked) – JVM的严重错误
│   ├── OutOfMemoryError
│   └── StackOverflowError
└── Exception                  (checked)
    ├── IOException
    ├── SQLException
    └── RuntimeException       (unchecked)
        ├── IllegalArgumentException
        ├── IllegalStateException
        └── NullPointerException

分类规则非常简单:

  • Checked异常是Exception及其子类,除了RuntimeException分支。编译器强制要求你处理这种类型。
  • Unchecked异常是RuntimeException、Error及其子类。编译器不强制要求你处理。

你几乎不应该捕获Error。当JVM内存不足时,应用程序很少能自动恢复。

3. Checked异常

Checked异常是method“契约”的一部分。当method声明throws时,调用者必须选择以下两种方式之一。第一种方式是用try/catch捕获错误。第二种方式是继续向上声明throws。

考虑一个支付网关,它可能拒绝交易。这种情况完全可能发生,调用者应该决定接下来如何处理:

public class PaymentDeclinedException extends Exception {
    private final String reason;

    public PaymentDeclinedException(String reason) {
        super("Payment declined: " + reason);
        this.reason = reason;
    }

    public String getReason() {
        return reason;
    }
}

public class PaymentGateway {
    public void charge(String cardNumber, long amount) throws PaymentDeclinedException {
        if (amount > 10_000_000) {
            throw new PaymentDeclinedException("超过限额");
        }
        System.out.println("Charged " + amount + " from card " + cardNumber);
    }
}

PaymentDeclinedException继承Exception,所以它是checked异常。如果你调用charge(...)但不处理错误,编译器会报错:

error: unreported exception PaymentDeclinedException; must be caught or declared to be thrown

调用者必须考虑失败情况:

public class CheckoutService {
    private final PaymentGateway gateway = new PaymentGateway();

    public String checkout(String card, long amount) {
        try {
            gateway.charge(card, amount);
            return "SUCCESS";
        } catch (PaymentDeclinedException e) {
            System.out.println("Reason: " + e.getReason());
            return "DECLINED";
        }
    }

    public static void main(String[] args) {
        var service = new CheckoutService();
        System.out.println(service.checkout("4111-xxxx", 50_000));
        System.out.println(service.checkout("4111-xxxx", 20_000_000));
    }
}

输出:

Charged 50000 from card 4111-xxxx
SUCCESS
Reason: 超过限额
DECLINED

这里错误被转换成了一个业务状态(DECLINED)。这是checked异常最擅长处理的情况。

4. Unchecked异常

Unchecked异常通常表示编程错误或违反前置条件。例如传递null、传递负数,或在对象处于错误状态时调用method。

public class Order {
    private final long id;
    private int quantity;
    private boolean shipped;

    public Order(long id, int quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("quantity must be > 0, got: " + quantity);
        }
        this.id = id;
        this.quantity = quantity;
    }

    public void changeQuantity(int newQuantity) {
        if (shipped) {
            throw new IllegalStateException("Order " + id + " has shipped, cannot change quantity");
        }
        this.quantity = newQuantity;
    }

    public void ship() {
        this.shipped = true;
    }
}

构造函数在输入无效时抛出IllegalArgumentException。方法changeQuantity在订单已发货时抛出IllegalStateException。没有人应该在每个调用处“捕获并处理”这些错误。正确的做法是修复调用代码。

在创建新异常类之前,你应该重用JDK中的现有异常:

情况 应该使用的Exception
参数无效 IllegalArgumentException
对象处于错误状态 IllegalStateException
必需参数为null NullPointerException(通过Objects.requireNonNull)
操作不支持 UnsupportedOperationException
索引超出范围 IndexOutOfBoundsException

5. 选择checked还是unchecked?

有一条务实的原则:问问调用者能否对这个错误做什么有用的事情。

  • 调用者能够合理地恢复,例如重试、选择替代方案或通知用户。此时checked异常是可接受的选择。
  • 错误由bug或基础设施故障引起,调用者无能为力。此时应使用unchecked异常。

在实践中,Spring和Hibernate等现代框架几乎只使用unchecked异常。例如,Spring将SQLException包装为DataAccessException。原因是checked异常有几个缺点:

  1. 它们逐层传播:repository层的throws导致service和controller层也要声明throws。
  2. 它们与lambda和Stream API不兼容(见第8节)。
  3. 它们暴露了实现细节。接口OrderRepository不应该声明throws SQLException。

注意: 对于Spring Boot应用程序,默认应对业务错误使用unchecked异常。然后使用@RestControllerAdvice集中处理。只有当你真正想强制调用者立即处理时,才使用checked异常。

6. 为应用程序设计自定义异常

6.1. 创建基类

你应该为每个domain创建一个基异常类。这样可以在需要时用一个catch块捕获该domain的所有错误:

public class OrderException extends RuntimeException {
    public OrderException(String message) {
        super(message);
    }

    public OrderException(String message, Throwable cause) {
        super(message, cause);
    }
}

该类必须始终有一个接收cause的构造函数。这样,原始错误在你包装它时不会丢失。

6.2. 携带上下文的子类

一个好的异常应该能回答“发生了什么事,涉及什么数据?“的问题。不要仅仅写"Not found":

public class OrderNotFoundException extends OrderException {
    private final long orderId;

    public OrderNotFoundException(long orderId) {
        super("Not found order id=" + orderId);
        this.orderId = orderId;
    }

    public long getOrderId() {
        return orderId;
    }
}

字段orderId被保存下来供上层使用。例如,controller advice可以读取它来为客户端返回JSON错误,而不必解析消息字符串。

6.3. 包装下层错误(异常转换)

当service层调用基础设施时,你应该将技术错误转换为domain错误,并保留cause:

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class OrderExportService {

    public void export(long orderId, Path target) {
        try {
            Files.writeString(target, "order=" + orderId);
        } catch (IOException e) {
            throw new OrderException("Export order " + orderId + " to " + target + " failed", e);
        }
    }
}

第二个参数e是cause。当打印堆栈跟踪时,你会看到两个层级:

OrderException: Export order 42 to /readonly/order.txt failed
    at OrderExportService.export(OrderExportService.java:12)
Caused by: java.nio.file.AccessDeniedException: /readonly/order.txt
    ...

如果你写throw new OrderException(e.getMessage())而不传递e,Caused by部分会消失。那样的话调试会变得非常困难。

7. 常见的反模式

吞掉异常。 这是最危险的错误,因为应用程序会继续运行,好像什么都没发生:

try {
    gateway.charge(card, amount);
} catch (PaymentDeclinedException e) {
    // TODO
}

如果你真的想忽略一个错误,你应该在注释中明确说明理由。至少也应该记录该错误。

既记录又重新抛出。 写log.error(...)然后throw e;会导致同一个错误在日志中出现多次。你应该只选择一个:在原地处理(并记录),或将其抛给上层处理。

捕获Exception或Throwable范围太广。 这样做,你会无意中也捕获了NullPointerException和其他bug。应该捕获你能处理的具体类型。唯一的合理例外是在应用程序的最外层,例如全局处理程序。

使用异常来控制流程。 创建异常很昂贵,因为需要收集堆栈跟踪。这样的代码也很难读:

// Don't do this
try {
    return Integer.parseInt(input);
} catch (NumberFormatException e) {
    return 0;
}

对于你预期会经常出错的数据,应该提前检查。你也可以返回Optional。异常应该保留给异常情况。

声明throws Exception。 这样做会完全消除关于可能发生何种错误的信息。它还强制所有调用者必须捕获一个非常宽泛的类型。

8. lambda中的Checked异常

接口Function不声明throws,所以下面的代码无法编译:

List<String> contents = paths.stream()
        .map(Files::readString) // error: unreported exception IOException
        .toList();

常见的处理方式是将错误包装为UncheckedIOException。JDK中已经为此目的提供了这个类:

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;

public class OrderFileReader {

    public List<String> readAll(List<Path> paths) {
        return paths.stream()
                .map(OrderFileReader::readQuietly)
                .toList();
    }

    private static String readQuietly(Path path) {
        try {
            return Files.readString(path);
        } catch (IOException e) {
            throw new UncheckedIOException("Cannot read " + path, e);
        }
    }
}

方法readQuietly将try/catch块隔离在pipeline之外,使stream保持简洁。readAll的调用者可以捕获UncheckedIOException,并通过getCause()获取原始的IOException。

9. try-with-resources和suppressed异常

使用需要关闭的资源时,应该使用try-with-resources。有一个容易被忽视的点:如果try块和close()都抛出错误,close()的错误不会覆盖主错误。它被作为suppressed异常附加到主错误中:

public class SuppressedDemo {

    static class ReportConnection implements AutoCloseable {
        void query() {
            throw new IllegalStateException("Query failed");
        }

        @Override
        public void close() {
            throw new IllegalStateException("Close failed");
        }
    }

    public static void main(String[] args) {
        try (var connection = new ReportConnection()) {
            connection.query();
        } catch (IllegalStateException e) {
            System.out.println("Main: " + e.getMessage());
            for (Throwable s : e.getSuppressed()) {
                System.out.println("Suppressed: " + s.getMessage());
            }
        }
    }
}

输出:

Main: Query failed
Suppressed: Close failed

如果你自己写try/finally并在finally中调用close(),“Close失败”的错误会覆盖“Query失败”。这样你就会丧失真正的原因。

10. 测试异常抛出行为

如果使用Spring Boot,spring-boot-starter-test已经包含了JUnit 5和AssertJ。对于纯Java项目,添加以下依赖:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.11.3</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
</dependency>

你应该检查异常的类型、消息、上下文数据和cause:

import org.junit.jupiter.api.Test;

import java.nio.file.Path;

import static org.assertj.core.api.Assertions.*;

class OrderExceptionTest {

    @Test
    void whenQuantityIsZero_thenThrowIllegalArgument() {
        assertThatThrownBy(() -> new Order(1L, 0))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("quantity must be > 0");
    }

    @Test
    void whenOrderShipped_thenCannotChangeQuantity() {
        var order = new Order(7L, 2);
        order.ship();

        assertThatIllegalStateException()
                .isThrownBy(() -> order.changeQuantity(5))
                .withMessage("Order 7 has shipped, cannot change quantity");
    }

    @Test
    void notFoundException_carriesOrderId() {
        assertThatThrownBy(() -> { throw new OrderNotFoundException(42L); })
                .isInstanceOf(OrderException.class)
                .hasFieldOrPropertyWithValue("orderId", 42L);
    }

    @Test
    void whenPaymentOverLimit_thenThrowCheckedException() {
        var gateway = new PaymentGateway();

        assertThatExceptionOfType(PaymentDeclinedException.class)
                .isThrownBy(() -> gateway.charge("4111-xxxx", 20_000_000))
                .satisfies(e -> assertThat(e.getReason()).isEqualTo("超过限额"));
    }

    @Test
    void whenExportFails_thenCauseIsPreserved() {
        var service = new OrderExportService();
        Path invalid = Path.of("/directory-does-not-exist/order.txt");

        assertThatThrownBy(() -> service.export(42L, invalid))
                .isInstanceOf(OrderException.class)
                .hasMessageContaining("Export order 42")
                .hasCauseInstanceOf(java.io.IOException.class);
    }
}

需要注意的几点:

  • assertThatThrownBy接收一个可以抛出任何Throwable的lambda,包括checked异常。因此你不需要在test中使用try/catch。
  • hasFieldOrPropertyWithValue读取gettergetOrderId()。这样你可以确保上下文被正确传递。
  • hasCauseInstanceOf断言在包装错误时没有丢失cause。如果缺少这个test,遗忘cause很容易被漏过。在test中,父目录不存在,所以Files.writeString抛出NoSuchFileException,它是IOException的子类。

11. 总结

在本文中,你看到checked异常用于调用者可以恢复的情况。Unchecked异常用于编程错误、条件违反或基础设施故障,调用者无法做任何事情。对于现代应用程序,特别是Spring Boot,unchecked异常是合理的默认选择。

设计自己的异常时,应遵循以下原则:

  • 为每个domain创建一个基类。
  • 将上下文数据放入子类。
  • 在各层之间转换错误时始终保留cause。

最后,你应该避免诸如吞掉异常、既记录又重新抛出,或捕获过于宽泛的Exception等反模式。同时,编写test来锁定代码的异常行为。

javaexceptionclean-codejunit5