上篇:Java 后端详解(二):注解、参数绑定、评论与用户认证
本篇以
GlobalExceptionHandler和entity包为例,讲清:异常如何统一返回给前端 、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 为什么要分开?
上篇讲过 CommentRequest 和 Comment 实体的区别,这里从数据库角度补充:
| 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 RuntimeException 和 return 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:为什么 save 后 id 从 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,方法名可自动生成查询 |