Setiap kali saya mulai menerapkan REST API baru menggunakan Spring, saya merasa sulit untuk memutuskan cara memvalidasi permintaan dan menangani pengecualian bisnis. Tidak seperti masalah API umum lainnya, Spring dan komunitasnya tampaknya tidak menyetujui praktik terbaik untuk memecahkan masalah ini, dan sulit untuk menemukan artikel bermanfaat tentang subjek tersebut.
Pada artikel ini, saya merangkum pengalaman saya dan memberikan beberapa saran tentang validasi antarmuka.
Arsitektur dan terminologi
Saya membuat aplikasi saya sendiri yang menyediakan Web-API, mengikuti pola arsitektur bawang ( Onion Architecture ) . Artikel ini bukan tentang arsitektur Onion, tetapi saya ingin menyebutkan beberapa poin utamanya yang penting dalam memahami pemikiran saya:
Pengontrol REST serta semua komponen web dan konfigurasi merupakan bagian dari lapisan "infrastruktur" eksternal .
Tingkat "layanan" tengah berisi layanan yang mengintegrasikan fungsi bisnis dan menangani masalah umum seperti keamanan atau transaksi.
Lapisan "domain" bagian dalam berisi logika bisnis tanpa tugas terkait infrastruktur seperti akses database, titik akhir web, dan sebagainya.
, . 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: neverapplication.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, , , . .