Java 后端解析(三):全局异常处理与 JPA 数据库映射

作者:袖梨 2026-06-27

一、本篇要解决的问题

问题涉及文件
Service 里 throw new RuntimeException("用户名已存在") 之后发生了什么?GlobalExceptionHandler.java
@Valid 校验失败时,谁把 400 错误返回给前端?GlobalExceptionHandler.java
@Entity@Column@GeneratedValue 分别是什么意思?entity/Article.java
Java 字段怎么变成 MySQL 的 CREATE TABLEapplication.yml + JPA

二、GlobalExceptionHandler 全貌

 复制代码@RestControllerAdvice
public class GlobalExceptionHandler {    @ExceptionHandler(RuntimeException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiResponse<Void> handleRuntimeException(RuntimeException e) {
        return ApiResponse.error(400, e.getMessage());
    }    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiResponse<Void> handleValidationException(MethodArgumentNotValidException e) {
        String message = e.getBindingResult().getFieldErrors().stream()
                .map(FieldError::getDefaultMessage)
                .collect(Collectors.joining("; "));
        return ApiResponse.error(400, message);
    }
}

它是整个后端的统一异常出口:任何地方抛出的特定异常,都会在这里被捕获,转成统一的 JSON 格式返回。


三、三个核心注解

1. @RestControllerAdvice — 全局拦截器

加在类上,表示:这个类专门处理 Controller 调用链中抛出的异常(包括 Service 里 throw 的)。

