Spring Boot 实战(五):接口工程化升级(统一返回 + 异常处理 + 错误码体系 + 异常流转机制)

一、前言

在前四篇中,我们已经把一个最小 Spring Boot 后端项目搭起来了,核心链路已经打通:

复制代码
请求
→ Controller
→ Service
→ Mapper
→ MySQL
→ 返回结果

到这一步,项目已经不是 Demo 了,而是一个能跑通完整链路的最小后端项目

但是,如果站在工程视角再看,会发现当前项目还存在几个明显问题:

复制代码
1. 返回结构还不够规范
2. 异常处理还不够分层
3. 错误码体系还没有建立
4. 业务异常和系统异常没有彻底区分
5. 对"异常是怎么流转的"理解还不够完整

这些问题,在练习阶段可能感觉不明显,但一旦项目稍微复杂一点,就会出现:

  • 前端不知道怎么判断失败类型
  • 接口返回风格不统一
  • 业务报错和系统报错混在一起
  • Controller 代码越来越乱
  • try-catch 到处都是
  • 问题排查越来越难

所以,这一篇的目标不是再加新功能,而是把前面那个"能跑的项目"升级成一个更像真实项目的工程化版本


二、本篇要解决的核心问题

本篇重点解决四件事:

复制代码
1. 统一返回结构(Result)
2. 统一错误码体系(ResultCodeEnum)
3. 自定义业务异常(BusinessException)
4. 全局异常处理(GlobalExceptionHandler)

并且把这几个点背后的逻辑全部讲清楚:

复制代码
- 为什么要统一返回
- 为什么需要错误码
- 为什么不能所有异常都用 RuntimeException
- 为什么要用全局异常处理,而不是 Controller 里到处 try-catch
- 异常到底是怎么一层一层流转到 GlobalExceptionHandler 的

这一篇本质上是在做一件事:

复制代码
把"会写接口"升级为"会设计接口"

三、为什么要统一返回结构

先看一个最常见的初学者写法。

1. 返回字符串

java 复制代码
@PostMapping("/register")
public String register(@RequestBody UserRegisterDTO dto) {
    return "注册成功";
}

这个写法能不能跑?

当然能跑。

但是问题也很明显:

复制代码
- 成功时返回字符串
- 失败时可能返回另一种字符串
- 异常时又变成 Spring 默认 JSON
- 前端无法统一处理

也就是说,这种写法的问题不在于"能不能用",而在于:

复制代码
不能形成规范

2. 为什么字符串返回不适合项目

因为真实项目中,前端关心的不只是"有没有内容",还关心:

复制代码
- 这次请求成功还是失败?
- 失败是什么类型?
- 是参数问题,还是业务问题,还是系统问题?
- 返回的数据在哪里?

如果只有一句:

复制代码
"注册成功"

那么前端根本没法建立统一处理逻辑。

所以我们需要一个统一的返回结构,把所有接口都收敛到一种格式。


四、统一返回结构设计

一个最常见、也最适合当前阶段的返回结构是:

复制代码
{
  "code": 0,
  "message": "成功",
  "data": {}
}

这里三个字段分别承担不同职责:

1. code

复制代码
给程序判断

前端通常不会只看 message,而是优先看 code。


2. message

复制代码
给人看

用于展示提示信息。


3. data

复制代码
真正返回的数据

成功时返回业务结果,失败时通常为 null。


五、Result 类实现

先定义统一返回体 Result<T>

java 复制代码
package org.example.arkbackend.common;

import lombok.Data;

@Data
public class Result<T> {

    private Integer code;
    private String message;
    private T data;

    public static <T> Result<T> success(T data) {
        Result<T> r = new Result<>();
        r.setCode(0);
        r.setMessage("成功");
        r.setData(data);
        return r;
    }

    public static <T> Result<T> fail(Integer code, String message) {
        Result<T> r = new Result<>();
        r.setCode(code);
        r.setMessage(message);
        r.setData(null);
        return r;
    }

    public static <T> Result<T> fail(String message) {
        Result<T> r = new Result<>();
        r.setCode(1);
        r.setMessage(message);
        r.setData(null);
        return r;
    }
}

六、为什么 Result.fail 里不要再抛异常

这个点必须讲清楚。

有些人会想:

复制代码
既然失败了,那我能不能在 Result.fail() 里面直接 throw?

答案是:

复制代码
不应该

原因很简单:

1. Result.fail 的职责

它应该只负责:

复制代码
组装失败结果

