SpringBoot 基建篇 - 统一返回数据类型、统一异常处理(超详细,可直接使用)

SpringBoot统一响应与异常处理

前言

本篇主要是为之后 SpringBoot 结合 DDD 领域驱动设计落地实践 的相关章节而服务,对于前面章节讲过的内容,后面不在赘述.

统一返回数据类型

ApiStatus

我们可以先定一个 ApiStatus,作用如下:

  • 统一管理所有接口的状态码,避免一些魔法数字(例如有人会直接在代码里写出 if (code == 8) { ... } 这种没有明确意义的常量)
  • 让前端可以直接判断出这个响应到底是成功还是失败了,已经失败的原因,进一步的前端就可以根据不同的 code(状态码),来展示对应不同的界面.

代码如下:

/**
 * 用来表明 api 的不同状态
 */
enum class ApiStatus(val code: Int, val msg: String) {
    //请求成功
    SUCCESS(0, "ok"),

    //请求失败
    FAIL(1, "request failed"),

    //非法请求
    INVALID_REQUEST(2, "invalid request"),

    //非法参数
    INVALID_PARAM(3, "invalid param"),

    //未绑定
    NOT_BINDED(4, "not binded"),

    //服务器错误
    SERVER_ERROR(5, "server error"),

    //没有注册
    NOT_REGISTERED(6, "not registered"),

    //token 过期
    TOKEN_EXPIRE(7, "token expire"),

    //访问频率被限制
    FREQUENCY_LIMITED(8, "frequency limited"),

    //没有权限
    NO_PERMISSION(9, "no permission"),

    //用户未登陆
    NOT_LOGIN(40001, "require login"),
    
    //用户被封
    USER_LOCKED(40002, "user is locked"),

    ;
}

ApiResp

1)定义一个 ApiResp 类,前端接收后端的响应结构统一如下:

import com.fasterxml.jackson.annotation.JsonInclude
import java.io.Serializable

/**
 * 用来处理作为 api http 的返回接口
 *
 * @author cyk
 */
data class ApiResp<T> (
    var code: Int,
    var msg: String,
    @field:JsonInclude(JsonInclude.Include.NON_NULL)
    var data: T? = null
): Serializable {

    companion object {
        private const val serialVersionUID: Long = 1L

        /**
         * 成功响应
         */
        fun <T> ok(data: T): ApiResp<T> {
            return ApiResp(
                ApiStatus.SUCCESS.code,
                ApiStatus.SUCCESS.msg,
                data
            )
        }

        fun <T> ok(): ApiResp<T> {
            return ApiResp(
                ApiStatus.SUCCESS.code,
                ApiStatus.SUCCESS.msg,
            )
        }

        /**
         * 约定: 错误响应,一定没有 data 数据
         */
        fun no(code: ApiStatus): ApiResp<ApiStatus> {
            return ApiResp(
                code.code,
                code.msg
            )
        }

        /**
         * 考虑灵活性,支持自定义 code 的信息
         */
        fun no(code: Int, msg: String): ApiResp<Unit> {
            return ApiResp(
                code,
                msg
            )
        }

    }
}

2)解释:

  • code:状态码,前端可以直接根据约定好状态码,直接判断出响应是成功还是失败了.
  • msg:描述信息,一般成功的响应,这里都会直接写一个 “ok”(当然你自定义其他的也无所谓),而对于错误的响应,这里则是对错误信息的具体描述,例如参数错误(如果对后台系统,甚至你还可以直接给更具体的错误原因).
  • data:具体响应数据.

3)@JsonInclude(JsonInclude.Include.NON_NULL) 这个注解有什么用?
表示被序列化成 json 的时候,这个字段如果为 null,就不输出;
你对前端的返回数据一般都是一个 json 结构,但是有些例如错误响应,是没有 data 数据,那么响应可能就是这样的.
在这里插入图片描述
既然这里是 null,其实 data 这个字段也可以不序列化,一定程度上减少网络开销~ 那么加上此注解后,效果如下:
在这里插入图片描述

3)为什么要实现 Serializable 接口?serialVersionUID 有啥用?

