在 MyBatis 3 中使用 foreach 遍历 Apache Commons Lang Pair 集合时,循环变量 item 可能会变成 String,并触发 There is no getter for property named 'left' 异常。根因不是 Pair 缺少 getLeft,而是 Pair 实现了 Map.Entry;MyBatis 会把 left/key 绑定给 index、right/value 绑定给 item。
如果传入的是 List<Pair<String, String>>,并且 Pair 的 right 恰好是字符串,那么后续访问 #{pair.left} 时,MyBatis 实际上是在解析这个字符串的 left 属性。
这就是下面这个异常真正想表达的事情:
There is no getter for property named 'left'
in 'class java.lang.String'
问题并不是 Pair#getLeft() 不符合 Java Bean 规范,而是执行到这里时,名为 pair 的变量已经不是 Pair 了。
问题如何复现
假设 Mapper 接收一组用户与角色的关系:
int batchInsert(
@Param("pairs")
List<Pair<String, String>> pairs
);
调用时传入:
List<Pair<String, String>> pairs = List.of(
Pair.of("1001", "admin"),
Pair.of("1002", "editor")
);
XML 中直接通过 left、right 读取两个值:
INSERT INTO user_role (user_id, role_code)
VALUES
<foreach collection="pairs" item="pair" separator=",">
(#{pair.left}, #{pair.right})
</foreach>
直觉上,每轮循环中的 pair 应该分别是:
Pair("1001", "admin")
Pair("1002", "editor")
但 MyBatis 实际绑定的并不是这个结果。
Apache Commons Pair 同时也是 Map.Entry
这里使用的是 org.apache.commons.lang3.tuple.Pair。它除了提供 getLeft() 和 getRight(),还实现了 Map.Entry<L, R>。Apache Commons Lang 的官方文档也明确了这组对应关系:key 是 left,value 是 right。
Pair.left == Map.Entry.key
Pair.right == Map.Entry.value
因此,下面四个调用可以分成两组:
pair.getLeft(); // 等价于 getKey()
pair.getRight(); // 等价于 getValue()
如果 Pair 只是作为普通对象交给属性解析器,left 和 right 本来都能正常读取。真正改变行为的是它的 Map.Entry 身份。
MyBatis foreach 会展开 Map.Entry
MyBatis 的 <foreach> 官方文档 专门说明了两种绑定方式:
- 遍历普通
Iterable或数组时,index是当前序号,item是当前元素; - 遍历
Map或Map.Entry集合时,index是 entry 的 key,item是 entry 的 value。
当前 ForEachSqlNode 的处理逻辑可以简化成下面这段伪代码:
for (Object element : iterable) {
if (element instanceof Map.Entry<?, ?> entry) {
bind(indexName, entry.getKey());
bind(itemName, entry.getValue());
} else {
bind(indexName, currentPosition);
bind(itemName, element);
}
}
这段分支原本让 Map 遍历更自然。例如:
<foreach collection="users" index="userId" item="user">
#{userId}, #{user.name}
</foreach>
当 users 是 Map<String, User> 时,key 会进入 userId,value 会进入 user。
问题在于,Apache Commons Pair 也满足 element instanceof Map.Entry。
item 为什么会变成 String
以这组数据为例:
Pair.of("1001", "admin")
进入 <foreach> 后,变量绑定会变成:
| Pair 中的值 | Map.Entry 语义 | foreach 变量 |
|---|---|---|
left = "1001" | entry.getKey() | index |
right = "admin" | entry.getValue() | item |
如果 XML 把 item 命名为 pair,完整过程就是:
Pair.of("1001", "admin")
↓
index = "1001"
pair = "admin"
↓
#{pair.left}
↓
读取 "admin" 的 left 属性
MyBatis 随后通过属性访问机制寻找 String 的 left getter,自然无法找到,于是异常中出现了 class java.lang.String。
这个类型信息很关键。它说明当前属性解析目标是 Pair 的 right 值,而不是 Pair 本身。如果 right 是 Long,异常里就可能出现 Long;如果 right 是另一个业务对象,MyBatis 尝试解析的也会是那个对象。
方案一:直接使用 index 和 item
既然 MyBatis 已经按照 Map.Entry 语义拆开 Pair,最小改动就是直接使用拆开后的两个变量:
INSERT INTO user_role (user_id, role_code)
VALUES
<foreach collection="pairs"
index="left"
item="right"
separator=",">
(#{left}, #{right})
</foreach>
此时:
Pair.left → foreach.index → left
Pair.right → foreach.item → right
需要注意的是,这里的 index 不再是 0、1、2 这样的 List 下标,而是 Map.Entry#getKey() 返回的对象。
这个方案适合改动范围较小、Pair 只在 Mapper 附近临时使用的场景。不过 XML 读者必须知道 MyBatis 对 Map.Entry 的特殊语义,否则 index="left" 仍然有些反直觉。
方案二:改用明确的参数对象
如果这组数据有稳定的业务含义,我更倾向于不要让 Pair 跨越 Mapper 边界。
例如“用户—角色关系”可以定义成一个明确的参数对象:
public final class UserRoleRow {
private final Long userId;
private final Long roleId;
public UserRoleRow(Long userId, Long roleId) {
this.userId = userId;
this.roleId = roleId;
}
public Long getUserId() {
return userId;
}
public Long getRoleId() {
return roleId;
}
}
Mapper 参数改为:
int batchInsert(
@Param("rows")
List<UserRoleRow> rows
);
XML 也回到常见的对象属性写法:
INSERT INTO user_role (user_id, role_id)
VALUES
<foreach collection="rows" item="row" separator=",">
(#{row.userId}, #{row.roleId})
</foreach>
这样做不仅避开了 Map.Entry 分支,也让参数本身带上了业务语义。userId、roleId 通常比 left、right 更容易理解,字段类型或校验规则发生变化时也更容易维护。
如果上层代码暂时必须保留 Pair,可以在进入 Mapper 前做一次转换:
List<UserRoleRow> rows = pairs.stream()
.map(pair -> new UserRoleRow(pair.getLeft(), pair.getRight()))
.toList();
不是所有名为 Pair 的类型都会触发
判断标准不是类型名是否叫 Pair,而是运行时元素是否实现了 Map.Entry。
Apache Commons Lang 的 Pair、ImmutablePair 和 MutablePair 都会进入这个分支,因为后两者继承自 Pair。其他库提供的二元组类型如果没有实现 Map.Entry,仍会被当作普通元素绑定给 item。
所以排查类似问题时,比起只看泛型声明,更值得确认实际元素类型及其实现的接口。
遇到 no getter 异常时先看实际类型
以后再看到类似异常:
There is no getter for property named 'xxx'
in 'class java.lang.String'
而传入参数明明是复杂对象,可以按下面的顺序检查:
- 先看异常中的实际类型,而不是只看 Mapper 方法签名;
- 确认
<foreach>的item和index分别绑定了什么; - 检查集合元素是否实现了
Map.Entry; - 再判断究竟是 getter 缺失,还是属性解析目标已经发生变化。
这次问题的关键链路可以压缩成一句话:
Apache Commons Pair 实现 Map.Entry
→ MyBatis foreach 按 key/value 展开
→ right 被绑定为 item
→ pair.left 实际变成 String.left
所以它不是 Pair getter 的兼容性问题,而是两个都很合理的接口设计叠在一起后,产生了一次不太明显的语义冲突。