也就是"包装返回体"。


2. throw 的职责

它负责:

复制代码
制造异常

也就是中断当前执行流程,把异常往上抛。


3. 两者不能混

如果你在 Result.fail() 里面又 throw 异常,就会导致职责混乱:

复制代码
异常
→ GlobalExceptionHandler
→ Result.fail()
→ fail 里再 throw
→ 再次异常
→ 再次进入异常处理

这会让异常流转变得非常难控。

所以一定要分清:

复制代码
throw = 制造异常
@ExceptionHandler = 接异常
Result.fail = 包装失败结果

七、为什么还需要错误码体系

如果现在只写:

复制代码
return Result.fail("用户不存在");

当然也能跑。

但问题是,项目一大就会出现很多错误场景:

复制代码
参数错误
用户不存在
用户名已存在
密码错误
权限不足
系统异常
数据库异常

如果只靠 message 区分,会有两个问题:

1. message 给人看,不适合程序判断

前端如果拿 "用户不存在" 去写判断,是非常脆弱的。


2. 没有统一标准

同样一个场景,不同人可能写:

复制代码
用户不存在
该用户不存在
未找到用户
找不到该用户

这样前端根本没法稳定处理。

所以必须引入:

复制代码
错误码

八、错误码体系设计

这里先做一个最小版本,够项目用就行。

java 复制代码
package org.example.arkbackend.common;

public enum ResultCodeEnum {

    SUCCESS(0, "成功"),
    FAIL(1, "失败"),

    PARAM_ERROR(1001, "参数错误"),
    USER_NOT_FOUND(1002, "用户不存在"),
    USERNAME_EXIST(1003, "用户名已存在"),

    SYSTEM_ERROR(5000, "系统异常");

    private final int code;
    private final String message;

    ResultCodeEnum(int code, String message) {
        this.code = code;
        this.message = message;
    }

    public int getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }
}

九、HTTP 状态码和业务码不是一回事

这一点必须分清楚。

很多人会把:

复制代码
200 / 404 / 500

复制代码
0 / 1001 / 1002 / 5000

混在一起。

其实它们是两层完全不同的东西。

1. HTTP 状态码

这是:

复制代码
协议层状态

比如:

  • 200:请求成功到达并处理
  • 404:路径不存在
  • 405:请求方法不允许
  • 500:服务器异常

这是 HTTP 协议本身定义的。


2. 业务码

这是:

复制代码
业务层状态

由你自己系统定义。

比如:

  • 0:成功
  • 1001:参数错误
  • 1002:用户不存在
  • 1003:用户名已存在
  • 5000:系统异常

3. 正确理解

复制代码
HTTP 状态码负责"协议层"
Result.code 负责"业务层"

这两个不能混着乱用。


十、为什么需要自定义业务异常

到这里,很多人会继续用:

java 复制代码
throw new RuntimeException("用户不存在");

这能不能用?

能用。

但问题是:

复制代码
RuntimeException 太泛了

它无法表达"这是业务异常"。


1. RuntimeException 是什么

它更像:

复制代码
一个通用异常盒子

任何事情都能往里装。

比如:

  • 用户不存在
  • 用户名重复
  • 密码错误
  • 空指针
  • 算术异常

全都可以塞进去。

但这就导致一个问题:

复制代码
无法区分"业务错误"和"系统错误"

2. 为什么要定义 BusinessException

因为"用户不存在""用户名已存在"这类错误,本质上不是程序 bug,而是:

复制代码
可预期的业务失败

这种错误应该单独有一类异常来表达。

所以我们定义:

java 复制代码
package org.example.arkbackend.exception;

import org.example.arkbackend.common.ResultCodeEnum;

public class BusinessException extends RuntimeException {

    private final Integer code;
    private final String message;

    public BusinessException(ResultCodeEnum codeEnum) {
        super(codeEnum.getMessage());
        this.code = codeEnum.getCode();
        this.message = codeEnum.getMessage();
    }

    public BusinessException(ResultCodeEnum codeEnum, String message) {
        super(message);
        this.code = codeEnum.getCode();
        this.message = message;
    }

    public Integer getCode() {
        return code;
    }

    @Override
    public String getMessage() {
        return message;
    }
}

十一、为什么 BusinessException 继承 RuntimeException

因为在 Spring 项目里,业务异常通常不希望每层都写:

复制代码
throws BusinessException

我们更希望它是:

复制代码
运行时异常

这样可以直接往外抛,再由全局异常处理器统一接住。

