1. 概述
错误处理是系统开发的核心关注点之一。在代码层面,它处理我们编写的代码抛出的异常;在服务层面,它涵盖所有返回的非成功响应。
在大型系统中,统一处理同类错误是最佳实践。例如,一个包含两个控制器的服务中,我们希望认证错误响应保持一致,以便于调试。更进一步,为了简化系统,我们可能希望所有服务返回相同的错误响应。通过全局异常处理器可以实现这种统一处理。
本文将聚焦 Micronaut 的错误处理机制。与大多数 Java 框架类似,Micronaut 提供了通用错误处理机制。我们将深入探讨该机制并通过示例演示其用法。
2. Micronaut 错误处理机制
编程中唯一可以确定的是:错误必然发生。无论代码写得多么优秀、测试覆盖多么全面,都无法完全避免错误。因此,系统中的错误处理方式应成为核心关注点。Micronaut 通过状态处理器和异常处理器等框架特性简化了错误处理。
如果你熟悉 Spring 的错误处理,那么上手 Micronaut 会很容易。Micronaut 提供了处理抛出异常的处理器,也提供了处理特定响应状态的处理器。在错误状态处理中,我们可以设置局部或全局作用域。异常处理则仅支持全局作用域。
值得注意的是,如果利用 Micronaut 环境配置能力,可以为不同激活环境设置不同的全局错误处理器。例如,若有一个发布事件消息的错误处理器,可以在本地环境中跳过消息发布功能。
3. 使用 @Error 注解处理错误
在 Micronaut 中,我们可以使用 @Error 注解定义错误处理器。该注解作用于方法级别,且必须位于 @Controller 注解的类中。它具有与其他控制器方法相似的功能,例如可以在参数上使用请求绑定注解来访问请求头、请求体等。
通过 @Error 注解,我们可以处理异常或响应状态码。这与其他仅提供异常处理器的流行 Java 框架有所不同。
错误处理器的一个特性是可以设置作用域。通过将作用域设置为 global,我们可以定义一个处理整个服务 404 响应的处理器。若不设置作用域,则处理器仅处理同一控制器中抛出的指定错误。
3.1. 使用 @Error 注解处理响应错误码
@Error 注解提供了按错误响应状态处理错误的方式。例如,我们可以定义统一处理所有 HttpStatus.NOT_FOUND 响应的方式。可处理的错误状态必须是 io.micronaut.http.HttpStatus 枚举中定义的值:
@Controller("/notfound")
public class NotFoundController {
@Error(status = HttpStatus.NOT_FOUND, global = true)
public HttpResponse<JsonError> notFound(HttpRequest<?> request) {
JsonError error = new JsonError("Page Not Found")
.link(Link.SELF, Link.of(request.getUri()));
return HttpResponse.<JsonError> notFound().body(error);
}
}
public class CustomException extends RuntimeException {
public CustomException(String message) {
super(message);
}
}
此控制器中定义了一个使用 @Error 注解的方法,处理 HttpStatus.NOT_FOUND 响应。作用域设置为 global,因此所有 404 错误都将经过此方法。处理后,所有此类错误将返回状态码 404,并附带包含错误消息 "Page Not Found" 和链接的修改后响应体。
注意,尽管使用了 @Controller 注解,但此控制器未指定任何 HttpMethod,因此它不完全作为常规控制器工作,但如前所述,它具有一些实现相似性。
现在假设我们有一个返回 NOT_FOUND 错误响应的接口:
@Get("/not-found-error")
public HttpResponse<String> endpoint1() {
return HttpResponse.notFound();
}
"/not-found-error" 接口应始终返回 404。如果访问此接口,NOT_FOUND 错误处理器将被触发:
@Test
public void whenRequestThatThrows404_thenResponseIsHandled(
RequestSpecification spec
) {
spec.given()
.basePath(ERRONEOUS_ENDPOINTS_PATH)
.when()
.get("/not-found-error")
.then()
.statusCode(404)
.body(Matchers.containsString("\"message\":\"Page Not Found\",\"_links\":"));
}
此 Micronaut 测试向 "/not-found-error" 接口发送 GET 请求,并返回预期的 404 状态码。通过断言响应体,我们可以验证响应是否经过处理器处理,因为错误消息是我们添加到处理器中的。
需要澄清的是,如果我们将基础路径和路径更改为指向 NotFoundController,由于此控制器中未定义 GET 方法(仅定义错误),则服务器将抛出 404,而处理器仍会处理它。
3.2. 使用 @Error 注解处理异常
在 Web 服务中,如果异常未被捕获和处理,控制器默认返回内部服务器错误。Micronaut 的错误处理机制提供了 @Error 注解用于此类情况。
让我们创建一个抛出异常的接口和一个处理这些特定异常的处理器:
@Error(exception = UnsupportedOperationException.class)
public HttpResponse<JsonError> unsupportedOperationExceptions(HttpRequest<?> request) {
log.info("Unsupported Operation Exception handled");
JsonError error = new JsonError("Unsupported Operation")
.link(Link.SELF, Link.of(request.getUri()));
return HttpResponse.<JsonError> notFound().body(error);
}
@Get("/unsupported-operation")
public HttpResponse<String> endpoint5() {
throw new UnsupportedOperationException();
}
"/unsupported-operation" 接口仅抛出 UnsupportedOperationException 异常。unsupportedOperationExceptions 方法使用 @Error 注解处理这些异常。由于此资源不受支持,它返回 404 错误码,并在响应体中包含消息 "Unsupported Operation"。注意,此示例中的作用域是局部的,因为我们未将其设置为 global。
如果访问此接口,我们将看到处理器处理它并返回 unsupportedOperationExceptions 方法中定义的响应:
@Test
public void whenRequestThatThrowsLocalUnsupportedOperationException_thenResponseIsHandled(
RequestSpecification spec
) {
spec.given()
.basePath(ERRONEOUS_ENDPOINTS_PATH)
.when()
.get("/unsupported-operation")
.then()
.statusCode(404)
.body(containsString("\"message\":\"Unsupported Operation\""));
}
@Test
public void whenRequestThatThrowsExceptionInOtherController_thenResponseIsNotHandled(
RequestSpecification spec
) {
spec.given()
.basePath(PROBES_ENDPOINTS_PATH)
.when()
.get("/readiness")
.then()
.statusCode(500)
.body(containsString("\"message\":\"Internal Server Error\""));
}
在第一个示例中,我们请求 "/unsupported-operation" 接口,该接口抛出 UnsupportedOperationException 异常。由于局部处理器位于同一控制器中,我们得到处理器返回的预期响应,其中包含修改后的错误消息 "Unsupported Operation"。
在第二个示例中,我们请求来自不同控制器的 "/readiness" 接口,该接口也抛出 UnsupportedOperationException 异常。由于此接口定义在不同控制器中,局部处理器不会处理该异常,因此我们得到错误码为 500 的默认响应。
4. 使用 ExceptionHandler 接口处理错误
Micronaut 还提供了实现 ExceptionHandler 接口的选项,用于在全局作用域处理特定异常。此方法每个异常需要一个类,这意味着默认情况下它们必须处于全局作用域。
Micronaut 提供了一些默认异常处理器,例如:
- jakarta.validation.ConstraintViolationException
- com.fasterxml.jackson.core.JsonProcessingException
- UnsupportedMediaException
- 更多
当然,如果需要,可以在我们的服务中覆盖这些处理器。
需要考虑的一点是异常层次结构。当我们为特定异常 A 创建处理器时,继承自 A 的异常 B 也将由同一处理器处理,除非我们为异常 B 单独实现处理器。后续章节将提供更多细节。
4.1. 处理异常
如前所述,我们可以使用 ExceptionHandler 接口在全局作用域处理特定类型的异常:
@Slf4j
@Produces
@Singleton
@Requires(classes = { CustomException.class, ExceptionHandler.class })
public class CustomExceptionHandler implements ExceptionHandler<CustomException, HttpResponse<String>> {
@Override
public HttpResponse<String> handle(HttpRequest request, CustomException exception) {
log.info("handling CustomException: [{}]", exception.getMessage());
return HttpResponse.ok("Custom Exception was handled");
}
}
在此类中,我们实现了该接口,该接口使用泛型定义要处理的异常类型。本例中是我们之前定义的 CustomException。该类需要使用 @Requires 注解,并包含异常类和接口。handle 方法接受触发异常的请求和异常对象作为参数。然后,我们简单地在响应体中添加自定义消息,返回 200 响应状态码。
现在假设我们有一个抛出 CustomException 的接口:
@Get("/custom-error")
public HttpResponse<String> endpoint3(@Nullable @Header("skip-error") String isErrorSkipped) {
if (isErrorSkipped == null) {
throw new CustomException("something else went wrong");
}
return HttpResponse.ok("Endpoint 3");
}
"/custom-error" 接口接受 isErrorSkipped 请求头,用于启用/禁用抛出的异常。如果不包含该请求头,则抛出异常:
@Test
public void whenRequestThatThrowsCustomException_thenResponseIsHandled(
RequestSpecification spec
) {
spec.given()
.basePath(ERRONEOUS_ENDPOINTS_PATH)
.when()
.get("/custom-error")
.then()
.statusCode(200)
.body(is("Custom Exception was handled"));
}
在此测试中,我们请求 "/custom-error" 接口,不包含请求头。因此抛出 CustomException 异常。然后,我们通过断言处理器返回的响应码和响应体,验证处理器已处理此异常。
4.2. 基于层次结构处理异常
对于未显式处理的异常,如果它们继承自具有处理器的异常,则隐式由同一处理器处理。假设我们有一个继承自 CustomException 的 CustomChildException:
public class CustomChildException extends CustomException {
public CustomChildException(String message) {
super(message);
}
}
并且有一个抛出此异常的接口:
@Get("/custom-child-error")
public HttpResponse<String> endpoint4(@Nullable @Header("skip-error") String isErrorSkipped) {
log.info("endpoint4");
if (isErrorSkipped == null) {
throw new CustomChildException("something else went wrong");
}
return HttpResponse.ok("Endpoint 4");
}
"/custom-child-error" 接口接受 isErrorSkipped 请求头,用于启用/禁用抛出的异常。如果不包含该请求头,则抛出异常:
@Test
public void whenRequestThatThrowsCustomChildException_thenResponseIsHandled(
RequestSpecification spec
) {
spec.given()
.basePath(ERRONEOUS_ENDPOINTS_PATH)
.when()
.get("/custom-child-error")
.then()
.statusCode(200)
.body(is("Custom Exception was handled"));
}
此测试访问 "/custom-child-error" 接口并触发 CustomChildException 异常。通过断言处理器返回的响应码和响应体,我们可以验证处理器也处理了此子异常。
5. 总结
本文深入探讨了 Micronaut 的错误处理机制。我们了解了多种错误处理方式:通过处理异常或错误响应状态码。我们还展示了如何在不同作用域(局部和全局)应用处理器。最后,我们通过代码示例演示了所有讨论的选项,并使用 Micronaut 测试验证了结果。
所有源代码可在 GitHub 上获取。