Skip to content

Spring WebFlux 错误响应处理:RFC 9457 标准化错误响应指南 ⚙️

引言:为什么需要标准化的错误响应? 🤔

在传统的 REST API 开发中,我们经常遇到这样的困扰:

  • 不同的开发者返回不同格式的错误信息
  • 客户端难以统一处理各种错误响应
  • 错误信息缺乏标准化,可读性差
  • 国际化支持困难

Spring WebFlux 通过支持 RFC 9457 标准("Problem Details for HTTP APIs"),为我们提供了一套完整的错误响应解决方案。

NOTE

RFC 9457 是一个国际标准,定义了 HTTP API 错误响应的标准格式,让错误信息更加结构化和可预测。

核心概念与架构 🏗️

Spring WebFlux 的错误响应机制基于以下四个核心抽象:

1. ProblemDetail - 错误详情容器

kotlin
// RFC 9457 标准错误响应的数据载体
data class CustomProblemDetail(
    val type: String,           // 错误类型 URI
    val title: String,          // 错误标题
    val status: Int,            // HTTP 状态码
    val detail: String,         // 详细描述
    val instance: String,       // 出错实例 URI
    val properties: Map<String, Any> = emptyMap() // 扩展字段
)

2. ErrorResponse - 错误响应契约

kotlin
// 所有 Spring WebFlux 异常都实现了这个接口
interface ErrorResponse {
    fun getStatusCode(): HttpStatusCode
    fun getHeaders(): HttpHeaders
    fun getBody(): ProblemDetail
    fun getDetailMessageCode(): String
    fun getDetailMessageArguments(): Array<Any>
}

3. ErrorResponseException - 基础实现

kotlin
// 自定义异常的便利基类
class CustomBusinessException(
    status: HttpStatusCode,
    detail: String,
    cause: Throwable? = null
) : ErrorResponseException(status, asProblemDetail(detail), cause) {
    
    companion object {
        private fun asProblemDetail(detail: String): ProblemDetail {
            return ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_REQUEST, 
                detail
            ).apply {
                type = URI.create("https://api.example.com/errors/business-rule")
                title = "业务规则违反"
            }
        }
    }
}

4. ResponseEntityExceptionHandler - 统一异常处理

kotlin
@ControllerAdvice
class GlobalExceptionHandler : ResponseEntityExceptionHandler() {
    
    // 自动处理所有 ErrorResponse 异常
    // 继承的 handleErrorResponse 方法会自动处理
    
    @ExceptionHandler(CustomBusinessException::class)
    fun handleCustomException(ex: CustomBusinessException): ResponseEntity<ProblemDetail> {
        return ResponseEntity
            .status(ex.statusCode)
            .body(ex.body)
    }
}

错误响应渲染机制 🎨

标准化渲染流程

实际应用示例

kotlin
@RestController
class UserController {
    
    @GetMapping("/users/{id}")
    fun getUser(@PathVariable id: Long): ResponseEntity<Any> {
        return try {
            val user = userService.findById(id)
            ResponseEntity.ok(user)
        } catch (e: UserNotFoundException) {
            // 非标准化错误响应
            ResponseEntity.status(404)
                .body(mapOf(
                    "error" to "User not found",
                    "message" to "用户不存在",
                    "code" to "USER_NOT_FOUND"
                ))
        }
    }
}
kotlin
@RestController
class UserController {
    
    @GetMapping("/users/{id}")
    fun getUser(@PathVariable id: Long): User {
        // 直接抛出异常,让统一异常处理器处理
        return userService.findById(id) 
            ?: throw UserNotFoundException("用户ID: $id 不存在")
    }
}

// 自定义异常实现 ErrorResponse
class UserNotFoundException(detail: String) : ErrorResponseException(
    HttpStatus.NOT_FOUND,
    ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, detail).apply {
        type = URI.create("https://api.example.com/errors/user-not-found") 
        title = "用户未找到"
    }
)

响应格式对比

json
{
  "error": "User not found",
  "message": "用户不存在", 
  "code": "USER_NOT_FOUND"
}
json
{
  "type": "https://api.example.com/errors/user-not-found",
  "title": "用户未找到",
  "status": 404,
  "detail": "用户ID: 123 不存在",
  "instance": "/users/123"
}

TIP

RFC 9457 格式的优势:

  • type: 提供错误类型的唯一标识
  • title: 人类可读的错误标题
  • status: 明确的HTTP状态码
  • detail: 具体的错误描述
  • instance: 出错的具体实例路径

扩展非标准字段 🔧

方式一:使用 Properties Map

kotlin
@ExceptionHandler(ValidationException::class)
fun handleValidation(ex: ValidationException): ProblemDetail {
    val problemDetail = ProblemDetail.forStatusAndDetail(
        HttpStatus.BAD_REQUEST, 
        "请求参数验证失败"
    )
    
    // 通过 properties Map 添加自定义字段
    problemDetail.setProperty("errors", ex.errors) 
    problemDetail.setProperty("timestamp", Instant.now()) 
    problemDetail.setProperty("requestId", UUID.randomUUID().toString()) 
    
    return problemDetail
}

