2. 场景设置

我们构建一个最小化的股票交易API,涉及仓库类、核心Bean和REST接口。先从资源配置开始:

@ApplicationPath("/exception-handling/*")
public class ExceptionHandlingConfig extends ResourceConfig {
    public ExceptionHandlingConfig() {
        packages("com.baeldung.jersey.exceptionhandling.rest");
    }
}

2.1. 核心Bean

只需两个Bean:Stock(股票)和Wallet(钱包)。Stock需包含价格属性用于验证,Wallet提供验证方法构建场景:

public class Wallet {
    private String id;
    private Double balance = 0.0;

    // getters and setters

    public Double addBalance(Double amount) {
        return balance += amount;
    }

    public boolean hasFunds(Double amount) {
        return (balance - amount) >= 0;
    }
}

2.2. REST接口

API包含两个核心接口,定义Bean的增删改查操作:

@Path("/stocks")
public class StocksResource {
    // POST and GET methods
}
@Path("/wallets")
public class WalletsResource {
    // POST and GET methods
}

StocksResource的GET方法为例:

@GET
@Path("/{ticker}")
@Produces(MediaType.APPLICATION_JSON)
public Response get(@PathParam("ticker") String id) {
    Optional<Stock> stock = stocksRepository.findById(id);
    stock.orElseThrow(() -> new IllegalArgumentException("ticker"));

    return Response.ok(stock.get())
      .build();
}

这里抛出了第一个异常,我们稍后处理,先观察其影响。

3. 抛出异常会发生什么?

未处理异常会暴露应用内部敏感信息。尝试获取不存在的Stock时,默认返回类似页面:

[默认异常页面示意图]

这种页面暴露了:

  • 应用服务器及版本(可能被攻击者利用)
  • 类名和行号(辅助攻击者分析)
  • 对用户无用的信息,损害API专业性

JAX-RS提供ExceptionMapperWebApplicationException控制异常响应。下面详解用法。

4. 使用 WebApplicationException 自定义异常

WebApplicationException是特殊RuntimeException,可自定义响应状态和实体。创建InvalidTradeException设置状态和消息:

public class InvalidTradeException extends WebApplicationException {
    public InvalidTradeException() {
        super("invalid trade operation", Response.Status.NOT_ACCEPTABLE);
    }
}

JAX-RS已预定义常用状态码的子类(如NotAllowedExceptionBadRequestException)。需要复杂错误信息时,可返回JSON响应。

4.1. JSON异常响应

创建简单Java类封装错误信息:

public class RestErrorResponse {
    private Object subject;
    private String message;

    // getters and setters
}

subject属性用于包装上下文数据,类型不限。

4.2. 实战应用

定义股票购买方法,整合所有组件:

@POST
@Path("/{wallet}/buy/{ticker}")
@Produces(MediaType.APPLICATION_JSON)
public Response postBuyStock(
  @PathParam("wallet") String walletId, @PathParam("ticker") String id) {
    Optional<Stock> stock = stocksRepository.findById(id);
    stock.orElseThrow(InvalidTradeException::new);

    Optional<Wallet> w = walletsRepository.findById(walletId);
    w.orElseThrow(InvalidTradeException::new);

    Wallet wallet = w.get();
    Double price = stock.get()
      .getPrice();

    if (!wallet.hasFunds(price)) {
        RestErrorResponse response = new RestErrorResponse();
        response.setSubject(wallet);
        response.setMessage("insufficient balance");
        throw new WebApplicationException(Response.status(Status.NOT_ACCEPTABLE)
          .entity(response)
          .build());
    }

    wallet.addBalance(-price);
    walletsRepository.save(wallet);

    return Response.ok(wallet)
      .build();
}

关键点:

  • 股票或钱包不存在时抛InvalidTradeException
  • 余额不足时构建RestErrorResponse并包装为WebApplicationException

4.3. 用例演示

创建股票:

$ curl 'http://localhost:8080/jersey/exception-handling/stocks' -H 'Content-Type: application/json' -d '{
    "id": "STOCK",
    "price": 51.57
}'

{"id": "STOCK", "price": 51.57}

创建钱包:

$ curl 'http://localhost:8080/jersey/exception-handling/wallets' -H 'Content-Type: application/json' -d '{
    "id": "WALLET",
    "balance": 100.0
}'

{"balance": 100.0, "id": "WALLET"}

购买股票:

$ curl -X POST 'http://localhost:8080/jersey/exception-handling/wallets/WALLET/buy/STOCK'

{"balance": 48.43, "id": "WALLET"}

余额不足时返回详细错误:

{
    "message": "insufficient balance",
    "subject": {
        "balance": 48.43,
        "id": "WALLET"
    }
}

5. 使用 ExceptionMapper 处理未捕获异常

重要:WebApplicationException需指定响应实体,否则仍显示默认错误页。用ExceptionMapper捕获特定异常并修改响应:

public class ServerExceptionMapper implements ExceptionMapper<WebApplicationException> {
    @Override
    public Response toResponse(WebApplicationException exception) {
        String message = exception.getMessage();
        Response response = exception.getResponse();
        Status status = response.getStatusInfo().toEnum();

        return Response.status(status)
          .entity(status + ": " + message)
          .type(MediaType.TEXT_PLAIN)
          .build();
    }
}

进阶版:根据状态码定制消息

switch (status) {
    case METHOD_NOT_ALLOWED:
        message = "HTTP METHOD NOT ALLOWED";
        break;
    case INTERNAL_SERVER_ERROR:
        message = "internal validation - " + exception;
        break;
    default:
        message = "[unhandled response code] " + exception;
}

5.1. 处理特定异常

为频繁抛出的异常创建专用Mapper。以IllegalArgumentException为例:

public class IllegalArgumentExceptionMapper
  implements ExceptionMapper<IllegalArgumentException> {
    @Override
    public Response toResponse(IllegalArgumentException exception) {
        return Response.status(Response.Status.EXPECTATION_FAILED)
          .entity(build(exception.getMessage()))
          .type(MediaType.APPLICATION_JSON)
          .build();
    }

    private RestErrorResponse build(String message) {
        RestErrorResponse response = new RestErrorResponse();
        response.setMessage("an illegal argument was provided: " + message);
        return response;
    }
}

所有未捕获的IllegalArgumentException都会被此Mapper处理。

5.2. 配置生效

在Jersey配置中注册Mapper:

public ExceptionHandlingConfig() {
    // packages ...
    register(IllegalArgumentExceptionMapper.class);
    register(ServerExceptionMapper.class);
}

效果:

  • 彻底消除默认错误页
  • 根据异常类型自动选择Mapper

测试获取不存在的股票:

$ curl 'http://localhost:8080/jersey/exception-handling/stocks/NONEXISTENT'

{"message": "an illegal argument was provided: ticker"}

错误HTTP方法触发通用Mapper:

$ curl -X POST 'http://localhost:8080/jersey/exception-handling/stocks/STOCK'

Method Not Allowed: HTTP 405 Method Not Allowed

6. 总结

本文系统介绍了Jersey异常处理的多种方案:

  • 核心价值:提升API友好性和安全性
  • 关键组件WebApplicationExceptionExceptionMapper
  • 最佳实践:组合使用自定义异常和全局Mapper

通过股票交易场景的实战演示,我们构建了更健壮的REST API。完整源码见GitHub仓库


原始标题:Exception Handling With Jersey