所以:

复制代码
BusinessException = 自定义的运行时业务异常

十二、全局异常处理为什么重要

如果没有全局异常处理,你项目里大概率会写成这样:

java 复制代码
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
    try {
        userService.deleteUser(id);
        return Result.success(null);
    } catch (Exception e) {
        return Result.fail("删除失败");
    }
}

这当然也能运行,但问题很大。

1. 重复代码

每个接口都要 try-catch。


2. 返回不统一

每个人 catch 里写的 message 都不一样。


3. 吞掉异常信息

原始异常丢失,不利于排查。


4. Controller 职责变重

Controller 本来应该只负责:

复制代码
接收参数 + 调用 Service + 返回结果

不应该承担复杂异常处理逻辑。

所以我们要把异常处理统一抽出去。


十三、GlobalExceptionHandler 实现

java 复制代码
package org.example.arkbackend.exception;

import lombok.extern.slf4j.Slf4j;
import org.example.arkbackend.common.Result;
import org.example.arkbackend.common.ResultCodeEnum;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * 业务异常
     */
    @ExceptionHandler(BusinessException.class)
    public Result<?> handleBusinessException(BusinessException e) {
        log.warn("业务异常:code={}, message={}", e.getCode(), e.getMessage());
        return Result.fail(e.getCode(), e.getMessage());
    }

    /**
     * 参数校验异常
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<?> handleValidException(MethodArgumentNotValidException e) {
        String message = "参数校验失败";
        if (e.getBindingResult().hasFieldErrors()) {
            message = e.getBindingResult().getFieldErrors().get(0).getDefaultMessage();
        }
        log.warn("参数异常:{}", message);
        return Result.fail(ResultCodeEnum.PARAM_ERROR.getCode(), message);
    }

    /**
     * 系统异常兜底
     */
    @ExceptionHandler(Exception.class)
    public Result<?> handleException(Exception e) {
        log.error("系统异常:", e);
        return Result.fail(ResultCodeEnum.SYSTEM_ERROR.getCode(),
                ResultCodeEnum.SYSTEM_ERROR.getMessage());
    }
}

十四、@RestControllerAdvice@ExceptionHandler 到底是什么关系

这个点很多人会混。

1. @RestControllerAdvice

它不是"异常注解",更准确说它是:

复制代码
全局增强类

告诉 Spring:

复制代码
这是一个全局生效的处理类

2. @ExceptionHandler

它才是真正声明:

复制代码
这个方法处理哪种异常

3. 两者关系

复制代码
@RestControllerAdvice = 提供全局作用域
@ExceptionHandler      = 指定处理异常类型

组合起来,才构成完整的全局异常处理能力。


十五、异常流转流程,必须彻底理解

这是这一篇最关键的一部分。

我们来看这个代码:

java 复制代码
@DeleteMapping("/delete/{id}")
public Result<Void> delete(@PathVariable Long id) {

    if (id <= 0) {
        return Result.fail("id不合法");
    }

    if (id == 999) {
        throw new BusinessException(ResultCodeEnum.USER_NOT_FOUND);
    }

    userService.deleteUser(id);
    return Result.success(null);
}

情况一:id <= 0

这里走的是:

复制代码
return Result.fail("id不合法");

这意味着:

复制代码
没有抛异常
直接返回结果

流程是:

复制代码
Controller
→ 直接 return
→ 返回前端

这种情况:

复制代码
不走 GlobalExceptionHandler

情况二:id == 999

这里走的是:

复制代码
throw new BusinessException(ResultCodeEnum.USER_NOT_FOUND);

这时候发生了什么?

第一步:当前方法立即中断

throw 一执行:

复制代码
delete 方法后面的代码不再执行

也就是说:

复制代码
userService.deleteUser(id);
return Result.success(null);

全部失效。


第二步:异常开始往上抛

当前 Controller 方法没有 try-catch,所以异常不会被这里处理。

于是:

复制代码
异常继续往上抛

第三步:到达 Spring MVC

请求最终是由 Spring 的 DispatcherServlet 调度的。

所以异常会被 Spring 框架层接到。


第四步:Spring 开始找异常处理器

Spring 会扫描:

复制代码
@RestControllerAdvice

找到你的 GlobalExceptionHandler


第五步:按类型匹配 @ExceptionHandler

现在抛出的是:

复制代码
BusinessException

Spring 会优先找最具体匹配:

复制代码
@ExceptionHandler(BusinessException.class)

