SpringBoot4 云端咖啡站 阶段二:数据访问与分层架构

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=true
  • update(...) > 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 声明

自测题

  1. 为什么需要连接池?HikariCP 的 maximum-pool-size 和 max-lifetime 分别控制什么?
  2. JDBC url 的三个查询参数各解决什么问题?漏掉 serverTimezone 会怎样?
  3. JdbcTemplate 帮你省掉了原生 JDBC 的哪些工作?query 和 update 分别对应什么 SQL?
  4. RowMapper 的作用是什么?为什么按列名取值比按下标安全?
  5. ? 占位符防注入的原理是什么?LIKE '%xx%' 的 % 应该写在哪里?
  6. 什么是幂等脚本?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>泛型 &lt;T&gt; 的意义:data 的类型跟着业务走------查单个是 Coffee,
 * 查列表是 List&lt;Coffee&gt;,编译期就能对上,不会拿错字段。</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&lt;T&gt; 信封,前端处理逻辑从此统一</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&lt;List&lt;Coffee&gt;&gt; */
    @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&lt;Coffee&gt; */
    @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 被所有请求共享;请求数据只能放方法参数或局部变量

自测题

  1. 三层各自的职责是什么?"查不到时抛什么异常"应该由哪层决定?
  2. 什么是 IoC?什么是 DI?容器装配 MenuController→CoffeeService→CoffeeRepository 的链条是怎么形成的?
  3. 为什么推荐构造器注入而不是 @Autowired 字段注入?(至少两条理由)
  4. Bean 默认是什么作用域?这要求 Bean 必须满足什么设计约束?
  5. record 自动生成哪些成员?访问器和普通 getter 写法有何不同?
  6. 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 章专门对比,现在先记住别用

参数从哪来?三种情况

  1. 单实体参数insert(Coffee coffee)#{name} 直接读 coffee.getName()
  2. 多个散参 :每个参数标 @Param("名字"),SQL 里按名引用
  3. 单散参可省略 @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 为它生成动态代理并注册成 Bean
  • LIKE 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 漏标;或该包不在扫描范围(跨章隔离所致,属正常设计)

自测题

  1. MyBatis 与 JdbcTemplate 的核心区别是什么?"SQL 还是你写的"指什么?
  2. @Mapper 生成的实现类是谁?什么时候生成的?(提示:动态代理)
  3. #{} 和 ? 的关系是什么?为什么说它是安全的?
  4. 三种参数传递方式分别是什么场景?
  5. 驼峰映射解决什么问题?对应的配置项叫什么?
  6. 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 &gt;= #{minPrice}
            </if>
            <if test="maxPrice != null">
                AND price &lt;= #{maxPrice}
            </if>
            <if test="inStock != null and inStock">
                AND stock &gt; 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 &gt;= #{qty}
    </update>

    <!-- 恢复库存 -->
    <update id="restoreStock">
        UPDATE coffee_menu
        SET stock = stock + #{qty}
        WHERE id = #{id}
    </update>

</mapper>

逐段说明

  • XML 里 >=/<= 要写成实体引用 &gt;=/&lt;=< 会破坏标签结构);也可以用 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 显式起名
${} 拼了用户输入 立即改成 #{};结构位用 ${} 必须白名单

自测题

  1. XML 与接口靠哪两个要素绑定?namespace 可以随便写吗?
  2. <where> 标签解决了手写 SQL 的哪两个麻烦?
  3. <foreach> 生成 IN 子句时 open/sePARATOR/close 各是什么作用?(注意拼写)
  4. #{} 与 ${} 的底层动作差异?各举一个正确使用场景。
  5. 扣库存为什么把 stock >= qty 放 WHERE 里而不是先 SELECT 再判断?
  6. 本章下单流程有什么数据一致性隐患?你想到的解决思路是什么?

下一章预告

上一节留下的雷马上引爆:本章下单流程中任何一步失败,已执行的数据库操作都无法撤回。

下一章学习事务------@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 都没写------这就是"声明式"。

两个生效铁律

  1. 方法必须是 public(非 public 无法被代理增强)
  2. 必须从外部经代理调用(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 连接,新开线程拿不到;需另想办法

自测题

  1. ACID 分别指什么?哪一个是靠程序员写代码保证的?
  2. @Transactional 默认对哪类异常回滚、哪类不回滚?怎么改?
  3. 为什么本章的审计日志要单独拆一个 AuditService,而不是写在 OrderService 里?
  4. REQUIRES_NEW 与 REQUIRED 的区别?各举一个适用场景?
  5. this.placeOrder(...) 在同类另一个方法里调用,事务会生效吗?为什么?三种解法是什么?
  6. TransactionTemplate.execute 的回调里抛异常会发生什么?

下一章预告

本章接口报错时客户端收到的是一大坨英文堆栈 JSON(timestamp/status/error/path),既难看又泄露内部信息。下一章做两件事:参数校验(@Valid 注解声明式校验请求体)+ 全局异常处理(@RestControllerAdvice 统一拦截所有异常,返回优雅的 Result 结构)------让 API 从"能用"进化到"专业"。

相关推荐
宠友信息1 小时前
社区类源码开发实践中的仿小红书系统技术要点分析
java·spring boot·redis·mysql·uni-app·vue·内容运营
摇滚侠2 小时前
《SpringBoot 3:入门与应用实战》第 9 章 使用 WebMvc 开发应用 阅读笔记 1
spring boot·笔记·后端
李昊哲小课3 小时前
SpringBoot4 云端咖啡站 阶段四:安全、文件与性能
spring boot·安全·性能优化·文件·性能
深念Y3 小时前
登录日志与管理员审计日志存储决策
前端·arm开发·后端·微服务·云原生·架构
南城以南溫暖如初1473 小时前
从零搭建24小时自助健身系统:技术选型与核心模块实战
java·spring boot·redis·mysql·vue·mybatis
磐链科技4 小时前
钱包开发中的跨平台架构:Flutter与Rust构建高性能移动端钱包
flutter·架构·rust
这个DBA有点耶4 小时前
数据库“家谱”系列之一:关系型数据库的4大核心组件详解
数据库·mysql·架构
李昊哲小课5 小时前
SpringBoot4 云端咖啡站 阶段五:交付与进阶
人工智能·spring boot·大模型·log4j·智能体
小白酷爱学习5 小时前
鸿蒙OS的开发语言与工具链:如何驾驭全新开发生态!
分布式·华为·架构·harmonyos