我使用Kotlin和Axon框架构建了一个简单的Spring Boot REST API,以"食品订购应用程序"为模板。在YouTube上的例子,现在我试图从我的POST/api/user/register
端点返回一个400响应,当客户端试图创建一个已经在使用的用户名的新帐户。
所以,就像Steven在上面的视频中所做的那样,我创建了一个新的自定义异常类UsernameTakenException
,如果用户名已经在使用,我从我的create user @CommandHandler函数中抛出这个异常。然后,我期望我能够从端点的控制器方法中catch (e: UsernameTakenException) { ... }
这个新的异常,并返回包含异常消息的400响应。
但是在做了一些测试之后,似乎我的端点函数只能捕获类型java.util.concurrent.ExecutionException
的异常,而不是我创建的自定义UsernameTakenException
类型。
UserManagementController.kt
文件:
@RestController
@RequestMapping("api/user")
class UserManagementController(
val commandGateway: CommandGateway,
val queryGateway: QueryGateway
) {
@PostMapping("register")
fun createUser(@RequestBody createUserReqBody: CreateUserRequest): ResponseEntity<Map<String, Any>> {
return try {
val result: CompletableFuture<Any> = commandGateway.send(
CreateUserCommand(
createUserReqBody.username,
createUserReqBody.password
)
)
ResponseEntity.ok(
mapOf(
"userId" to result.get()
)
)
} catch (e: ExecutionException) { // I want to catch `UsernameTakenException` instead
ResponseEntity.badRequest().body(
mapOf(
// This at least gets the message string to the client:
"error" to "${e.cause?.message}"
)
)
}
}
// other endpoints...
}
这是我的User.kt
聚合文件:
@Aggregate
data class User(
@AggregateIdentifier
var userId: UUID? = null,
var username: String? = null,
var password: String? = null,
var userService: UserService? = null
) {
constructor() : this(null, null, null, null)
@CommandHandler
constructor(command: CreateUserCommand, userService: UserService) : this(
UUID.randomUUID(),
command.username,
command.password
) {
if (userService.usernameExists(command.username)) {
// This message needs to get back to the client somehow:
throw UsernameTakenException("This username is already taken")
}
AggregateLifecycle.apply(
UserCreatedEvent(
userId!!,
command.username,
command.password
)
)
}
// other handlers...
}
这是我的exceptions.kt
文件:
class UsernameTakenException(message: String) : Exception(message)
// other custom exceptions...
我在这里错过了什么,还是走错了路?
我终于解决了这个问题。
因此,首先,我是在正确的轨道上与我的原始代码,最终我能够使它工作,只是一些调整,我将分享下面。问题解释:由于我不完全理解的原因,Axon框架故意不在堆栈中传播任何自定义异常,并将它们包装/转换为CommandExecutionException
类型,并允许开发人员将错误代码,错误类别,错误消息等放在特殊的details
字段中。这样做的想法是,您永远不会遇到这样的情况:命令处理程序应用程序(因为这是一个分布式应用程序)在其路径上有自定义异常类,但客户端没有。更多细节请参阅文档和这个AxonIQ论坛帖子。
旁白:在我看来,文档可以方式更清楚地说明这一点,并且可以包含一个代码示例来说明这一点。我知道我不是第一个被这种行为迷惑的人。此外,如果不传播自定义异常的全部原因是客户端也需要在其路径中包含该异常类,那么…客户就不能把它加到他们的路径上吗?强制客户端从
CommandExecutionException
的details
子字段访问所需的所有信息解决不了任何问题——当客户端必须从e.getDetails().myErrorCode
获取错误码时,客户端和命令处理程序之间仍然存在紧密耦合。那么,它与在分布式应用程序的两条路径中都添加自定义异常类有什么不同呢?
无论如何,这是我的解决方案,在命令处理程序中抛出自定义异常,并让它们触发不同的HTTP响应:
UserManagementController.kt
:
@RestController
@RequestMapping("api/user")
class UserManagementController(
val commandGateway: CommandGateway,
val queryGateway: QueryGateway
) {
@PostMapping("register")
fun createUser(
@RequestBody createUserReqBody: CreateUserRequest
): CompletableFuture<ResponseEntity<Map<String, String>>> {
return commandGateway.send<UUID>(
CreateUserCommand(
createUserReqBody.username,
createUserReqBody.password
)
).thenApply { ResponseEntity.ok(mapOf("userId" to it.toString())) }
}
// other endpoints...
}
User.kt
:
@Aggregate
data class User(
@AggregateIdentifier
var userId: UUID? = null,
var username: String? = null,
var password: String? = null,
) {
constructor() : this(null, null, null)
@CommandHandler
constructor(
command: CreateUserCommand,
userService: UserService
) : this(
UUID.randomUUID(),
command.username,
command.password
) {
if (userService.usernameAlreadyTaken(command.username)) {
throw UsernameAlreadyTakenException()
}
AggregateLifecycle.apply(
UserCreatedEvent(
userId!!,
command.username,
command.password
)
)
}
// other handlers...
}
exceptions.kt
:
data class RestExceptionDetails(
val message: String,
val httpCode: HttpStatus
)
// So as you can see, your custom exception essentially becomes a POJO:
class UsernameAlreadyTakenException() : CommandExecutionException(
null,
null,
RestExceptionDetails("This username is already taken", HttpStatus.BAD_REQUEST)
)
RestExceptionHandling.kt
:
@RestControllerAdvice(assignableTypes = [UserManagementController::class])
class RestExceptionHandling {
@ExceptionHandler(CommandExecutionException::class)
fun handleRestExceptions(
e: CommandExecutionException
): ResponseEntity<Map<String, Any>> {
val details: RestExceptionDetails? = e.getDetails<RestExceptionDetails>()
.orElse(null)
if (details == null) {
throw e
}
// The values you pass inside the `details` field can now change which
// HTTP responses get sent back to the client (e.g.: 400, 404, etc.)
return ResponseEntity.status(details.httpCode).body(
mapOf("error" to details.message)
)
}
}