生成的响应:

json
{
  "type": "about:blank",
  "title": "Bad Request", 
  "status": 400,
  "detail": "请求参数验证失败",
  "instance": "/api/users",
  "errors": ["用户名不能为空", "邮箱格式不正确"],
  "timestamp": "2024-01-15T10:30:00Z",
  "requestId": "550e8400-e29b-41d4-a716-446655440000"
}

方式二:继承 ProblemDetail

kotlin
// 自定义 ProblemDetail 子类
class ValidationProblemDetail : ProblemDetail {
    
    var errors: List<String> = emptyList()
    var timestamp: Instant = Instant.now()
    var requestId: String = UUID.randomUUID().toString()
    
    constructor(original: ProblemDetail) : super(original) 
    
    companion object {
        fun create(detail: String, errors: List<String>): ValidationProblemDetail {
            val original = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_REQUEST, detail
            )
            return ValidationProblemDetail(original).apply {
                this.errors = errors
            }
        }
    }
}

@ExceptionHandler(ValidationException::class)
fun handleValidation(ex: ValidationException): ValidationProblemDetail {
    return ValidationProblemDetail.create(
        "请求参数验证失败",
        ex.errors
    )
}

自定义与国际化支持 🌐

消息代码模式

Spring WebFlux 使用以下模式生成消息代码:

problemDetail.[type|title|detail].[完全限定异常类名]

配置消息资源

properties
# 默认英文消息
problemDetail.title.com.example.UserNotFoundException=User Not Found
problemDetail.detail.com.example.UserNotFoundException=The requested user with ID {0} does not exist

problemDetail.title.com.example.ValidationException=Validation Failed  
problemDetail.detail.com.example.ValidationException=Request validation failed: {0}
properties
# 中文消息
problemDetail.title.com.example.UserNotFoundException=用户未找到
problemDetail.detail.com.example.UserNotFoundException=请求的用户ID {0} 不存在

problemDetail.title.com.example.ValidationException=参数验证失败
problemDetail.detail.com.example.ValidationException=请求参数验证失败:{0}

国际化异常处理器

kotlin
@ControllerAdvice
class I18nExceptionHandler : ResponseEntityExceptionHandler() {
    
    @Autowired
    private lateinit var messageSource: MessageSource
    
    @ExceptionHandler(UserNotFoundException::class)
    fun handleUserNotFound(
        ex: UserNotFoundException,
        locale: Locale
    ): ResponseEntity<ProblemDetail> {
        
        val problemDetail = ProblemDetail.forStatus(HttpStatus.NOT_FOUND)
        
        // 国际化标题和详情
        problemDetail.title = messageSource.getMessage( 
            "problemDetail.title.${ex.javaClass.name}", 
            null, locale 
        ) 
        
        problemDetail.detail = messageSource.getMessage( 
            "problemDetail.detail.${ex.javaClass.name}", 
            arrayOf(ex.userId), locale 
        ) 
        
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problemDetail)
    }
}

常见异常的消息代码表

异常类型消息代码参数说明
HandlerMethodValidationException{0} 验证错误列表方法参数验证失败
MethodNotAllowedException{0} 当前方法, {1} 支持的方法列表HTTP方法不允许
MissingRequestValueException{0} 值标签, {1} 值名称缺少请求值
NotAcceptableStatusException{0} 支持的媒体类型列表不可接受的响应类型
UnsupportedMediaTypeStatusException{0} 不支持的类型, {1} 支持的类型列表不支持的媒体类型

客户端错误处理 💻

WebClient 错误处理

kotlin
@Service
class UserApiClient {
    
    private val webClient = WebClient.builder()
        .baseUrl("https://api.example.com")
        .build()
    
    suspend fun getUser(id: Long): User? {
        return try {
            webClient.get()
                .uri("/users/{id}", id)
                .retrieve()
                .awaitBody<User>()
        } catch (ex: WebClientResponseException) {
            // 解析 RFC 9457 错误响应
            val problemDetail = ex.getResponseBodyAs(ProblemDetail::class.java) 
            
            when (problemDetail?.status) {
                404 -> {
                    logger.warn("用户不存在: ${problemDetail.detail}")
                    null
                }
                400 -> {
                    logger.error("请求参数错误: ${problemDetail.detail}")
                    throw IllegalArgumentException(problemDetail.detail)
                }
                else -> {
                    logger.error("API调用失败: ${problemDetail?.detail}")
                    throw RuntimeException("用户服务不可用")
                }
            }
        }
    }
}

自定义错误响应拦截器

kotlin
@Configuration
class WebFluxConfig : WebFluxConfigurer {
    
