使用Spring进行验证和异常处理

每当我开始使用Spring实现新的REST API时,都会发现很难决定如何验证请求和处理业务异常。与其他常见的API问题不同,Spring及其社区似乎并未就解决这些问题的最佳实践达成共识,并且很难找到有关该主题的有用文章。

在本文中,我总结了我的经验,并对接口验证提供了一些建议。

体系结构和术语

我按照洋葱架构 (Onion Architecture的模式创建自己的应用程序,以提供Web-API  本文不是关于Onion体系结构的,但我想提及它对理解我的思想很重要的一些关键点:

  • REST控制器 以及任何Web组件和配置都是外部 “基础结构”层的一部分 。

  • 中间的 “服务”层 包含集成业务功能并解决常见问题(例如安全性或交易)的服务。

  • 内部的“域”层  包含业务逻辑,而没有任何与基础架构相关的任务,例如数据库访问,Web端点等。

洋葱架构层的草图和典型Spring类的位置。
Spring.

, .   REST  :

  •    «».

  • - - .

  • ,    ,     ( ).

  •    , , , .

  • , .

  •   -.  .

  • , , .

在请求,服务级别和域级别进行验证。
, .

, :

  • .  ,   API .  , Jackson,  ,  @NotNull.     .

  • , .  .

  • , . .

   , . Spring Boot Jackson . ,      BGG:

@GetMapping("/newest")
Flux<ThreadsPerBoardGame> getThreads(@RequestParam String user, @RequestParam(defaultValue = "PT1H") Duration since) {
    return threadService.findNewestThreads(user, since);
}

          :

curl -i localhost:8080/threads/newest
HTTP/1.1 400 Bad Request
Content-Type: application/json
Content-Length: 189

{"timestamp":"2020-04-15T03:40:00.460+0000","path":"/threads/newest","status":400,"error":"Bad Request","message":"Required String parameter 'user' is not present","requestId":"98427b15-7"}

curl -i "localhost:8080/threads/newest?user=chrigu&since=a"
HTTP/1.1 400 Bad Request
Content-Type: application/json
Content-Length: 156

{"timestamp":"2020-04-15T03:40:06.952+0000","path":"/threads/newest","status":400,"error":"Bad Request","message":"Type mismatch.","requestId":"7600c788-8"}

Spring Boot    .  ,

server:
  error:
    include-stacktrace: never

 application.yml .    BasicErrorController   Web MVC  DefaultErrorWebExceptionHandler  WebFlux, ErrorAttributes.

  @RequestParam  .   @ModelAttribute , @RequestBody  ,

@GetMapping("/newest/obj")
Flux<ThreadsPerBoardGame> getThreads(@Valid ThreadRequest params) {
    return threadService.findNewestThreads(params.user, params.since);
}

static class ThreadRequest {
    @NotNull
    private final String user;
    @NotNull
    private final Duration since;

    public ThreadRequest(String user, Duration since) {
        this.user = user;
        this.since = since == null ? Duration.ofHours(1) : since;
    }
}

@RequestParam ,       ,     bean-,  @NotNull Java / Kotlin.  bean-,  @Valid.

bean- ,  BindException  WebExchangeBindException .  BindingResult, .  ,

curl "localhost:8080/java/threads/newest/obj" -i
HTTP/1.1 400 Bad Request
Content-Type: application/json
Content-Length: 1138

{"timestamp":"2020-04-17T13:52:39.500+0000","path":"/java/threads/newest/obj","status":400,"error":"Bad Request","message":"Validation failed for argument at index 0 in method: reactor.core.publisher.Flux<ch.chrigu.bgg.service.ThreadsPerBoardGame> ch.chrigu.bgg.infrastructure.web.JavaThreadController.getThreads(ch.chrigu.bgg.infrastructure.web.JavaThreadController$ThreadRequest), with 1 error(s): [Field error in object 'threadRequest' on field 'user': rejected value [null]; codes [NotNull.threadRequest.user,NotNull.user,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [threadRequest.user,user]; arguments []; default message [user]]; default message [darf nicht null sein]] ","requestId":"c87c7cbb-17","errors":[{"codes":["NotNull.threadRequest.user","NotNull.user","NotNull.java.lang.String","NotNull"],"arguments":[{"codes":["threadRequest.user","user"],"arguments":null,"defaultMessage":"user","code":"user"}],"defaultMessage":"darf nicht null sein","objectName":"threadRequest","field":"user","rejectedValue":null,"bindingFailure":false,"code":"NotNull"}]}

, , API.  Spring Boot:

curl "localhost:8080/java/threads/newest/obj?user=chrigu&since=a" -i
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
Content-Length: 513

{"timestamp":"2020-04-17T13:56:42.922+0000","path":"/java/threads/newest/obj","status":500,"error":"Internal Server Error","message":"Failed to convert value of type 'java.lang.String' to required type 'java.time.Duration'; nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.String] to type [java.time.Duration] for value 'a'; nested exception is java.lang.IllegalArgumentException: Parse attempt failed for value [a]","requestId":"4c0dc6bd-21"}

, , since.  , MVC .  .  , bean- ErrorAttributes ,    .  status.

DefaultErrorAttributes,   @ResponseStatus, ResponseStatusException .  .  , , , , .  - @ExceptionHandler . , , . , , (rethrow):

@ControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(TypeMismatchException::class)
    fun handleTypeMismatchException(e: TypeMismatchException): HttpStatus {
        throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid value '${e.value}'", e)
    }

    @ExceptionHandler(WebExchangeBindException::class)
    fun handleWebExchangeBindException(e: WebExchangeBindException): HttpStatus {
        throw object : WebExchangeBindException(e.methodParameter!!, e.bindingResult) {
            override val message = "${fieldError?.field} has invalid value '${fieldError?.rejectedValue}'"
        }
    }
}

Spring Boot , , , Spring.  , , , :

  •  try/catch (MVC)  onErrorResume() (Webflux).  , , , , .

  •   @ExceptionHandler .  @ExceptionHandler (Throwable.class) .

  •    , @ResponseStatus ResponseStatusException, .

Spring Boot , .  , , .

, .  , ,   , , Java Kotlin,    , ,  .   .




All Articles