Lecture12-MyBatisPlus关联关系映射

文章目录

    [toc]

    单词词性音标含义
    Associationn./əˌsəʊsiˈeɪʃn/关联(对一:查询结果中嵌套一个对象)
    Collectionn./kəˈlekʃn/集合(对多:查询结果中嵌套一个集合)
    ResultMapn./rɪˈzʌlt mæp/结果映射(XML 中声明「查询列 → 对象属性」的规则)
    Joinv./dʒɔɪn/连接(JOIN 把多张表按关联条件拼成一张结果集)
    Intermediate Table-/ˌɪntəˈmiːdiət ˈteɪbl/中间表(多对多关系的桥梁表)
    Streamn./striːm/流(Java 8 的集合处理 API)
    toMap-/tuː mæp/转成 Map(StreamAPI 收集器,按 key 建立索引)
    Foreign Key-/ˈfɒrən kiː/外键(一张表里指向另一张表主键的列)

    Lecture12-MyBatisPlus关联关系映射

    一、关联关系的核心概念

    前 11 章的所有操作都是单表的。但现实中的表之间通过外键相互关联,页面上的数据往往来自多张表:

    • t_emp 表里的 dept_id 只是部门编号,页面上要显示的是「部门名称」
    • 点进一个部门,要看到这个部门下的所有员工
    • 一个员工可以拥有多个角色(管理员、开发……)

    这就要用到「关联关系」。先记住三种基本关系:

    数据库表之间的三种关联关系

    关系示例关联方式常用查询方向
    一对一员工 ↔ 员工档案档案表存 emp_id 外键员工 → 档案;档案 → 员工
    一对多部门 ↔ 员工(多对一)员工表存 dept_id 外键部门 → 员工列表;员工 → 所属部门
    多对多员工 ↔ 角色中间表存 emp_id、role_id员工 → 角色列表;角色 → 员工列表

    注意「多对一」和「一对多」是一体两面:站在员工看部门是多对一,站在部门看员工是一对多,底层都是外键 dept_id。多对多则必须借助中间表。

    关键认知:MyBatis-Plus 的自动能力只覆盖单表。跨表关联的数据,有两种组装途径:

    1. MyBatis 原生映射:自己写一条关联 SQL + association/collection 映射规则,框架装出嵌套对象
    2. MyBatis-Plus 推荐做法:单表多次查询(SQL 自动生成)+ StreamAPI/Lambda 在 Java 里组装

    本章把这两种做法各讲一个典型例子,最后处理两个常见的特殊情况,够日常开发使用。


    二、MyBatis 原生映射方式:association(对一)与 collection(对多)

    1、思路与文件约定

    原生映射的思路是:一条关联 SQL 查出嵌套结构,映射规则写在 XML 文件。这是 MyBatis 本身的机制,MyBatis-Plus 完全兼容(接口里的自定义方法不影响原有的 BaseMapper 方法)。

    三个约定(缺一不可):

    1. XML 文件放在 resources/mapper 目录(MyBatis-Plus 默认扫描 classpath*:/mapper/**/*.xml,无需额外配置) 言外之意:如果存放的目录不叫 mapper 那么就必须到配置文件中指定 image-20260928090305492
    2. XML 的 <mapper namespace> 写 Mapper 接口的全类名
    3. 接口方法名与 XML 中 <select> 的 id 一致,方法上不写任何注解,只留签名

    image-20260928092048380

    两个标签的分工:

    标签适用关系装进属性
    <association>对一(员工 → 部门)一个对象(employee.dept)
    <collection>对多(部门 → 员工)一个集合(dept.empList)

    注意:当我们自己在 Mapper.xml 配置文件写 SQL 语句,MybatisPlus 通过生成 SQL 语句附加的功能就都无效了,需要我们自己写!!! 比如:逻辑删除


    2、对一:查询员工带部门(association)

    ①Mapper 接口新增方法(不加注解)

    // SQL 与映射都在 XML 里,方法只写签名
    Employee selectEmployeeWithDeptXml(@Param("empId") Integer empId);
    

    ②创建 resources/mapper/EmployeeMapper.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">
    <mapper namespace="com.atguigu.mp.mapper.EmployeeMapper">
    
        <!-- 一对一:association 把查询出的部门列装进 employee.dept -->
        <resultMap id="employeeWithDeptMap" type="com.atguigu.mp.entity.Employee">
            <id column="emp_id" property="empId"/>
            <result column="emp_no" property="empNo"/>
            <result column="emp_name" property="empName"/>
            <result column="emp_salary" property="empSalary"/>
            <association property="dept" javaType="com.atguigu.mp.entity.Dept">
                <id column="dept_id" property="deptId"/>
                <result column="dept_name" property="deptName"/>
            </association>
        </resultMap>
    
        <select id="selectEmployeeWithDeptXml" resultMap="employeeWithDeptMap">
            SELECT e.emp_id, e.emp_no, e.emp_name, e.emp_salary, e.dept_id, d.dept_name
            FROM t_emp e LEFT JOIN t_dept d ON e.dept_id = d.dept_id
            WHERE e.emp_id = #{empId}
        </select>
    
    </mapper>
    

    ③测试

    @Test
    public void testSelectEmployeeWithDeptXml() {
        Employee employee = employeeMapper.selectEmployeeWithDeptXml(1);
        System.out.println("员工 = " + employee.getEmpName());
        System.out.println("所属部门 = " + employee.getDept().getDeptName());
    }
    
    ==>  Preparing: SELECT e.emp_id, e.emp_no, e.emp_name, e.emp_salary, e.dept_id, d.dept_name FROM t_emp e LEFT JOIN t_dept d ON e.dept_id = d.dept_id WHERE e.emp_id = ?
    ==> Parameters: 1(Integer)
    

    结果里 employee.getDept() 就是一个完整的 Dept 对象(含 deptId、deptName)。


    3、对多:查询部门带员工列表(collection)

    ①Mapper 接口

    // 对多:查部门,并把该部门所有员工装进 dept.empList
    List<Dept> selectDeptWithEmployees(@Param("deptId") Integer deptId);
    

    ②XML 中追加 resultMap 和 select

        <!-- 对多:collection 把多行员工装进 dept.empList -->
        <resultMap id="deptWithEmpMap" type="com.atguigu.mp.entity.Dept">
            <id column="dept_id" property="deptId"/>
            <result column="dept_name" property="deptName"/>
            <collection property="empList" ofType="com.atguigu.mp.entity.Employee">
                <id column="emp_id" property="empId"/>
                <result column="emp_name" property="empName"/>
                <result column="emp_salary" property="empSalary"/>
            </collection>
        </resultMap>
    
        <select id="selectDeptWithEmployees" resultMap="deptWithEmpMap">
            SELECT d.dept_id, d.dept_name, e.emp_id, e.emp_name, e.emp_salary
            FROM t_dept d LEFT JOIN t_emp e ON e.dept_id = d.dept_id
            WHERE d.dept_id = #{deptId}
        </select>
    

    说明:JOIN 之后每个员工占一行,MyBatis 会按 resultMap 中 <id>(dept_id)把多行合并成一个部门对象,员工装进 empList(具体去重合并机制本教程不展开,记住结果即可)。

    ③测试

    @Test
    public void testSelectDeptWithEmployeesXml() {
        Dept dept = deptMapper.selectDeptWithEmployees(1);
        System.out.println("部门 = " + dept.getDeptName());
        dept.getEmpList().forEach(e -> System.out.println("  员工:" + e.getEmpName()));
    }
    
    ==>  Preparing: SELECT d.dept_id, d.dept_name, e.emp_id, e.emp_name, e.emp_salary FROM t_dept d LEFT JOIN t_emp e ON e.dept_id = d.dept_id WHERE d.dept_id = ?
    ==> Parameters: 1(Integer)
    

    ④语法关键点

    标签名功能属性名属性功能
    id映射主键字段column
    property
    指定数据库表字段名称
    指定实体类对象属性名
    result映射非主键普通字段column
    property
    指定数据库表字段名称
    指定实体类对象属性名
    association映射对一关联关系javaType指定一的一端实体类类型全类名
    collection映射对多关联关系ofType指定多的一端实体类类型全类名

    4、图解:XML resultMap 结构

    XML resultMap 实现关联查询


    5、注意事项

    1. namespace 与接口全限定名、方法名与 <select> 的 id 必须一一对应,否则运行时报「Invalid bound statement (not found)」
    2. 自定义 SQL 不享受 MyBatis-Plus 的自动功能:逻辑删除等条件自己写(如 AND e.deleted = 0)
    3. resultMap 中没列出的列不会映射:SELECT 要写全需要的列
    4. 对一用 <association>,对多用 <collection>(ofType 写集合元素类型)

    三、MyBatis-Plus 不写 SQL:StreamAPI + Lambda 组装对象(推荐)

    1、思路

    原生映射要自己写一条 JOIN SQL;MyBatis-Plus 更常用的做法是完全不写 SQL:

    1. 单表查询(SQL 由 MyBatis-Plus 自动生成)
    2. 关联信息用第二次单表查询补齐
    3. 用 StreamAPI + Lambda(toMap / groupingBy)按主键把两次结果组装成一个完整对象

    优点:SQL 全部自动生成、逻辑透明、天然适配分页(见第四章)。日常开发优先推荐这种方式。


    2、示例:查询员工列表并补上部门名称

    @Test
    public void testAssembleDept() {
        // ① 查员工列表(单表,MP 自动生成 SQL)
        List<Employee> employeeList = employeeMapper.selectList(null);
    
        // ② 收集所有 dept_id(去重)
        List<Integer> deptIds = employeeList.stream()
                .map(Employee::getDeptId)
                .filter(Objects::nonNull)
                .distinct()
                .toList();
    
        // ③ 一次性批量查询部门(IN 查询,一条 SQL)
        List<Dept> deptList = deptMapper.selectBatchIds(deptIds);
    
        // ④ 转成 Map:deptId -> Dept,方便按 id 取
        Map<Integer, Dept> deptMap = deptList.stream()
                .collect(Collectors.toMap(Dept::getDeptId, dept -> dept));
    
        // ⑤ 逐个填充到员工对象
        employeeList.forEach(e -> e.setDept(deptMap.get(e.getDeptId())));
    
        // 查看结果:员工对象里带着部门
        employeeList.forEach(e -> System.out.println(
                e.getEmpName() + " -> " + (e.getDept() == null ? "无部门" : e.getDept().getDeptName())));
    }
    

    控制台日志(两次查询,全程没有手写 SQL):

    ==>  Preparing: SELECT emp_id,emp_no,emp_name,emp_salary,emp_birthday,phone_num,dept_id FROM t_emp WHERE deleted=0
    ==>  Preparing: SELECT dept_id,dept_name FROM t_dept WHERE dept_id IN ( ? , ? )
    

    MP 不写 SQL:StreamAPI + Lambda 组装对象


    3、N+1 问题:为什么不能逐条查关联数据

    第 2 节示例里,补部门名称用的是 selectBatchIds 一次 IN 查询。如果换成「for 循环里逐个查」,就会踩中著名的 N+1 问题:

    • 第 1 次查询:查出 N 条员工记录(1 次 SQL)
    • 接下来:为每个员工单独查一次部门(N 次 SQL)
    • SQL 总数 = 1 + N 次,名字就是这么来的
    // ❌ 错误写法:for 循环里逐个查部门 → 1 + N 次 SQL
    for (Employee e : employeeList) {
        Dept dept = deptMapper.selectById(e.getDeptId());   // 每个员工 1 次
        e.setDept(dept);
    }
    // 100 个员工 → 101 次 SQL,员工越多越慢
    

    危害:SQL 次数与数据量成正比,每次 SQL 都有数据库往返和解析开销——100 个员工 101 次 SQL 还能忍,1000 个员工就是 1001 次 SQL,页面会明显变慢。

    N+1 问题示意

    正确做法就是第 2 节的写法:收集 dept_id 集合 → 一次 IN 查询拿回全部部门 → 内存里组装,SQL 总数固定为 2 次,与员工数量无关。

    提示:MyBatis 原生的「嵌套查询」(association / collection 里再指定一个 select)本质上也是 N+1,所以第二章只介绍了「一条 JOIN」的写法;日常开发优先用批量查 + 组装。


    4、要点小结

    • 补关联字段的固定套路:收集外键 → selectBatchIds / listByIds 一次 IN 查询 → toMap 建索引 → 组装(避免 N+1,见第 3 节)
    • toMap 按主键建立索引,组装时按 id 取值是 O(1)
    • 员工实体里的 dept 字段要加 @TableField(exist = false)(表中没有对应列,仅用于组装),部门实体里的 empList 同理

    四、特殊情况的处理

    1、分页涉及多张表:不能使用关联查询

    ①问题:JOIN 分页会导致 count 计算错误

    分页时 MyBatis-Plus 分页插件会自动生成一条 count 语句统计总记录数。如果分页 SQL 里用了 JOIN 且把「多」的一侧展开(比如部门 JOIN 员工),主表(部门)的每一行会重复出现:

    • count 把重复的行也数进去 → 总记录数错误
    • 总记录数错 → 总页数跟着错 → 分页错乱

    分页多表查询:不能使用关联查询

    ②正确做法:先分页主表,再批量补关联

    分页在主表上先查,再给本页记录补关联信息,count 只数主表、天然正确:

    // 目标:部门分页列表,每个部门带上员工列表
    // 1、先分页查主表 t_dept(MP 自动生成 SQL,count 只数部门)
    IPage<Dept> page = new Page<>(1, 2);
    deptService.page(page);
    List<Dept> deptList = page.getRecords();
    
    // 2、收集本页部门的 dept_id
    List<Integer> deptIds = deptList.stream()
            .map(Dept::getDeptId)
            .toList();
    
    // 3、按 dept_id 批量查员工(IN 一条 SQL)
    List<Employee> employeeList = employeeService.lambdaQuery()
            .in(Employee::getDeptId, deptIds)
            .list();
    
    // 4、把员工按部门分组:deptId -> List<Employee>
    Map<Integer, List<Employee>> deptId2EmpList = employeeList.stream()
            .collect(Collectors.groupingBy(Employee::getDeptId));
    
    // 5、组装:给每个部门填充员工列表
    deptList.forEach(dept -> dept.setEmpList(deptId2EmpList.get(dept.getDeptId())));
    
    // 6、输出验证
    System.out.println("总记录数 = " + page.getTotal());   // 正确
    System.out.println("总页数 = " + page.getPages());     // 正确
    
    ==>  Preparing: SELECT COUNT(*) AS total FROM t_dept
    ==>  Preparing: SELECT dept_id,dept_name FROM t_dept LIMIT ?
    ==>  Parameters: 2(Long)
    ==>  Preparing: SELECT emp_id,emp_no,emp_name,emp_salary,emp_birthday,phone_num,dept_id FROM t_emp WHERE deleted=0 AND dept_id IN ( ? , ? )
    

    口诀:分页不 JOIN。只要分页涉及多张表,一律「先分页主表 → 收集外键 → IN 批量查关联表 → 组装」。


    2、多对多 + 中间表:只查一部分数据的最佳策略

    ⓪为什么多对多需中间表

    比如老师和学生的关联关系:

    • 老师对应的学生 id:1、2、3、4、5、6、7、8
    • 学生对应的老师 id:9、8、7

    如果没有中间表,数据库表就没法体现这个关联关系,因为一个字段无法保存多个值。

    具体来说,就是老师表中学生 id 这个外键列无法保存多个值

    此时必须用中间表来表示:

    中间表自身的主键字段老师表的 id学生表的 id
    191
    292
    393
    484
    585
    686
    777
    878

    ①场景与前提

    员工和角色是多对多,通过中间表 t_emp_role(存 emp_id、role_id)关联。本节的前提是:不管是基于查询条件还是分页,都只查询一部分数据。

    此时如果「员工 JOIN 中间表 JOIN 角色表」,会出现两个问题:主表行重复导致 count 错误(同上),而且把大量不需要的角色行也查出来了。

    ②最佳策略:不 JOIN,中间表当普通表分三步查

    先按条件/分页查出主表的一部分 → 查中间表(IN 主键)→ 查角色表(IN 角色ID)→ 组装。每一步都是单表查询(SQL 自动生成),只查需要的数据,count 也天然正确。

    // 目标:按条件查出部分员工,每个员工带上角色列表
    // 前提:查询带条件 / 分页 → 只查询一部分数据
    
    // 1、按条件查询部分员工(比如部门 1 的员工;分页场景把 .list() 换成 .page() 即可)
    List<Employee> employeeList = employeeService.lambdaQuery()
            .eq(Employee::getDeptId, 1)
            .list();
    
    // 2、收集员工 ID
    List<Integer> empIdList = employeeList.stream()
            .map(Employee::getEmpId)
            .toList();
    
    // 3、查中间表:按 emp_id 批量查,得到 (empId, roleId) 关系
    List<EmpRole> empRoleList = empRoleService.lambdaQuery()
            .in(EmpRole::getEmpId, empIdList)
            .list();
    
    // 把中间表结果按员工分组:empId -> List<roleId>
    Map<Integer, List<Integer>> empId2RoleIds = empRoleList.stream()
            .collect(Collectors.groupingBy(EmpRole::getEmpId,
                    Collectors.mapping(EmpRole::getRoleId, Collectors.toList())));
    
    // 4、收集需要查的角色 ID,批量查角色表
    List<Integer> roleIdList = empRoleList.stream()
            .map(EmpRole::getRoleId)
            .distinct()
            .toList();
    Map<Integer, SysRole> roleMap = sysRoleService.listByIds(roleIdList).stream()
            .collect(Collectors.toMap(SysRole::getRoleId, role -> role));
    
    // 5、组装:每个员工按分组结果取角色
    employeeList.forEach(e -> {
        List<Integer> roleIds = empId2RoleIds.getOrDefault(e.getEmpId(), Collections.emptyList());
        e.setSysRoleList(roleIds.stream()
                .map(roleMap::get)
                .filter(Objects::nonNull)
                .toList());
    });
    
    ==>  Preparing: SELECT emp_id,emp_no,emp_name,emp_salary,emp_birthday,phone_num,dept_id FROM t_emp WHERE deleted=0 AND dept_id=?
    ==>  Preparing: SELECT id,emp_id,role_id FROM t_emp_role WHERE emp_id IN ( ? , ? )
    ==>  Preparing: SELECT role_id,role_name FROM t_role WHERE role_id IN ( ? )
    

    ③为什么不用「一条 SQL(XML resultMap)」?

    多对多也可以像标题二那样,在 Mapper.xml 里写一条「员工 JOIN 中间表 JOIN 角色表」的 SQL,用 collection 把角色装进员工。但在只查一部分数据的前提下,这种写法有两个绕不开的问题:

    1. 分页 count 会错:JOIN 展开后,一个员工有几个角色就占几行(主表行重复),分页插件自动统计的总记录数会把重复行算进去,总数和总页数就错了
    2. 会查出不必要的数据:JOIN 是"全量展开",只要查出这批员工,他们的所有角色都会被带出来,结果集被放大,做不到"查多少拿多少"

    而「分三次查 + 组装」的方案:count 只数主表、天然正确;每一步只查需要的行。SQL 虽然多了两条,但都是简单的单表查询,逻辑透明、好调试,整体更可控。

    补充:XML 一条 SQL 并非一无是处——查「单个对象的完整嵌套详情」(不分页、一次查全)时它更简洁、数据库往返更少,团队有 SQL 审查规范时也便于 DBA 统一优化。两种做法可以按场景共存,但列表 / 分页场景优先用多次查询组装。


    要点回顾:

    • 中间表就是一个普通表,照常用 lambdaQuery() 单表查
    • 全程不 JOIN:不会行重复、分页 count 正确
    • 只查需要的数据:中间表只查这 N 个员工的记录,角色表只查这 N 个员工用到的角色

    五、本章小结

    • 三种关联关系:一对一、一对多(多对一)、多对多(中间表)
    • 原生映射:一条 JOIN SQL + XML <association>(对一)/ <collection>(对多),由框架装出嵌套结构
    • MP 推荐做法:不写 SQL,单表多次查询 + StreamAPI/Lambda(toMap / groupingBy)组装,逻辑透明、分页友好
    • 特殊情况①:分页多表不能 JOIN(主表行重复 → count 错误),先分页主表再 IN 批量补关联
    • 特殊情况②:多对多 + 中间表,只查一部分数据时「分页/条件查主表 → IN 查中间表 → IN 查关联表 → 组装」,全程不 JOIN

    至此,关联关系映射这一章讲完。下一章将介绍逆向工程(代码生成器),实现「建表 → 一键生成骨架代码」的开发提效。

    end