Note:
序列化:将你定义的对象(例如ApiResp)转化成一种约定的传输格式(例如 JSON 格式)
反序列化:将约定的传输格式(例如 JSON 格式)转化成你定义的对象(例如 ApiResp)

  • 实现 Serializable 接口,是为了让你的对象可以序列化成 字节流(例如保存到文件、Redis 或者网络传输等),之后能在通过反序列化还原成原对象.
  • serialVersionUID 是一个版本号,当你序列化一个对象时,JVM 就会把 类名、字段结构、serialVersionUID 一起写入文件,反序列化时,会检查当前类定义的 serialVersionUID 和文件中的时候否一致,如果不一致就会报错.

如果我们自己不手动定义 serialVersionUID,JVM 就会自动生成一个 UID,这就会导致一个问题:你将 类A 序列化成字节流保存到文件后,然后你修改了 类A(例如 加一个字段、改名字、改字段顺序等),自动生成的 UID 就会变化,将来你把文件中的数据再反序列化成 类A 的时候,JVM 就会对比 serialVersionUID 发现不一致,最后报错

4)我自定义的 serialVersionUID 值有什么要求么?
没有任何要求,我一般常见用的做法就是:

  • 简单起见,直接写成固定的 serialVersionUID = 1L 就行(不同类的 serialVersionUID 相同也无所谓)
  • 或者让 IDEA 直接给你生成一串数字.

统一异常处理

1)统一异常处理可以通过以下两个注解实现:

  • @RestControllerAdvice:等价于 @ControllerAdvice + @ResponseBody,@ResponseBody 就是说把你给前端返回的数据序列化成 JSON,而不是一个页面;@ControllerAdvice 就是 AOP 切面处理了(控制器通知类),他不会拦截请求本身,而是拦截 Controller 层方法执行过程中的某些特殊环节(例如 异常处理、数据绑定、模型属性初始化).
  • @ExceptionHandler:用来标注某个方法,他能处理的异常类型.

2)工作原理:简单来讲,就是 Controller 抛出异常后,就会去 @ControllerAdvice 类中找有没有被 @ExceptionHandler 标注的对应异常的处理方法.

