MyBatis-Plus (MP) 本身主要聚焦于单表 CURD 的增强,对于复杂对象映射(关联查询),它不提供自动化的 ORM 级联查询机制(像 Hibernate/JPA 那样)。其核心思路是 @TableField 注解 + ResultMap 配置 + 手动查询组装(或自定义 SQL)。以下是如何在 MyBatis-Plus 中高效处理复杂映射的详细指南:

核心概念

  1. ResultMap (核心): MyBatis 的核心配置,用于定义如何将 SQL 查询结果的列映射到 Java 对象的属性(包括嵌套对象的属性)。MP 基于 MyBatis,完全兼容 ResultMap
  2. @TableField (MP 注解): 用于标记实体类属性与数据库表字段的对应关系。在关联映射中,主要用来排除非表字段exist = false),避免 MP 的自动注入逻辑处理这些关联对象属性。
  3. 关联类型:
    • 一对一 (<association> / @One): 一个对象包含另一个对象的引用 (e.g., Order 包含 User user)。
    • 一对多 (<collection> / @Many): 一个对象包含另一个对象的集合 (e.g., User 包含 List<Order> orders)。
    • 多对多: 通常通过中间表拆解为两个一对多关系 (e.g., User - UserRole - RoleUser 包含 List<Role> roles)。
  4. 查询方式:
    • JOIN 查询 (推荐): 编写单条 SQL 使用 JOIN 一次性查询出主对象及其关联的所有子对象数据,通过 ResultMap 进行复杂映射。性能通常更好(减少数据库交互次数)。
    • N+1 查询 (谨慎使用): 先查询主对象列表 (1),然后为每个主对象发起额外的查询获取其关联对象 (N)。容易导致性能问题,但在某些分页或延迟加载场景下可能有用(MP 本身不直接支持延迟加载,需结合 MyBatis 或自定义)。

操作步骤 (以 JOIN + ResultMap 为主)

场景示例

  • User (用户) 1 : N Order (订单)
  • Order (订单) 1 : 1 Product (商品) - 假设简化
  • User M : M Role (角色) - 通过 user_role 中间表

实体类定义 (关键:@TableField(exist = false))

// User.java
@Data
@TableName("t_user")
public class User {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String username;
    private String email;

    // 1:N 关联 - 用户的所有订单 (非数据库表字段)
    @TableField(exist = false)
    private List<Order> orders;

    // M:N 关联 - 用户的所有角色 (非数据库表字段)
    @TableField(exist = false)
    private List<Role> roles;
}

// Order.java
@Data
@TableName("t_order")
public class Order {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String orderNo;
    private Long userId; // 外键指向 User.id
    private Long productId; // 外键指向 Product.id (简化)

    // 1:1 关联 - 订单对应的用户 (非数据库表字段)
    @TableField(exist = false)
    private User user;

    // 1:1 关联 - 订单购买的商品 (非数据库表字段)
    @TableField(exist = false)
    private Product product;
}

// Product.java (简化)
@Data
@TableName("t_product")
public class Product {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String name;
    private BigDecimal price;
}

// Role.java
@Data
@TableName("t_role")
public class Role {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String roleName;
    private String description;
}

步骤 1: 编写自定义 SQL 和 ResultMap (XML 方式 - 最常用)

UserMapper.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.example.mapper.UserMapper">

    <!-- 定义复杂的 ResultMap 用于映射用户及其订单(包含商品)和角色 -->
    <resultMap id="UserWithOrdersAndRolesMap" type="User">
        <!-- 映射 User 自身的基本字段 -->
        <id column="user_id" property="id"/>
        <result column="username" property="username"/>
        <result column="email" property="email"/>

        <!-- 1:N 映射 orders 集合 -->
        <collection property="orders" ofType="Order">
            <id column="order_id" property="id"/>
            <result column="order_no" property="orderNo"/>
            <!-- 订单内的 1:1 映射 user (通常这里只映射id即可,避免循环引用,或根据需要) -->
            <association property="user" javaType="User">
                <id column="user_id" property="id"/> <!-- 只关联id防止循环 -->
            </association>
            <!-- 订单内的 1:1 映射 product -->
            <association property="product" javaType="Product">
                <id column="product_id" property="id"/>
                <result column="product_name" property="name"/>
                <result column="price" property="price"/>
            </association>
        </collection>

        <!-- M:N 映射 roles 集合 (通过中间表 JOIN) -->
        <collection property="roles" ofType="Role">
            <id column="role_id" property="id"/>
            <result column="role_name" property="roleName"/>
            <result column="description" property="description"/>
        </collection>
    </resultMap>

    <!-- 自定义 SQL 查询:使用 JOIN 一次性获取所有数据 -->
    <select id="selectUserWithOrdersAndRoles" resultMap="UserWithOrdersAndRolesMap">
        SELECT
            u.id AS user_id, u.username, u.email,
            o.id AS order_id, o.order_no, o.product_id,
            p.id AS product_id, p.name AS product_name, p.price, -- 商品信息
            r.id AS role_id, r.role_name, r.description
        FROM t_user u
        LEFT JOIN t_order o ON u.id = o.user_id -- 1:N 关联订单
        LEFT JOIN t_product p ON o.product_id = p.id -- 订单的1:1关联商品
        LEFT JOIN user_role ur ON u.id = ur.user_id -- M:N 中间表
        LEFT JOIN t_role r ON ur.role_id = r.id -- M:N 关联角色
        WHERE u.id = #{userId} -- 根据用户ID查询
    </select>
