Spring Boot + MyBatis 实战:构建可维护的 Java Web 后台接口与统一异常处理体系
做 Java Web 后台时,很多项目一开始都能跑,但跑着跑着就“乱”了:
接口返回格式不统一、异常到处 try-catch、数据库访问代码散落、排查线上问题时日志也对不上。
我自己做中小型后台接口时,最常见的痛点有三个:
- 接口能用,但不稳定:报错时前端拿到的响应格式五花八门。
- 代码能写,但不易维护:Controller、Service、Mapper 的职责混在一起。
- 功能能上线,但后续难扩展:新增业务后,异常码、校验逻辑、日志策略很快失控。
这篇文章我们就从一个**“用户管理接口”的小例子出发,用 Spring Boot + MyBatis 搭一个可运行的后台,并把统一响应**、统一异常处理、参数校验、常见坑排查 一次理顺。
背景与问题
很多教程会把重点放在“怎么把接口跑起来”,但在真实项目里,真正拉开维护成本差距的,往往是这些细节:
- 返回值是否统一
- 业务异常是否可控
- 系统异常是否能兜底
- 参数校验是否前置
- SQL 与业务逻辑是否分层
- 日志是否便于定位问题
如果这些没设计好,后面会出现这种情况:
- 前端需要针对每个接口单独写错误分支
- 同一类错误在不同接口里返回不同文案
- 业务异常被吞掉,只看到 “500 Internal Server Error”
- 数据库唯一索引冲突时,用户看到的是一堆底层异常堆栈
我们希望最终达到的效果是:
- 成功响应格式统一
- 失败响应格式统一
- 已知业务错误可读、可控
- 未知系统错误能兜底并打日志
- Controller 保持轻薄,Service 专注业务,Mapper 专注数据访问
前置知识与环境准备
技术栈
- JDK 17
- Spring Boot 3.x
- MyBatis Spring Boot Starter
- MySQL 8.x
- Maven
项目结构建议
src/main/java/com/example/demo
├── DemoApplication.java
├── common
│ ├── api
│ │ ├── ApiResponse.java
│ │ ├── ErrorCode.java
│ │ └── BizException.java
│ └── handler
│ └── GlobalExceptionHandler.java
├── controller
│ └── UserController.java
├── dto
│ ├── UserCreateRequest.java
│ └── UserQueryRequest.java
├── entity
│ └── User.java
├── mapper
│ └── UserMapper.java
└── service
├── UserService.java
└── impl
└── UserServiceImpl.java
这个结构不复杂,但非常适合中小型后台接口的起步阶段。
核心原理
这一套方案的核心,其实就四件事:
- 分层明确
- 响应统一
- 异常统一
- 校验前置
1. 分层明确
职责尽量别混:
Controller:接收请求、参数校验、返回结果Service:业务规则、事务、异常抛出Mapper:数据库 CRUDEntity/DTO:数据承载
如果你把 SQL 写进 Controller、把异常处理写进每个方法里,后面改起来会非常痛苦。
2. 统一响应
接口返回统一结构,比如:
{
"code": 0,
"message": "success",
"data": {
"id": 1,
"username": "alice"
}
}
失败时:
{
"code": 4001,
"message": "用户不存在",
"data": null
}
这样前端就能统一处理:
code == 0认为成功- 其他情况按错误码走提示或拦截逻辑
3. 统一异常处理
异常建议分两类:
- 业务异常:可预期,比如“用户不存在”“用户名重复”
- 系统异常:不可预期,比如空指针、数据库连接失败
业务异常由我们主动抛出,例如:
throw new BizException(ErrorCode.USER_NOT_FOUND);
系统异常交给全局异常处理器统一兜底。
4. 参数校验前置
很多本该在入参阶段发现的问题,却拖到了业务逻辑甚至数据库阶段才报错。
例如用户名为空、本该是邮箱却传了任意字符串。
通过 jakarta.validation 注解,可以把这类错误挡在 Controller 层。
整体处理流程图
flowchart TD
A[客户端请求] --> B[Controller 接收参数]
B --> C[参数校验]
C -->|通过| D[Service 处理业务]
C -->|失败| E[GlobalExceptionHandler]
D --> F[Mapper 执行 SQL]
F --> G[返回结果]
D -->|抛出 BizException| E
D -->|抛出其他异常| E
G --> H[ApiResponse.success]
E --> I[ApiResponse.fail]
请求与异常处理时序图
sequenceDiagram
participant Client as 客户端
participant Controller as UserController
participant Service as UserService
participant Mapper as UserMapper
participant Handler as GlobalExceptionHandler
Client->>Controller: POST /users
Controller->>Controller: 参数校验
alt 校验通过
Controller->>Service: createUser(req)
Service->>Mapper: insert(user)
Mapper-->>Service: rows/id
Service-->>Controller: user
Controller-->>Client: ApiResponse.success(data)
else 校验失败
Controller-->>Handler: 抛出 MethodArgumentNotValidException
Handler-->>Client: ApiResponse.fail(参数错误)
end
alt 业务异常
Service-->>Handler: BizException
Handler-->>Client: ApiResponse.fail(业务码)
end
alt 系统异常
Service-->>Handler: Exception
Handler-->>Client: ApiResponse.fail(系统繁忙)
end
实战代码(可运行)
下面我们搭一个简单的“用户管理”接口,包含:
- 新增用户
- 根据 ID 查询用户
- 统一返回结构
- 统一异常处理
- 参数校验
- MyBatis 映射
1. pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>demo</artifactId>
<version>1.0.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- mybatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>
<!-- mysql -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- lombok,可选 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
2. 建表 SQL
CREATE DATABASE IF NOT EXISTS demo DEFAULT CHARACTER SET utf8mb4;
USE demo;
CREATE TABLE IF NOT EXISTS t_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL,
age INT DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
这里我特意给 username 加了唯一索引,后面能顺便演示“重复用户名”的异常处理。
3. application.yml
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
logging:
level:
com.example.demo: info
map-underscore-to-camel-case: true 很实用,它能把数据库字段 created_at 自动映射为 Java 属性 createdAt。
4. 启动类
package com.example.demo;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@MapperScan("com.example.demo.mapper")
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
5. 统一响应对象
ApiResponse.java
package com.example.demo.common.api;
public class ApiResponse<T> {
private int code;
private String message;
private T data;
public ApiResponse() {
}
public ApiResponse(int code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(0, "success", data);
}
public static <T> ApiResponse<T> fail(int code, String message) {
return new ApiResponse<>(code, message, null);
}
public int getCode() {
return code;
}
public void setCode(int code) {
this.code = code;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
}
6. 错误码定义
ErrorCode.java
package com.example.demo.common.api;
public enum ErrorCode {
PARAM_INVALID(4000, "参数校验失败"),
USER_NOT_FOUND(4001, "用户不存在"),
USERNAME_DUPLICATE(4002, "用户名已存在"),
SYSTEM_ERROR(5000, "系统繁忙,请稍后再试");
private final int code;
private final String message;
ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
public int getCode() {
return code;
}
public String getMessage() {
return message;
}
}
错误码不要乱写字符串,最好统一枚举管理。
真实项目里,我一般会按模块拆分,比如用户模块 41xx、订单模块 42xx。
7. 业务异常
BizException.java
package com.example.demo.common.api;
public class BizException extends RuntimeException {
private final int code;
private final String message;
public BizException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.code = errorCode.getCode();
this.message = errorCode.getMessage();
}
public BizException(int code, String message) {
super(message);
this.code = code;
this.message = message;
}
public int getCode() {
return code;
}
@Override
public String getMessage() {
return message;
}
}
8. 全局异常处理器
GlobalExceptionHandler.java
package com.example.demo.common.handler;
import com.example.demo.common.api.ApiResponse;
import com.example.demo.common.api.BizException;
import com.example.demo.common.api.ErrorCode;
import jakarta.validation.ConstraintViolationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(BizException.class)
public ApiResponse<Void> handleBizException(BizException e) {
return ApiResponse.fail(e.getCode(), e.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResponse<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
String message = e.getBindingResult()
.getFieldErrors()
.stream()
.findFirst()
.map(error -> error.getField() + " " + error.getDefaultMessage())
.orElse(ErrorCode.PARAM_INVALID.getMessage());
return ApiResponse.fail(ErrorCode.PARAM_INVALID.getCode(), message);
}
@ExceptionHandler(ConstraintViolationException.class)
public ApiResponse<Void> handleConstraintViolationException(ConstraintViolationException e) {
return ApiResponse.fail(ErrorCode.PARAM_INVALID.getCode(), e.getMessage());
}
@ExceptionHandler(DuplicateKeyException.class)
public ApiResponse<Void> handleDuplicateKeyException(DuplicateKeyException e) {
return ApiResponse.fail(ErrorCode.USERNAME_DUPLICATE.getCode(), ErrorCode.USERNAME_DUPLICATE.getMessage());
}
@ExceptionHandler(Exception.class)
public ApiResponse<Void> handleException(Exception e) {
log.error("系统异常", e);
return ApiResponse.fail(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage());
}
}
这里的关键点是:
BizException:处理业务错误MethodArgumentNotValidException:处理@RequestBody校验失败ConstraintViolationException:处理@RequestParam/@PathVariable校验失败DuplicateKeyException:处理数据库唯一键冲突Exception:最终兜底
我自己很少建议在业务代码里到处 try-catch,因为那样很容易把异常处理逻辑打散。
9. 实体类
User.java
package com.example.demo.entity;
import java.time.LocalDateTime;
public class User {
private Long id;
private String username;
private String email;
private Integer age;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}
10. DTO
UserCreateRequest.java
package com.example.demo.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
public class UserCreateRequest {
@NotBlank(message = "不能为空")
private String username;
@NotBlank(message = "不能为空")
@Email(message = "格式不正确")
private String email;
@Min(value = 1, message = "不能小于1")
@Max(value = 150, message = "不能大于150")
private Integer age;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
}
UserQueryRequest.java
package com.example.demo.dto;
public class UserQueryRequest {
private Long id;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
}
这个 UserQueryRequest 在当前例子里不是必需的,但当查询条件变多时,它会比散落的参数更好维护。
11. Mapper
UserMapper.java
package com.example.demo.mapper;
import com.example.demo.entity.User;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface UserMapper {
@Insert("""
INSERT INTO t_user(username, email, age)
VALUES(#{username}, #{email}, #{age})
""")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insert(User user);
@Select("""
SELECT id, username, email, age, created_at, updated_at
FROM t_user
WHERE id = #{id}
""")
User selectById(Long id);
@Select("""
SELECT id, username, email, age, created_at, updated_at
FROM t_user
WHERE username = #{username}
""")
User selectByUsername(String username);
}
这里为了方便演示,直接用了注解 SQL。
如果 SQL 很复杂,建议切回 XML,维护起来会更清晰。
12. Service 接口
UserService.java
package com.example.demo.service;
import com.example.demo.dto.UserCreateRequest;
import com.example.demo.entity.User;
public interface UserService {
User createUser(UserCreateRequest request);
User getUserById(Long id);
}
13. Service 实现
UserServiceImpl.java
package com.example.demo.service.impl;
import com.example.demo.common.api.BizException;
import com.example.demo.common.api.ErrorCode;
import com.example.demo.dto.UserCreateRequest;
import com.example.demo.entity.User;
import com.example.demo.mapper.UserMapper;
import com.example.demo.service.UserService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
public UserServiceImpl(UserMapper userMapper) {
this.userMapper = userMapper;
}
@Override
@Transactional
public User createUser(UserCreateRequest request) {
User user = new User();
user.setUsername(request.getUsername());
user.setEmail(request.getEmail());
user.setAge(request.getAge());
userMapper.insert(user);
return userMapper.selectById(user.getId());
}
@Override
public User getUserById(Long id) {
User user = userMapper.selectById(id);
if (user == null) {
throw new BizException(ErrorCode.USER_NOT_FOUND);
}
return user;
}
}
你可能会问:创建用户前要不要先查一次用户名是否存在?
可以,但不能只靠前置查询,因为并发下依然可能重复。
真正兜底的是数据库唯一索引。也就是说:
- 业务层可提前提示
- 数据库层必须最终保证一致性
14. Controller
UserController.java
package com.example.demo.controller;
import com.example.demo.common.api.ApiResponse;
import com.example.demo.dto.UserCreateRequest;
import com.example.demo.entity.User;
import com.example.demo.service.UserService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/users")
@Validated
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@PostMapping
public ApiResponse<User> createUser(@Valid @RequestBody UserCreateRequest request) {
return ApiResponse.success(userService.createUser(request));
}
@GetMapping("/{id}")
public ApiResponse<User> getUserById(@PathVariable @Min(value = 1, message = "必须大于等于1") Long id) {
return ApiResponse.success(userService.getUserById(id));
}
}
注意这里的 @Validated,它是让 @PathVariable、@RequestParam 这类校验生效的关键之一,很多人第一次都会漏掉。
逐步验证清单
项目启动后,可以按下面顺序验证。
1. 新增用户成功
curl -X POST "http://localhost:8080/users" \
-H "Content-Type: application/json" \
-d '{
"username": "alice",
"email": "[email protected]",
"age": 20
}'
预期响应:
{
"code": 0,
"message": "success",
"data": {
"id": 1,
"username": "alice",
"email": "[email protected]",
"age": 20,
"createdAt": "2024-05-25T20:00:00",
"updatedAt": "2024-05-25T20:00:00"
}
}
2. 参数校验失败
curl -X POST "http://localhost:8080/users" \
-H "Content-Type: application/json" \
-d '{
"username": "",
"email": "not-email",
"age": 200
}'
预期类似:
{
"code": 4000,
"message": "username 不能为空",
"data": null
}
这里返回第一条错误信息,足够前端提示。
如果你要返回全部字段错误,也可以在异常处理器里自己组装列表。
3. 重复用户名
再次插入:
curl -X POST "http://localhost:8080/users" \
-H "Content-Type: application/json" \
-d '{
"username": "alice",
"email": "[email protected]",
"age": 22
}'
预期:
{
"code": 4002,
"message": "用户名已存在",
"data": null
}
4. 查询不存在的用户
curl "http://localhost:8080/users/99999"
预期:
{
"code": 4001,
"message": "用户不存在",
"data": null
}
5. 路径参数非法
curl "http://localhost:8080/users/0"
预期:
{
"code": 4000,
"message": "getUserById.id: 必须大于等于1",
"data": null
}
不同版本的校验异常消息格式可能略有差异,这个是正常的。
分层关系图
classDiagram
class UserController {
+createUser(request)
+getUserById(id)
}
class UserService {
<<interface>>
+createUser(request)
+getUserById(id)
}
class UserServiceImpl {
-UserMapper userMapper
}
class UserMapper {
+insert(user)
+selectById(id)
+selectByUsername(username)
}
class GlobalExceptionHandler {
+handleBizException(e)
+handleMethodArgumentNotValidException(e)
+handleConstraintViolationException(e)
+handleDuplicateKeyException(e)
+handleException(e)
}
UserController --> UserService
UserServiceImpl ..|> UserService
UserServiceImpl --> UserMapper
常见坑与排查
这部分很重要。我当时第一次把“统一异常处理 + 参数校验 + MyBatis”一起接起来时,踩坑基本都集中在这里。
坑 1:@PathVariable 校验不生效
现象
/users/0 没有触发 @Min(1),接口照样执行。
原因
Controller 类上缺少 @Validated。
修复
@RestController
@RequestMapping("/users")
@Validated
public class UserController {
}
坑 2:参数校验依赖没引入
现象
@NotBlank、@Email 写了,但完全不报错。
原因
缺少:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Spring Boot 3 里别指望它“自动都给你带上”,很多时候最好显式引入。
坑 3:数据库字段和 Java 属性映射不上
现象
created_at 查出来后,createdAt 是 null。
原因
没有开启驼峰映射。
修复
mybatis:
configuration:
map-underscore-to-camel-case: true
或者你自己在 SQL 里显式起别名:
SELECT created_at AS createdAt
坑 4:重复用户名报了 500
现象
数据库唯一索引冲突,但前端看到的是系统错误。
原因
没有单独处理 DuplicateKeyException。
修复
在全局异常处理中补上:
@ExceptionHandler(DuplicateKeyException.class)
public ApiResponse<Void> handleDuplicateKeyException(DuplicateKeyException e) {
return ApiResponse.fail(4002, "用户名已存在");
}
坑 5:把所有异常都吞掉了
反面写法
try {
userMapper.insert(user);
} catch (Exception e) {
return null;
}
这种写法后面排查非常难,日志不全、响应不准、调用链断掉。
建议
- 业务异常:主动抛
BizException - 系统异常:让它往上抛给全局异常处理器
- 必要时只在关键边界层记录日志
坑 6:Controller 写太重
反面写法
@PostMapping
public ApiResponse<?> create(@RequestBody UserCreateRequest request) {
if (request.getUsername() == null || request.getUsername().isEmpty()) {
return ApiResponse.fail(4000, "用户名不能为空");
}
User user = new User();
user.setUsername(request.getUsername());
userMapper.insert(user);
return ApiResponse.success(user);
}
问题在于:
- 校验散落
- SQL 直接进 Controller
- 业务和接口耦合
正确思路
- Controller 只做“接收 + 调用 + 返回”
- 业务都进 Service
- 数据访问交给 Mapper
安全/性能最佳实践
统一异常处理不只是“代码看起来整齐”,它还直接影响安全性和性能表现。
1. 不要把底层异常信息直接返回给前端
错误示例:
{
"code": 5000,
"message": "### Error updating database. Cause: java.sql.SQLIntegrityConstraintViolationException ..."
}
这会暴露:
- 表名
- 字段名
- SQL 结构
- 驱动信息
正确做法是:
- 前端只拿到通用错误信息
- 详细堆栈写入服务端日志
2. 利用数据库约束兜底
像“用户名唯一”这种规则,应用层检查不是最终保障。
必须有数据库唯一索引,否则并发下迟早出问题。
建议:
- 唯一索引:用户名、手机号、订单号等
- 非空约束:关键字段
- 外键/逻辑约束:按团队规范决定是否使用
3. 避免 N+1 查询和过度往返数据库
本篇示例很简单,只查一次问题不大。
但真实业务里常见这种写法:
for (Long id : ids) {
userMapper.selectById(id);
}
这会造成多次数据库往返。
建议:
- 批量查询优先
- 能用
IN (...)的别循环单查 - 复杂联表场景考虑专门的查询对象
4. 日志要有边界,不要全打
经验上,我一般这样做:
- 业务异常:不一定打
error,很多是正常分支 - 系统异常:打
error - 参数错误:通常不需要打印完整堆栈
否则线上日志会被“无意义错误”刷满,真正的问题反而淹没了。
5. 响应体不要直接返回数据库实体到处复用
这篇文章为了可运行、简洁,直接返回了 User。
但在正式项目里,更推荐区分:
Entity:数据库对象Request DTO:入参对象Response VO/DTO:出参对象
原因很简单:
- 避免把不该暴露的字段返回出去
- 降低数据库结构变更对接口层的影响
- 更适合版本演进
比如后面你加了 password_hash、deleted、version 字段,就不该直接暴露给前端。
6. 统一错误码,但别过度设计
很多团队一开始就搞上百个错误码,结果没人记得住,也没人维护。
比较务实的建议是:
- 先按模块分段
- 先覆盖核心错误场景
- 文档同步维护
- 避免一个错误码对应多个含义
如果只是一个中后台管理系统,别上来就搞成“企业级宇宙错误码体系”。
7. 合理使用事务
本例在创建用户时加了:
@Transactional
单表插入看似不加也能跑,但如果后面业务变成:
- 新增用户
- 写用户角色表
- 写审计日志表
那事务就很关键了。
注意边界:
- 事务放在 Service 层
- 不要把事务打到 private 方法自调用上,可能不生效
- 不要在大事务里做远程调用,容易拖慢持锁时间
可以继续演进的方向
如果你准备把这套结构用于真实项目,下一步我建议从下面几个点升级:
1. 分离出统一返回工具类与错误码文档
比如维护一份接口错误码说明表,方便前后端统一认知。
2. 增加分页查询接口
常见后台接口离不开分页,可以再加:
pageNumpageSize- 条件筛选
- 排序白名单
3. 加入链路日志与请求 ID
出现线上问题时,你会非常感谢自己提前做了这件事。
最起码让一条请求在日志里能串起来。
4. 接入接口鉴权
比如:
- Spring Security
- JWT
- Token 会话校验
- 角色权限控制
统一异常处理也要兼容认证异常、权限异常。
总结
这篇文章我们做了一件很实用的事:
用 Spring Boot + MyBatis 搭了一个可维护的 Java Web 后台接口骨架,并把统一异常处理体系落了地。
你可以把它记成一套最小可用原则:
- Controller 轻:只收参与返回
- Service 稳:只写业务规则
- Mapper 专:只做数据访问
- 响应统一:前端处理简单
- 异常统一:业务错误可控,系统错误可兜底
- 校验前置:错误尽早发现
- 数据库约束兜底:保证最终一致性
如果你现在手头有一个“能跑但越来越乱”的 Spring Boot 项目,我很建议先别急着重构大业务,而是优先把这三件事补齐:
- 统一响应结构
- 全局异常处理
- 参数校验与错误码规范
这是维护性提升最明显、投入产出比也最高的一步。
最后给一个落地建议:
先在一个最小模块试行这套规范,比如用户、字典、配置管理模块,跑顺以后再推广到全项目。
这样成本低、阻力小,也更容易让团队接受。