3)最佳实践:

  • 项目中所有参数相关的错误,统一抛出 IllegalArgumentException 异常.(例如 throw IllegalArgumentException("username 不能为空")
  • 项目中其他所有意料之内的错误,统一抛出 IllegalStateException 异常. (例如 throw IllegalStateException("user 信息保存失败")

统一异常处理代码如下

import org.cyk.tpl.infra.model.ApiResp
import org.cyk.tpl.infra.model.ApiStatus
import org.slf4j.LoggerFactory
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice

/**
 * 统一异常处理
 */
@RestControllerAdvice
class ApplicationExceptionHandler {

    companion object {
        private val log = LoggerFactory.getLogger(ApplicationExceptionHandler::class.java)
    }

    /**
     * 处理非法参数异常,使用 warn 日志级别
     */
    @ExceptionHandler(IllegalArgumentException::class)
    fun illegalArgumentExceptionHandle(e: IllegalArgumentException): ApiResp<Unit> {
        log.warn("非法参数,原因: {}", e.message)
        return ApiResp.no(ApiStatus.INVALID_PARAM.code, e.message ?: "")
    }

    /**
     * 处理其他所有意料之内的异常,使用 error 日志级别
     */
    @ExceptionHandler(IllegalStateException::class)
    fun illegalStateExceptionHandle(e: IllegalStateException): ApiResp<Unit> {
        log.error("非法状态", e)
        return ApiResp.no(ApiStatus.SERVER_ERROR.code, e.message ?: "")
    }

    /**
     * 处理其他所有意料之外的异常(兜底)
     */
    @ExceptionHandler(Exception::class)
    fun exceptionHandle(e: Exception): ApiResp<Unit> {
        log.error("服务器异常", e)
        return ApiResp.no(ApiStatus.SERVER_ERROR.code, e.message ?: "")
    }

}

注意:
对于异常信息描述(例如 "user 信息保存失败"),不可直接暴露具体参数(例如 "user 信息保存失败 ${user}"),因为这里的错误信息是用户可见的,对于我们开发人员排查错误需要记录具体参数时,在抛异常前面手动打上日志即可,例如如下:

        val user = Userinfo(15)
        // 保存逻辑 ...
        val saveSuccess = false
        if (saveSuccess.not()) {
            log.error("user 信息保存失败. user: {}", user)
            throw IllegalStateException("user 信息保存失败")
        }

4)三种不同异常处理方式的效果如下:

import org.cyk.tpl.infra.model.ApiResp
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("test/")
class TestApi {

    @PostMapping("arg/")
    fun arg(): ApiResp<Unit> {
        throw IllegalArgumentException("username 不能为空")
    }

    @PostMapping("state/")
    fun state(): ApiResp<Unit> {
        throw IllegalStateException("user 信息保存失败")
    }

    @PostMapping("other")
    fun other(): ApiResp<Exception> {
        throw RuntimeException("运行失败")
    }

}

运行触发后,日志效果如下:

2025-11-02T16:05:32.966+08:00  WARN 28305 --- [tpl] [nio-8080-exec-1] o.c.t.i.aop.ApplicationExceptionHandler  : 非法参数,原因: username 不能为空
2025-11-02T16:05:37.978+08:00 ERROR 28305 --- [tpl] [nio-8080-exec-3] o.c.t.i.aop.ApplicationExceptionHandler  : 非法状态

java.lang.IllegalStateException: user 信息保存失败
	at org.cyk.tpl.api.TestApi.state(TestApi.kt:20) ~[classes/:na]
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[na:na]
	at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[na:na]
	at kotlin.reflect.jvm.internal.calls.CallerImpl$Method.callMethod(CallerImpl.kt:97) ~[kotlin-reflect-2.2.20.jar:2.2.20-release-333]
	at kotlin.reflect.jvm.internal.calls.CallerImpl$Method$Instance.call(CallerImpl.kt:113) ~[kotlin-reflect-2.2.20.jar:2.2.20-release-333]
	at kotlin.reflect.jvm.internal.KCallableImpl.callDefaultMethod$kotlin_reflection(KCallableImpl.kt:250) ~[kotlin-reflect-2.2.20.jar:2.2.20-release-333]
	at kotlin.reflect.jvm.internal.KCallableImpl.callBy(KCallableImpl.kt:155) ~[kotlin-reflect-2.2.20.jar:2.2.20-release-333]
	at org.springframework.web.method.support.InvocableHandlerMethod$KotlinDelegate.invokeFunction(InvocableHandlerMethod.java:335) ~[spring-web-6.2.12.jar:6.2.12]
	at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:255) ~[spring-web-6.2.12.jar:6.2.12]
	at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:191) ~[spring-web-6.2.12.jar:6.2.12]
	at 
......
	

2025-11-02T16:05:41.824+08:00 ERROR 28305 --- [tpl] [nio-8080-exec-4] o.c.t.i.aop.ApplicationExceptionHandler  : 服务器异常

java.lang.RuntimeException: 运行失败
	at org.cyk.tpl.api.TestApi.other(TestApi.kt:25) ~[classes/:na]
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[na:na]
	at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[na:na]
	at kotlin.reflect.jvm.internal.calls.CallerImpl$Method.callMethod(CallerImpl.kt:97) ~[kotlin-reflect-2.2.20.jar:2.2.20-release-333]
	at kotlin.reflect.jvm.internal.calls.CallerImpl$Method$Instance.call(CallerImpl.kt:113) ~[kotlin-reflect-2.2.20.jar:2.2.20-release-333]
	at kotlin.reflect.jvm.internal.KCallableImpl.callDefaultMethod$kotlin_reflection(KCallableImpl.kt:250) ~[kotlin-reflect-2.2.20.jar:2.2.20-release-333]
	at kotlin.reflect.jvm.internal.KCallableImpl.callBy(KCallableImpl.kt:155) ~[kotlin-reflect-2.2.20.jar:2.2.20-release-333]
	at org.springframework.web.method.support.InvocableHandlerMethod$KotlinDelegate.invokeFunction(InvocableHandlerMethod.java:335) ~[spring-web-6.2.12.jar:6.2.12]
	at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:255) ~[spring-web-6.2.12.jar:6.2.12]
	at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:191) ~[spring-web-6.2.12.jar:6.2.12]
	at 
......
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

陈亦康

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值