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

上篇:Java 后端详解(二):注解、参数绑定、评论与用户认证

入门:项目介绍-Spring-Boot-Vue博客系统

本篇以 GlobalExceptionHandlerentity 包为例,讲清:异常如何统一返回给前端Java 类如何映射成数据库表


一、本篇要解决的问题

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

二、GlobalExceptionHandler 全貌

java 复制代码
@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 的)。

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

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

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

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

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

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

位置 抛出的异常
AuthService.register throw new RuntimeException("用户名已存在")
AuthService.login throw new RuntimeException("用户名或密码错误")
CommentService.create throw new RuntimeException("文章不存在")
ArticleService.delete throw new RuntimeException("无权操作该文章")
UserService throw new RuntimeException("未登录")

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

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

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

最终前端收到的响应:

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

四、两条异常处理链路

链路 A:业务异常(RuntimeException

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

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

对应代码:

java 复制代码
// 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 会调用 handleRuntimeException Service 里只负责抛异常 ,不会、也不能写 handleRuntimeException(...)
方法名要对上才会处理 Spring 看的是异常类型RuntimeException.class),不是方法名
Handler 和 Service 有直接引用关系 两者互不 import,完全由 Spring 在运行时串联

完整机制如下:

markdown 复制代码
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 响应

对应代码分工:

java 复制代码
// 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 语言本身的「自动跳转」。

若同时存在多个 @ExceptionHandler,Spring 会优先匹配最具体 的子类。例如 MethodArgumentNotValidException 也是 RuntimeException 的子类,但校验失败会走 handleValidationException,而不是 handleRuntimeException

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

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

处理代码:

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

示例响应:

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

两条链路对比

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

五、ApiResponse:统一响应格式

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

java 复制代码
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);
    }
}
场景 code message data
成功 200 "success" 或自定义 实际数据
业务错误 400 "用户名已存在" null
校验失败 400 "评论内容不能为空" null

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


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

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

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

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

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


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

整体关系

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

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

配置文件 application.yml

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

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


八、实体类上的注解

Article 为例:

java 复制代码
@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
@Data Lombok:自动生成 getter/setter 不是 JPA 注解

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

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

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

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

strategy 是什么意思?

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

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

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

插入时的流程

java 复制代码
Article article = new Article();
article.setTitle("我的第一篇文章");
// 此时 article.getId() == null,不用管 id

articleRepository.save(article);
// 数据库执行:INSERT INTO article (title, ...) VALUES (...)
// 数据库自动生成 id = 1
// JPA 把 id 回填:article.getId() == 1

等价 SQL:

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

几种主键策略对比

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

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

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

java 复制代码
@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 不包含该列

项目中的实际用法

java 复制代码
// 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

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

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

@Column@GeneratedValue 的区别

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

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

java 复制代码
@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

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

字段 类型 说明
id Long 自增 主键
title VARCHAR(200) NOT NULL 标题
content TEXT NOT NULL 正文
summary VARCHAR(500) 摘要,可空
author VARCHAR(50) NOT NULL 作者昵称(冗余展示)
userId BIGINT 作者用户 ID
createdAt / updatedAt DATETIME NOT NULL 创建/更新时间

comment 表(Comment.java

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.entity com.blog.dto
注解 @Entity @Column 等 JPA 注解 @NotBlank @Size 等校验注解
用途 映射数据库表 接收/返回 API 数据
id 有(数据库自增) 一般没有
password User UserResponse 没有(安全)

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


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

java 复制代码
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 还支持按方法名自动生成查询:

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

CommentRepository

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

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


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

把本篇和上篇串起来:

sql 复制代码
1. POST /api/articles/5/comments
   Body: { "content": "不错" }
   Header: Authorization: Bearer <token>

2. JwtAuthenticationFilter 解析 token → 当前用户写入 SecurityContext

3. Controller: @PathVariable articleId=5, @Valid @RequestBody CommentRequest
   → 若 content 为空 → MethodArgumentNotValidException → GlobalExceptionHandler → 400

4. 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 能写在同一个字段上吗?

主键字段一般写:

java 复制代码
@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,方法名可自动生成查询

相关推荐
前端Hardy1 小时前
又一个 AI 神器火了!
前端·javascript·后端
NE_STOP1 小时前
vibe Coding -- 小项目实战
java
神奇小汤圆2 小时前
面试被问烂的Java虚拟机调优,我用一个实战案例给你讲得明明白白
后端
明月_清风3 小时前
开发者网络概念全扫盲:一篇搞定
后端·网络协议
明月_清风3 小时前
零信任入门:从"城堡护城河"到"每次进门都要刷卡"
后端
站大爷IP4 小时前
Python循环中修改字典键导致遍历异常深度解析实战案例
后端
掘金者阿豪7 小时前
高可用读写分离实战(二):我把数据库主库停了,结果整个集群的反应和我想象的不一样
后端
掘金者阿豪7 小时前
《高可用读写分离集群实战》系列(一)
后端