Mapper 数据库访问层¶
本文先讲原理和 hhjava 对应关系,第 7 节提供从建表到 HTTP 查询的完整练习。第一次学习时,不必先理解登录协议。
阅读前先认识这些词¶
| 词语 | 基础含义 |
|---|---|
| 数据库、表、行、列 | 数据库管理数据;表描述一种数据;行是一条记录;列是记录的属性,例如书籍的 id 和名称 |
| 主键 | 唯一标识一条记录的字段或字段组合;本练习使用 Long 类型的 id |
| SQL(Structured Query Language,结构化查询语言) | 操作关系数据库的语言;SELECT 查询,INSERT 新增,UPDATE 修改,DELETE 删除 |
| JDBC(Java Database Connectivity,Java 数据库连接) | Java 访问关系数据库的标准接口;驱动负责和具体数据库通信 |
| DataSource(数据源) | 为程序提供数据库连接的对象;连接池复用连接,减少每次重新连接的成本 |
| ResultSet(结果集) | JDBC 查询获得的行列结果;MyBatis 根据映射规则转换成 Java 对象 |
| DAO(Data Access Object,数据访问对象) | 对数据访问职责的抽象;Mapper 是本项目实现这类职责的接口形式 |
| CRUD(Create、Read、Update、Delete) | 新增、读取、修改、删除的合称 |
| XML(Extensible Markup Language,可扩展标记语言) | 使用成对标签表达结构;本文用于把 SQL 与 Java 方法关联 |
| 事务 | 把多个数据库操作作为一组提交或回滚;例如注册的账号和角色关联应一起成功 |
1. Mapper 是什么¶
Mapper 是数据访问接口:Java 方法表达“要查什么或写什么”,MyBatis 负责执行绑定的 SQL,并把结果转换成 Java 对象。
在三层架构中,Controller 处理 HTTP,Service 编排业务和事务,Mapper 负责数据库访问。不要把登录规则、密码比较或 HTTP 响应放到 Mapper 中。
Controller → Service → Mapper → 数据库
↑
接口方法 + SQL 映射
接口由 MyBatis 创建代理实现,通常不需要自己写 RoleMapperImpl,也不需要业务代码手工调用 SqlSession.selectList("某个字符串")。
2. MyBatis 和 MyBatis-Plus 如何配合¶
MyBatis 可以通过 XML 或注解定义 SQL;MyBatis-Plus 的 BaseMapper<T> 进一步提供常用 CRUD 方法。当前 hhjava 两种方式一起使用:
- 简单主键查询、插入等使用
BaseMapper提供的方法。 - 用户名查询、角色关联查询等使用明确的自定义 Mapper 方法和 XML。
- 不是每个 Mapper 都必须有一个 XML 文件。
例如,UserRoleMapper extends BaseMapper<UserRole> 供注册流程调用 insert(userRole)。当前没有自定义关联查询方法,因此不需要对应的 UserRoleMapper.xml。
3. 用户角色怎样一次查询出来¶
当前涉及三张表:
| 表 | 含义 |
|---|---|
user |
用户账号 |
role |
角色定义 |
user_role |
用户与角色的关联,包含 user_id、role_id |
一个用户可以对应多个角色,同一角色也可以属于多个用户,所以用关联表表达多对多关系。
RoleMapper 中的方法声明为:
// 方法节选:参数名与 XML 中的 #{userId} 对应。
List<Role> findRolesByUserId(@Param("userId") Long userId);
RoleMapper.xml 中的当前查询:
<select id="findRolesByUserId" resultMap="BaseResultMap">
select distinct r.id, r.name
from role r
inner join user_role ur on ur.role_id = r.id
where ur.user_id = #{userId,jdbcType=BIGINT}
order by r.id
</select>
理解这条 SQL:
- 根据
user_id筛选该用户的关联记录。 - 使用
role_id = r.id找到实际存在的角色。 distinct避免重复关联让同一个角色返回多次。order by r.id提供稳定顺序;没有匹配角色时,集合查询返回空列表。
这里“角色一次查询”不等于“整个登录只执行一次 SQL”。UserServiceImpl 先查询用户,再通过这条 JOIN 查询角色,合计两条账号读取 SELECT;登录还包含密码计算、会话写入等操作。
移动端密码认证成功后直接复用 AuthenticatedUser 中的用户 ID 和角色快照,不重复调用用户查询。Refresh Token 刷新则仍查询当前用户和角色。原因见 认证和授权。
4. 接口和 XML 怎么对应¶
当前文件位置如下,均相对于 hhjava 根目录:
hhjava-service/hhjava-user/src/main/
├── java/com/hh/user/mapper/
│ ├── UserMapper.java
│ ├── RoleMapper.java
│ └── UserRoleMapper.java
└── resources/com/hh/user/mapper/
├── UserMapper.xml
└── RoleMapper.xml
需要同时满足:
- Mapper 接口被项目的 MyBatis Mapper 扫描发现。
- XML 的
namespace是接口全限定名,例如com.hh.user.mapper.RoleMapper。 - SQL 标签的
id与接口方法名一致。 resultMap="BaseResultMap"引用本 XML 中实际定义的映射,负责结果列到Role属性的对应。- XML 被打包到 classpath,且符合应用实际的 Mapper XML 加载配置。
resources 内容会按 Maven 资源规则进入产物。Spring Boot 集成不要求每个项目都另外手工维护 mybatis.xml;先检查当前项目配置,不要同时添加重复的加载入口。
5. 参数安全与常见错误¶
#{userId} 通过预编译参数绑定传值,不是把请求字符串拼入 SQL。${...} 是文本替换,不要用它直接接收用户输入作为查询条件。MyBatis Mapper XML 参数说明
遇到 Invalid bound statement,按顺序检查扫描、namespace、方法名、XML 资源加载和构建产物,而不是直接增加一个无内容的 XML 文件。
新增业务时优先判断 BaseMapper 是否已经提供所需操作;确实需要关联或复杂筛选时再写自定义 SQL。事务应放在 Service,保证注册中的用户记录与角色关联一起成功或回滚。
6. 验证步骤¶
在 hhjava 根目录执行:
mvn -pl hhjava-service/hhjava-user -am test
检查多角色、重复关联、无角色、孤立关联的查询结果,并确认 BaseMapper 的关联插入等操作仍可用。内存数据库测试不能替代真实 MySQL 的索引、SQL 执行计划和 Flyway 验证;是否需要新增索引应依据实际表结构与执行计划决定。
7. 动手:写一个可运行的书籍查询接口¶
目标:GET /books?minPrice=30 从数据库筛选书籍,返回 JSON。先完成 Spring Boot 第 9 节 的 hhjava-learning-basics,下文文件路径相对这个独立学习工程,操作都在 Mac。不向 hhjava 或远端 MySQL 新增表。
7.1 添加依赖¶
在学习工程 pom.xml 已有的 <dependencies> 内追加下面两个 dependency,不新建第二个 dependencies 标签:
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.7</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
MyBatis-Plus 版本与当前 hhjava 对照,不表示最新版本。Boot 3 使用对应的 boot3 Starter,不再同时加另一套 MyBatis Starter。H2 是可嵌入 Java 进程的关系数据库,本例使用内存模式,退出进程后数据消失。IDEA 重新加载 Maven,确认依赖下载完成。MyBatis-Plus 官方接入说明
7.2 配置数据源并准备演示表¶
将学习工程的配置替换为以下完整文件,不要把它原样追加到已有 YAML 后造成重复 spring 键。
文件:src/main/resources/application.yml
spring:
application:
name: hhjava-learning-basics
datasource:
url: jdbc:h2:mem:learning;DB_CLOSE_DELAY=-1
username: sa
password: ""
driver-class-name: org.h2.Driver
sql:
init:
mode: always
server:
address: 127.0.0.1
port: 18080
mybatis-plus:
mapper-locations: classpath:/mapper/*.xml
jdbc:h2:mem:learning 指本进程的 learning 内存库,不连接 ECS;DB_CLOSE_DELAY=-1 让连接暂时关闭时保留内存库,进程退出仍丢失。空密码仅用于此不开放控制台的内存练习,不能用作 MySQL/服务器配置。mode: always 让 Boot 加载后面的建表及数据文件;正式 hhjava 使用 Flyway 迁移,不能复制这套启动初始化方案覆盖已有数据库。
文件:src/main/resources/schema.sql
CREATE TABLE IF NOT EXISTS book (
id BIGINT PRIMARY KEY,
book_name VARCHAR(100) NOT NULL,
price DECIMAL(10, 2) NOT NULL
);
文件:src/main/resources/data.sql
MERGE INTO book (id, book_name, price) KEY(id)
VALUES (1, 'Java 入门', 29.90), (2, 'Spring Boot 入门', 49.90);
DECIMAL(10,2) 保留两位小数,Java 使用 BigDecimal 对应,避免用二进制浮点表示金额引入误差。MERGE ... KEY 是本练习的 H2 写法,按主键插入或更新便于重复初始化,不能当作 MySQL 通用语句。
7.3 创建实体¶
文件:src/main/java/com/example/learning/book/Book.java
package com.example.learning.book;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.math.BigDecimal;
@TableName("book")
public class Book {
@TableId
private Long id;
@TableField("book_name")
private String name;
private BigDecimal price;
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; }
}
数据库的 book_name 与 Java 的 name 不相同:@TableField 指导 MyBatis-Plus 生成的 SQL,下一步的 resultMap 指导自定义 SQL 的结果映射,两者各有用途。getter/setter 是读取、设置属性的方法,先显式写出以便理解;不是不装 Lombok 就无法使用 MyBatis。
7.4 编写 Mapper 接口和 XML¶
文件:src/main/java/com/example/learning/book/BookMapper.java
package com.example.learning.book;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import java.math.BigDecimal;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface BookMapper extends BaseMapper<Book> {
List<Book> findByMinPrice(@Param("minPrice") BigDecimal minPrice);
}
interface 只声明“能做什么”;框架运行时生成代理对象,调用接口时代理寻找 SQL。@Mapper 让集成组件识别它,也可在配置类统一使用 @MapperScan,本例只用前者。BaseMapper<Book> 表示通用增删改查针对 Book,不需要手工实现 selectById。
文件:src/main/resources/mapper/BookMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.learning.book.BookMapper">
<resultMap id="BookResultMap" type="com.example.learning.book.Book">
<id column="id" property="id"/>
<result column="book_name" property="name"/>
<result column="price" property="price"/>
</resultMap>
<select id="findByMinPrice" resultMap="BookResultMap">
SELECT id, book_name, price
FROM book
WHERE price >= #{minPrice}
ORDER BY id
</select>
</mapper>
逐项对照:namespace 对应接口全名;select 的 id 对应方法名;@Param("minPrice") 对应 #{minPrice};resultMap 把行映射到 Book。> 是 XML 中 > 的实体写法,不改变 SQL 比较含义。
如果 SQL 使用 book_name AS name 且其他列与属性已匹配,也可以使用 resultType="com.example.learning.book.Book",不必再写 resultMap;两者不要同时填写在同一个 select 上。类型别名可缩短全限定名,但不是必需配置,本例用全名方便定位。MyBatis 结果映射说明
7.5 创建 Service 和 Controller¶
文件:src/main/java/com/example/learning/book/BookService.java
package com.example.learning.book;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class BookService {
private final BookMapper bookMapper;
// 构造器注入:Spring 把生成的 Mapper 代理传进来,无需自己 new 一个实现类。
public BookService(BookMapper bookMapper) {
this.bookMapper = bookMapper;
}
public List<Book> findByMinPrice(BigDecimal minPrice) {
return bookMapper.findByMinPrice(minPrice);
}
}
简单练习直接使用一个 Service 类即可,不为每个类机械增加接口和 Impl。真实业务包含多步写入时,在 Service 定义事务与规则;本例只有一次查询。
文件:src/main/java/com/example/learning/book/BookController.java
package com.example.learning.book;
import com.example.learning.ApiResponse;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@GetMapping("/books")
public ApiResponse<List<Book>> books(
@RequestParam(name = "minPrice", defaultValue = "0") BigDecimal minPrice) {
return ApiResponse.success(bookService.findByMinPrice(minPrice));
}
}
练习实体仅有公开书籍字段,直接返回便于观察映射。用户实体有密码等秘密,真实接口应转换为安全响应 DTO,不能照搬“直接返回实体”。
7.6 启动并验证完整链路¶
- 在学习工程根目录运行
mvn test,应仍通过问候接口测试。 - 停止之前的学习进程,重新运行
mvn spring-boot:run,确保新代码和初始化文件已加载。 - 在另一 Mac 终端执行下面请求;Reqable 使用同样 URL、GET、无请求体。
curl -i 'http://127.0.0.1:18080/books?minPrice=30'
curl -i 'http://127.0.0.1:18080/books?minPrice=100'
第一条预期 HTTP 200、data 只有 id 为 2 的书;第二条 HTTP 200、data 是空数组 []。JSON 数字可能显示 49.9 或 49.90,数值含义一致。
{"code":200,"message":"成功","data":[{"id":2,"name":"Spring Boot 入门","price":49.90}]}
7.7 把查询结果写成测试¶
文件:src/test/java/com/example/learning/BookQueryTest.java
package com.example.learning;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class BookQueryTest {
@Autowired
private MockMvc mvc;
@Test
void shouldFilterBooksFromDatabase() throws Exception {
mvc.perform(get("/books").param("minPrice", "30"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(1))
.andExpect(jsonPath("$.data[0].name").value("Spring Boot 入门"));
}
@Test
void shouldReturnEmptyArrayWhenNothingMatches() throws Exception {
mvc.perform(get("/books").param("minPrice", "100"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data").isEmpty());
}
}
在学习工程根目录执行 mvn -Dtest=BookQueryTest test,预期通过。这里走到真实 H2 查询,不是把 Mapper 返回值写死的模拟测试;仍不等于真实 MySQL 兼容性验证。
7.8 出错时沿调用链定位¶
| 错误 | 原因与处理 |
|---|---|
| 找不到 BookMapper Bean | 检查 @Mapper、包位置、Starter 是否引入以及 Maven 是否重新加载 |
Invalid bound statement |
对照 namespace、方法名、mapper-locations;构建后确认 target/classes/mapper/BookMapper.xml 存在 |
| 表不存在 | 检查 schema.sql 在 resources 下、sql.init 是否启用、是否误连另一数据源 |
| 数据有值但 name 是 null | 检查 book_name → name 的 resultMap;只有 @TableField 不代表任意手写 SQL 都已配置结果映射 |
| 请求返回 400 | 例如 minPrice=abc 不能转成 BigDecimal;提交数字,不要修改 SQL 来掩盖请求格式错误 |
若 XML 必须放在 Java 源码树内,需要明确配置 Maven 的资源复制;本练习选择标准 resources 目录,少一项构建约定。不要为解决资源缺失,把全部 .java 源码作为运行资源打包。