Domaで1対多(1対N)マッピングを行う
Javaには様々なO/Rマッピングフレームワーク(以降ORM)が存在します。
今回のテーマであるDomaはその一つですが、似たような性質を持つjOOQやSpring JDBCでも参考になると思います。
DomaにはCriteria APIがあり、その一部として1対多へのマッピング機能が提供されています。
しかし、今回はいくつかの理由でこの機能を使わず1対多へのマッピングしたい場合になります。
- ORMをシンプルに利用したい(DSLなどの学習コストを減らしたい)
- SQLを直接利用したい(最適なクエリーの選択やDB固有の機能を活かしたい)
- マッピング先のオブジェクトはORMに依存しない独自クラスを利用したい(不要なデフォルトコンストラクタやgetter/setter、ミュータブルなコレクション、ORM用のアノテーション、ネスト構造など)
したがって、このエントリー中のDomaに関する内容は、SQLを直接利用する場合を前提としています。
TL;DR📝
- Domaでも1対多のマッピングはできる
- APIがSimpleなのでEasyなレイヤーを用意する
- マッピングするオブジェクトにORM固有の制約は必要なく、静的検査を最大限活かせる
課題👿
そもそもORMの役割として、DBとオブジェクトという異なる性質をもったデータをマッピングしますが、これによりインピーダンス・ミスマッチが発生することがあり、その代表が1対多の場合です。
これに対しORMによりその解決方法は変わってきます。
- MyBatis、JPA、Hibernate
XMLまたはアノテーションとリフレクションにより、1対多のマッピングをORMが行う - Doma、 jOOQ、Spring JDBC
Javaのマッピングコードにより、1対多のマッピングをユーザーが行う
この中で個人的にDomaが魅力的だと思ったのは、SQLという異なる言語を利用しても、素早く、安全に開発ができるという点でした。
具体的には以下があげられれます。
- 静的検査範囲が広くランタイムエラーが最小化されている(SQL内変数もコンパイラで検知できる)
- ネイティブなSQLを記述するのに適している(2-way SQL)
- XMLやアノテーション、DSLなど、ORMを使うための機能をほとんど覚えなくても利用できる(学習コストが低い)
ここからはこの中でも、同じようなSQLを書くORMであるMyBatisを比較対象に、Domaにおける1対多マッピングについて説明できればと思います。
SimpleとEasy🆚
Domaは数あるORMの中でもシンプルな部類に分類されると思います。
その点は作者の中村さんのエントリからも伺えます。
学習コストが低く、Simpleな一方で、今回ようなケースはユーザーに任されている部分もあります。
1対多マッピングを例にした場合、MyBatisはこの問題をEasyに解決する仕組みが用意されています。
このようにXML(またはアノテーション)定義を行うことでマッピングを実現できるため、ORMの使い方さえ覚えてしまえば詳細処理を考える必要がありません。
もう少し違いを整理してみましょう。
MyBatisはORMがオブジェクトへのマッピング(1対多マッピング)提供しており、ORMが決めたルール(記法)に従いXMLを定義することで1対多マッピングを実現しています。
Domaではここに相当する機能はユーザーに任されているため、スキーマからオブジェクトへのマッピングをJavaのコードで行う必要があります。
逆に言うと、MyBatis相当のルール(記法)を実現する仕組みがあれば、全てJavaのコードで1対多のマッピングを実現できるようになります。
従ってここからは、どのようにその仕組みを作るかを順を追って説明します。
Domaによる1対多マッピング⚡
今回は以下のテーブルを例にしたいと思います。
このテーブルから以下のSQLでユーザーの一覧データを取得します。
また、このデータをマッピングするクラス定義として以下があります。
SELECT結果を受け取るResultEntityクラスと、マッピング先のUser、Tweet、Comment、Likeクラスです。
※ 以降の処理は説明用に、一部省略している場合があります。
ここからは順を追って、Domaにおける1対多のマッピングを説明しますが、結論だけ知りたい方は 7. Collect検索 をご覧ください。
✅ 1. 基本的なマッピング
最も多く利用するのは、ListですべてのEntityを取得するパターンだと思います。
取得したEntityのListを、最終的に出力したいUserのListに変換していきます。今回のような畳込みを行いたい場合にはStream#reduceが活用できます。
Daoの結果から直接出力したいList<User>の変換ができるようになりました。
✅ 2. 生成したインスタンスのキャッシュ化
1の実装では、取得したEntityごとに生成するインスタンスのListを毎回条件一致するまで走査しています。
この処理は既に生成済みのインスタンスに値をセットする上で必要になるのですが、取得するEntityの量が増えれば増えるほど走査量が増えるためパフォーマンスの悪化に繋がります。
MyBatisの実装を見てみると、DefaultResultSetHandler内で生成したオブジェクトをキャッシュするようになっており、取得したデータと、マッピング対象のオブジェクトを元に再利用できるようになっています。
今回のケースでも同様にMapを利用したキャッシュを作ってみます。
キャッシュのキーには取得したEntityと生成対象のオブジェクト情報を元に、データが一意になる処理をListで保持するようにしています。
このキャッシュ機構により、Listの走査が不要となり、効率的なマッピングが可能になります。
✅ 3. 処理の汎用化
ここまでの処理では、それぞれ異なるクラスのマッピング処理を、それぞれのif配下で行ってきました。
この処理をルール化するにはもう一歩汎用化が必要になるため、内部の処理を同じ処理に切り出してみます。
✅ 4. 処理の共通化
ここまですると、変換処理に独自要素はなくネストしている以外は基本同じ処理になっています。
したがって、これらの処理を別のメソッドに切り出すことが可能になります。
汎用化した処理を外部に切り出したことにより、マッピングの処理本体はかなり整理されました。
ここまででかなり処理内容が集約されてきてますが、まだいくつかの問題が残っています。
- reduce()の引数であるArrayListの生成や結合処理を毎回書かなければならない
- マッピング処理にキャッシュという内部処理を意識したコードが存在する
しかしreduceのメソッドは引数が固定されてるためこれらを避けることはできません。
こういった場合に便利なのがStream APIの集約処理であるCollectorです。
CollectorはCollectorsとして提供されてるメソッド以外にも自身で定義し、集約処理を簡潔にするために利用できます。
✅ 5. Collectorの活用
reduceをcollectorに変更してみます。
これだけでは先程と変わらないので、Collectorを生成するメソッドを別に定義してみます。
Collectorを実装することによりボイラープレートであった、ArrayListの生成や結合処理を意識することなく、マッピングを行えるようになりました。
✅ 6. キャッシュの隠蔽
ここまでmappingメソッド引数としてキャッシュを渡してきました、これらも隠蔽できるとより簡潔に実装できるようになります。
これを実現するためにはキャッシュを隠蔽たクラスを用意し、引数として受け取るようにします。
更に汎用的なメソッドだったreduceByをマッピングに特化した別メソッドとして定義、キャッシュを提供するようにしてみます。
また、変数定義を減らすのであれば以下のように定義もできます。
ここまで来るとキャッシュを意識した処理がなくなり、必要最小限の変換処理のみに集中できるようになります。
✅ 7. Collect検索
最後になりますがDomaにはCollect検索が用意されており、Daoの引数として直接Collectorを渡すことができます。
このように、ボイラープレート部分を外部に切り出すことで、必要となる処理のみでマッピングを実現できるようになります。
好みはあると思いますが、MyBatisのコードと比較しても記述量には遜色なく、Javaのコードのため、オブジェクトの生成や、要素の追加処理が分かりやすく、コンパイル時に問題も検知できるようになります。
💡その他の選択肢
ここで詳細は取り上げませんが、用途次第ではMyBatisのようにリフレクションを併用するという選択肢もあります。
マッピングの処理の制約増加や実行時例外とトレードオフはありますが、アノテーションを利用して、Identifierを省略したり、フィールド名などを読み込むことでCreatorやSetterの定義を省略することも可能です。
また別のアプローチとして、@gakuzzzz さんが紹介してくださっているマッピング方法もあります。
https://gakuzzzz.github.io/slides/doma_practice/#29
それだけではなく、Java 13からはText Blocksが導入され、Domaの@Sqlと組み合わせることで、JavaのコードのみでSQL定義の管理しやすくなりました。
Java 14からはRecordが導入され、毎回必要となるEntity定義もよりシンプルに行えるようになっています。
まとめ🚀
いろいろ紹介しましたが ”1.基本的なマッピング” も冗長な部分はありますが、読みやすいと思う人も多いでしょう。
これは今回切り出した処理がMyBatis同様、Javaのメソッドによってルール化されており、それを理解する学習コストが発生するためです。
逆に言えば一度覚えてしまえば、コンパイラのサポートのもと普段通りのJavaのコードでマッピングが記述でき、必要であれば実装をすぐに確認したり、自分の用途に合わせ最適化もできます。
DomaにはSQLの結果(Entity)とのSimpleなマッピングに集中し、1対多のような高度なマッピングはJavaのコードで行うことで他の高機能なORM相当の処理にも対応できるようになります。今回はその一部として参考になればと思います。