SpringBoot4 云端咖啡站 阶段二:数据访问与分层架构
第 4 章:连接 MySQL 与 JdbcTemplate
本章目标
- 理解连接池存在的意义,认识 HikariCP
- 配置 datasource 四要素(url/username/password/driver)
- 掌握 JdbcTemplate 的 query/update 两套方法与 RowMapper 映射
- 用 schema.sql / data.sql 实现启动时自动初始化表结构和数据
- 理解
?占位符为什么能防 SQL 注入
上一章的痛点:数据在内存 List 里,重启就清零。
本章接入 MySQL------从此咖啡站有了"记忆"。
知识点讲解
为什么需要连接池
一次数据库操作的完整流程:建立 TCP 连接 → 认证 → 执行 SQL → 断开。建连+认证要几十毫秒,而 SQL 本身可能只要 1 毫秒------大部分时间浪费在握手。
连接池的思路:启动时就建好一批连接放在"池子"里,用完归还、复用,不销毁。

没有池:请求1 [建连→认证→SQL→断开] 请求2 [建连→认证→SQL→断开] ...
有池后:启动时建好 N 个连接
请求1 [借一个→SQL→还回去]
请求2 [借一个→SQL→还回去] ← 建连成本只花一次
Spring Boot 内置 HikariCP(日语"光",业界公认最快的连接池),你不用装任何东西,配好 url 它就开始工作。启动日志里这两行就是它在干活:
HikariPool-1 - Starting...
HikariPool-1 - Start completed.
常用参数:maximum-pool-size(池上限,默认 10)、minimum-idle(常备空闲数)、max-lifetime(连接寿命,必须小于 MySQL 的 wait_timeout)。
JDBC 连接串解剖
yaml
url: jdbc:mysql://118.31.221.165:3306/sb_coffee?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
└┬─┘ └─┬─┘ └──────┬──────┘ └┬┘ └───┬──┘ └────────────── 参数区 ─────────────┘
协议 子协议 主机 端口 库名
useSSL=false:教学环境不加密传输(生产建议开启)allowPublicKeyRetrieval=true:MySQL 8 默认认证插件需要取服务器公钥serverTimezone=Asia/Shanghai:不定时区,时间字段会差 8 小时
JdbcTemplate:两套方法走天下
JdbcTemplate 是 Spring 对原生 JDBC 的薄封装。原生 JDBC 要自己拿连接、建 Statement、遍历 ResultSet、关资源、处理受检异常;JdbcTemplate 全部代劳,你只剩两件事:
| 你要做的事 | 对应 API |
|---|---|
| 写 SQL + 传参数 | query(sql, rowMapper, args...) ------ SELECT 系 |
| 定义"一行→对象"怎么映射 | RowMapper lambda |
SELECT 用 query(返回 List):
java
List<Coffee> list = jdbc.query("SELECT ... FROM coffee_menu WHERE name LIKE ?",
MAPPER, "%" + keyword + "%");
// ↑ SQL ↑ 占位符参数按顺序填入
INSERT/UPDATE/DELETE 用 update(返回受影响行数):
java
int rows = jdbc.update("UPDATE coffee_menu SET price = ? WHERE id = ?", price, id);
boolean exists = rows > 0; // 0 行受影响 = 目标不存在
? 占位符与 SQL 注入
java
// 危险写法:字符串拼接(用户输入 "'; DROP TABLE coffee_menu; --" 会怎样?)
String sql = "SELECT * FROM coffee_menu WHERE name = '" + input + "'";
// 安全写法:占位符(参数只当"数据"处理,永远不会被当成 SQL 语法执行)
String sql = "SELECT * FROM coffee_menu WHERE name = ?";
jdbc.query(sql, MAPPER, input);
原理:预编译语句(PreparedStatement)先把 SQL 结构发给数据库编译好,参数后补------代码与数据彻底分离。本教程所有 SQL 一律占位符。
注意一个细节:LIKE 模糊查询的
%要拼在参数值 里("%" + keyword + "%"),而不是拼进 SQL 字符串------拼值是安全的,拼 SQL 不安全。
schema.sql / data.sql 启动初始化
yaml
spring.sql.init:
mode: always # 每次启动都执行
schema-locations: classpath:chapter04/schema.sql # 先建表
data-locations: classpath:chapter04/data.sql # 后插数据
配套要求脚本必须幂等 (重复执行结果不变):建表用 CREATE TABLE IF NOT EXISTS;插数据用 INSERT ... WHERE NOT EXISTS 防重。
本项目约定:第 04 章把全部表结构一次建成最终形态(含后面章节才用的 image/stock 列、订单三件套、member 表),之后任何章节不再改表------保证任意一章启动都能直接跑。
完整代码(最终版)
本章共 5 个新文件 + application.yaml 新增 ch04 段。
src/main/resources/application.yaml(新增部分)
yaml
# ============================================================
# 【第 04 章】数据库段:HikariCP 连接池 + SQL 脚本初始化
# ============================================================
---
spring:
config:
activate:
on-profile: ch04
# 覆盖公共段的"排除自动配置":本章起真的要连数据库了
autoconfigure:
exclude: []
datasource:
# JDBC 连接串:协议 //主机:端口/库名;参数部分:
# useSSL=false 教学环境不启用 SSL 加密连接
# allowPublicKeyRetrieval=true MySQL8+ 认证需要允许取公钥
# serverTimezone 指定时区,避免时间字段差 8 小时
url: jdbc:mysql://118.31.221.165:3306/sb_coffee?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
username: root
password: lihaozhe
# 驱动类可省略(url 能推断),显式写出便于初学者理解
driver-class-name: com.mysql.cj.jdbc.Driver
# HikariCP 是 Spring Boot 默认连接池(业界最快),常用调优项:
hikari:
# 池内最大连接数:并发请求超过这个数就要排队等连接
maximum-pool-size: 10
# 最小空闲连接:池里常备的"待命"连接数
minimum-idle: 2
# 连接最长空闲时间,超时回收(毫秒)
idle-timeout: 600000
# 连接最大存活时间(必须小于 MySQL 的 wait_timeout)
max-lifetime: 1800000
# SQL 初始化脚本:应用启动时自动执行(幂等脚本,重复执行不出错)
sql:
init:
# always=每次启动都执行(配合 IF NOT EXISTS 幂等写法)
mode: always
# 脚本位置:schema 先建表、data 再插初始数据
schema-locations: classpath:chapter04/schema.sql
data-locations: classpath:chapter04/data.sql
# MyBatis 相关(starter 已引入,先关掉 mapper 扫描避免警告;
# 第 06 章才正式使用 MyBatis)
mybatis:
mapper-locations: classpath*:chapter06/*.xml
逐段说明:
autoconfigure.exclude: []:空列表覆盖公共段的排除名单------Profile 叠加时列表整体替换,正好用来"解除封印"- datasource 四要素 + hikari 调优项,注释已逐条解释
- sql.init 的两个脚本有先后顺序:先 schema 后 data
mybatis.mapper-locations指向还不存在的目录没关系(MyBatis 允许空列表);第 6 章会真正用到
src/main/resources/chapter04/schema.sql
sql
-- ============================================================
-- 第 04 章:咖啡菜单表结构(幂等脚本:IF NOT EXISTS,可重复执行)
--
-- 表设计说明:
-- 本表从第 04 章一直用到第 17 章,字段一次建全(含后续章节
-- 要用的 image 列),后续章节不再改表------这是教学项目的约定。
-- ============================================================
-- 咖啡饮品菜单表
CREATE TABLE IF NOT EXISTS coffee_menu (
-- 自增主键:数据库自动分配 id,对应第 02 章手写的 AtomicLong
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '商品id',
-- VARCHAR(50):名称不会太长;NOT NULL 禁止空值
name VARCHAR(50) NOT NULL COMMENT '饮品名称',
-- DECIMAL(10,2):总共 10 位、小数 2 位------数据库里金额的标准类型,
-- 与 Java 的 BigDecimal 对应(绝不用 FLOAT/DOUBLE 存钱)
price DECIMAL(10,2) NOT NULL COMMENT '价格(元)',
description VARCHAR(200) NULL COMMENT '风味简介',
-- 第 12 章文件上传后存的图片访问路径(先占位)
image VARCHAR(255) NULL COMMENT '图片路径',
-- 库存杯数:下单扣减(第 08 章事务的核心角色)
stock INT NOT NULL DEFAULT 0 COMMENT '库存杯数',
-- 时间戳由数据库维护,记录创建/修改时刻
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上架时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='咖啡菜单表';
-- 订单表(第 07/08/14 章使用;提前建好保证幂等)
CREATE TABLE IF NOT EXISTS coffee_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '订单id',
order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '订单号(对外展示)',
member_id BIGINT NULL COMMENT '会员id(未登录为NULL)',
total_amount DECIMAL(10,2) NOT NULL COMMENT '订单总额',
-- 订单状态:CREATED已创建/PAID已支付/CANCELLED已取消
status VARCHAR(16) NOT NULL DEFAULT 'CREATED' COMMENT '订单状态',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间',
paid_at DATETIME NULL COMMENT '支付时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='订单主表';
-- 订单明细表(一个订单多杯咖啡 → 一对多)
CREATE TABLE IF NOT EXISTS coffee_order_item (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '明细id',
order_id BIGINT NOT NULL COMMENT '所属订单id',
menu_id BIGINT NOT NULL COMMENT '饮品id(coffee_menu.id)',
menu_name VARCHAR(50) NOT NULL COMMENT '下单时的名称快照',
price DECIMAL(10,2) NOT NULL COMMENT '下单时的单价快照',
quantity INT NOT NULL COMMENT '杯数'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='订单明细表';
-- 会员表(第 11 章登录认证使用)
CREATE TABLE IF NOT EXISTS member (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '会员id',
phone VARCHAR(20) NOT NULL UNIQUE COMMENT '手机号(登录账号)',
password_hash VARCHAR(100) NOT NULL COMMENT 'BCrypt密码哈希',
nickname VARCHAR(50) NOT NULL COMMENT '昵称',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='会员表';
src/main/resources/chapter04/data.sql
sql
-- ============================================================
-- 第 04 章:初始菜单数据(幂等:用 INSERT ... WHERE NOT EXISTS 防重复)
-- ============================================================
-- 经典三款 + 库存。只有当同名饮品不存在时才插入。
INSERT INTO coffee_menu (name, price, description, stock)
SELECT '拿铁', 28.00, '浓缩咖啡与蒸汽牛奶的经典组合', 100
WHERE NOT EXISTS (SELECT 1 FROM coffee_menu WHERE name = '拿铁');
INSERT INTO coffee_menu (name, price, description, stock)
SELECT '美式', 22.00, '浓缩咖啡加热水,清爽纯粹', 100
WHERE NOT EXISTS (SELECT 1 FROM coffee_menu WHERE name = '美式');
INSERT INTO coffee_menu (name, price, description, stock)
SELECT '燕麦白', 32.00, '燕麦奶与浓缩的丝滑碰撞', 80
WHERE NOT EXISTS (SELECT 1 FROM coffee_menu WHERE name = '燕麦白');
说明 :INSERT ... SELECT ... WHERE NOT EXISTS 是 SQL 标准的幂等插入写法------SELECT 出来的行只有在"不存在同名饮品"时才会被插入。重启十次也只有三条数据。
src/main/java/com/lihaozhe/chapter04/Coffee.java
java
package com.lihaozhe.chapter04;
import java.math.BigDecimal;
/**
* 第 04 章:咖啡实体类 ------ 与数据库表 coffee_menu 一一对应。
*
* <p>与第 02 章的 Coffee 相比多了 stock/image 字段(表里建了)。</p>
*
* <p>金额字段依然用 BigDecimal:JDBC 驱动会把 DECIMAL 列
* 自动映射为 BigDecimal,精度无损。</p>
*/
public class Coffee {
/** 商品编号(对应表的自增主键 id) */
private Long id;
/** 饮品名称 */
private String name;
/** 价格 */
private BigDecimal price;
/** 风味简介 */
private String description;
/** 图片路径(第 12 章使用) */
private String image;
/** 库存杯数 */
private Integer stock;
public Coffee() {
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getImage() {
return image;
}
public void setImage(String image) {
this.image = image;
}
public Integer getStock() {
return stock;
}
public void setStock(Integer stock) {
this.stock = stock;
}
}
src/main/java/com/lihaozhe/chapter04/CoffeeDao.java
java
package com.lihaozhe.chapter04;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
/**
* 第 04 章:JdbcTemplate 版菜单仓库 ------ SQL 直接写在 Java 代码里。
*
* <p>JdbcTemplate 是 Spring 对原生 JDBC 的薄封装,替你管掉了三件最烦的事:</p>
* <ol>
* <li>获取/归还数据库连接(从 HikariCP 连接池)</li>
* <li>创建/关闭 Statement、处理 SQLException</li>
* <li>结果集 ResultSet → Java 对象的逐列搬运(配合 RowMapper)</li>
* </ol>
*
* <p>它的局限(下一章引出三层架构、第 6 章引出 MyBatis 的原因):
* 每个方法都要手写完整 SQL + 手动映射,样板代码多。</p>
*/
@Repository
public class CoffeeDao {
private final JdbcTemplate jdbc;
/**
* RowMapper:定义"结果集的一行 → Coffee 对象"的转换规则。
* rs.getString("name") 按【列名】取值(而不是列下标),SQL 改列序也不怕。
* 定义成静态常量:无状态,所有方法共用一份。
*/
private static final RowMapper<Coffee> MAPPER = (rs, rowNum) -> {
Coffee c = new Coffee();
c.setId(rs.getLong("id"));
c.setName(rs.getString("name"));
c.setPrice(rs.getBigDecimal("price"));
c.setDescription(rs.getString("description"));
c.setImage(rs.getString("image"));
c.setStock(rs.getInt("stock"));
return c;
};
/**
* 构造器注入 JdbcTemplate------它由 Boot 自动配置创建
* (看到 datasource 配置就自动 new 一个 JdbcTemplate Bean,连接来自 HikariCP)。
*/
public CoffeeDao(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
/** 查全部;? 占位符传参防注入(带参写法见 findByKeyword) */
public List<Coffee> findAll() {
String sql = """
SELECT id, name, price, description, image, stock
FROM coffee_menu ORDER BY id
""";
return jdbc.query(sql, MAPPER);
}
/**
* 按名称模糊搜索。LIKE 的通配符 % 拼在【参数值】里,
* 而不是拼在 SQL 字符串里------这是防 SQL 注入的关键习惯。
*/
public List<Coffee> findByKeyword(String keyword) {
String sql = "SELECT id, name, price, description, image, stock FROM coffee_menu WHERE name LIKE ? ORDER BY id";
return jdbc.query(sql, MAPPER, "%" + keyword + "%");
}
/** 按 id 查单个;query 返回空列表时用 stream 找第一个,包成 Optional */
public Optional<Coffee> findById(Long id) {
String sql = "SELECT id, name, price, description, image, stock FROM coffee_menu WHERE id = ?";
return jdbc.query(sql, MAPPER, id).stream().findFirst();
}
/**
* 新增;update() 用于 INSERT/UPDATE/DELETE,返回"受影响行数"。
* GeneratedKeyHolder 用来承接数据库自增生成的主键值。
*/
public Coffee insert(Coffee coffee) {
String sql = "INSERT INTO coffee_menu(name, price, description, stock) VALUES(?, ?, ?, ?)";
var keyHolder = new org.springframework.jdbc.support.GeneratedKeyHolder();
jdbc.update(connection -> {
// PreparedStatementCreator:拿到连接自己建语句;
// Statement.RETURN_GENERATED_KEYS 告诉驱动"我要取回自增id"
var ps = connection.prepareStatement(sql, java.sql.Statement.RETURN_GENERATED_KEYS);
ps.setString(1, coffee.getName());
ps.setBigDecimal(2, coffee.getPrice());
ps.setString(3, coffee.getDescription());
ps.setInt(4, coffee.getStock() == null ? 0 : coffee.getStock());
return ps;
}, keyHolder);
// 数据库生成的 id 回填到实体(调用方就能拿到新商品的编号)
coffee.setId(keyHolder.getKey().longValue());
return coffee;
}
/** 改价;受影响行数为 0 说明 id 不存在 */
public boolean updatePrice(Long id, BigDecimal price) {
String sql = "UPDATE coffee_menu SET price = ? WHERE id = ?";
return jdbc.update(sql, price, id) > 0;
}
/** 删除;同理用受影响行数判断存在性 */
public boolean deleteById(Long id) {
return jdbc.update("DELETE FROM coffee_menu WHERE id = ?", id) > 0;
}
}
逐行说明:
MAPPER静态常量:lambda 实现 RowMapper 接口;(rs, rowNum)是结果集光标和行号(行号一般用不上)findAll()里的"""..."""是 Java 15+ 的文本块,多行 SQL 不用再拼+insert()比 update 版复杂:因为要把自增 id 取回来,需要 KeyHolder 机制;第 6 章 MyBatis 里这坨代码会缩成一个useGeneratedKeys=trueupdate(...) > 0:把"受影响行数"翻译成业务语义"目标是否存在"
src/main/java/com/lihaozhe/chapter04/MenuController.java
java
package com.lihaozhe.chapter04;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 第 04 章:菜单接口(数据库版)。
*
* <p>与第 02 章的 MenuController 接口路径、参数完全一致------
* 但数据源从内存 List 换成了 MySQL。重启应用数据还在,这就是持久化。</p>
*/
@RestController
@RequestMapping("/api/menu")
public class MenuController {
private final CoffeeDao dao;
public MenuController(CoffeeDao dao) {
this.dao = dao;
}
/** GET /api/menu?keyword=xx → 列表/搜索;keyword 交给 SQL 的 LIKE 处理 */
@GetMapping
public List<Coffee> list(@RequestParam(required = false) String keyword) {
if (keyword == null || keyword.isBlank()) {
return dao.findAll();
}
return dao.findByKeyword(keyword);
}
/** GET /api/menu/{id} → 详情 */
@GetMapping("/{id}")
public Coffee detail(@PathVariable Long id) {
return dao.findById(id)
.orElseThrow(() -> new IllegalArgumentException("咖啡不存在: id=" + id));
}
/** POST /api/menu → 上架新品 */
@PostMapping
public Coffee create(@RequestBody Coffee coffee) {
if (coffee.getName() == null || coffee.getName().isBlank()) {
throw new IllegalArgumentException("饮品名称不能为空");
}
if (coffee.getPrice() == null || coffee.getPrice().signum() < 0) {
throw new IllegalArgumentException("价格必须为非负数");
}
return dao.insert(coffee);
}
/** 改价接口(演示 UPDATE):POST /api/menu/{id}/price,body 直接是数字 */
@PostMapping("/{id}/price")
public String changePrice(@PathVariable Long id, @RequestBody BigDecimal price) {
boolean ok = dao.updatePrice(id, price);
if (!ok) {
throw new IllegalArgumentException("咖啡不存在: id=" + id);
}
return "已改价 id=" + id + " → " + price;
}
/** DELETE /api/menu/{id} → 下架 */
@DeleteMapping("/{id}")
public String remove(@PathVariable Long id) {
boolean ok = dao.deleteById(id);
if (!ok) {
throw new IllegalArgumentException("咖啡不存在: id=" + id);
}
return "已下架 id=" + id;
}
}
src/main/java/com/lihaozhe/chapter04/CoffeeApplication4.java
java
package com.lihaozhe.chapter04;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 第 04 章:数据库章启动类。
*
* <p>激活 ch04 Profile 段后:</p>
* <ul>
* <li>autoconfigure.exclude 被覆盖为空 → DataSource/JdbcTemplate 自动配置生效</li>
* <li>HikariCP 按 datasource.url/username/password 建立连接池</li>
* <li>spring.sql.init 执行 schema.sql(建表)和 data.sql(插初始数据)</li>
* </ul>
*/
@SpringBootApplication
public class CoffeeApplication4 {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(CoffeeApplication4.class);
app.setAdditionalProfiles("ch04");
app.run(args);
}
}
运行验证
第 1 步:确认数据库可达
远程 MySQL 需要允许你的 IP 访问;本地库则把 yaml 的 url 改成 localhost:3306。启动前可先用任何 MySQL 客户端确认能连上并手动建库(若还没有):
sql
CREATE DATABASE IF NOT EXISTS sb_coffee CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
第 2 步:编译启动
bash
mvn compile exec:java -Dexec.mainClass=com.lihaozhe.chapter04.CoffeeApplication4
预期日志(关键三步):
HikariPool-1 - Starting... ← 连接池启动
HikariPool-1 - Start completed. ← 连接成功
Started CoffeeApplication4 in 4.xxx seconds ← 应用就绪
如果报 Communications link failure:数据库地址不通/防火墙拦截;报 Access denied:账号密码错。
第 3 步:curl 验证 CRUD(另开终端)
bash
# ① 查全部 ------ data.sql 已自动插了三款经典饮品
curl http://localhost:8080/api/menu
预期输出:
json
[{"description":"浓缩咖啡与蒸汽牛奶的经典组合","id":1,"image":null,"name":"拿铁","price":28.00,"stock":100},{"description":"浓缩咖啡加热水,清爽纯粹","id":2,"image":null,"name":"美式","price":22.00,"stock":100},{"description":"燕麦奶与浓缩的丝滑碰撞","id":3,"image":null,"name":"燕麦白","price":32.00,"stock":80}]
注意:字段顺序变了(description 在前)、多了 image/stock------这是数据库列的真实样子。
bash
# ② 上架新品(中文走文件避免终端编码问题)
printf '{"name":"\xe6\x91\xa9\xe5\x8d\xa1","price":30,"description":"\xe7\xbb\x8f\xe5\x85\xb8\xe6\x84\x8f\xe5\xbc\x8f\xe6\xb5\x93\xe7\xbc\xa9"}' > mocha.json
curl -X POST http://localhost:8080/api/menu -H "Content-Type: application/json; charset=utf-8" --data-binary @mocha.json
预期输出:{"description":"经典意式浓缩","id":4,...,"stock":null}------id=4 是数据库分配的(KeyHolder 取回)。
bash
# ③ 改价
printf '25.5' > price.json
curl -X POST http://localhost:8080/api/menu/4/price -H "Content-Type: application/json" --data-binary @price.json
预期输出:已改价 id=4 → 25.5
bash
# ④ 模糊搜索
curl "http://localhost:8080/api/menu?keyword=%E6%8B%BF%E9%93%81"
预期输出:只有拿铁一条。
bash
# ⑤ 下架
curl -X DELETE http://localhost:8080/api/menu/4
预期输出:已下架 id=4
第 4 步:见证持久化(本章灵魂实验)
Ctrl+C 停掉应用 → 重新执行第 2 步启动 → 再查:
bash
curl http://localhost:8080/api/menu
数据还在!对比第 02 章"重启即失忆"的内存版------这就是持久化的意义。(同时观察日志:sql.init 再次执行了 schema/data,但因为幂等脚本,数据没有被重复插入。)
验证完毕停止应用。
常见坑
| 现象 | 原因与解决 |
|---|---|
| Communications link failure | 主机不通:检查 IP/端口/防火墙/白名单 |
| Access denied for user | 账号或密码错误;远程库还要授权 root 远程访问 |
| Unknown database 'sb_coffee' | 忘了建库;先执行 CREATE DATABASE |
| 时间差 8 小时 | url 少了 serverTimezone=Asia/Shanghai |
| 表已存在但结构是旧的 | 教学项目约定不改表;确需变更时 DROP TABLE 再启动(会丢数据) |
| 中文入库变问号 | 库/表字符集不是 utf8mb4;对照 schema.sql 的 CHARSET 声明 |
自测题
- 为什么需要连接池?HikariCP 的 maximum-pool-size 和 max-lifetime 分别控制什么?
- JDBC url 的三个查询参数各解决什么问题?漏掉 serverTimezone 会怎样?
- JdbcTemplate 帮你省掉了原生 JDBC 的哪些工作?query 和 update 分别对应什么 SQL?
- RowMapper 的作用是什么?为什么按列名取值比按下标安全?
?占位符防注入的原理是什么?LIKE '%xx%' 的 % 应该写在哪里?- 什么是幂等脚本?schema.sql 和 data.sql 分别用了什么手法保证幂等?
下一章预告
看看现在的 CoffeeDao:Controller 里混着 SQL,一个 Dao 又当数据层又当业务层......代码能跑但已经隐隐发臭。下一章引入企业开发的标准姿势------三层架构(Controller/Service/Repository),顺便讲透 Spring 最核心的概念:IoC 容器与依赖注入。
第 5 章:三层架构与 IoC/DI
本章目标
- 理解 Controller / Service / Repository 三层的职责边界
- 讲透 Spring 的核心:IoC 容器、Bean、依赖注入(构造器注入)
- 掌握 Bean 生命周期与单例作用域
- 引入 record DTO 与简化版 Result<T> 统一响应
- 用 CommandLineRunner 观察容器的装配过程
上一章的痛点:SQL 混在 Controller 里、校验逻辑和查询逻辑搅在一起。
本章把代码按职责拆成三层,并借机讲清楚一个问题------我们从来没有 new 过 Repository,它是谁创建的?又是谁把它塞进 Service 构造器的?
知识点讲解
三层架构:接待员、店长、仓库管理员

为什么值得分层? 单个功能看是绕路了,但换来的好处立竿见影:
- 可替换:明天从 MySQL 换 PostgreSQL,Repository 重写,Service 一行不动
- 可复用:"上架新品"的业务规则写在 Service 里,Web 接口能调、定时任务也能调
- 可测试:测业务规则时可以给 Service 塞一个假的 Repository(第 15 章 MockMvc 实战)
IoC 容器与 Bean
IoC(Inversion of Control,控制反转):对象的创建权从"你自己 new"反转成"框架创建"。
传统写法:
java
CoffeeRepository repo = new CoffeeRepository(); // 你自己造
CoffeeService service = new CoffeeService(repo); // 你自己组装
Spring 方式:
java
@Service
public class CoffeeService {
private final CoffeeRepository repository;
public CoffeeService(CoffeeRepository repository) { // 只要声明"我需要"
this.repository = repository;
}
}
你没 new 过任何东西------IoC 容器 (ApplicationContext)启动时扫描所有带 @Component/@Service/@Repository/@RestController 注解的类,为每个类创建一个实例并放进容器 ,这些被容器管理的对象就叫 Bean。
容器怎么知道 CoffeeService 需要 Repository?看你构造器的参数。创建 Bean 时发现参数也是 Bean,就自动传进去------这就是 DI(Dependency Injection,依赖注入)。整个装配链条:
JdbcTemplate(自动配置)
↑ 注入
CoffeeRepository(扫描 @Repository 创建)
↑ 注入
CoffeeService(扫描 @Service 创建)
↑ 注入
MenuController(扫描 @RestController 创建)
理论:为什么叫"控制反转"?
正向:我的代码 new 我依赖的对象 → 我控制一切。
反转:框架创建对象、组装依赖 → 控制权交给框架。
收益:组件之间只依赖抽象的"需要什么",不关心"谁来造"。于是同一份代码,测试时注入假对象、生产时注入真实现,切换零成本。
三种注入方式对比:
| 方式 | 写法 | 评价 |
|---|---|---|
| 构造器注入 | 构造器参数接收 | 唯一推荐:字段可 final、依赖必填、方便测试 |
| Setter 注入 | setXxx 方法 | 可选依赖场景才用 |
| 字段注入 | @Autowired 打在字段上 | 老教程常见;无法 final、隐藏依赖,别学 |
Bean 生命周期与作用域
单例作用域(默认):每个 Bean 在整个容器里只有一个实例。200 个并发请求打进来,用的是同一个 CoffeeService------所以 Bean 必须无状态(不要在字段里存"当前用户"这类请求数据;第 11 章的 ThreadLocal 会再回到这个话题)。
生命周期三步:
1. 实例化 ------ 容器调用构造器(此刻完成依赖注入)
2. 初始化 ------ @PostConstruct 标注的方法(属性都就位了,做自检/预热)
3. 使用中 ...... 销毁前 ------ @PreDestroy 标注的方法(释放资源)
演示:本章在 CommandLineRunner 里打印 Bean 信息(见下文),亲眼看看容器里有什么。
record:不可变数据载体
Java 16+ 的 record 专为"装数据"而生:
java
public record Result<T>(int code, String message, T data) { }
一行自动获得:全参构造器、访问器 code()/message()/data()(不是 getCode!)、equals/hashCode/toString。不可变(没有 setter)------数据传输对象天然适合这种"创建后不该被改"的角色。
统一响应信封 Result<T>
上一章接口返回格式混乱。本章统一为三段式信封:
json
{ "code": 0, "message": "ok", "data": ... }
前端从此一套逻辑通吃:先判 code,再取 data。(错误时 code 是什么、message 从哪来,第 9 章的全局异常机制接手。)
完整代码(最终版)
本章共 6 个新文件 + application.yaml 新增 ch05 段。
src/main/resources/application.yaml(新增部分)
yaml
# ============================================================
# 【第 05 章】三层架构章:与 ch04 相同的数据库配置(复用同一套表)
# ============================================================
---
spring:
config:
activate:
on-profile: ch05
autoconfigure:
exclude: []
datasource:
url: jdbc:mysql://118.31.221.165:3306/sb_coffee?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
username: root
password: lihaozhe
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 2
idle-timeout: 600000
max-lifetime: 1800000
# 第 5 章不再执行建表脚本(第 4 章已建好),但保留初始数据幂等插入也无妨;
# 干脆全关------表和数据都在库里了
sql:
init:
mode: never
逐段说明:
- datasource 与 ch04 完全一致(同一个库同一套表);真实项目里这里会换成 dev/prod 不同地址
sql.init.mode: never:表已建好、初始数据已插入,不再重复执行脚本
src/main/java/com/lihaozhe/chapter05/Coffee.java
java
package com.lihaozhe.chapter05;
import java.math.BigDecimal;
/**
* 第 05 章:咖啡实体类(与表 coffee_menu 对应,同第 04 章结构)。
*/
public class Coffee {
/** 商品编号 */
private Long id;
/** 饮品名称 */
private String name;
/** 价格 */
private BigDecimal price;
/** 风味简介 */
private String description;
/** 图片路径 */
private String image;
/** 库存杯数 */
private Integer stock;
public Coffee() {
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getImage() {
return image;
}
public void setImage(String image) {
this.image = image;
}
public Integer getStock() {
return stock;
}
public void setStock(Integer stock) {
this.stock = stock;
}
}
src/main/java/com/lihaozhe/chapter05/Result.java
java
package com.lihaozhe.chapter05;
/**
* 第 05 章:统一响应封装(简化版)。
*
* <p>上一章的问题:接口返回格式五花八门------列表是数组、详情是对象、
* 删除是一句话、出错是框架的 500 页。前端没法写统一的处理逻辑。</p>
*
* <p>解法:所有接口都返回同一个"信封",真实数据装在 data 里:</p>
* <pre>
* { "code": 0, 0=成功,非 0=各类错误码
* "message": "ok", 人能读懂的提示
* "data": {...} 真正的业务数据
* }
* </pre>
*
* <p>泛型 <T> 的意义:data 的类型跟着业务走------查单个是 Coffee,
* 查列表是 List<Coffee>,编译期就能对上,不会拿错字段。</p>
*
* <p>用 record 定义:Java 16+ 的"不可变数据载体",一行顶过去一整个类
* (自动生成构造器/getter/equals/hashCode/toString)。
* 第 9 章会升级为完整版(加 ErrorCode 枚举与全局异常联动)。</p>
*/
public record Result<T>(int code, String message, T data) {
/** 成功 + 数据;message 统一给 "ok" */
public static <T> Result<T> ok(T data) {
return new Result<>(0, "ok", data);
}
/** 失败 + 错误码 + 提示;data 为 null */
public static <T> Result<T> fail(int code, String message) {
return new Result<>(code, message, null);
}
}
src/main/java/com/lihaozhe/chapter05/CoffeeCreateRequest.java
java
package com.lihaozhe.chapter05;
import java.math.BigDecimal;
/**
* 第 05 章:新增饮品的请求 DTO(record 演示)。
*
* <p>为什么不用 Coffee 实体直接接收?</p>
* 实体有 id/stock/image 等字段------上架时这些字段应该由系统管理,
* 不该让客户端随便传。DTO 只暴露"客户端真正该填的字段",
* 天然防止恶意传参(比如把库存改成负数)。
*/
public record CoffeeCreateRequest(
String name, // 饮品名称
BigDecimal price, // 价格
String description // 简介
) {
}
src/main/java/com/lihaozhe/chapter05/CoffeeRepository.java
java
package com.lihaozhe.chapter05;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
/**
* 第 05 章:Repository 层 ------ 只管数据存取,不掺任何业务判断。
*
* <p>与第 04 章的 CoffeeDao 代码几乎一样,但职责边界被严格限定:</p>
* <ul>
* <li>允许:写 SQL、映射结果集</li>
* <li>禁止:校验参数合法性、拼提示文案、决定"查不到怎么办"------那些是 Service 的事</li>
* </ul>
*
* <p>命名从 Dao 改为 Repository:Spring 的语义化注解 @Repository
* 本来就对应这个分层术语。</p>
*/
@Repository
public class CoffeeRepository {
private final JdbcTemplate jdbc;
/** "一行 → Coffee" 映射器(与第 04 章相同) */
private static final RowMapper<Coffee> MAPPER = (rs, rowNum) -> {
Coffee c = new Coffee();
c.setId(rs.getLong("id"));
c.setName(rs.getString("name"));
c.setPrice(rs.getBigDecimal("price"));
c.setDescription(rs.getString("description"));
c.setImage(rs.getString("image"));
c.setStock(rs.getInt("stock"));
return c;
};
public CoffeeRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
/** 查全部 */
public List<Coffee> findAll() {
String sql = """
SELECT id, name, price, description, image, stock
FROM coffee_menu ORDER BY id
""";
return jdbc.query(sql, MAPPER);
}
/** 按名称模糊搜索 */
public List<Coffee> findByKeyword(String keyword) {
String sql = "SELECT id, name, price, description, image, stock FROM coffee_menu WHERE name LIKE ? ORDER BY id";
return jdbc.query(sql, MAPPER, "%" + keyword + "%");
}
/** 按 id 查单个;查不到返回空 Optional(怎么提示由上层决定) */
public Optional<Coffee> findById(Long id) {
String sql = "SELECT id, name, price, description, image, stock FROM coffee_menu WHERE id = ?";
return jdbc.query(sql, MAPPER, id).stream().findFirst();
}
/** 新增并回填自增 id */
public Coffee insert(Coffee coffee) {
String sql = "INSERT INTO coffee_menu(name, price, description, stock) VALUES(?, ?, ?, ?)";
var keyHolder = new org.springframework.jdbc.support.GeneratedKeyHolder();
jdbc.update(connection -> {
var ps = connection.prepareStatement(sql, java.sql.Statement.RETURN_GENERATED_KEYS);
ps.setString(1, coffee.getName());
ps.setBigDecimal(2, coffee.getPrice());
ps.setString(3, coffee.getDescription());
ps.setInt(4, coffee.getStock() == null ? 0 : coffee.getStock());
return ps;
}, keyHolder);
coffee.setId(keyHolder.getKey().longValue());
return coffee;
}
/** 改价 */
public boolean updatePrice(Long id, BigDecimal price) {
return jdbc.update("UPDATE coffee_menu SET price = ? WHERE id = ?", price, id) > 0;
}
/** 删除 */
public boolean deleteById(Long id) {
return jdbc.update("DELETE FROM coffee_menu WHERE id = ?", id) > 0;
}
}
src/main/java/com/lihaozhe/chapter05/CoffeeService.java
java
package com.lihaozhe.chapter05;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.stereotype.Service;
/**
* 第 05 章:Service 层 ------ 业务规则的唯一归属地。
*
* <p>它夹在 Controller 和 Repository 中间:</p>
* <pre>
* Controller 接待员:管 HTTP 协议(收参数、回 JSON)
* ↓ 调用
* Service 店长:定规矩(参数合法吗、查不到怎么办、组合多个操作)
* ↓ 调用
* Repository 仓库管理员:只管存取数据,不问为什么
* </pre>
*
* <p>本章 Service 承担的业务规则:</p>
* <ol>
* <li>上架前校验名称/价格(原来写在 Controller 里的 if 挪到这里)</li>
* <li>"咖啡不存在"统一翻译成 IllegalArgumentException(第 9 章会升级为业务异常)</li>
* <li>新增时补默认库存(业务决定:新品默认备货 50 杯)</li>
* </ol>
*/
@Service
public class CoffeeService {
private final CoffeeRepository repository;
/**
* 构造器注入:Spring 容器创建 CoffeeService 时,
* 发现构造器需要一个 CoffeeRepository 参数,
* 就自动把容器里那个 Repository Bean 传进来。
* (IoC/DI 的完整原理见本章"知识点讲解"。)
*/
public CoffeeService(CoffeeRepository repository) {
this.repository = repository;
}
/** 查全部菜单 */
public List<Coffee> getMenu() {
return repository.findAll();
}
/** 按关键字搜索 */
public List<Coffee> search(String keyword) {
return repository.findByKeyword(keyword);
}
/**
* 查详情;查不到时抛异常------"不存在算不算错误、错误怎么说"
* 是业务决策,所以放 Service。
*/
public Coffee getById(Long id) {
return repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("咖啡不存在: id=" + id));
}
/**
* 上架新品:DTO → 实体的转换 + 业务默认值 + 校验都在这里。
* Controller 只负责"把请求递进来",完全不知道这些规则的存在。
*/
public Coffee create(CoffeeCreateRequest request) {
// ---- 业务规则 1:必填与取值范围校验 ----
if (request.name() == null || request.name().isBlank()) {
throw new IllegalArgumentException("饮品名称不能为空");
}
BigDecimal price = request.price();
if (price == null || price.signum() < 0) {
throw new IllegalArgumentException("价格必须为非负数");
}
// record 的访问器是 name() 而不是 getName()------这是它的语法特点
// ---- DTO → 实体转换 ----
Coffee coffee = new Coffee();
coffee.setName(request.name());
coffee.setPrice(price);
coffee.setDescription(request.description());
coffee.setStock(50); // ---- 业务规则 2:新品默认备货 50 杯 ----
return repository.insert(coffee);
}
/** 改价:存在性判断在这里做 */
public void changePrice(Long id, BigDecimal price) {
if (price == null || price.signum() < 0) {
throw new IllegalArgumentException("价格必须为非负数");
}
if (!repository.updatePrice(id, price)) {
throw new IllegalArgumentException("咖啡不存在: id=" + id);
}
}
/** 下架:同样先确认存在 */
public void remove(Long id) {
if (!repository.deleteById(id)) {
throw new IllegalArgumentException("咖啡不存在: id=" + id);
}
}
}
src/main/java/com/lihaozhe/chapter05/MenuController.java
java
package com.lihaozhe.chapter05;
import java.util.List;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 第 05 章:菜单接口(三层架构 + 统一响应版)。
*
* <p>对比第 04 章,本 Controller 瘦身成功:</p>
* <ul>
* <li>没有一行 SQL、没有一个业务 if------那些都在 Service/Repository 里</li>
* <li>所有方法返回 Result<T> 信封,前端处理逻辑从此统一</li>
* </ul>
*/
@RestController
@RequestMapping("/api/menu")
public class MenuController {
/** Controller 只依赖 Service------它根本不知道 Repository 的存在(分层隔离) */
private final CoffeeService service;
public MenuController(CoffeeService service) {
this.service = service;
}
/** GET /api/menu?keyword=xx → Result<List<Coffee>> */
@GetMapping
public Result<List<Coffee>> list(@RequestParam(required = false) String keyword) {
List<Coffee> data = (keyword == null || keyword.isBlank())
? service.getMenu()
: service.search(keyword);
return Result.ok(data);
}
/** GET /api/menu/{id} → Result<Coffee> */
@GetMapping("/{id}")
public Result<Coffee> detail(@PathVariable Long id) {
return Result.ok(service.getById(id));
}
/**
* POST /api/menu → 上架。
* 注意入参是 CoffeeCreateRequest DTO 而不是实体------
* 客户端能填什么字段由 DTO 说了算。
*/
@PostMapping
public Result<Coffee> create(@RequestBody CoffeeCreateRequest request) {
return Result.ok(service.create(request));
}
/** POST /api/menu/{id}/price → 改价;body 是裸数字如 25.5 */
@PostMapping("/{id}/price")
public Result<String> changePrice(@PathVariable Long id, @RequestBody java.math.BigDecimal price) {
service.changePrice(id, price);
// 操作类接口的 data 放一句提示(也可以放 null)
return Result.ok("已改价 id=" + id + " → " + price);
}
/** DELETE /api/menu/{id} → 下架 */
@DeleteMapping("/{id}")
public Result<String> remove(@PathVariable Long id) {
service.remove(id);
return Result.ok("已下架 id=" + id);
}
}
src/main/java/com/lihaozhe/chapter05/CoffeeApplication5.java
java
package com.lihaozhe.chapter05;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 第 05 章:三层架构章启动类。
*
* <p>激活 ch05 段后,容器里会依次装配出这些 Bean(依赖链条):</p>
* <pre>
* JdbcTemplate(自动配置,来自 datasource)
* ↑ 注入
* CoffeeRepository(@Repository 扫描注册)
* ↑ 注入
* CoffeeService(@Service 扫描注册)
* ↑ 注入
* MenuController(@RestController 扫描注册)
* </pre>
*/
@SpringBootApplication
public class CoffeeApplication5 {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(CoffeeApplication5.class);
app.setAdditionalProfiles("ch05");
app.run(args);
}
}
运行验证
第 1 步:编译启动
bash
mvn compile exec:java -Dexec.mainClass=com.lihaozhe.chapter05.CoffeeApplication5
预期日志:
The following 1 profile is active: "ch05"
HikariPool-1 - Start completed.
Started CoffeeApplication5 in 3.xxx seconds
第 2 步:验证统一信封格式
bash
curl http://localhost:8080/api/menu
预期输出------注意最外层的 code/message/data 三段结构:
json
{"code":0,"message":"ok","data":[{"description":"浓缩咖啡与蒸汽牛奶的经典组合","id":1,"image":null,"name":"拿铁","price":28.00,"stock":100},{"description":"浓缩咖啡加热水,清爽纯粹","id":2,"image":null,"name":"美式","price":22.00,"stock":100},{"description":"燕麦奶与浓缩的丝滑碰撞","id":3,"image":null,"name":"燕麦白","price":32.00,"stock":80}]}
bash
# 详情
curl http://localhost:8080/api/menu/1
预期输出:{"code":0,"message":"ok","data":{"description":...,"id":1,...}}
第 3 步:验证 DTO 上架(stock 由服务端补默认值)
bash
printf '{"name":"\xe7\x91\xb0\xe5\x86\xb0\xe6\x8b\xbf\xe9\x93\x81","price":33,"description":"\xe7\x8e\xab\xe7\x91\xb0\xe8\x8a\xb1\xe4\xb8\x8e\xe5\x86\xb0\xe5\x8d\x9a\xe5\xa5\xb6"}' > rose.json
curl -X POST http://localhost:8080/api/menu -H "Content-Type: application/json; charset=utf-8" --data-binary @rose.json
预期输出(注意 stock=50------请求里根本没传,是 Service 补的业务默认值):
json
{"code":0,"message":"ok","data":{"description":"玫瑰花与冰博奶","id":5,"image":null,"name":"瑰冰拿铁","price":33,"stock":50}}
bash
# 清理演示数据
curl -X DELETE http://localhost:8080/api/menu/5
预期输出:{"code":0,"message":"ok","data":"已下架 id=5"}
验证完毕停止应用。
常见坑
| 现象 | 原因与解决 |
|---|---|
| 启动报找不到 CoffeeRepository 的 Bean | 类忘了加 @Repository;或包不在启动类的扫描范围内 |
| 循环依赖启动失败 | A 注入 B、B 又注入 A------重新设计职责划分,别用 @Lazy 绕 |
| record 反序列化失败 | Jackson 3 支持 record,但要确认字段名与 JSON key 一致 |
| Bean 里存了请求数据导致串数据 | 单例 Bean 被所有请求共享;请求数据只能放方法参数或局部变量 |
自测题
- 三层各自的职责是什么?"查不到时抛什么异常"应该由哪层决定?
- 什么是 IoC?什么是 DI?容器装配 MenuController→CoffeeService→CoffeeRepository 的链条是怎么形成的?
- 为什么推荐构造器注入而不是 @Autowired 字段注入?(至少两条理由)
- Bean 默认是什么作用域?这要求 Bean 必须满足什么设计约束?
- record 自动生成哪些成员?访问器和普通 getter 写法有何不同?
- DTO 和实体为什么要分开?让实体直接接请求体会冒出什么风险?
下一章预告
Repository 里六个方法,每个都要手写完整 SQL、手动 RowMapper 映射------insert 还要 KeyHolder 取主键,样板代码一大坨。这些活儿模式高度固定,能不能让框架替我们干?下一章引入 MyBatis 注解版:接口上标个注解,SQL 就位、映射全自动。
第 6 章:MyBatis 注解版
本章目标
- 理解 MyBatis 在数据访问层的定位:SQL 写在哪、谁来执行、怎么映射
- 掌握 @Mapper + @Select/@Insert/@Update/@Delete 四件套
- 掌握 #{} 占位符与 @Param 参数绑定
- 学会 useGeneratedKeys 取自增主键、LIMIT 手工分页
- 开启 SQL 日志,亲眼看到每条执行的 SQL
上一章的痛点:Repository 里每个方法都要手写 SQL 字符串 + RowMapper 映射,insert 还要 KeyHolder 一大坨。
本章引入 MyBatis------SQL 还是你写的(这是它和 JPA 的本质区别),但映射和执行全部自动化。
知识点讲解
MyBatis 是什么、为什么选它
MyBatis 是国内企业使用率最高的持久层框架,定位一句话:你写 SQL(写在注解或 XML 里),它管其余一切。
| 对比 | JdbcTemplate | MyBatis |
|---|---|---|
| SQL 写在哪 | Java 字符串 | 注解 / XML 文件 |
| 结果映射 | 手写 RowMapper | 自动(列名=字段名 或驼峰映射) |
| 自增主键回填 | KeyHolder 五行 | @Options 一行 |
| 动态 SQL | 字符串拼接(痛苦) | XML 标签(第 7 章) |
| 学习曲线 | 低 | 低-中 |
它的魔法是动态代理 :CoffeeMapper 是个接口,没有任何实现类。启动时 MyBatis 用 JDK 动态代理生成一个实现对象注册进容器;你调用 mapper.findById(1L) 时,代理对象拦截方法调用 → 找到方法上注解里的 SQL → 填参数 → 执行 → 按规则把结果集转成 Coffee 返回。(动态代理的完整原理第 10 章 AOP 会再讲一遍,那里是它的主场。)
四个基础注解
java
@Mapper // 标在接口上:请 MyBatis 生成实现并注册为 Bean
public interface CoffeeMapper {
@Select("SELECT ... WHERE id = #{id}") // SELECT → 方法返回 List/单个
Coffee findById(@Param("id") Long id);
@Insert("INSERT INTO ... VALUES(#{name}, ...)") // INSERT → int 受影响行数
@Options(useGeneratedKeys = true, keyProperty = "id")
int insert(Coffee coffee);
@Update("UPDATE ... SET price = #{price}") // UPDATE → int 受影响行数
int updatePrice(...);
@Delete("DELETE FROM ... WHERE id = #{id}") // DELETE → int 受影响行数
int deleteById(@Param("id") Long id);
}
#{} 占位符与 @Param
#{xxx}:安全占位符,底层就是 JDBC 的?预编译------防注入,永远用它${xxx}:字符串直接替换(有注入风险)------第 7 章专门对比,现在先记住别用
参数从哪来?三种情况:
- 单实体参数 :
insert(Coffee coffee)→#{name}直接读 coffee.getName() - 多个散参 :每个参数标
@Param("名字"),SQL 里按名引用 - 单散参可省略 @Param,但统一都写是企业规范
结果映射的自动规则
MyBatis 把结果集列名对号入座到实体字段:
- 列名
price↔ 字段price:完全同名,直接映射 - 列名
created_at↔ 字段createdAt:需要开驼峰映射
yaml
mybatis:
configuration:
map-underscore-to-camel-case: true # 下划线列名 → 驼峰字段
本教程的表没有驼峰列,但企业库几乎必有(created_at/updated_at/order_no),所以配置里提前开了。
LIMIT 手工分页
MySQL 分页语法:LIMIT 偏移量, 条数。第 page 页、每页 size 条 → 偏移量 (page-1)*size:
sql
LIMIT 0,5 -- 第 1 页
LIMIT 5,5 -- 第 2 页
LIMIT 10,5 -- 第 3 页
本章手算偏移量传入;真实项目会用 PageHelper 插件自动拼------但手工版能让你看清分页的本质就是一条 SQL。
完整代码(最终版)
本章共 4 个新文件 + application.yaml 新增 ch06 段。
src/main/resources/application.yaml(新增部分)
yaml
# ============================================================
# 【第 06 章】MyBatis 注解版:与 ch05 相同的数据库配置
# ============================================================
---
spring:
config:
activate:
on-profile: ch06
autoconfigure:
exclude: []
datasource:
url: jdbc:mysql://118.31.221.165:3306/sb_coffee?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
username: root
password: lihaozhe
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 2
idle-timeout: 600000
max-lifetime: 1800000
sql:
init:
mode: never
# MyBatis 专属配置:
mybatis:
# XML mapper 文件位置(第 07 章使用;注解版用不到,先声明规范位置)
mapper-locations: classpath*:chapter07/*.xml
configuration:
# 驼峰映射:数据库列 created_at ↔ Java 字段 createdAt 自动对齐。
# (本教程表里没有驼峰列,但这是企业项目必开的开关)
map-underscore-to-camel-case: true
# 控制台打印 SQL 日志------学习期强烈建议开着,能看到 MyBatis 发出的每条 SQL 和参数
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
# @Mapper 接口的扫描方式二选一:
# A. 每个接口标 @Mapper(本章用法,直观)
# B. 启动类标 @MapperScan("包名") 一网打尽(企业项目接口多时更省事)
src/main/java/com/lihaozhe/chapter06/Coffee.java
java
package com.lihaozhe.chapter06;
import java.math.BigDecimal;
/**
* 第 06 章:咖啡实体类(同第 04/05 章结构,对应表 coffee_menu)。
*/
public class Coffee {
/** 商品编号 */
private Long id;
/** 饮品名称 */
private String name;
/** 价格 */
private BigDecimal price;
/** 风味简介 */
private String description;
/** 图片路径 */
private String image;
/** 库存杯数 */
private Integer stock;
public Coffee() {
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getImage() {
return image;
}
public void setImage(String image) {
this.image = image;
}
public Integer getStock() {
return stock;
}
public void setStock(Integer stock) {
this.stock = stock;
}
}
src/main/java/com/lihaozhe/chapter06/CoffeeMapper.java
java
package com.lihaozhe.chapter06;
import java.math.BigDecimal;
import java.util.List;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/**
* 第 06 章:MyBatis 注解版菜单 Mapper ------ 一个接口替代整个 Repository 类。
*
* <p>对比第 05 章的 JdbcTemplate 版,三个变化:</p>
* <ol>
* <li>SQL 写在注解里,接口不用写实现------MyBatis 启动时为它生成动态代理对象</li>
* <li>结果映射全自动:列名与字段名一致(或开了驼峰映射)就不用手写 RowMapper</li>
* <li>取自增主键只需 @Options(useGeneratedKeys = true)------KeyHolder 那坨代码消失了</li>
* </ol>
*
* <p>@Mapper 告诉 MyBatis"给我生成实现并注册成 Spring Bean",
* 所以 Service 注入它的方式与注入普通 Bean 完全一样。</p>
*/
@Mapper
public interface CoffeeMapper {
/**
* 查全部。
* #{...} 是 MyBatis 的安全占位符(等价 JDBC 的 ?);
* SQL 里没有参数时可以不写占位符。
*/
@Select("""
SELECT id, name, price, description, image, stock
FROM coffee_menu ORDER BY id
""")
List<Coffee> findAll();
/**
* 按名称模糊搜索。
* 多个参数时用 @Param 给每个参数起名字,SQL 里按名引用。
*/
@Select("SELECT id, name, price, description, image, stock FROM coffee_menu WHERE name LIKE CONCAT('%', #{keyword}, '%') ORDER BY id")
List<Coffee> findByKeyword(@Param("keyword") String keyword);
/**
* 按 id 查单个;查不到时返回 null(调用方自己判断)。
* 列名 id/name/price... 与实体字段完全同名 → 映射零配置。
*/
@Select("SELECT id, name, price, description, image, stock FROM coffee_menu WHERE id = #{id}")
Coffee findById(@Param("id") Long id);
/**
* 新增;@Options 让 MyBatis 取回自增主键填进实体的 id 字段
* (等价第 05 章 KeyHolder 的全部工作)。
*/
@Insert("INSERT INTO coffee_menu(name, price, description, stock) VALUES(#{name}, #{price}, #{description}, #{stock})")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insert(Coffee coffee);
/**
* 改价。单个参数也可以不起 @Param 名,直接 #{price} 引用;
* 但企业规范建议统一都写上,可读性好。
*/
@Update("UPDATE coffee_menu SET price = #{price} WHERE id = #{id}")
int updatePrice(@Param("id") Long id, @Param("price") BigDecimal price);
/** 删除;返回受影响行数(0 = 不存在),语义与 JdbcTemplate 版相同 */
@Delete("DELETE FROM coffee_menu WHERE id = #{id}")
int deleteById(@Param("id") Long id);
/**
* 手工分页:LIMIT 偏移量, 每页条数。
* 第 1 页:LIMIT 0,5;第 2 页:LIMIT 5,5......偏移量 = (page-1)*size。
* (真实项目的分页插件 PageHelper 会自动拼 LIMIT,原理相同)
*/
@Select("""
SELECT id, name, price, description, image, stock
FROM coffee_menu ORDER BY id LIMIT #{offset}, #{size}
""")
List<Coffee> findPage(@Param("offset") int offset, @Param("size") int size);
}
逐行说明:
@Mapper:接口级标记,MyBatis 为它生成动态代理并注册成 BeanLIKE CONCAT('%', #{keyword}, '%'):模糊匹配的 % 放在 SQL 的 CONCAT 函数里拼参数值 ------比 Java 拼"%" + keyword + "%"更清晰,同样是安全的@Options(useGeneratedKeys = true, keyProperty = "id"):insert 后把数据库生成的自增 id 填进参数对象的 id 字段findPage:分页的两个数字也是参数,照旧走#{}占位符(有人担心 LIMIT 后能不能用占位符------能,因为它是值不是结构)
src/main/java/com/lihaozhe/chapter06/MenuController.java
java
package com.lihaozhe.chapter06;
import java.util.LinkedHashMap;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 第 06 章:菜单接口(MyBatis 注解版)。
*
* <p>为了突出"数据访问层的替换",本章故意把接口写薄:
* 简单查询直接走 Mapper(教学演示);复杂规则仍应放 Service(企业规范)。</p>
*/
@RestController
@RequestMapping("/api/menu")
public class MenuController {
private final CoffeeMapper mapper;
public MenuController(CoffeeMapper mapper) {
this.mapper = mapper;
}
/**
* GET /api/menu?keyword=xx&page=1&size=5
* 演示:搜索 + 手工分页二合一;不带分页参数则全量返回
*/
@GetMapping
public Object list(@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "0") int size) {
// page/size 用 defaultValue 兜底:不传就是 0,代表"不分页"
if (page > 0 && size > 0) {
int offset = (page - 1) * size; // 第 1 页偏移 0,第 2 页偏移 size......
List<Coffee> rows = (keyword == null || keyword.isBlank())
? mapper.findPage(offset, size)
: mapper.findByKeyword(keyword);
// 分页响应多带一个 page 信息,前端好显示"第几页"
return new LinkedHashMap<String, Object>() {{
put("page", page);
put("size", size);
put("rows", rows);
}};
}
if (keyword == null || keyword.isBlank()) {
return mapper.findAll();
}
return mapper.findByKeyword(keyword);
}
/** GET /api/menu/{id} → 详情;查不到返回 null(data 为 null 的信封留给第 9 章完善) */
@GetMapping("/{id}")
public Coffee detail(@PathVariable Long id) {
return mapper.findById(id);
}
/** POST /api/menu → 上架;返回的实体带数据库生成的 id */
@PostMapping
public Coffee create(@RequestBody Coffee coffee) {
if (coffee.getStock() == null) {
coffee.setStock(50); // 新品默认备货
}
mapper.insert(coffee); // useGeneratedKeys 已把自增 id 回填进 coffee
return coffee;
}
}
src/main/java/com/lihaozhe/chapter06/CoffeeApplication6.java
java
package com.lihaozhe.chapter06;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 第 06 章:MyBatis 注解版启动类。
*
* <p>激活 ch06 段后,MyBatis 自动配置生效:</p>
* <ul>
* <li>扫描所有 @Mapper 接口 → 生成动态代理实现并注册为 Bean</li>
* <li>map-underscore-to-camel-case 开启驼峰映射</li>
* <li>StdOutImpl 让 SQL 打印到控制台(学习期神器)</li>
* </ul>
*/
@SpringBootApplication
public class CoffeeApplication6 {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(CoffeeApplication6.class);
app.setAdditionalProfiles("ch06");
app.run(args);
}
}
运行验证
第 1 步:编译启动
bash
mvn compile exec:java -Dexec.mainClass=com.lihaozhe.chapter06.CoffeeApplication6
预期日志:
Started CoffeeApplication6 in 3.xxx seconds
第 2 步:查询验证(同时盯着应用的控制台!)
bash
curl http://localhost:8080/api/menu
预期 JSON 输出三款经典饮品;同时控制台打印了 SQL:
==> Preparing: SELECT id, name, price, description, image, stock FROM coffee_menu ORDER BY id
==> Parameters:
<== Total: 3
Preparing 是预编译的 SQL 模板,Parameters 是实际填入的占位符值,Total 是返回行数------这就是 MyBatis 干活的现场直播。
第 3 步:分页验证
bash
curl "http://localhost:8080/api/menu?page=1&size=2"
预期输出:
json
{"page":1,"size":2,"rows":[{..."id":1...},{..."id":2...}]}
只回了前两条。控制台对应 SQL 尾部多了 LIMIT ?,? 和参数 [0(Integer), 2(Integer)]。
第 4 步:新增验证(观察自增 id 回填)
bash
printf '{"name":"\xe6\xbe\xb3\xe7\x91\x9e\xe9\xbb\x91","price":36,"description":"\xe6\xbe\xb3\xe7\x91\x9e\xe5\xa5\xb6\xe6\xb2\xb9\xe4\xb8\x8e\xe6\xb5\x93\xe7\xbc\xa9"}' > aussie.json
curl -X POST http://localhost:8080/api/menu -H "Content-Type: application/json; charset=utf-8" --data-binary @aussie.json
预期输出(id=6 由数据库分配并通过 useGeneratedKeys 回填):
json
{"description":"澳瑞奶油与浓缩","id":6,"image":null,"name":"澳瑞黑","price":36,"stock":50}
控制台的 insert 日志:
==> Preparing: INSERT INTO coffee_menu(name, price, description, stock) VALUES(?, ?, ?, ?)
==> Parameters: 澳瑞黑(String), 36(BigDecimal), ...
验证完毕停止应用。
常见坑
| 现象 | 原因与解决 |
|---|---|
| BindingException: Parameter 'keyword' not found | 多参数没标 @Param;补上 |
| 实体某字段一直是 null | 列名与字段名不一致且没开驼峰映射 |
| insert 后 id 还是 null | 忘了 @Options(useGeneratedKeys=true);或 keyProperty 名字写错 |
| 接口注入报找不到 Bean | @Mapper 漏标;或该包不在扫描范围(跨章隔离所致,属正常设计) |
自测题
- MyBatis 与 JdbcTemplate 的核心区别是什么?"SQL 还是你写的"指什么?
- @Mapper 生成的实现类是谁?什么时候生成的?(提示:动态代理)
- #{} 和 ? 的关系是什么?为什么说它是安全的?
- 三种参数传递方式分别是什么场景?
- 驼峰映射解决什么问题?对应的配置项叫什么?
- LIMIT 分页中第 4 页、每页 3 条的偏移量是多少?
下一章预告
注解版很爽,直到你遇到这样的需求:"名称含 xx、价格在 xx 到 xx 之间、有库存才显示、支持按价格排序"------条件全是可选的,SQL 要根据传参动态拼装,注解里写 <script> 拼字符串会把人逼疯。
下一章把 SQL 搬进 XML 文件,用 <if><where><foreach> 标签优雅地搞定动态 SQL。
第 7 章:MyBatis XML 动态 SQL
本章目标
- 把 SQL 从注解搬进 XML:namespace 与 id 的绑定机制
- 掌握动态 SQL 四大标签:<where>、<if>、<set>、<foreach>
- 学会用 resultMap 自定义列映射、<sql>/<include> 消除重复
- 分清 #{} 与 ${} 的本质区别(SQL 注入现场演示)
- 写出"下单扣库存"流程,亲眼看见没有事务时的数据不一致隐患
上一章的痛点:条件全是可选的搜索没法优雅地写在注解里。
本章把 SQL 搬进 XML------标签化的动态拼装,是 MyBatis 真正的杀手锏。
知识点讲解
注解版 vs XML 版
| 注解版(第 6 章) | XML 版(本章) | |
|---|---|---|
| 适合 | 简单固定 SQL | 动态条件、复杂映射、长 SQL |
| 位置 | Java 接口里 | resources 下 .xml 文件 |
| 绑定方式 | 直接标在方法上 | namespace=接口全限定名,id=方法名 |
两者可以混用(一个接口一部分方法用注解、一部分用 XML),但企业规范通常统一走 XML,便于 DBA 审阅 SQL。
XML 怎么和接口对上?两把钥匙:
xml
<mapper namespace="com.lihaozhe.chapter07.CoffeeMapper"> <!-- 钥匙1:接口全限定名 -->
<select id="search" ...>...</select> <!-- 钥匙2:方法名 -->
</mapper>
四大动态标签
<if test="..."> ------ 条件成立才拼接片段:
xml
<if test="keyword != null and keyword != ''">
AND name LIKE CONCAT('%', #{keyword}, '%')
</if>
test 里写 OGNL 表达式(就是简化版的条件判断),and/or 用小写。
<where> ------ 智能包裹 WHERE:
- 内部一个条件都没拼 → 不输出 WHERE
- 拼了条件 → 自动加 WHERE,并剪掉开头多余的
AND
没有它你得自己写 <where>1=1 <if>AND ... 这种丑陋补丁。
<set> ------ UPDATE 的智能 SET:自动剪掉末尾多余的逗号(第 8 章"部分更新"场景会用到)。
xml
<update id="partialUpdate">
UPDATE coffee_menu
<set>
<if test="name != null">name = #{name},</if>
<if test="price != null">price = #{price},</if>
</set>
WHERE id = #{id}
</update>
<foreach> ------ 集合展开(IN 查询、批量插入):
xml
<select id="findByIds">
SELECT * FROM coffee_menu WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
<!-- ids=[1,3,5] 时生成:WHERE id IN (?, ?, ?) -->
resultMap:自定义映射
列名与字段名对不上时(老库、联表查询),显式声明映射规则:
xml
<resultMap id="coffeeMap" type="com.lihaozhe.chapter07.Coffee">
<id property="id" column="id"/> <!-- id 标签:主键列 -->
<result property="name" column="menu_name"/> <!-- 列 menu_name → 属性 name -->
</resultMap>
本章列名本来就一致,仍显式写出完整形态供你抄模板。
<sql>/<include>:公共片段复用。列清单写一次,多处 include:
xml
<sql id="columns">id, name, price, description, image, stock</sql>
...
SELECT <include refid="columns"/> FROM coffee_menu ...
#{} 与 ${}:一图看穿 SQL 注入
| #{} | ${} | |
|---|---|---|
| 底层动作 | 预编译占位符 ?,值后填 | 字符串直接替换进 SQL |
| 安全性 | 安全 | 有注入风险 |
| 适用 | 一切参数值 | 只能用于不可能被用户控制的表名列名等结构位 |
注入现场演示(想象有人写了 ${keyword}):
输入:"'; DROP TABLE coffee_menu; --"
拼出的 SQL:
SELECT * FROM coffee_menu WHERE name LIKE '%''; DROP TABLE coffee_menu; --'%'
└─ 正常查询结束 └─ 新语句!删表! └─ 注释掉尾巴
而 #{keyword} 会把它当成一个普通字符串参数去匹配,查不到就返回空列表------仅此而已。
结论:值永远走 #{}。${} 只在白名单校验后的排序字段这类结构位使用,且必须先校验合法值集合。
下单流程与事务预告
本章实现了下单三步:查商品 → 扣库存 → 生成订单。扣库存 SQL 有个防御细节:
sql
UPDATE coffee_menu SET stock = stock - #{qty} WHERE id = #{id} AND stock >= #{qty}
stock >= qty 放在 WHERE 里:库存不足时影响行数为 0,代码据此报"库存不足",永远不会扣成负数。
但整个流程没有事务保护------如果第②步成功、第③步失败,库存已经少了却没有订单产生。这个坑下一章 @Transactional 来填。
完整代码(最终版)
本章共 5 个新文件 + application.yaml 新增 ch07 段。
src/main/resources/application.yaml(新增部分)
yaml
# ============================================================
# 【第 07 章】MyBatis XML 动态 SQL:与 ch06 相同的数据库配置
# ============================================================
---
spring:
config:
activate:
on-profile: ch07
autoconfigure:
exclude: []
datasource:
url: jdbc:mysql://118.31.221.165:3306/sb_coffee?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
username: root
password: lihaozhe
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 2
idle-timeout: 600000
max-lifetime: 1800000
sql:
init:
mode: never
mybatis:
mapper-locations: classpath*:chapter07/*.xml
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
src/main/java/com/lihaozhe/chapter07/Coffee.java
java
package com.lihaozhe.chapter07;
import java.math.BigDecimal;
/**
* 第 07 章:咖啡实体(同前几章结构)。
*/
public class Coffee {
/** 商品编号 */
private Long id;
/** 饮品名称 */
private String name;
/** 价格 */
private BigDecimal price;
/** 风味简介 */
private String description;
/** 图片路径 */
private String image;
/** 库存杯数 */
private Integer stock;
public Coffee() {
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getImage() {
return image;
}
public void setImage(String image) {
this.image = image;
}
public Integer getStock() {
return stock;
}
public void setStock(Integer stock) {
this.stock = stock;
}
}
src/main/java/com/lihaozhe/chapter07/CoffeeQuery.java
java
package com.lihaozhe.chapter07;
import java.math.BigDecimal;
/**
* 第 07 章:菜单搜索条件对象。
*
* <p>动态查询的痛点:条件全是可选的------keyword 可能传、minPrice 可能传、
* inStock 可能传,也可能全不传。SQL 必须按"实际传了什么"动态拼装。</p>
*
* <p>把一堆散参数打包成一个对象传入 Mapper,是 MyBatis 动态 SQL 的标准姿势:
* XML 里用 #{keyword} 直接读对象的属性(通过 getter)。</p>
*
* <p>【为什么用"不可变对象 + 构造器"而不是 setter】
* 条件对象是纯粹的参数载体,创建后不该被改------构造器一次性赋值,
* 少一半代码且线程安全。(MyBatis 只要求有 getter,不强制 setter。)</p>
*/
public class CoffeeQuery {
/** 名称关键字(可 null = 不过滤) */
private final String keyword;
/** 价格下限(可 null) */
private final BigDecimal minPrice;
/** 价格上限(可 null) */
private final BigDecimal maxPrice;
/** 是否只看有库存的(null 视为不过滤) */
private final Boolean inStock;
public CoffeeQuery(String keyword, BigDecimal minPrice, BigDecimal maxPrice, Boolean inStock) {
this.keyword = keyword;
this.minPrice = minPrice;
this.maxPrice = maxPrice;
this.inStock = inStock;
}
public String getKeyword() {
return keyword;
}
public BigDecimal getMinPrice() {
return minPrice;
}
public BigDecimal getMaxPrice() {
return maxPrice;
}
public Boolean getInStock() {
return inStock;
}
}
src/main/java/com/lihaozhe/chapter07/CoffeeMapper.java
java
package com.lihaozhe.chapter07;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* 第 07 章:菜单 Mapper(XML 动态 SQL 版)。
*
* <p>接口里只剩方法签名------SQL 全部搬到 resources/chapter07/CoffeeMapper.xml。
* MyBatis 按"namespace = 接口全限定名" + "标签 id = 方法名"把两边对上。</p>
*
* <p>本章新增两个订单相关方法,为第 08 章事务做铺垫:</p>
* 扣库存(deductStock)和查商品(findById)将在下单流程里先后执行,
* "扣了库存但订单没建成"的事故正是下一章 @Transactional 要消灭的。
*/
@Mapper
public interface CoffeeMapper {
/** 动态条件查询(SQL 在 XML 里,用 where/if 标签拼装) */
List<Coffee> search(CoffeeQuery query);
/** 按 id 查单个(演示 resultMap 自定义映射) */
Coffee findById(@Param("id") Long id);
/**
* 扣减库存:stock = stock - qty。
* WHERE 里带 stock >= qty 条件:库存不足时受影响行数为 0,
* 上层据此判断"库存不够"而不是扣成负数。
*/
int deductStock(@Param("id") Long id, @Param("qty") int qty);
/** 恢复库存(取消订单时回滚库存用) */
int restoreStock(@Param("id") Long id, @Param("qty") int qty);
}
src/main/resources/chapter07/CoffeeMapper.xml
xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!--
第 07 章:菜单 Mapper 的 XML ------ 动态 SQL 的主场。
namespace 必须是接口的全限定名:MyBatis 靠它把 XML 和接口绑定。
每个语句标签的 id = 接口方法名,一一对应。
-->
<mapper namespace="com.lihaozhe.chapter07.CoffeeMapper">
<!--
resultMap:自定义"列 → 属性"映射规则。
本章表列名与字段同名,其实可以省略;这里显式写一遍,
是为了让你看到完整形态------遇到列名对不上的老表时就靠它。
-->
<resultMap id="coffeeMap" type="com.lihaozhe.chapter07.Coffee">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="price" column="price"/>
<result property="description" column="description"/>
<result property="image" column="image"/>
<result property="stock" column="stock"/>
</resultMap>
<!-- 公共列清单(sql 片段 + include 引用,消除重复) -->
<sql id="columns">id, name, price, description, image, stock</sql>
<!--
动态条件查询:
<where> 自动处理 WHERE 关键字与多余的 AND------内部有条件才输出 WHERE,
并把开头多余的 "AND" 剪掉
<if test> 条件成立才拼接该片段;test 里写 OGNL 表达式
-->
<select id="search" parameterType="com.lihaozhe.chapter07.CoffeeQuery"
resultMap="coffeeMap">
SELECT <include refid="columns"/>
FROM coffee_menu
<where>
<if test="keyword != null and keyword != ''">
AND name LIKE CONCAT('%', #{keyword}, '%')
</if>
<if test="minPrice != null">
AND price >= #{minPrice}
</if>
<if test="maxPrice != null">
AND price <= #{maxPrice}
</if>
<if test="inStock != null and inStock">
AND stock > 0
</if>
</where>
ORDER BY id
</select>
<!-- 按 id 查单个(使用上面的 resultMap) -->
<select id="findById" resultMap="coffeeMap">
SELECT <include refid="columns"/>
FROM coffee_menu
WHERE id = #{id}
</select>
<!-- 扣库存:WHERE 带 stock >= qty 保证不会扣成负数 -->
<update id="deductStock">
UPDATE coffee_menu
SET stock = stock - #{qty}
WHERE id = #{id} AND stock >= #{qty}
</update>
<!-- 恢复库存 -->
<update id="restoreStock">
UPDATE coffee_menu
SET stock = stock + #{qty}
WHERE id = #{id}
</update>
</mapper>
逐段说明:
- XML 里
>=/<=要写成实体引用>=/<=(<会破坏标签结构);也可以用 CDATA 包裹 <if test="inStock != null and inStock">:第二个inStock是布尔真值判断(null 判断防拆箱 NPE)- 生成的 SQL 在控制台可见:传什么条件拼什么片段,全不传时连 WHERE 都不出现
src/main/java/com/lihaozhe/chapter07/MenuController.java
java
package com.lihaozhe.chapter07;
import java.math.BigDecimal;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 第 07 章:动态搜索 + 下单接口(无事务版,故意留缺陷给第 08 章修)。
*/
@Slf4j
@RestController
@RequestMapping("/api")
public class MenuController {
private final CoffeeMapper mapper;
public MenuController(CoffeeMapper mapper) {
this.mapper = mapper;
}
/**
* GET /api/menu/search?keyword=拿铁&minPrice=20&inStock=true
* 条件全部可选------传什么拼什么,全不传就是全表。
*/
@GetMapping("/menu/search")
public List<Coffee> search(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) BigDecimal minPrice,
@RequestParam(required = false) BigDecimal maxPrice,
@RequestParam(required = false) Boolean inStock) {
CoffeeQuery query = new CoffeeQuery(keyword, minPrice, maxPrice, inStock);
return mapper.search(query);
}
/**
* POST /api/order → 下单一杯咖啡(无事务版!)
*
* 业务流程:查商品 → 扣库存 → "生成订单"。
* 本章用日志模拟订单落库(真正的多表写入留给第 08 章配事务)。
*
* 【故意埋的雷】如果扣库存成功、模拟生成订单时抛异常,
* 库存已经减了却收不回来------数据不一致。下一章 @Transactional 上场。
*/
@PostMapping("/order")
public OrderResult order(@RequestBody OrderRequest request) {
// ① 查商品(确认存在 + 拿当前价格)
Coffee coffee = mapper.findById(request.menuId());
if (coffee == null) {
throw new IllegalArgumentException("咖啡不存在: id=" + request.menuId());
}
int qty = request.quantity() == null ? 1 : request.quantity();
BigDecimal total = coffee.getPrice().multiply(BigDecimal.valueOf(qty));
// ② 扣库存;WHERE stock >= qty 保证不足时影响行数为 0
int affected = mapper.deductStock(request.menuId(), qty);
if (affected == 0) {
throw new IllegalStateException("库存不足: " + coffee.getName());
}
// ③ 模拟生成订单:正常时输出订单信息
String orderNo = "CO-" + System.currentTimeMillis();
log.info("[订单生成] {} {} x{} = {} 元", orderNo, coffee.getName(), qty, total);
return new OrderResult(orderNo, coffee.getName(), qty, total);
}
/** 下单请求体:record 承载 menuId + quantity */
public record OrderRequest(Long menuId, Integer quantity) {
}
/** 下单响应体:record 替代 LinkedHashMap 手拼 JSON */
public record OrderResult(String orderNo, String name, int quantity, BigDecimal total) {
}
}
src/main/java/com/lihaozhe/chapter07/CoffeeApplication7.java
java
package com.lihaozhe.chapter07;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 第 07 章:动态 SQL 章启动类。
*
* <p>激活 ch07 段后,mybatis.mapper-locations 指向的
* classpath:chapter07/*.xml 被加载,与 @Mapper 接口绑定。</p>
*/
@SpringBootApplication
public class CoffeeApplication7 {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(CoffeeApplication7.class);
app.setAdditionalProfiles("ch07");
app.run(args);
}
}
运行验证
第 1 步:编译启动
bash
mvn compile exec:java -Dexec.mainClass=com.lihaozhe.chapter07.CoffeeApplication7
第 2 步:动态查询组合拳(同时观察控制台 SQL 变化)
bash
# ① 无条件 → 全表
curl "http://localhost:8080/api/menu/search"
控制台 SQL 没有 WHERE:
==> Preparing: SELECT id, name, ... FROM coffee_menu ORDER BY id
<== Total: 4
bash
# ② 只给关键字
curl "http://localhost:8080/api/menu/search?keyword=%E6%8B%BF%E9%93%81"
控制台 SQL 出现了 LIKE:
==> Preparing: SELECT ... WHERE name LIKE CONCAT('%', ?, '%') ORDER BY id
==> Parameters: 拿铁(String)
<== Total: 1
bash
# ③ 组合条件:价格下限 + 有库存
curl "http://localhost:8080/api/menu/search?minPrice=30&inStock=true"
控制台 SQL 两个条件都拼上了:
==> Preparing: SELECT ... WHERE price >= ? AND stock > 0 ORDER BY id
==> Parameters: 30(BigDecimal)
预期 JSON:燕麦白和澳瑞黑两条。
第 3 步:下单扣库存
bash
printf '{"menuId":2,"quantity":3}' > order.json
curl -X POST http://localhost:8080/api/order -H "Content-Type: application/json" --data-binary @order.json
预期输出:
json
{"orderNo":"CO-1787576316661","name":"美式","quantity":3,"total":66.00}
再查美式的库存验证真的扣了:
bash
curl "http://localhost:8080/api/menu/search?keyword=%E7%BE%8E%E5%BC%8F"
预期:美式的 "stock":97(原 100 − 3)。
bash
# 库存不足防线验证:一次买 999 杯
printf '{"menuId":2,"quantity":999}' > order2.json
curl -X POST http://localhost:8080/api/order -H "Content-Type: application/json" --data-binary @order2.json
预期:500 错误(库存不足异常),且再查库存仍是 97------WHERE 防线生效。
第 4 步:思考题时间(本章埋雷)
假如第③步"生成订单"时程序崩溃了呢?库存已经扣了、订单却没有------钱货两空。怎么让"扣库存 + 写订单"要么都成、要么都不算?
这就是事务。带着这个问题进入第 8 章。
验证完毕停止应用。
常见坑
| 现象 | 原因与解决 |
|---|---|
| Invalid bound statement (not found) | namespace 或 id 对不上;mapper-locations 路径不对 |
| XML 里写 < 报错 | 用 < 转义或 CDATA;>= 可直接写 |
| if 判断数字 0 失效 | OGNL 里 != '' 对数字 0 有坑;数值类型只判 null |
| foreach 的 collection 名写错 | 单 List 参数默认叫 list/collection,最好 @Param 显式起名 |
| ${} 拼了用户输入 | 立即改成 #{};结构位用 ${} 必须白名单 |
自测题
- XML 与接口靠哪两个要素绑定?namespace 可以随便写吗?
- <where> 标签解决了手写 SQL 的哪两个麻烦?
- <foreach> 生成 IN 子句时 open/sePARATOR/close 各是什么作用?(注意拼写)
- #{} 与 ${} 的底层动作差异?各举一个正确使用场景。
- 扣库存为什么把
stock >= qty放 WHERE 里而不是先 SELECT 再判断? - 本章下单流程有什么数据一致性隐患?你想到的解决思路是什么?
下一章预告
上一节留下的雷马上引爆:本章下单流程中任何一步失败,已执行的数据库操作都无法撤回。
下一章学习事务------@Transactional 一个注解,让"扣库存+写订单"同生共死;顺便搞懂 ACID、传播行为和那个著名的自调用失效坑。
第 8 章:事务 ------ 让数据操作"同生共死"
本章目标
- 理解事务与 ACID 四特性(用咖啡店的语言讲明白)
- 掌握 @Transactional 声明式事务:原理、回滚规则、rollbackFor
- 亲手制造故障,亲眼看到"扣了库存又自动还原"
- 学会 REQUIRES_NEW 传播行为:业务回滚、审计日志必须留下
- 认识两大著名坑:自调用失效、受检异常不回滚
- 见识编程式事务 TransactionTemplate(注解之外的另一种姿势)
上一章埋的雷正式引爆:下单流程里"扣库存成功、生成订单失败",库存却收不回来。本章用 @Transactional 一个注解根治它。
知识点讲解
什么是事务
事务 = 一组数据库操作打包成一个不可分割的整体:
- 全成功 → 提交 commit,所有改动生效
- 任一步失败 → 回滚 rollback,所有改动撤销(就像没发生过)
经典比喻是转账:"扣 A 的钱 + 加 B 的钱"必须绑在一起------扣完钱系统崩了、B 没收到,没有事务就是灾难。
本章的场景一模一样:扣库存 + 写订单主表 + 写订单明细三步必须同生共死。
ACID 四特性
| 特性 | 含义 | 谁负责 |
|---|---|---|
| A 原子性 Atomicity | 全成或全不成,不可分割 | 数据库(undo log) |
| C 一致性 Consistency | 事务前后数据都处于合法状态(库存不为负、账目平) | 你写的代码 |
| I 隔离性 Isolation | 并发事务互不干扰 | 数据库(锁 + MVCC) |
| D 持久性 Durability | 一旦提交,断电也不丢 | 数据库(redo log) |
记忆口诀:A/I/D 是数据库提供的手段,C 是我们写代码要达成的目的。
@Transactional 声明式事务的原理
一句话:Spring 启动时给 OrderService 生成代理对象,事务逻辑长在代理身上。

Controller ──调用──▶ OrderService代理 ──开启事务──▶ 原始对象.placeOrder()
│ │
│ 正常返回 → connection.commit()│ 扣库存/写订单/写明细
│ 抛RuntimeException │
▼ → connection.rollback()
调用链经过代理时:先拿连接 setAutoCommit(false) 开启事务;方法正常结束就 commit(),抛出 RuntimeException/Error 就 rollback()。你一行事务 API 都没写------这就是"声明式"。
两个生效铁律:
- 方法必须是 public(非 public 无法被代理增强)
- 必须从外部经代理调用(
this.方法名()是自调用,不走代理 = 没有事务!)
回滚规则:默认只回滚 RuntimeException
| 抛出的异常 | 默认行为 |
|---|---|
| RuntimeException 及其子类 | 回滚 |
| Error | 回滚 |
| 受检异常(IOException 等) | 提交!(新手头号大坑) |
所以企业规范都显式写 @Transactional(rollbackFor = Exception.class),把受检异常也纳入回滚范围。本章代码全部这样写,养成肌肉记忆。
传播行为:REQUIRES_NEW
默认传播行为 REQUIRED = "有事务就加入,没有就新建"。问题:审计日志加入业务事务后,业务一回滚日志也没了。
REQUIRES_NEW = "挂起当前事务,开全新事务独立提交":

placeOrder 主事务 [====挂起====恢复====回滚!]
│
└─ auditService.log() 新事务 [--提交✓]
↓
audit_log 表记录永存(不受主事务影响)
关键前提:审计方法必须放在另一个 Bean 里。 放同一个类里 this 调用就绕过了代理,REQUIRES_NEW 形同虚设------这就是为什么本章拆出了 AuditService。
编程式事务 TransactionTemplate
注解式覆盖 95% 场景;编程式的价值在于精确控制事务边界------比如只想把循环里的一小段包进事务避免长事务。两者底层用同一个事务管理器。
java
return transactionTemplate.execute(status -> {
// 这里的代码在一个事务里跑
// 抛异常自动回滚并向上传播
return order;
});
starter-jdbc 已自动装配 DataSourceTransactionManager 和 TransactionTemplate,直接注入就能用。
完整代码(最终版)
本章共 6 个新文件 + application.yaml 新增 ch08 段 + schema.sql 追加 audit_log 表。
src/main/resources/chapter04/schema.sql(追加部分)
在文件末尾追加:
sql
-- 审计日志表(第 08 章事务章:REQUIRES_NEW 独立事务演示)
-- 主事务回滚时,这张表的记录依然存在------这就是独立提交的证据
CREATE TABLE IF NOT EXISTS audit_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '日志id',
action VARCHAR(50) NOT NULL COMMENT '动作类型',
detail VARCHAR(255) NOT NULL COMMENT '详情',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='审计日志表';
src/main/resources/application.yaml(新增部分)
yaml
# ============================================================
# 【第 08 章】事务章:数据库配置与 ch06/ch07 相同。
# 事务无需额外配置------starter-jdbc 已自动装配 DataSourceTransactionManager,
# 方法上标 @Transactional 即生效。
# 本章新增了 audit_log 表,所以重新开启 sql.init 执行幂等建表脚本。
# ============================================================
---
spring:
config:
activate:
on-profile: ch08
autoconfigure:
exclude: []
datasource:
url: jdbc:mysql://118.31.221.165:3306/sb_coffee?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
username: root
password: lihaozhe
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 2
idle-timeout: 600000
max-lifetime: 1800000
sql:
init:
mode: always
schema-locations: classpath:chapter04/schema.sql
src/main/java/com/lihaozhe/chapter08/Coffee.java
java
package com.lihaozhe.chapter08;
import java.math.BigDecimal;
/**
* 第 08 章:咖啡实体(同前几章结构)。
*/
public class Coffee {
/** 商品编号 */
private Long id;
/** 饮品名称 */
private String name;
/** 价格 */
private BigDecimal price;
/** 风味简介 */
private String description;
/** 图片路径 */
private String image;
/** 库存杯数 */
private Integer stock;
public Coffee() {
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getImage() {
return image;
}
public void setImage(String image) {
this.image = image;
}
public Integer getStock() {
return stock;
}
public void setStock(Integer stock) {
this.stock = stock;
}
}
src/main/java/com/lihaozhe/chapter08/CoffeeOrder.java
java
package com.lihaozhe.chapter08;
import java.math.BigDecimal;
/**
* 第 08 章:订单实体(对应 coffee_order 表)。
*
* <p>只放本章用得到的字段:id 由数据库自增生成后回填,
* createdAt 由数据库默认值维护,这里不映射。</p>
*/
public class CoffeeOrder {
/** 订单 id(数据库自增,@Options 回填) */
private Long id;
/** 订单号(对外展示用) */
private String orderNo;
/** 会员 id(本章演示传 null 表示未登录下单) */
private Long memberId;
/** 订单总额 */
private BigDecimal totalAmount;
/** 订单状态 */
private String status;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getOrderNo() {
return orderNo;
}
public void setOrderNo(String orderNo) {
this.orderNo = orderNo;
}
public Long getMemberId() {
return memberId;
}
public void setMemberId(Long memberId) {
this.memberId = memberId;
}
public BigDecimal getTotalAmount() {
return totalAmount;
}
public void setTotalAmount(BigDecimal totalAmount) {
this.totalAmount = totalAmount;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}
src/main/java/com/lihaozhe/chapter08/CoffeeMapper.java
java
package com.lihaozhe.chapter08;
import java.math.BigDecimal;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/**
* 第 08 章:菜单与订单 Mapper(注解版 SQL,重点全在 Service 层事务)。
*
* <p>本章 SQL 简单固定,用回第 06 章的注解风格即可;
* 动态拼装的需求上一章已经练过,这里不再重复。</p>
*/
@Mapper
public interface CoffeeMapper {
/** 按 id 查单个商品(下单前确认存在并取价格) */
@Select("""
SELECT id, name, price, description, image, stock
FROM coffee_menu
WHERE id = #{id}
""")
Coffee findById(@Param("id") Long id);
/**
* 扣库存:WHERE 带 stock >= qty 防止扣成负数。
* 返回受影响行数:0 = 库存不足。
*/
@Update("""
UPDATE coffee_menu
SET stock = stock - #{qty}
WHERE id = #{id} AND stock >= #{qty}
""")
int deductStock(@Param("id") Long id, @Param("qty") int qty);
/**
* 写订单主表。@Options 把数据库生成的自增 id 回填到
* order.id 字段------写明细表时要用它当外键。
*/
@Insert("""
INSERT INTO coffee_order (order_no, member_id, total_amount, status)
VALUES (#{orderNo}, #{memberId}, #{totalAmount}, 'CREATED')
""")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insertOrder(CoffeeOrder order);
/** 写订单明细(快照下单时的名称和单价,防止以后改价影响历史订单) */
@Insert("""
INSERT INTO coffee_order_item (order_id, menu_id, menu_name, price, quantity)
VALUES (#{orderId}, #{menuId}, #{menuName}, #{price}, #{quantity})
""")
int insertOrderItem(@Param("orderId") Long orderId,
@Param("menuId") Long menuId,
@Param("menuName") String menuName,
@Param("price") BigDecimal price,
@Param("quantity") int quantity);
}
src/main/java/com/lihaozhe/chapter08/AuditMapper.java
java
package com.lihaozhe.chapter08;
import java.util.List;
import java.util.Map;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/**
* 第 08 章:审计日志 Mapper。
*
* <p>审计日志 = "谁在什么时候干了什么"的流水记录。它的特殊诉求:
* 即使业务事务回滚(比如下单失败),"有人尝试过下单且失败了"
* 这个事实也要留痕------所以写日志必须用独立事务(REQUIRES_NEW)。</p>
*/
@Mapper
public interface AuditMapper {
@Insert("""
INSERT INTO audit_log (action, detail)
VALUES (#{action}, #{detail})
""")
int insert(@Param("action") String action, @Param("detail") String detail);
@Select("SELECT id, action, detail, created_at FROM audit_log ORDER BY id DESC LIMIT 10")
List<Map<String, Object>> findRecent();
}
src/main/java/com/lihaozhe/chapter08/AuditService.java
java
package com.lihaozhe.chapter08;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
* 第 08 章:审计日志 Service ------ REQUIRES_NEW 独立事务演示。
*
* <p>【为什么要单独一个 Bean】
* 事务传播行为只在"代理调用"时生效。OrderService 里直接 this 调用
* 自己的 writeAuditLog 是自调用------不走代理,REQUIRES_NEW 形同虚设。
* 把日志方法放到 AuditService 里,OrderService 注入后调用,
* 才是真正的跨 Bean 代理调用,独立事务才会真的开起来。</p>
*
* <p>【REQUIRES_NEW 的行为】
* 挂起当前业务事务 → 新开一个连接/事务写日志并立即提交 → 恢复业务事务。
* 之后业务事务回滚,已提交的日志不受影响。</p>
*/
@Service
public class AuditService {
private final AuditMapper auditMapper;
public AuditService(AuditMapper auditMapper) {
this.auditMapper = auditMapper;
}
/**
* 独立事务写审计日志:调用方回滚,本条记录照样落库。
*/
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void log(String action, String detail) {
auditMapper.insert(action, detail);
log.info("[审计日志-独立事务已提交] {} - {}", action, detail);
}
/** 查最近 10 条审计记录(给验证端点用) */
public java.util.List<java.util.Map<String, Object>> recent() {
return auditMapper.findRecent();
}
}
src/main/java/com/lihaozhe/chapter08/OrderService.java
java
package com.lihaozhe.chapter08;
import java.math.BigDecimal;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
/**
* 第 08 章:下单 Service ------ 事务的主战场。
*
* <p>【理论知识:什么是事务】
* 事务 = 一组数据库操作,要么全部成功(提交 commit),要么全部撤销(回滚 rollback),
* 不允许"做了一半"。经典比喻:转账 = "扣 A 的钱 + 加 B 的钱"两步,
* 扣完钱系统崩了、B 没收到------没有事务就是灾难。</p>
*
* <p>【ACID 四特性】
* A 原子性 Atomicity:全成或全不成,不可分割;
* C 一致性 Consistency:事务前后数据都处于合法状态(库存不为负、账目平);
* I 隔离性 Isolation:并发事务互不干扰(MySQL 默认 REPEATABLE READ);
* D 持久性 Durability:一旦提交,断电也不丢(靠 redo log 保证)。
* 其中 A/I/D 是数据库提供的手段,C 是我们写代码要达成的目的。</p>
*
* <p>【Spring 的事务抽象】
* Spring 不管你底层用 JDBC/MyBatis/JPA,统一用事务管理器管理事务。
* starter-jdbc 已自动装配 DataSourceTransactionManager,
* @Transactional 标在方法上,Spring 通过 AOP 代理自动包裹:
* 方法正常返回 → 提交;抛出 RuntimeException/Error → 回滚(受检异常默认不回滚!)。</p>
*/
@Service
public class OrderService {
private final CoffeeMapper mapper;
/** 审计日志服务(独立 Bean → 跨代理调用 → REQUIRES_NEW 才生效) */
private final AuditService auditService;
/** 编程式事务模板(与注解式对照演示) */
private final TransactionTemplate transactionTemplate;
public OrderService(CoffeeMapper mapper, AuditService auditService,
TransactionTemplate transactionTemplate) {
this.mapper = mapper;
this.auditService = auditService;
this.transactionTemplate = transactionTemplate;
}
/**
* 正常下单:扣库存 → 写订单主表 → 写订单明细,三步一个事务。
*
* <p>@Transactional 原理一句话:Spring 启动时给 OrderService 生成代理对象,
* 外部调用本方法前,代理先从连接池拿连接并 setAutoCommit(false) 开启事务;
* 方法正常结束就 commit(),抛 RuntimeException 就 rollback()。
* 你一行事务 API 都没写------这就是声明式事务。</p>
*
* <p>rollbackFor = Exception.class:把回滚范围扩大到受检异常。
* 默认只回滚 RuntimeException 和 Error------新手最常踩的坑
* (抛了个受检异常结果没回滚)。显式写出养成习惯。</p>
*
* @param menuId 饮品 id
* @param quantity 杯数
* @param simulateFailure 故障开关:true 时三步都执行完后抛异常,验证回滚
*/
@Transactional(rollbackFor = Exception.class)
public CoffeeOrder placeOrder(Long menuId, int quantity, boolean simulateFailure) {
// ① 查商品:确认存在 + 拿价格快照
Coffee coffee = mapper.findById(menuId);
if (coffee == null) {
throw new IllegalArgumentException("咖啡不存在: id=" + menuId);
}
// ② 写审计日志(REQUIRES_NEW 独立事务):
// 注意顺序------先记日志再执行业务。等会儿 simulateFailure=true 时,
// 主事务回滚,但这条日志依然在 audit_log 表里。
auditService.log("ORDER_ATTEMPT", "尝试下单 menuId=" + menuId + " x" + quantity);
// ③ 扣库存(WHERE stock >= qty 防超卖;影响行数 0 说明库存不够)
int affected = mapper.deductStock(menuId, quantity);
if (affected == 0) {
throw new IllegalStateException("库存不足: " + coffee.getName());
}
// ④ 写订单主表
CoffeeOrder order = new CoffeeOrder();
order.setOrderNo("CO-" + System.currentTimeMillis());
order.setMemberId(null); // 未登录下单
order.setTotalAmount(coffee.getPrice()
.multiply(BigDecimal.valueOf(quantity)));
mapper.insertOrder(order); // 自增 id 已回填到 order.id
// ⑤ 写订单明细(名称/单价快照)
mapper.insertOrderItem(order.getId(), menuId,
coffee.getName(), coffee.getPrice(), quantity);
// ⑥ 故障开关:三步都执行完了再抛异常 → 观察前面写入被整体回滚
if (simulateFailure) {
throw new IllegalStateException("【模拟故障】订单生成后系统崩溃!");
}
return order;
}
/**
* 编程式事务:TransactionTemplate 把"要事务保护的代码"包进回调。
*
* <p>声明式(@Transactional)覆盖 95% 场景;编程式的价值在于精确控制
* 事务边界------比如只想把循环里的一小段包进事务避免长事务。
* 两种方式底层用的是同一个事务管理器。</p>
*/
public CoffeeOrder placeOrderProgrammatic(Long menuId, int quantity) {
return transactionTemplate.execute(status -> {
Coffee coffee = mapper.findById(menuId);
if (coffee == null) {
throw new IllegalArgumentException("咖啡不存在: id=" + menuId);
}
if (mapper.deductStock(menuId, quantity) == 0) {
throw new IllegalStateException("库存不足: " + coffee.getName());
}
CoffeeOrder order = new CoffeeOrder();
order.setOrderNo("CO-" + System.currentTimeMillis());
order.setTotalAmount(coffee.getPrice()
.multiply(BigDecimal.valueOf(quantity)));
mapper.insertOrder(order);
mapper.insertOrderItem(order.getId(), menuId,
coffee.getName(), coffee.getPrice(), quantity);
return order;
});
}
}
src/main/java/com/lihaozhe/chapter08/OrderController.java
java
package com.lihaozhe.chapter08;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 第 08 章:下单接口 ------ 验证事务回滚的入口。
*/
@RestController
@RequestMapping("/api")
public class OrderController {
private final OrderService orderService;
/** 审计服务:提供只读查询端点,验证"主事务回滚了、日志还在" */
private final AuditService auditService;
/** 库存查询用(只读演示,教学从简直接注入 mapper) */
private final CoffeeMapper coffeeMapper;
public OrderController(OrderService orderService, AuditService auditService,
CoffeeMapper coffeeMapper) {
this.orderService = orderService;
this.auditService = auditService;
this.coffeeMapper = coffeeMapper;
}
/** 下单响应:record 组件名即 JSON 字段名(替代 LinkedHashMap 手拼) */
public record OrderView(Long orderId, String orderNo, BigDecimal total) {
}
/** 库存查询响应 */
public record StockView(Long id, String name, Integer stock) {
}
/**
* POST /api/order
* {"menuId":2,"quantity":2,"simulateFailure":false}
*
* Controller 调 Service 是"跨 Bean 调用",走的是代理------
* 这正是 @Transactional 生效的正确姿势(自调用失效的原理见文档)。
*/
@PostMapping("/order")
public OrderView order(@RequestBody OrderRequest request) {
CoffeeOrder order = orderService.placeOrder(
request.menuId(),
request.quantity() == null ? 1 : request.quantity(),
Boolean.TRUE.equals(request.simulateFailure()));
return new OrderView(order.getId(), order.getOrderNo(), order.getTotalAmount());
}
/**
* POST /api/order/programmatic ------ 编程式事务版下单,效果与上面等价。
*/
@PostMapping("/order/programmatic")
public OrderView orderProgrammatic(@RequestBody OrderRequest request) {
CoffeeOrder order = orderService.placeOrderProgrammatic(
request.menuId(),
request.quantity() == null ? 1 : request.quantity());
return new OrderView(order.getId(), order.getOrderNo(), order.getTotalAmount());
}
/** GET /api/menu/{id}/stock ------ 查库存,验证回滚后库存是否还原 */
@GetMapping("/menu/{id}/stock")
public StockView stock(@PathVariable Long id) {
Coffee coffee = coffeeMapper.findById(id);
if (coffee == null) {
throw new IllegalArgumentException("咖啡不存在: id=" + id);
}
return new StockView(id, coffee.getName(), coffee.getStock());
}
/**
* GET /api/audit/recent ------ 最近 10 条审计日志。
* 主事务回滚后这里仍能看到 ORDER_ATTEMPT 记录 → REQUIRES_NEW 生效。
*/
@GetMapping("/audit/recent")
public List<Map<String, Object>> auditRecent() {
return auditService.recent();
}
/** 下单请求体:simulateFailure=true 时在事务末尾抛异常,验证回滚 */
public record OrderRequest(Long menuId, Integer quantity, Boolean simulateFailure) {
}
}
src/main/java/com/lihaozhe/chapter08/CoffeeApplication8.java
java
package com.lihaozhe.chapter08;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 第 08 章:事务章启动类。
*
* <p>ch08 段复用与 ch05~ch07 相同的数据库连接;
* spring-boot-starter-jdbc 已自动配置 DataSourceTransactionManager,
* @Transactional 开箱即用,无需任何额外注册。</p>
*/
@SpringBootApplication
public class CoffeeApplication8 {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(CoffeeApplication8.class);
app.setAdditionalProfiles("ch08");
app.run(args);
}
}
运行验证
第 1 步:编译启动
bash
mvn compile exec:java -Dexec.mainClass=com.lihaozhe.chapter08.CoffeeApplication8
启动后确认基线(拿铁库存、空审计日志):
bash
curl http://localhost:8080/api/menu/1/stock
# {"id":1,"name":"拿铁","stock":100}
curl http://localhost:8080/api/audit/recent
# []
第 2 步:场景一 ------ 正常下单(事务提交)
bash
printf '{"menuId":1,"quantity":2,"simulateFailure":false}' > o1.json
curl -X POST http://localhost:8080/api/order -H "Content-Type: application/json" --data-binary @o1.json
预期输出(订单入库,库存扣减):
json
{"orderId":3,"orderNo":"CO-1787580530053","total":56.00}
bash
curl http://localhost:8080/api/menu/1/stock
预期:拿铁 "stock":98(100 − 2,真实落库)。
第 3 步:场景二 ------ 模拟故障(事务回滚,本章高潮)
bash
printf '{"menuId":1,"quantity":5,"simulateFailure":true}' > o2.json
curl -w "\nHTTP_STATUS=%{http_code}\n" -X POST http://localhost:8080/api/order \
-H "Content-Type: application/json" --data-binary @o2.json
预期:HTTP 500(模拟故障异常)。此刻思考:刚才扣的 5 杯库存去哪了?
bash
curl http://localhost:8080/api/menu/1/stock
预期:仍是 "stock":98! 扣掉的 5 杯被事务回滚还原了------这就是原子性。
bash
curl http://localhost:8080/api/audit/recent
预期:能看到两条 ORDER_ATTEMPT 记录------包括失败那次!审计日志走了 REQUIRES_NEW 独立事务,主事务回滚不影响它:
json
[{"action":"ORDER_ATTEMPT","created_at":"2026-08-24T22:09:07","id":2,"detail":"尝试下单 menuId=1 x5"},
{"action":"ORDER_ATTEMPT","created_at":"2026-08-24T22:08:50","id":1,"detail":"尝试下单 menuId=1 x2"}]
一个请求同时见证了两个事务的不同命运:业务回滚 ✓ 日志留存 ✓。
第 4 步:场景三 ------ 编程式事务
bash
printf '{"menuId":3,"quantity":1}' > o3.json
curl -X POST http://localhost:8080/api/order/programmatic -H "Content-Type: application/json" --data-binary @o3.json
预期输出:
json
{"orderId":5,"orderNo":"CO-1787580567462","total":32.00}
bash
curl http://localhost:8080/api/menu/3/stock
预期:燕麦白 "stock":79(80 − 1)------TransactionTemplate 与注解效果完全等价。
第 5 步:场景四 ------ 库存不足防线
bash
printf '{"menuId":3,"quantity":9999}' > o4.json
curl -s -o /dev/null -w "HTTP_STATUS=%{http_code}\n" -X POST http://localhost:8080/api/order \
-H "Content-Type: application/json" --data-binary @o4.json
curl http://localhost:8080/api/menu/3/stock
预期:500 + 燕麦白仍是 "stock":79------WHERE stock >= qty 防线未破,控制台可见 IllegalStateException: 库存不足: 燕麦白。
验证完毕停止应用。
常见坑
| 现象 | 原因与解决 |
|---|---|
| 抛了异常却不回滚 | 抛的是受检异常(默认不回滚);加 rollbackFor = Exception.class |
| @Transactional 标了没效果 | 同类内 this 自调用绕过了代理;拆到另一个 Bean 或注入自身代理 |
| 非 public 方法上标注解 | 代理无法增强非 public 方法,静默失效 |
| REQUIRES_NEW 不生效 | 也是自调用;传播行为只在跨 Bean 代理调用时生效 |
| 事务方法里有 HTTP/RPC 调用 | 长事务占用连接拖垮连接池;远程调用移出事务 |
| 多线程里事务丢失 | 事务绑定 ThreadLocal 连接,新开线程拿不到;需另想办法 |
自测题
- ACID 分别指什么?哪一个是靠程序员写代码保证的?
- @Transactional 默认对哪类异常回滚、哪类不回滚?怎么改?
- 为什么本章的审计日志要单独拆一个 AuditService,而不是写在 OrderService 里?
- REQUIRES_NEW 与 REQUIRED 的区别?各举一个适用场景?
this.placeOrder(...)在同类另一个方法里调用,事务会生效吗?为什么?三种解法是什么?- TransactionTemplate.execute 的回调里抛异常会发生什么?
下一章预告
本章接口报错时客户端收到的是一大坨英文堆栈 JSON(timestamp/status/error/path),既难看又泄露内部信息。下一章做两件事:参数校验(@Valid 注解声明式校验请求体)+ 全局异常处理(@RestControllerAdvice 统一拦截所有异常,返回优雅的 Result 结构)------让 API 从"能用"进化到"专业"。