</mapper>

OrderMapper.xml 中 (获取订单及其用户和商品):

<mapper namespace="com.example.mapper.OrderMapper">
    <resultMap id="OrderWithUserAndProductMap" type="Order">
        <id column="id" property="id"/>
        <result column="order_no" property="orderNo"/>
        <!-- 1:1 关联用户 -->
        <association property="user" javaType="User">
            <id column="user_id" property="id"/>
            <result column="username" property="username"/>
            <result column="email" property="email"/>
        </association>
        <!-- 1:1 关联商品 -->
        <association property="product" javaType="Product">
            <id column="product_id" property="id"/>
            <result column="product_name" property="name"/>
            <result column="price" property="price"/>
        </association>
    </resultMap>

    <select id="selectOrderWithUserAndProduct" resultMap="OrderWithUserAndProductMap">
        SELECT
            o.id, o.order_no, o.user_id, o.product_id,
            u.username, u.email,
            p.name AS product_name, p.price
        FROM t_order o
        LEFT JOIN t_user u ON o.user_id = u.id
        LEFT JOIN t_product p ON o.product_id = p.id
        WHERE o.id = #{orderId}
    </select>
</mapper>

步骤 2: 在 Mapper 接口中声明自定义方法

// UserMapper.java (extends BaseMapper<User>)
public interface UserMapper extends BaseMapper<User> {
    // 使用步骤1中定义的select和resultMap
    User selectUserWithOrdersAndRoles(@Param("userId") Long userId);
}

// OrderMapper.java (extends BaseMapper<Order>)
public interface OrderMapper extends BaseMapper<Order> {
    Order selectOrderWithUserAndProduct(@Param("orderId") Long orderId);
}

步骤 3: 在 Service 或 Controller 中调用

@Service
public class UserServiceImpl implements UserService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public User getUserWithDetails(Long userId) {
        return userMapper.selectUserWithOrdersAndRoles(userId);
    }
}

@Service
public class OrderServiceImpl implements OrderService {

    @Autowired
    private OrderMapper orderMapper;

    @Override
    public Order getOrderWithDetails(Long orderId) {
        return orderMapper.selectOrderWithUserAndProduct(orderId);
    }
}

常见错误

  1. 忘记 @TableField(exist = false) 导致 MP 尝试将关联对象属性当作数据库字段处理,引发 Unknown column 错误或字段找不到异常。
  2. ResultMap 配置错误:
    • id / result / association / collectionpropertycolumn 拼写错误。
    • ofType / javaType 指定错误。
    • 嵌套映射层级错误。
    • 列名别名冲突: 多个表有相同列名 (如 id, name) 时,在 SQL 中必须使用别名 (AS) 区分,并在 ResultMap 中使用别名 column
  3. N+1 查询问题 (当使用 N+1 方式时): 循环查询关联数据导致数据库压力剧增,性能极差。强烈建议优先使用 JOIN + ResultMap。
  4. 循环引用 (StackOverflowError): 例如 User 里有 List<Order>Order 里又有 User user。如果序列化 (如 JSON 返回给前端) 时不处理,会导致无限递归。解决方案:
    • ResultMap 中只映射必要的关联字段 (如只映射 id)。
    • 使用 @JsonIgnore (Jackson) 或 @JSONField(serialize=false) (Fastjson) 在序列化时忽略特定属性。
    • 使用 DTO (Data Transfer Object) 代替实体直接返回,在 DTO 中精确控制要暴露的数据和关联关系。
  5. 分页 + 关联查询问题: 使用 JOIN 查询一对多/多对多时,如果主表记录有多条关联子表记录,MyBatis 默认的分页 (Page 对象) 返回的主记录数会是重复的(因为 JOIN 导致行数变多)。解决方案:
    • 使用 DISTINCTGROUP BY 主表ID 确保主表记录唯一性(但计算总数可能不准)。
    • 最佳实践: 先分页查询主表ID列表,再根据ID列表查询关联数据组装。MP 的 selectPage 不适用于复杂 JOIN 分页。
  6. 空指针异常 (NPE): 关联对象可能为 null (如 LEFT JOIN 未匹配到)。在代码中访问关联对象的属性时要进行 null 检查。

