一、前言
在前四篇中,我们已经把一个最小 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 提供规范
二十五、结语
到这里,你前面那个最小后端项目,其实已经完成了一次非常关键的升级:
从"最小项目"
升级为
"工程化最小项目"
这一步非常重要,因为它标志着你已经不再只是"会写接口",而是在真正学习:
如何设计后端系****统