MyBatis-Plus (MP) 本身主要聚焦于单表 CURD 的增强,对于复杂对象映射(关联查询),它不提供自动化的 ORM 级联查询机制(像 Hibernate/JPA 那样)。其核心思路是 @TableField
注解 + ResultMap
配置 + 手动查询组装(或自定义 SQL)。以下是如何在 MyBatis-Plus 中高效处理复杂映射的详细指南:
核心概念
ResultMap
(核心): MyBatis 的核心配置,用于定义如何将 SQL 查询结果的列映射到 Java 对象的属性(包括嵌套对象的属性)。MP 基于 MyBatis,完全兼容ResultMap
。@TableField
(MP 注解): 用于标记实体类属性与数据库表字段的对应关系。在关联映射中,主要用来排除非表字段(exist = false
),避免 MP 的自动注入逻辑处理这些关联对象属性。- 关联类型:
- 一对一 (
<association>
/@One
): 一个对象包含另一个对象的引用 (e.g.,Order
包含User
user)。 - 一对多 (
<collection>
/@Many
): 一个对象包含另一个对象的集合 (e.g.,User
包含List<Order>
orders)。 - 多对多: 通常通过中间表拆解为两个一对多关系 (e.g.,
User
-UserRole
-Role
,User
包含List<Role>
roles)。
- 一对一 (
- 查询方式:
- JOIN 查询 (推荐): 编写单条 SQL 使用
JOIN
一次性查询出主对象及其关联的所有子对象数据,通过ResultMap
进行复杂映射。性能通常更好(减少数据库交互次数)。 - N+1 查询 (谨慎使用): 先查询主对象列表 (1),然后为每个主对象发起额外的查询获取其关联对象 (N)。容易导致性能问题,但在某些分页或延迟加载场景下可能有用(MP 本身不直接支持延迟加载,需结合 MyBatis 或自定义)。
- JOIN 查询 (推荐): 编写单条 SQL 使用
操作步骤 (以 JOIN + ResultMap 为主)
场景示例
User
(用户) 1 : NOrder
(订单)Order
(订单) 1 : 1Product
(商品) - 假设简化User
M : MRole
(角色) - 通过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);
}
}
常见错误
- 忘记
@TableField(exist = false)
: 导致 MP 尝试将关联对象属性当作数据库字段处理,引发Unknown column
错误或字段找不到异常。 ResultMap
配置错误:id
/result
/association
/collection
的property
或column
拼写错误。ofType
/javaType
指定错误。- 嵌套映射层级错误。
- 列名别名冲突: 多个表有相同列名 (如
id
,name
) 时,在 SQL 中必须使用别名 (AS
) 区分,并在ResultMap
中使用别名column
。
- N+1 查询问题 (当使用 N+1 方式时): 循环查询关联数据导致数据库压力剧增,性能极差。强烈建议优先使用 JOIN + ResultMap。
- 循环引用 (StackOverflowError): 例如
User
里有List<Order>
,Order
里又有User user
。如果序列化 (如 JSON 返回给前端) 时不处理,会导致无限递归。解决方案:- 在
ResultMap
中只映射必要的关联字段 (如只映射id
)。 - 使用
@JsonIgnore
(Jackson) 或@JSONField(serialize=false)
(Fastjson) 在序列化时忽略特定属性。 - 使用 DTO (Data Transfer Object) 代替实体直接返回,在 DTO 中精确控制要暴露的数据和关联关系。
- 在
- 分页 + 关联查询问题: 使用
JOIN
查询一对多/多对多时,如果主表记录有多条关联子表记录,MyBatis 默认的分页 (Page
对象) 返回的主记录数会是重复的(因为 JOIN 导致行数变多)。解决方案:- 使用
DISTINCT
或GROUP BY
主表ID 确保主表记录唯一性(但计算总数可能不准)。 - 最佳实践: 先分页查询主表ID列表,再根据ID列表查询关联数据组装。MP 的
selectPage
不适用于复杂 JOIN 分页。
- 使用
- 空指针异常 (NPE): 关联对象可能为
null
(如 LEFT JOIN 未匹配到)。在代码中访问关联对象的属性时要进行null
检查。
注意事项
- 明确需求: 不要过度查询关联数据,只查询当前业务场景真正需要的数据。避免传输和组装不必要的大对象图。
- 性能第一: 优先选择 JOIN + 单次查询 +
ResultMap
映射的方式。避免 N+1。 - DTO 是好朋友: 在 Service 层或 Controller 层,考虑将复杂的带有关联的实体对象转换为更精简、更适合特定视图或 API 响应的 DTO。这能有效解决循环引用、暴露过多信息、性能等问题。
- 分页陷阱: 深刻理解分页与关联查询 (
JOIN
) 结合时的问题,并采用正确的解决方案(先分页主ID再查关联)。 - 代码组织: 将复杂的
ResultMap
和自定义 SQL 放在 XML 文件中,保持 Mapper 接口的清晰。大型项目可以考虑按模块拆分 XML。 - 测试: 务必为复杂的关联查询编写单元测试或集成测试,验证映射结果的正确性和完整性。
使用技巧
- 利用 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/@Many
的select
属性会触发额外的查询,本质上是 N+1。要避免性能问题,应直接在@Select
中写 JOIN 并在@Results
中映射结果。 - 查询构造器 + 自定义 SQL 片段 (谨慎): 对于条件多变的关联查询,可以尝试用
QueryWrapper
构造主查询条件,结合apply
方法拼接自定义的关联 JOIN 片段 (JOIN ... ON ...
)。这需要小心 SQL 注入和拼接复杂度。 - 懒加载 (需集成其他库或自定义): MP 本身不提供。可考虑集成 MyBatis 的懒加载支持 (需配置
aggressiveLazyLoading
,lazyLoadingEnabled
) 或使用第三方库 (如 mybatis-lazy)。务必评估性能影响和复杂性。 - 复用
ResultMap
: 使用<resultMap extends="baseMap" ...>
继承已有的基础映射,避免重复配置公共字段映射。
最佳实践与性能优化
- JOIN + ResultMap 是王道: 对于大多数需要立即加载关联数据的场景,这是性能最优、最可控的方式。
- 严格使用 DTO: 强烈推荐 在 Service 层或专门的 Assembler/Converter 层,将包含关联数据的 Entity 对象转换为只包含必要数据的 DTO 对象返回给 Controller 或前端。这解决了循环引用、信息泄露、序列化性能、过度获取数据等问题。
- 避免查询整个对象图: 只查询需要的关联层级和字段。例如,在订单列表页,查询订单和关联的商品名称即可,不需要加载商品的完整详情和用户的所有信息。
- 分页优化:
- 步骤 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 分页的行数膨胀问题。
- 步骤 1: 使用 MP 的
- 索引优化: 确保关联查询 (
JOIN
,WHERE
,ORDER BY
) 涉及的字段上建立了合适的数据库索引。 - 监控与分析: 使用数据库慢查询日志、APM 工具 (如 SkyWalking, Pinpoint) 或 MyBatis 的 SQL 打印功能,监控复杂查询的执行时间和性能瓶颈,持续优化 SQL 语句和索引。
- 缓存策略: 对于读多写少且变化不频繁的关联数据 (如省市县、角色权限),考虑引入缓存 (Redis, Memcached, MyBatis 二级缓存 - 谨慎使用)。在 Service 层组装数据时优先查询缓存。
总结
掌握 MyBatis-Plus 的复杂映射关键在于:
- 深刻理解
ResultMap
和@TableField(exist=false)
。 - 坚持使用
JOIN
查询 +ResultMap
映射作为主要手段。 - 警惕并规避 N+1 查询和分页陷阱。
- 积极使用 DTO 进行数据转换和组装。
- 实施分页场景下的“先分页主ID,再查关联组装”策略。
- 持续关注性能优化和索引。
通过遵循这些原则和实践,你可以在享受 MyBatis-Plus 单表操作便捷性的同时,高效、可控地处理复杂的对象关联映射需求。