    override fun configureErrorResponseInterceptors(
        registry: ErrorResponseInterceptorRegistry
    ) {
        // 注册错误响应拦截器
        registry.addInterceptor { errorResponse, exchange ->
            // 添加请求追踪ID
            val requestId = exchange.request.headers.getFirst("X-Request-ID")
                ?: UUID.randomUUID().toString()
            
            errorResponse.body.setProperty("requestId", requestId)
            errorResponse.body.setProperty("timestamp", Instant.now())
            
            // 记录错误日志
            logger.error(
                "API错误 [{}]: {} - {}", 
                requestId, 
                errorResponse.statusCode,
                errorResponse.body.detail
            )
            
            errorResponse
        }
    }
}

完整实践示例 🚀

点击查看完整的用户管理API错误处理实现
kotlin
// 1. 自定义异常
sealed class UserException(
    status: HttpStatus,
    detail: String,
    cause: Throwable? = null
) : ErrorResponseException(status, createProblemDetail(status, detail), cause) {
    
    companion object {
        private fun createProblemDetail(status: HttpStatus, detail: String): ProblemDetail {
            return ProblemDetail.forStatusAndDetail(status, detail).apply {
                type = URI.create("https://api.example.com/errors/user")
            }
        }
    }
}

class UserNotFoundException(userId: Long) : UserException(
    HttpStatus.NOT_FOUND,
    "用户ID: $userId 不存在"
)

class UserValidationException(
    val errors: List<String>
) : UserException(
    HttpStatus.BAD_REQUEST, 
    "用户信息验证失败"
)

// 2. 业务服务
@Service
class UserService {
    
    private val users = mutableMapOf<Long, User>()
    
    fun findById(id: Long): User {
        return users[id] ?: throw UserNotFoundException(id)
    }
    
    fun create(userRequest: CreateUserRequest): User {
        val errors = validateUser(userRequest)
        if (errors.isNotEmpty()) {
            throw UserValidationException(errors)
        }
        
        val user = User(
            id = generateId(),
            name = userRequest.name,
            email = userRequest.email
        )
        users[user.id] = user
        return user
    }
    
    private fun validateUser(request: CreateUserRequest): List<String> {
        val errors = mutableListOf<String>()
        
        if (request.name.isBlank()) {
            errors.add("用户名不能为空")
        }
        
        if (!request.email.contains("@")) {
            errors.add("邮箱格式不正确")
        }
        
        return errors
    }
    
    private fun generateId(): Long = System.currentTimeMillis()
}

// 3. 控制器
@RestController
@RequestMapping("/api/users")
class UserController(private val userService: UserService) {
    
    @GetMapping("/{id}")
    fun getUser(@PathVariable id: Long): User {
        return userService.findById(id)
    }
    
    @PostMapping
    fun createUser(@RequestBody request: CreateUserRequest): User {
        return userService.create(request)
    }
}

// 4. 全局异常处理器
@ControllerAdvice
class GlobalExceptionHandler : ResponseEntityExceptionHandler() {
    
    @ExceptionHandler(UserValidationException::class)
    fun handleUserValidation(ex: UserValidationException): ResponseEntity<ProblemDetail> {
        val problemDetail = ex.body
        
        // 添加验证错误详情
        problemDetail.setProperty("errors", ex.errors)
        problemDetail.setProperty("errorCount", ex.errors.size)
        
        return ResponseEntity
            .status(ex.statusCode)
            .body(problemDetail)
    }
}

// 5. 数据类
data class User(
    val id: Long,
    val name: String,
    val email: String
)

data class CreateUserRequest(
    val name: String,
    val email: String
)

最佳实践建议 ⭐

1. 异常设计原则

IMPORTANT

  • 为不同的业务场景创建专门的异常类
  • 继承 ErrorResponseException 以获得标准化支持
  • 提供有意义的错误类型 URI 和标题

2. 错误信息设计

TIP

  • 详情字段:提供足够的上下文信息,但避免暴露敏感数据
  • 类型字段:使用有意义的 URI,便于客户端分类处理
  • 标题字段:简洁明了,适合直接展示给用户

3. 国际化策略

NOTE

  • 将所有错误消息外部化到资源文件
  • 使用参数化消息支持动态内容
  • 考虑不同地区的文化差异

4. 客户端集成

WARNING

客户端应该:

  • 优先检查 type 字段进行错误分类
  • 使用 status 字段确定HTTP状态
  • detail 字段用于日志记录和调试
  • title 字段用于用户界面显示

总结 🎉

Spring WebFlux 的 RFC 9457 错误响应支持为我们提供了:

标准化:统一的错误响应格式,提高API的一致性
可扩展性:支持自定义字段,满足特殊业务需求
国际化:完整的多语言支持机制
客户端友好:结构化的错误信息,便于程序处理
开发效率:减少样板代码,专注业务逻辑

通过采用这套标准化的错误处理机制,我们可以构建更加健壮、用户友好的 REST API,同时大大简化错误处理的复杂性。