每当我开始使用Spring实现新的REST API时,都会发现很难决定如何验证请求和处理业务异常。与其他常见的API问题不同,Spring及其社区似乎并未就解决这些问题的最佳实践达成共识,并且很难找到有关该主题的有用文章。
在本文中,我总结了我的经验,并对接口验证提供了一些建议。
体系结构和术语
我按照洋葱架构 (Onion Architecture)的模式创建自己的应用程序,以提供Web-API 。本文不是关于Onion体系结构的,但我想提及它对理解我的思想很重要的一些关键点:
REST控制器 以及任何Web组件和配置都是外部 “基础结构”层的一部分 。
中间的 “服务”层 包含集成业务功能并解决常见问题(例如安全性或交易)的服务。
内部的“域”层 包含业务逻辑,而没有任何与基础架构相关的任务,例如数据库访问,Web端点等。
, . 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, , , . .