 复制代码Controller 调用 ServiceService 抛出异常
        ↓
异常向上冒泡,Spring 按类型匹配 @ExceptionHandler
        ↓
返回统一 JSON(ApiResponse

和普通 @ControllerAdvice 的区别:@RestControllerAdvice = @ControllerAdvice + @ResponseBody,返回值直接写成 JSON,不用再额外标注。

2. @ExceptionHandler(异常类型.class) — 指定处理哪种异常

 复制代码@ExceptionHandler(RuntimeException.class)
public ApiResponse<Void> handleRuntimeException(RuntimeException e) { ... }

含义:当任何地方抛出 RuntimeException(及其子类)时,执行这个方法

本项目 Service 里的业务错误都用 RuntimeException

位置抛出的异常
AuthService.registerthrow new RuntimeException("用户名已存在")
AuthService.loginthrow new RuntimeException("用户名或密码错误")
CommentService.createthrow new RuntimeException("文章不存在")
ArticleService.deletethrow new RuntimeException("无权操作该文章")
UserServicethrow new RuntimeException("未登录")

3. @ResponseStatus(HttpStatus.BAD_REQUEST) — 设置 HTTP 状态码

 复制代码@ResponseStatus(HttpStatus.BAD_REQUEST)  // HTTP 400

告诉 Spring:这个方法返回时,HTTP 响应状态码设为 400 Bad Request

最终前端收到的响应:

 复制代码{
  "code": 400,
  "message": "用户名已存在",
  "data": null
}

四、两条异常处理链路

链路 A:业务异常(RuntimeException

以注册时用户名重复为例:

 复制代码POST /api/auth/register  { "username": "summer", ... }
        ↓
AuthController → AuthService.register()
        ↓
userRepository.existsByUsername("summer") == truethrow new RuntimeException("用户名已存在")
        ↓
异常向上冒泡(Service → Controller,不会正常 return)
        ↓
Spring 扫描 @RestControllerAdvice,按异常类型匹配 @ExceptionHandler
        ↓
调用 handleRuntimeException(e)   ← 不是 throw 直接调用的return ApiResponse.error(400, "用户名已存在")
        ↓
HTTP 400 + JSON

对应代码:

 复制代码// AuthService.java
if (userRepository.existsByUsername(request.getUsername())) {
    throw new RuntimeException("用户名已存在");
}// GlobalExceptionHandler.java
@ExceptionHandler(RuntimeException.class)
public ApiResponse<Void> handleRuntimeException(RuntimeException e) {
    return ApiResponse.error(400, e.getMessage());  // e.getMessage() = "用户名已存在"
}

e.getMessage() 就是 throw 时括号里的字符串。

重要:throw 并不是直接调用 handleRuntimeException

很多人看到 throw new RuntimeException("用户名已存在") 就会以为代码「自动跳」到了 handleRuntimeException其实不是

误解实际情况
throw 会调用 handleRuntimeExceptionService 里只负责抛异常,不会、也不能写 handleRuntimeException(...)
方法名要对上才会处理Spring 看的是异常类型RuntimeException.class),不是方法名
Handler 和 Service 有直接引用关系两者互不 import,完全由 Spring 在运行时串联

完整机制如下:

 复制代码1. AuthService 里执行 throw new RuntimeException("用户名已存在")
2. 当前方法中断,异常沿调用栈向上抛:Service → Controller
3. Controller 没有 try-catch,异常继续向上
4. Spring MVC 拦截到这个未处理异常
5. 发现存在 @RestControllerAdvice 标注的 GlobalExceptionHandler
6. 在其中查找 @ExceptionHandler,匹配异常类型:
   - 抛出的是 RuntimeException → 匹配 handleRuntimeException
   - 若是 MethodArgumentNotValidException → 匹配更具体的 handleValidationException
7. Spring 调用匹配到的方法,把异常对象 e 传进去
8. 方法 return ApiResponse.error(400, e.getMessage()) → 写成 HTTP 响应

对应代码分工:

 复制代码// AuthService — 只管「出错了」,不管怎么返回 JSON
throw new RuntimeException("用户名已存在");// GlobalExceptionHandler — 只管「怎么响应」,不用知道是谁抛的
@ExceptionHandler(RuntimeException.class)
public ApiResponse<Void> handleRuntimeException(RuntimeException e) {
    return ApiResponse.error(400, e.getMessage());
}

一句话throw 负责中断业务;@RestControllerAdvice + @ExceptionHandler 负责按异常类型接住并转成统一 JSON。这是 Spring 的全局异常处理机制,不是 Java 语言本身的「自动跳转」。

上篇讲过 @Valid 触发 DTO 字段校验。校验失败时,Spring 不会进 Service,而是直接抛 MethodArgumentNotValidException

 复制代码POST /api/auth/register  { "username": "", "password": "123" }@Valid 检查 RegisterRequest
        ↓
username 违反 @NotBlank → 校验失败
        ↓
抛出 MethodArgumentNotValidException(不会进入 AuthServiceGlobalExceptionHandler.handleValidationException()
        ↓
收集所有字段错误信息,用 "; " 拼接HTTP 400 + JSON

处理代码:

 复制代码String message = e.getBindingResult().getFieldErrors().stream()
        .map(FieldError::getDefaultMessage)   // 取 @NotBlank(message="...") 里的 message
        .collect(Collectors.joining("; "));   // 多个错误用分号连接
return ApiResponse.error(400, message);

示例响应:

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

两条链路对比

业务异常参数校验失败
异常类型RuntimeExceptionMethodArgumentNotValidException
触发位置Service 里 throwController 入参 @Valid
是否进入 Service是(抛异常前可能已执行部分逻辑)
message 来源e.getMessage()DTO 注解里的 message = "..."
处理方法handleRuntimeExceptionhandleValidationException

五、ApiResponse:统一响应格式

异常处理和正常接口共用同一个响应结构:

 复制代码public class ApiResponse<T> {
    private int code;       // 业务状态码
    private String message;
    private T data;    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(200, "success", data);
    }    public static <T> ApiResponse<T> error(int code, String message) {
        return new ApiResponse<>(code, message, null);
    }
}
场景codemessagedata
成功200"success" 或自定义实际数据
业务错误400"用户名已存在"null
校验失败400"评论内容不能为空"null

前端只需判断 code === 200 即成功,否则读 message 提示用户。


六、为什么用 RuntimeException 而不是自定义异常?

当前项目是简化写法:所有业务错误统一抛 RuntimeException,靠 message 字符串区分。

更规范的做法是自定义异常:

 复制代码public class BusinessException extends RuntimeException {
    private final int code;
    // ...
}

对本项目而言,RuntimeException + 全局处理器已经足够。要点是:不要在 Controller 里 try-catch,让异常自然抛到 GlobalExceptionHandler


七、JPA 实体:Java 类 ↔ 数据库表

整体关系

 复制代码@Entity 类(Java)          MySQL 表
─────────────────         ─────────────
Article.java        →     article 表
User.java           →     users 表
Comment.java        →     comment 表

JPA(Java Persistence API)负责:把 Java 对象映射成数据库表,把字段读写转换成 SQL

配置文件 application.yml

 复制代码spring:
  jpa:
    hibernate:
      ddl-auto: update    # 启动时自动建表/更新表结构
    show-sql: true        # 控制台打印 SQL

ddl-auto: update 表示:实体类改了,启动时自动 ALTER TABLE,开发阶段很方便(生产环境建议改为 validate 或用手动迁移)。


八、实体类上的注解

Article 为例: img_6a3f2406473f430.webp

 复制代码@Data
@Entity
@Table(name = "article")
public class Article {    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;    @Column(nullable = false, length = 200)
    private String title;    @Column(nullable = false, columnDefinition = "TEXT")
    private String content;    @Column(length = 500)
    private String summary;    @Column(nullable = false, length = 50)
    private String author;    private Long userId;    @CreationTimestamp
    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;    @UpdateTimestamp
    @Column(nullable = false)
    private LocalDateTime updatedAt;
}

类级别注解

注解作用示例
@Entity标记这是数据库实体,JPA 会管理它必须有,否则不映射
@Table(name = "article")指定表名不写则默认用类名 Article
@DataLombok:自动生成 getter/setter不是 JPA 注解

User 表名用 users 而不是 user,因为 user 在 MySQL 中是保留字:

 复制代码@Table(name = "users")
public class User { ... }

九、@Id@GeneratedValue:主键怎么来

 复制代码@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
注解含义
@Id这是主键字段
@GeneratedValue主键值自动生成,不用自己设
strategy = GenerationType.IDENTITY策略:用数据库自增(MySQL 的 AUTO_INCREMENT

strategy 是什么意思?

strategy@GeneratedValue参数名,意思是「用哪种策略生成主键」。

 复制代码@GeneratedValue(strategy = GenerationType.IDENTITY)   // 数据库自增
@GeneratedValue(strategy = GenerationType.SEQUENCE)     // 数据库序列(PostgreSQL)
@GeneratedValue(strategy = GenerationType.AUTO)        // 让 JPA 自动选择

本项目用 MySQL,选 IDENTITY 最合适。

插入时的流程

 复制代码Article article = new Article();
article.setTitle("我的第一篇文章");
// 此时 article.getId() == null,不用管 idarticleRepository.save(article);
// 数据库执行:INSERT INTO article (title, ...) VALUES (...)
// 数据库自动生成 id = 1
// JPA 把 id 回填:article.getId() == 1

等价 SQL:

 复制代码CREATE TABLE article (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,  -- @Id + @GeneratedValue(IDENTITY)
    ...
);

几种主键策略对比

策略谁生成 id典型数据库
IDENTITY数据库插入时自增MySQL、SQL Server
SEQUENCE数据库序列 nextvalPostgreSQL、Oracle
AUTOJPA 按数据库自动选通用
TABLE单独一张表维护 id较少用

十、@Column:列的类型与约束

@Column 描述普通字段对应数据库列的规则(主键一般用 @Id + @GeneratedValue,不必再加 @Column)。

 复制代码@Column(nullable = false, length = 200)
private String title;
属性含义对应 SQL
nullable = false不允许为空NOT NULL
length = 200字符串最大长度VARCHAR(200)
unique = true值必须唯一UNIQUE
columnDefinition = "TEXT"自定义列类型TEXT
updatable = false插入后不允许更新更新 SQL 不包含该列

项目中的实际用法

 复制代码// User.java — 登录名唯一、最长 50
@Column(nullable = false, unique = true, length = 50)
private String username;// Article.java — 正文用大文本类型
@Column(nullable = false, columnDefinition = "TEXT")
private String content;// Article.java — 创建时间:必填、不可更新
@CreationTimestamp
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;

不写 @Column

 复制代码private Long userId;   // 无 @Column

JPA 使用默认规则:字段名 userId → 列名 user_id(驼峰转下划线),类型按 Java 类型推断。

@Column@GeneratedValue 的区别

@Column@GeneratedValue
管什么列的类型、长度、是否必填主键 id 怎么自动生成
用在哪普通字段(也可用于主键,但少见)@Id 主键
类比 SQLVARCHAR(200) NOT NULLAUTO_INCREMENT

十一、时间字段:@CreationTimestamp@UpdateTimestamp

 复制代码@CreationTimestamp
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;@UpdateTimestamp
@Column(nullable = false)
private LocalDateTime updatedAt;
注解作用
@CreationTimestamp插入时自动填入当前时间
@UpdateTimestamp每次更新时自动刷新为当前时间

代码里不用手动 setCreatedAt(new Date()),保存时 Hibernate 自动处理。

updatable = false 保证 createdAt 创建后不会被改掉。


十二、三张表对照

users 表(User.java

 复制代码@Entity
@Table(name = "users")
public class User {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(nullable = false, unique = true, length = 50)
    private String username;
    @Column(nullable = false)
    private String password;      // BCrypt 密文
    @Column(nullable = false, length = 50)
    private String nickname;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}

article 表(Article.java

字段类型说明
idLong 自增主键
titleVARCHAR(200) NOT NULL标题
contentTEXT NOT NULL正文
summaryVARCHAR(500)摘要,可空
authorVARCHAR(50) NOT NULL作者昵称(冗余展示)
userIdBIGINT作者用户 ID
createdAt / updatedAtDATETIME NOT NULL创建/更新时间

comment 表(Comment.java

 复制代码@Entity
@Table(name = "comment")
public class Comment {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(nullable = false)
    private Long articleId;       // 属于哪篇文章
    private Long parentId;        // 父评论 ID,顶级评论为 null
    @Column(nullable = false, columnDefinition = "TEXT")
    private String content;
    @Column(nullable = false, length = 50)
    private String author;
    private LocalDateTime createdAt;
}

十三、Entity 与 DTO 为什么要分开?

上篇讲过 CommentRequestComment 实体的区别,这里从数据库角度补充:

Entity(实体)DTO(请求/响应对象)
包路径com.blog.entitycom.blog.dto
注解@Entity @Column 等 JPA 注解@NotBlank @Size 等校验注解
用途映射数据库表接收/返回 API 数据
id有(数据库自增)一般没有
passwordUserUserResponse 没有(安全)

原则:数据库结构归 Entity,API 入参归 DTO,不要混用。


十四、Repository:怎么操作数据库

 复制代码public interface ArticleRepository extends JpaRepository<Article, Long> {
}

JpaRepository<Article, Long> 中:

  • Article:操作的实体类型
  • Long:主键类型(对应 @Id private Long id

继承后自动拥有常用方法,无需写 SQL:

方法作用
save(article)新增或更新
findById(id)按主键查询
findAll()查全部
deleteById(id)按主键删除
existsById(id)判断是否存在

UserRepository 还支持按方法名自动生成查询:

 复制代码Optional<User> findByUsername(String username);   // SELECT * FROM users WHERE username = ?
boolean existsByUsername(String username);        // SELECT COUNT(*) > 0 ...

CommentRepository

 复制代码List<Comment> findByArticleIdOrderByCreatedAtAsc(Long articleId);
// SELECT * FROM comment WHERE article_id = ? ORDER BY created_at ASC

方法名即查询:findBy + 字段名 + OrderBy + 排序字段 + Asc/Desc


十五、一次完整的「发表评论」数据流

把本篇和上篇串起来:

 复制代码1. POST /api/articles/5/comments
   Body: { "content": "不错" }
   Header: Authorization: Bearer <token>2. JwtAuthenticationFilter 解析 token → 当前用户写入 SecurityContext3. Controller: @PathVariable articleId=5, @Valid @RequestBody CommentRequest
   → 若 content 为空 → MethodArgumentNotValidException → GlobalExceptionHandler → 4004. CommentService.create(5, request)
   → articleRepository.existsById(5) == false
   → throw new RuntimeException("文章不存在")
   → GlobalExceptionHandler → 400 "文章不存在"5. 校验通过:
   Comment comment = new Comment();
   comment.setArticleId(5);
   comment.setContent("不错");
   comment.setAuthor(当前用户昵称);
   commentRepository.save(comment);
   → INSERT INTO comment (...) VALUES (...)
   → id 由数据库 AUTO_INCREMENT 生成
   → createdAt 由 @CreationTimestamp 自动填入6. 返回 ApiResponse.success("评论成功", comment)

十六、常见问题

Q1:throw new RuntimeExceptionreturn ApiResponse.error 有什么区别?

  • throw:在 Service 里表示「出错了,中断执行」,由 GlobalExceptionHandler 统一处理
  • return ApiResponse.error:在 Handler 里构造最终 JSON

Service 层只负责 throw,不要自己写 return error

Q1.1:为什么 throw new RuntimeException 能触发 handleRuntimeException

不是 throw 直接调用了那个方法,而是 Spring 在异常向上冒泡时,根据 @ExceptionHandler(RuntimeException.class) 按类型匹配后自动调用。详见上文 「重要:throw 并不是直接调用 handleRuntimeException 一节。

不会。@Valid 在 Controller 入参绑定阶段就失败了,直接走 handleValidationException

Q3:strategy = GenerationType.IDENTITY@Column 能写在同一个字段上吗?

主键字段一般写:

 复制代码@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

不需要再加 @Column@GeneratedValue 管 id 怎么生成,@Column 管列约束,职责不同。

Q4:为什么 saveid 从 null 变成有值?

IDENTITY 策略下,数据库执行 INSERT 后生成自增 id,JPA 会把这个值回填到 Java 对象的 id 字段。

Q5:生产环境 ddl-auto: update 安全吗?

开发阶段方便,但生产环境建议改为 validate(只校验不修改)或使用 Flyway/Liquibase 做版本化迁移,避免实体改动意外改表结构。


十七、本篇小结

主题关键知识点
全局异常@RestControllerAdvice + @ExceptionHandler + @ResponseStatus
业务错误Service throw new RuntimeException("消息") → Handler 转 ApiResponse.error
参数校验@Valid 失败 → MethodArgumentNotValidException → 拼接字段 message
实体映射@Entity @Table 类对应表
主键@Id + @GeneratedValue(strategy = IDENTITY) = 数据库自增
列约束@Column(nullable, length, unique, columnDefinition)
时间@CreationTimestamp / @UpdateTimestamp 自动维护
数据访问JpaRepository 继承即得 CRUD,方法名可自动生成查询

相关文章

精彩推荐