跳转到内容
123xiao | 无名键客

《Spring Boot + MyBatis 实战:构建可维护的 Java Web 后台接口与统一异常处理体系》

字数: 0 阅读时长: 1 分钟

Spring Boot + MyBatis 实战:构建可维护的 Java Web 后台接口与统一异常处理体系

做 Java Web 后台时,很多项目一开始都能跑,但跑着跑着就“乱”了:
接口返回格式不统一、异常到处 try-catch、数据库访问代码散落、排查线上问题时日志也对不上。

我自己做中小型后台接口时,最常见的痛点有三个:

  1. 接口能用,但不稳定:报错时前端拿到的响应格式五花八门。
  2. 代码能写,但不易维护:Controller、Service、Mapper 的职责混在一起。
  3. 功能能上线,但后续难扩展:新增业务后,异常码、校验逻辑、日志策略很快失控。

这篇文章我们就从一个**“用户管理接口”的小例子出发,用 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. 分层明确
  2. 响应统一
  3. 异常统一
  4. 校验前置

1. 分层明确

职责尽量别混:

  • Controller:接收请求、参数校验、返回结果
  • Service:业务规则、事务、异常抛出
  • Mapper:数据库 CRUD
  • Entity/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 查出来后,createdAtnull

原因

没有开启驼峰映射。

修复

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_hashdeletedversion 字段,就不该直接暴露给前端。


6. 统一错误码,但别过度设计

很多团队一开始就搞上百个错误码,结果没人记得住,也没人维护。

比较务实的建议是:

  • 先按模块分段
  • 先覆盖核心错误场景
  • 文档同步维护
  • 避免一个错误码对应多个含义

如果只是一个中后台管理系统,别上来就搞成“企业级宇宙错误码体系”。


7. 合理使用事务

本例在创建用户时加了:

@Transactional

单表插入看似不加也能跑,但如果后面业务变成:

  • 新增用户
  • 写用户角色表
  • 写审计日志表

那事务就很关键了。

注意边界:

  • 事务放在 Service 层
  • 不要把事务打到 private 方法自调用上,可能不生效
  • 不要在大事务里做远程调用,容易拖慢持锁时间

可以继续演进的方向

如果你准备把这套结构用于真实项目,下一步我建议从下面几个点升级:

1. 分离出统一返回工具类与错误码文档

比如维护一份接口错误码说明表,方便前后端统一认知。

2. 增加分页查询接口

常见后台接口离不开分页,可以再加:

  • pageNum
  • pageSize
  • 条件筛选
  • 排序白名单

3. 加入链路日志与请求 ID

出现线上问题时,你会非常感谢自己提前做了这件事。
最起码让一条请求在日志里能串起来。

4. 接入接口鉴权

比如:

  • Spring Security
  • JWT
  • Token 会话校验
  • 角色权限控制

统一异常处理也要兼容认证异常、权限异常。


总结

这篇文章我们做了一件很实用的事:
Spring Boot + MyBatis 搭了一个可维护的 Java Web 后台接口骨架,并把统一异常处理体系落了地。

你可以把它记成一套最小可用原则:

  • Controller 轻:只收参与返回
  • Service 稳:只写业务规则
  • Mapper 专:只做数据访问
  • 响应统一:前端处理简单
  • 异常统一:业务错误可控,系统错误可兜底
  • 校验前置:错误尽早发现
  • 数据库约束兜底:保证最终一致性

如果你现在手头有一个“能跑但越来越乱”的 Spring Boot 项目,我很建议先别急着重构大业务,而是优先把这三件事补齐:

  1. 统一响应结构
  2. 全局异常处理
  3. 参数校验与错误码规范

这是维护性提升最明显、投入产出比也最高的一步。

最后给一个落地建议:
先在一个最小模块试行这套规范,比如用户、字典、配置管理模块,跑顺以后再推广到全项目。
这样成本低、阻力小,也更容易让团队接受。


分享到:

上一篇
《区块链中智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建-113》
下一篇
《Web逆向实战:基于浏览器开发者工具定位并还原前端加密请求参数的完整方法-85》