[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 上的回答都和官方文檔一樣否定了這種可能性。看起來,筆者似乎發現了一個很有意思的原創結論也說不定(笑)。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM