[MyBatis黑魔法] 用纯注解实现联合查询(JOIN)的结果映射

Posted 现代魔术工房

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[MyBatis黑魔法] 用纯注解实现联合查询(JOIN)的结果映射相关的知识,希望对你有一定的参考价值。

0x01 起因

一切都始于一个看上去很简单的需求。笔者的博客数据库内有这么三张表:

CREATE TABLE `article` (
  `id` int PRIMARY KEY
);
CREATE TABLE `tag` (
  `id` int PRIMARY KEY
);
CREATE TABLE `article_tag` (
  `article_id` int NOT NULL,
  `tag_id` int NOT NULL
);

很容易看出,这是一个文章(article)和标签(tag)之间的多对多关系。现在,笔者想要从数据持久层获取到文章列表,并且得到每个文章被打上的标签,映射到如下实体类中:

class Article {
	int id;
	List<Tag> tags;
}

class Tag {
	int id;
}

用 MyBatis 来实现的话,有如下两种思路。

子查询

先用如下语句查询出文章列表:

SELECT id FROM article;

然后遍历结果集,用每行的 id 列作为参数执行:

SELECT t.id FROM tag t,article_tag at WHERE t.id = at.tag_id AND at.id = #{id};

用 MyBatis 的注解方式实现这个不难,只需按照如下方式定义 Mapper:

public interface Mapper {
	@Select("SELECT id FROM article")
	@Results({
		@Result(property = "id", column = "id", id = true),
		@Result(property = "tags", column = "id", many = @Many(select = "getTagByArticleId"))
	})
	List<Article> getArticles();

	@Select("SELECT t.id FROM tag t,article_tag at WHERE t.id = at.tag_id AND at.id = #{id}")
	@Results({
		@Result(property = "id", column = "t.id", id = true)
	})
	List<Tag> getTagByArticleId(@Param("id") int id);
}

@Many 注解会自动对每个 Article 对象调用 getTagByArticleId 方法,将结果填充到 tags 属性中。然而,这种解法虽然简单,却有着致命的性能问题:假设文章数量为 n,MyBatis 不仅需要执行一条 SQL 来查询文章,还需要执行 n 条附带的 SQL 语句来查询所有文章的标签。这就是著名的「n+1 问题」。如果文章很多,这种查询效率是非常感人的。

联合查询

另一种做法就是直接用一条 SQL 来查询全部信息:

SELECT a.id AS id,t.id AS tag_id FROM article a
INNER JOIN article_tag at, tag t ON at.article_id = a.id AND t.id = at.tag_id;

这种带 JOIN 关键字的查询称为联合查询,它会为每对满足条件的 article 和 tag 都生成一个结果行。虽然这条语句返回的结果包含不少冗余信息(重复的文章id),但执行的 SQL 语句数缩减到了仅仅一条,效率肯定会比 n+1 条查询高不少。

现在问题来了,如何编写 Mapper 来映射查询结果呢?

0x02 调查

笔者打开 MyBatis 的官方文档查找解决方案,没想到却被当头浇了一盆冷水。在 Java API 一节有关 @Many 的介绍中,有着如下叙述:

You will notice that join mapping is not supported via the Annotations API. This is due to the limitation in Java Annotations that does not allow for circular references.

看样子,MyBatis 官方已经明确否认了利用 Java 注解进行联合查询映射的可能性,只能通过在 Mapper 相同目录下编写同名 xml 配置文件的方式去定义 ResultMap 了。当然,这种解决方案非常不优雅:不仅引入了一个本来不应存在的配置文件,将 SQL 和接口割裂,甚至格式还是笔者最讨厌的 xml ——这种格式根本不存在所谓的「可读性」!虽然 MyBatis 最初的确是一个 xml 框架,但发展到现在也已经有了和时代接轨的注解 API,笔者不愿相信它连一个如此简单的问题也无法解决。

真的到此为止了吗?在放弃之前,笔者至少想弄清楚其中的缘由。什么是「循环引用(circular reference)」?文档对此没有明确的说明,但根据字面意义来推测的话,应该是指类似如下形式的注解:

@interface Foo {
	Bar value() default @Bar;
}