匹配成功。


第六步:调用处理方法

于是执行:

复制代码
handleBusinessException(e)

最终返回:

复制代码
{
  "code": 1002,
  "message": "用户不存在",
  "data": null
}

最终完整链路

复制代码
请求
→ Controller
→ throw BusinessException
→ Controller 不处理
→ Spring DispatcherServlet 捕获
→ GlobalExceptionHandler
→ @ExceptionHandler(BusinessException.class)
→ Result.fail(...)
→ 返回前端

十六、兜底异常处理会不会每次都触发

不会。

这个点一定要讲清楚。

你现在有三个处理器:

java 复制代码
@ExceptionHandler(BusinessException.class)
@ExceptionHandler(MethodArgumentNotValidException.class)
@ExceptionHandler(Exception.class)

Spring 的匹配规则是:

复制代码
优先匹配最具体的异常

所以:

1. 如果抛的是 BusinessException

会命中:

复制代码
handleBusinessException

不会走兜底。


2. 如果抛的是 MethodArgumentNotValidException

会命中:

复制代码
handleValidException

不会走兜底。


3. 如果抛的是空指针、数据库异常等其他没单独处理的异常

才会走:

复制代码
handleException

所以 Exception.class 这个 handler 的角色是:

复制代码
兜底

它不是每次都触发,而是:

复制代码
前面没人接住时,最后由它接

十七、那是不是可以在代码里自己 try-catch

当然可以。

比如:

java 复制代码
try {
    userService.deleteUser(id);
} catch (Exception e) {
    return Result.fail("删除失败");
}

这完全合法。

但关键不是"能不能写",而是:

复制代码
什么时候该写,什么时候不该写

1. 不推荐在 Controller 里滥用 try-catch

因为这会导致:

复制代码
- 重复代码
- 返回不统一
- 异常逻辑分散
- Controller 职责过重

2. 推荐做法

复制代码
Controller:尽量不写 try-catch
Service:必要时做局部异常转换
GlobalExceptionHandler:统一收口

3. try-catch 适合用在哪

比如你要做"异常转换":

java 复制代码
try {
    userMapper.insert(user);
} catch (DuplicateKeyException e) {
    throw new BusinessException(ResultCodeEnum.USERNAME_EXIST);
}

这就很合理,因为你把数据库底层异常转换成了更清晰的业务异常。


十八、为什么要区分业务异常和系统异常

这是接口工程化的核心之一。

1. 业务异常

比如:

  • 用户不存在
  • 用户名已存在
  • 参数不合法
  • 权限不足

这些错误是:

复制代码
可预期的

可以明确告诉前端。


2. 系统异常

比如:

  • 空指针
  • SQL 异常
  • 第三方接口宕机
  • IO 异常

这些错误是:

复制代码
不可预期的

这时候不能把原始细节直接返回给前端,否则会暴露系统内部实现。

所以系统异常应该统一返回:

复制代码
系统异常

而把真实错误堆栈打到日志里。


十九、日志在异常体系里的作用

这一步千万不能省。

1. 业务异常

建议打:

复制代码
log.warn(...)

因为这是:

复制代码
可预期问题

2. 系统异常

建议打:

复制代码
log.error("系统异常:", e)

这样会把完整堆栈打出来,方便定位问题。


3. 为什么 @Slf4j 很重要

因为它会自动帮你生成:

java 复制代码
private static final Logger log = ...

让你可以直接写:

复制代码
log.info(...)
log.warn(...)
log.error(...)

这和 Android 里的 Log 很像,但后端日志体系更完整、更适合线上排查。


二十、Controller / Service 怎么配合这套异常设计

Controller 推荐写法

java 复制代码
@DeleteMapping("/delete/{id}")
public Result<Void> delete(@PathVariable Long id) {
    userService.deleteUser(id);
    return Result.success(null);
}

职责非常单纯:

复制代码
收参数 + 调服务 + 返回结果

Service 推荐写法

java 复制代码
@Override
public void deleteUser(Long id) {
    if (id == null || id <= 0) {
        throw new BusinessException(ResultCodeEnum.PARAM_ERROR, "id不合法");
    }

    if (id == 999L) {
        throw new BusinessException(ResultCodeEnum.USER_NOT_FOUND);
    }

    // 删除逻辑
}

职责是:

复制代码
承载业务逻辑
发现业务错误时抛 BusinessException

二十一、优化前后对比

优化前

java 复制代码
{
  "message": "用户不存在"
}