注意事项

  1. 明确需求: 不要过度查询关联数据,只查询当前业务场景真正需要的数据。避免传输和组装不必要的大对象图。
  2. 性能第一: 优先选择 JOIN + 单次查询 + ResultMap 映射的方式。避免 N+1。
  3. DTO 是好朋友: 在 Service 层或 Controller 层,考虑将复杂的带有关联的实体对象转换为更精简、更适合特定视图或 API 响应的 DTO。这能有效解决循环引用、暴露过多信息、性能等问题。
  4. 分页陷阱: 深刻理解分页与关联查询 (JOIN) 结合时的问题,并采用正确的解决方案(先分页主ID再查关联)。
  5. 代码组织: 将复杂的 ResultMap 和自定义 SQL 放在 XML 文件中,保持 Mapper 接口的清晰。大型项目可以考虑按模块拆分 XML。
  6. 测试: 务必为复杂的关联查询编写单元测试或集成测试,验证映射结果的正确性和完整性。

使用技巧

  1. 利用 MyBatis 注解 (@Results, @Result, @One, @Many): 如果偏好注解而非 XML,可以在 Mapper 接口方法上使用这些注解定义 ResultMap。但复杂映射时 XML 通常更清晰易读。
    @Select("SELECT o.*, u.id AS user_id, u.username FROM t_order o LEFT JOIN t_user u ON o.user_id = u.id WHERE o.id = #{id}")
    @Results(id = "orderWithUserMap", value = {
        @Result(property = "id", column = "id"),
        @Result(property = "orderNo", column = "order_no"),
        @Result(property = "user", column = "user_id",
                one = @One(select = "com.example.mapper.UserMapper.selectById")) // 这里会触发N+1查询!
    })
    Order selectOrderWithUserById(Long id);
    
    注意:@One/@Manyselect 属性会触发额外的查询,本质上是 N+1。要避免性能问题,应直接在 @Select 中写 JOIN 并在 @Results 中映射结果。
  2. 查询构造器 + 自定义 SQL 片段 (谨慎): 对于条件多变的关联查询,可以尝试用 QueryWrapper 构造主查询条件,结合 apply 方法拼接自定义的关联 JOIN 片段 (JOIN ... ON ...)。这需要小心 SQL 注入和拼接复杂度。
  3. 懒加载 (需集成其他库或自定义): MP 本身不提供。可考虑集成 MyBatis 的懒加载支持 (需配置 aggressiveLazyLoading, lazyLoadingEnabled) 或使用第三方库 (如 mybatis-lazy)。务必评估性能影响和复杂性。
  4. 复用 ResultMap 使用 <resultMap extends="baseMap" ...> 继承已有的基础映射,避免重复配置公共字段映射。

最佳实践与性能优化

  1. JOIN + ResultMap 是王道: 对于大多数需要立即加载关联数据的场景,这是性能最优、最可控的方式。
  2. 严格使用 DTO: 强烈推荐 在 Service 层或专门的 Assembler/Converter 层,将包含关联数据的 Entity 对象转换为只包含必要数据的 DTO 对象返回给 Controller 或前端。这解决了循环引用、信息泄露、序列化性能、过度获取数据等问题。
  3. 避免查询整个对象图: 只查询需要的关联层级和字段。例如,在订单列表页,查询订单和关联的商品名称即可,不需要加载商品的完整详情和用户的所有信息。
  4. 分页优化:
    • 步骤 1: 使用 MP 的 Page<User>Page<Long> 分页查询主表 (t_user) 数据,只获取主记录ID列表(或核心字段)。
    • 步骤 2: 从分页结果中提取主ID列表 (List<Long> userIds)。
    • 步骤 3: 根据 userIds 执行 一次 自定义查询 (使用 WHERE user_id IN (userIds)) 获取这些用户的所有关联数据 (订单、角色等)。
    • 步骤 4: 在 Java 内存中,将步骤 3 查询到的关联数据手动组装到步骤 1 得到的分页主记录列表 (List<User>) 中对应的对象上。
    • 优点:主表分页准确高效,关联查询次数恒定(1次或少量几次),避免了 JOIN 分页的行数膨胀问题。
  5. 索引优化: 确保关联查询 (JOIN, WHERE, ORDER BY) 涉及的字段上建立了合适的数据库索引。
  6. 监控与分析: 使用数据库慢查询日志、APM 工具 (如 SkyWalking, Pinpoint) 或 MyBatis 的 SQL 打印功能,监控复杂查询的执行时间和性能瓶颈,持续优化 SQL 语句和索引。
  7. 缓存策略: 对于读多写少且变化不频繁的关联数据 (如省市县、角色权限),考虑引入缓存 (Redis, Memcached, MyBatis 二级缓存 - 谨慎使用)。在 Service 层组装数据时优先查询缓存。

总结

掌握 MyBatis-Plus 的复杂映射关键在于:

  1. 深刻理解 ResultMap@TableField(exist=false)
  2. 坚持使用 JOIN 查询 + ResultMap 映射作为主要手段。
  3. 警惕并规避 N+1 查询和分页陷阱。
  4. 积极使用 DTO 进行数据转换和组装。
  5. 实施分页场景下的“先分页主ID,再查关联组装”策略。
  6. 持续关注性能优化和索引。

通过遵循这些原则和实践,你可以在享受 MyBatis-Plus 单表操作便捷性的同时,高效、可控地处理复杂的对象关联映射需求。