@interface Bar {
	Foo value() default @Foo;
}

经过测试,上面的注解确实无法通过编译。这下就不难理解官方文档的悲观态度了:为了进行联合查询映射,需要在 ResultMap 中 定义另一个 ResultMap 来映射子对象,然而 @Results 注解中并不能包含另一个 @Results 注解。这样一来,纯注解下的 ResultMap 嵌套定义就无法完成了。

万念俱灰之时,笔者突然灵光一闪,回忆起官方文档中有关联合查询映射的一段 xml 代码:

<resultMap id="blogResult" type="Blog">
  <id property="id" column="blog_id" />
  <result property="title" column="blog_title"/>
  <association property="author"
    resultMap="authorResult" />
  <association property="coAuthor"
    resultMap="authorResult"
    columnPrefix="co_" />
</resultMap>

这段代码并没有直接在 ResultMap 标签中定义另一个 ResultMap,而是引用了另一个在别处定义好的 resultMap,同样完成了子对象的映射。最重要的是,完成这一切所需的关键属性——resultMap 和 columnPrefix,在 Java API 中的 @Many 注解内竟然能找到对应的属性:

resultMap(available since 3.5.5), which is the fully qualified name of a result map that map to a single container object from select result. columnPrefix(available since 3.5.5), which is column prefix for grouping select columns at nested result map.

这两个属性都是 3.5.5 版本新加入的。除此之外,@Results 注解的 id 属性可以定义 ResultMap 的 id:

The id attribute is the name of the result mapping.

看到这里,笔者不禁会心一笑——这个过时的 Java API 文档,是时候更新一下它的结论了。

0x03 解决

以下是笔者最终编写的 Mapper 代码(当然,需要在 MyBatis 3.5.5 以上的版本中运行):

public interface Mapper {
	@Select("SELECT a.id AS id,t.id AS tag_id FROM article a"
	+ " INNER JOIN article_tag at, tag t ON at.article_id = a.id AND t.id = at.tag_id")
	@Results({
		@Result(property = "id", column = "id", id = true),
		@Result(property = "tags", many = @Many(resultMap = "tagMap", columnPrefix = "tag_"))
	})
	List<Article> getArticles();

	@Select("SELECT id FROM tag WHERE id = #{id}")
	@Results(id = "tagMap", value = {
		@Result(property = "id", column = "id", id = true)
	})
	Tag getTag(@Param("id") int id);
}

位于下方的 @Results 注解定义了一个名为 tagMap 的 ResultMap,它会被上方的 @Many 注解引用。除此之外,columnPrefix 属性会为 tagMap 中的所有列名都加上一个 tag_ 前缀,这样一来就能匹配上联合查询 SQL 语句中实际返回的列名(例如 tag_id)了。

事实上,getTag 这个方法并没有被真的调用,甚至上面 Select 注解中的 SQL 语句也不会被执行。定义这个方法只是因为 @Results 注解必定要依存于一个方法,换句话来说,这个方法只是占位符而已。可能有人会认为它很鸡肋,但笔者觉得也没那么严重——又有谁能保证 tagMap 永远不会被某个有用的方法单独使用呢?

经过测试,上面的 Mapper 完美地完成了任务。值得一提的是,引用的 ResultMap 不一定要定义在同一个 Mapper 中,也可以用全限定名去引用 Mapper 外部的 ResultMap。具体方法留给各位自行探索,笔者不再赘述。

顺带一提,截至笔者完成这篇文章时,Google 上依然搜不到任何利用 Java 注解进行联合查询映射的方法,仅有的几篇 StackOverflow 上的回答都和官方文档一样否定了这种可能性。看起来,笔者似乎发现了一个很有意思的原创结论也说不定(笑)。

以上是关于[MyBatis黑魔法] 用纯注解实现联合查询(JOIN)的结果映射的主要内容,如果未能解决你的问题,请参考以下文章

Mybatis注解开发:使用注解实现一对一一对多多对多查询

MyBatis注解开发之一对多查询

MyBatis注解开发之多对多查询

Mybatis -- MyBatis的注解实现复杂映射开发

MyBatis注解开发之一对一查询

MyBatis注解开发---实现自定义映射关系和关联查询