或者直接:

复制代码
删除失败

优化后

复制代码
{
  "code": 1002,
  "message": "用户不存在",
  "data": null
}

再比如参数错误:

复制代码
{
  "code": 1001,
  "message": "用户名不能为空",
  "data": null
}

系统异常:

复制代码
{
  "code": 5000,
  "message": "系统异常",
  "data": null
}

这时候整个接口体系就从:

复制代码
能跑

升级成了:

复制代码
能维护、能协作、能扩展

二十二、本篇的核心收获

这一篇你真正掌握的,不只是几段代码,而是一整套接口工程化思维:

复制代码
1. Result 统一返回
2. ResultCodeEnum 统一错误码
3. BusinessException 表达业务异常
4. GlobalExceptionHandler 统一异常出口
5. 异常流转机制(throw → Spring → handler)
6. 业务异常和系统异常分层处理
7. Controller / Service 职责进一步清晰

二十三、一句话总结

接口工程化的关键,不只是把功能写出来,而是建立统一的返回结构、错误码体系和异常处理机制,让项目从"能跑"升级为"可维护、可协作、可扩展"


二十四、项目结构目录(工程分层)

在完成本篇优化后,项目结构已经从简单 Demo,升级为具备基础工程规范的分层结构:

复制代码
org.example.arkbackend
├── common                 # 通用层
│   ├── Result.java        # 统一返回结构
│   └── ResultCodeEnum.java # 错误码枚举
│
├── controller             # 接口层
│   └── UserController.java
│
├── dto                    # 数据传输对象
│   └── UserRegisterDTO.java
│
├── entity                 # 实体类(数据库对象)
│   └── User.java
│
├── exception              # 异常处理层
│   ├── BusinessException.java       # 业务异常
│   └── GlobalExceptionHandler.java  # 全局异常处理
│
├── mapper                 # 数据访问层(MyBatis)
│   └── UserMapper.java
│
├── service                # 业务层接口
│   └── UserService.java
│
├── service.impl           # 业务实现层
│   └── UserServiceImpl.java
│
└── ArkBackendApplication.java  # 启动类

分层职责说明

1. controller(接口层)
复制代码
接收请求 → 参数校验 → 调用 Service → 返回 Result

特点:

复制代码
❌ 不写业务逻辑
❌ 不写 try-catch
✔ 保持简洁

2. service(业务层)
复制代码
处理核心业务逻辑

特点:

复制代码
✔ 写业务逻辑
✔ 抛 BusinessException
✔ 不关心返回格式

3. mapper(数据层)
复制代码
负责数据库操作

特点:

复制代码
✔ 只做 CRUD
❌ 不写业务逻辑

4. exception(异常层)
复制代码
统一处理系统异常 + 业务异常

包括:

复制代码
BusinessException         → 业务异常
GlobalExceptionHandler    → 全局异常处理

5. common(通用层)
复制代码
提供全局通用能力

包括:

复制代码
Result           → 统一返回结构
ResultCodeEnum   → 错误码体系

一句话总结结构设计

复制代码
Controller 控制流程
Service 承载业务
Mapper 操作数据
Exception 统一异常
Common 提供规范

二十五、结语

到这里,你前面那个最小后端项目,其实已经完成了一次非常关键的升级:

复制代码
从"最小项目"
升级为
"工程化最小项目"

这一步非常重要,因为它标志着你已经不再只是"会写接口",而是在真正学习:

如何设计后端系****统

相关推荐
Rust研习社1 小时前
Rust 智能指针 Cell 与 RefCell 的内部可变性
开发语言·后端·rust
夕颜1112 小时前
Skill 机器人 vs Hermes Agent:两种「AI 越用越聪明」的路径
后端
杨凯凡3 小时前
【012】图与最短路径:了解即可
java·数据结构
比特森林探险记3 小时前
【无标题】
java·前端
椰猫子3 小时前
Javaweb(Filter、Listener、AJAX、JSON)
java·开发语言
IT_陈寒3 小时前
SpringBoot自动配置把我都整不会了
前端·人工智能·后端
朝新_4 小时前
【Spring AI 】核心知识体系梳理:从入门到实战
java·人工智能·spring
一 乐4 小时前
旅游|基于springboot + vue旅游信息推荐系统(源码+数据库+文档)
java·vue.js·spring boot·论文·旅游·毕设·旅游信息推荐系统
覆东流4 小时前
第1天:Python环境搭建 & 第一个程序
开发语言·后端·python