MyBatis が private フィールドに値をセットする仕組みの調査
こんにちは。株式会社 Interfamilia の Waka です。
7月に入りましたが、まだまだ梅雨が明けず、雨が降ったり降らなかったり。ジメジメとした蒸し暑い気候が続いています。
これからドンドン熱くなると思いますので、熱中症に気を付けながら、日々の生活を過ごしたいですね。
そんな感じで、蒸し暑くて憂鬱な季節ではありますが、嬉しいこともありました。この度 晴れて正社員になったのです!
本来は約1年間の研修を通して、正社員登用が行われるのですが…この研修期間を短縮してもらい、4ヶ月早く正社員になれました!嬉しいですね。
今後も優秀なエンジニアを目指して、技術研鑽に努めようと思います。
今回の記事では、そんな自分が日々の開発業務で学んだ、MyBatisの仕様に関するTIPSをご紹介したいと思います!
目次
「MyBatis」 は、 SQL と Javaオブジェクトをマッピングできる、便利なフレームワークです。
データベースから取得した情報を、Javaのソースコードに渡す連携処理を実現できます。
ただ、MyBatisのマッピングの原理・ルールについては謎が多い です。
かなり柔軟なマッピングが可能で、外部からアクセス不可の private
フィールドにも、値をセットできてしまいます。
おそらく、Javaオブジェクト側で定義した Getter / Setter メソッドを使用しているのだと思われますが…実際のところ原理がよく分かりません。
MyBatis 公式ドキュメント
も確認してみましたが…めぼしい情報は見当たりませんでした。
気になってネット検索してみたところ、以下のような情報を発見しました。
値のセットは、 Setter メソッドがあればそれ経由で、なければフィールドに直接行われる。
後学のために、この記述が正しいのかどうか、実際に検証して確かめることにしました。
調査の結果、MyBatis には以下のような仕様があると分かりました!
- Javaオブジェクトにコンストラクタが定義されている場合、それが呼び出され、インスタンス化が行われる。
- 以下の優先度でコンストラクタが呼び出される。複数ある場合は、優先度が高いものが呼び出される。
- デフォルトコンストラクタ
- 引数付きコンストラクタ
- (暗黙のデフォルトコンストラクタ)
- 以下の優先度でコンストラクタが呼び出される。複数ある場合は、優先度が高いものが呼び出される。
以下の条件に該当する場合、Javaオブジェクトのフィールドに値をセットできる。
- RDBのカラム名と、Javaオブジェクトのフィールド名が一致する場合。
mapUnderscoreToCamelCase = true
の指定で、スネークケース ⇒ キャメルケース への自動変換、値のセットができる。(デフォルトではfalse
)- 上記の設定がない場合は、RDBからSELECTした順番で、フィールドに値をセットしていく。
resultMap
の<result>
要素で、カラム名とフィールド名をマッピングしている場合。
- RDBのカラム名と、Javaオブジェクトのフィールド名が一致する場合。
値をセットする際には、Setterの有無で処理が変わる。
- Setter がある場合、当該フィールドの Setter を呼び出し、値をセットする。
- Setter が無い場合、MyBatis がフィールドに直接アクセスして、値をセットする。
- 「仕様2」の条件を満たさなくても、引数付きコンストラクタでインスタンスを生成した場合は、値をセット可能。
- ただし、RDBのカラムデータを、SELECTした順番でコンストラクタの引数に渡すため、想定外の動作を引き起こす可能性がある。
- Getter は、値のセットには一切使われない。
ここからは、実際の検証内容と、上記の結論に至った経緯について解説したいと思います。
Java:openjdk 11.0.9
Spring Boot:2.5.0
MyBatis:3.5.7
PostgreSQL:11.6
調査用に作成したソースコードです。
郵便住所データベースから取得した情報を、Address
クラスに格納する!という操作になります。
以下の private
フィールドを設定しました。
- 「postalCode」:郵便住所 を格納する String変数。
- 「streetName」:町名 を格納する String変数。
|
|
以下2種類のSELECT文を用意しました。
- 「selectAddress1」:
resultMap
で、RDBのカラム名と、Javaのフィールド名をマッピングする。 - 「selectAddress2」:
resultType
で、Address
クラスをそのまま指定。フィールドのマッピング設定はしない。
|
|
Javaオブジェクト内で コンストラクタが定義されている場合、最初にそれが呼び出されて、インスタンスを生成します。
デフォルトコンストラクタと、引数付きコンストラクタがある場合は、デフォルトコンストラクタが優先的に呼び出される ようです。
ただし、後述の「仕様2」の条件を満たさず、値がセットされないまま処理が進行すると、nullオブジェクトとして処理が行われます。
インスタンスが生成された後、Setter が呼び出され、各フィールドに値がセットされます。
コンストラクタで初期化されたフィールド値も、Setterによって上書きされる ことが分かりました。
Setterが無くても、以下の条件に該当する場合、フィールドに値をセット可能です。
- RDBのカラム名・Javaオブジェクトのフィールド名を一致させ、
mapUnderscoreToCamelCase = true
を設定した場合。 resultMap
を設定して、カラム名とフィールド名を正しくマッピングしている場合。
以下の例では、resultMap
でマッピング設定をした後、Setterをコメントアウトして処理を実行しています。
コンストラクタで初期化した値を上書きできることから、Setter以外の何らかの方法で private
フィールドに直接アクセスして、値をセットしている と推測できます。
つまり、Setterは必須でない ことが分かります。
ただし、Setter が存在する場合は、そちらが優先的に使われる ようです。
以下の例では、resultMap
でセットしたフィールド値を、Setterで上書きしています。
また、resultMap
もしくは resultType
を使ってマッピング設定を行う場合は、以下の点に注意すべきだと分かりました。
resultMap
を使う場合は、<result>
要素を指定してカラム名・フィールド名を一致させることで、フィールドに値をセットすることが可能です。
しかし、以下のように <result>
要素を省略すると、前述の条件を満たさず、値がセットできなくなります。
|
|
resultType
を使う場合は、以下の方法でカラム名・フィールド名を一致させれば、値をセット可能です。
- SELECTする カラムに
AS
で別名をつける- RDBのカラム名・Javaのフィールド名を一致させることで、Javaオブジェクトの Setter を呼び出せるようになり、フィールド値をセット可能になります。
|
|
map-underscore-to-camel-case: true
を設定するapplication.yml
に設定を追記して、スネークケース ⇒ キャメルケースに自動変換。- これにより、対応するJavaオブジェクトの Setter が呼び出され、フィールド値をセットできます。
|
|
「仕様2」の条件に合致していなくても、引数付きコンストラクタを呼び出せる場合は、インスタンス生成時にフィールドに値がセットできます。
ただし「仕様1」で紹介したように、デフォルトコンストラクタが存在するとそちらが優先で使われるので、注意が必要です。
また、MyBatisのマッピングがうまく機能せず、RDBから取得したデータを Javaオブジェクトに正しく格納できない場合もあります。
例えば、以下のソースコードでは、コンストラクタの引数の順番を逆にしてみました。
本来なら postal_code
⇒ postalCode
とマッピングされるハズです。
しかし…何故か postal_code
⇒ streetName
とセットされる、想定外の動作を引き起こしてしまいました。
この結果から、RDBから取得してきた順番で、フィールド値を順次セットしていることが分かります。
今回はフィールド値が両方とも String だったので、エラーは発生せず、値が逆にセットされるという異常現象が起きました。
String と int の組み合わせなど、複数の型をフィールド値に指定した場合は、ここでエラーが発生します。
Address
クラスの Getterメソッドにブレークポイントを置いて、デバッグモードで調査しましたが…Getterを通過する処理は1つもありませんでした。
このことから、MyBatis が Getterを一切使用していない ことが分かります。
冒頭でご紹介した ↓ のQiita情報は、「仕様2」の条件を満たすを場合、正しいと言える ことが、今回の調査で分かりました。
値のセットは、 Setter メソッドがあればそれ経由で、なければフィールドに直接行われる。
MyBatis を利用する場合、仕様を正しく理解した上で、RDB のカラム名・Javaオブジェクトのフィールド名を、正しくマッピングさせる必要があります。
マッピングの際には、基本的にresultMap
を使えば問題が少ない です。<result>
要素を指定することで、あらゆるJavaオブジェクトに対して柔軟に対応できます。記述量が多くなりがちですが、安定を取るならコッチです。
一方、resultType
は使い勝手が悪い ので、プリミティブ型やラッパークラス、Stringなどの決まった型を扱う程度に留めておくのが無難だと思われます。
これまでの自分は、ネット上の情報や経験則を頼りに、何となくMyBatis を使っていました。
以前は「スネークケース ⇒ キャメルケースの変換なども、MyBatisが自動的にやってくれるのだろう」と思い込んでいましたが…実際は明示的に設定しないと機能しません。
また、resultMap
と resultType
の違いもよく分かっていなかったのですが、今回の調査で、実際の仕様に関する知見を深めることができました。
今後、MyBatisを使って何らかの実装をするときの参考にしたいと思います!
ここからは余談です。調査の過程で、MyBatis では privateフィールドに値をセットする際に、Javaのリフレクション機能を使用している! という情報がありました。
That’s correct, reflection is used to access private fields, but only if accessing private fields is not restricted.
引用:How Mybatis (iBatis) read my private variable? - Stack Overflow
Javaリフレクション は、クラス名・メソッド名・変数名等を指定して、動的に実行する標準ライブラリです。
コレを使うと、なんと private
なフィールド・メソッド等、強引に参照することも可能 なのだとか。
公式ドキュメントに記載が無いのですが、MyBatisはこの仕組みを利用して、privateフィールドに値をセットしている…らしいです。
この情報が本当なのかどうか、実際にJavaリフレクションを使用して、private
フィールドの操作を試みました。
まず、privateなフィールド値 countryName
を持つ Country
クラスを作成しました。
|
|
クラス作成後、試しに外部パッケージから参照してみようとしたところ、不可視の状態のため、エラーとなりました。
ちゃんと private
修飾子が活きていますね。
Country
クラスを操作するための実行クラスです。
Javaリフレクションを利用して、private
なフィールドから値を get/set して、コンソール上に表示できるか試します。
Field
:フィールド操作用の専用クラス。getDeclaredField()
で、フィールドを呼び出し。setAccessible(true)
で、privateフィールドへのアクセス許可を得ます。
|
|
上記ソースコードを実行したところ、以下の結果が得られました。countryName
の初期値「日本」と、その後 Field.set()
でセットした「アメリカ」の文字列が、順番に出力されています。
この検証から、Javaリフレクションの機能を利用すると、private
フィールドの値を外部から get/set できる ことが分かりました。
公式ドキュメントに言及が無いので何とも言えませんが… おそらく MyBatis は、このJavaリフレクション機能を活用して、値のセットを行っているのでしょう。また1つ賢くなりました!