MyBatis が private フィールドに値をセットする仕組みの調査

こんにちは。株式会社 Interfamilia の Waka です。

7月に入りましたが、まだまだ梅雨が明けず、雨が降ったり降らなかったり。ジメジメとした蒸し暑い気候が続いています。
これからドンドン熱くなると思いますので、熱中症に気を付けながら、日々の生活を過ごしたいですね。

そんな感じで、蒸し暑くて憂鬱な季節ではありますが、嬉しいこともありました。この度 晴れて正社員になったのです!

本来は約1年間の研修を通して、正社員登用が行われるのですが…この研修期間を短縮してもらい、4ヶ月早く正社員になれました!嬉しいですね。
今後も優秀なエンジニアを目指して、技術研鑽に努めようと思います。

今回の記事では、そんな自分が日々の開発業務で学んだ、MyBatisの仕様に関するTIPSをご紹介したいと思います!


目次

背景

「MyBatis」 は、 SQL と Javaオブジェクトをマッピングできる、便利なフレームワークです。
データベースから取得した情報を、Javaのソースコードに渡す連携処理を実現できます。

ただ、MyBatisのマッピングの原理・ルールについては謎が多い です。
かなり柔軟なマッピングが可能で、外部からアクセス不可の private フィールドにも、値をセットできてしまいます。

おそらく、Javaオブジェクト側で定義した Getter / Setter メソッドを使用しているのだと思われますが…実際のところ原理がよく分かりません。
MyBatis 公式ドキュメント も確認してみましたが…めぼしい情報は見当たりませんでした。

気になってネット検索してみたところ、以下のような情報を発見しました。

値のセットは、 Setter メソッドがあればそれ経由で、なければフィールドに直接行われる。

引用:MyBatis 使い方メモ - Qiita

後学のために、この記述が正しいのかどうか、実際に検証して確かめることにしました。


結論

調査の結果、MyBatis には以下のような仕様があると分かりました!


MyBatisの仕様

仕様1 - インスタンス生成

  • Javaオブジェクトにコンストラクタが定義されている場合、それが呼び出され、インスタンス化が行われる。
    • 以下の優先度でコンストラクタが呼び出される。複数ある場合は、優先度が高いものが呼び出される。
      1. デフォルトコンストラクタ
      2. 引数付きコンストラクタ
      3. (暗黙のデフォルトコンストラクタ)

仕様2 - フィールドに値をセットする

  • 以下の条件に該当する場合、Javaオブジェクトのフィールドに値をセットできる。

    1. RDBのカラム名と、Javaオブジェクトのフィールド名が一致する場合。
      • mapUnderscoreToCamelCase = true の指定で、スネークケース ⇒ キャメルケース への自動変換、値のセットができる。(デフォルトでは false)
      • 上記の設定がない場合は、RDBからSELECTした順番で、フィールドに値をセットしていく。
    2. resultMap<result> 要素で、カラム名とフィールド名をマッピングしている場合。
  • 値をセットする際には、Setterの有無で処理が変わる。

    • Setter がある場合、当該フィールドの Setter を呼び出し、値をセットする。
    • Setter が無い場合、MyBatis がフィールドに直接アクセスして、値をセットする。

仕様3 - 例外的に値がセットされるケース

  • 「仕様2」の条件を満たさなくても、引数付きコンストラクタでインスタンスを生成した場合は、値をセット可能。
    • ただし、RDBのカラムデータを、SELECTした順番でコンストラクタの引数に渡すため、想定外の動作を引き起こす可能性がある。

仕様4 - Getter は無関係

  • Getter は、値のセットには一切使われない。

補足説明

ここからは、実際の検証内容と、上記の結論に至った経緯について解説したいと思います。

バージョン・環境

Java:openjdk 11.0.9
Spring Boot:2.5.0
MyBatis:3.5.7
PostgreSQL:11.6

テストコード

調査用に作成したソースコードです。
郵便住所データベースから取得した情報を、Address クラスに格納する!という操作になります。

Address クラス

以下の private フィールドを設定しました。

  • 「postalCode」:郵便住所 を格納する String変数。
  • 「streetName」:町名 を格納する String変数。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package mybatis.test.model;

public class Address {

	private String postalCode;

	private String streetName;

	public String getPostalCode() {
		return postalCode;
	}

	public void setPostalCode(String postalCode) {
		this.postalCode = postalCode;
	}

	public String getStreetName() {
		return streetName;
	}

	public void setStreetName(String streetName) {
		this.streetName = streetName;
	}

        //引数付きコンストラクタ
	public Address(String postalCode, String streetName) {
		this.postalCode = postalCode;
		this.streetName = streetName;
	}

        //デフォルトコンストラクタ
	public Address() {
	}

}

MyBatisTestMapper.xml

以下2種類のSELECT文を用意しました。

  • 「selectAddress1」:resultMap で、RDBのカラム名と、Javaのフィールド名をマッピングする。
  • 「selectAddress2」:resultType で、Address クラスをそのまま指定。フィールドのマッピング設定はしない。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<resultMap type="mybatis.test.model.Address" id="address">
<result property="postalCode" column="postal_code"/>
<result property="streetName" column="street_name"/>
</resultMap>

    <select id="selectAddress1" resultMap="address">
        SELECT postal_code
             , street_name
          FROM address.addresses
         WHERE postal_code = #{postalCode};
    </select>

    <select id="selectAddress2" resultType="mybatis.test.model.Address">
        SELECT postal_code
             , street_name
          FROM address.addresses
         WHERE postal_code = #{postalCode};
    </select>

「仕様1 - インスタンス生成」について

Javaオブジェクト内で コンストラクタが定義されている場合、最初にそれが呼び出されて、インスタンスを生成します。
デフォルトコンストラクタと、引数付きコンストラクタがある場合は、デフォルトコンストラクタが優先的に呼び出される ようです。

コンストラクタが呼び出されて、インスタンスを生成
コンストラクタが呼び出されて、インスタンスを生成

ただし、後述の「仕様2」の条件を満たさず、値がセットされないまま処理が進行すると、nullオブジェクトとして処理が行われます。

インスタンスが生成されても、値がセットされずnullオブジェクトになることも
インスタンスが生成されても、値がセットされずnullオブジェクトになることも

「仕様2 - フィールドに値をセットする」について

インスタンスが生成された後、Setter が呼び出され、各フィールドに値がセットされます。

コンストラクタで初期化されたフィールド値も、Setterによって上書きされる ことが分かりました。

setPostalCode() が呼び出される
setPostalCode() が呼び出される
setStreetName() も呼び出される
setStreetName() も呼び出される
Setter経由で値がセットされた
Setter経由で値がセットされた

Setterが無くても、以下の条件に該当する場合、フィールドに値をセット可能です。

  • RDBのカラム名・Javaオブジェクトのフィールド名を一致させ、mapUnderscoreToCamelCase = true を設定した場合。
  • resultMap を設定して、カラム名とフィールド名を正しくマッピングしている場合。

以下の例では、resultMap でマッピング設定をした後、Setterをコメントアウトして処理を実行しています。
コンストラクタで初期化した値を上書きできることから、Setter以外の何らかの方法で private フィールドに直接アクセスして、値をセットしている と推測できます。

つまり、Setterは必須でない ことが分かります。

試しにSetterをコメントアウト
試しにSetterをコメントアウト
Setterが無くても、フィールドに値をセット可能 (条件に合致する場合)
Setterが無くても、フィールドに値をセット可能 (条件に合致する場合)

ただし、Setter が存在する場合は、そちらが優先的に使われる ようです。
以下の例では、resultMap でセットしたフィールド値を、Setterで上書きしています。

setStreetName() だけを用意
setStreetName() だけを用意
Setterが機能して「杜王町」と上書きされた
Setterが機能して「杜王町」と上書きされた

また、resultMap もしくは resultType を使ってマッピング設定を行う場合は、以下の点に注意すべきだと分かりました。


resultMap を使う場合

resultMap を使う場合は、<result> 要素を指定してカラム名・フィールド名を一致させることで、フィールドに値をセットすることが可能です。

しかし、以下のように <result> 要素を省略すると、前述の条件を満たさず、値がセットできなくなります。

1
2
3
4
5
6
7
8
9
<resultMap type="mybatis.test.model.Address" id="address">
</resultMap>
<!-- ↑resultMapで、値のマッピングができていない -->

    <select id="selectAddress1" resultMap="address">
        SELECT postal_code
          FROM address.addresses
         WHERE postal_code = #{postalCode};
    </select>

resultType を使う場合

resultType を使う場合は、以下の方法でカラム名・フィールド名を一致させれば、値をセット可能です。

  1. SELECTする カラムに AS で別名をつける
    • RDBのカラム名・Javaのフィールド名を一致させることで、Javaオブジェクトの Setter を呼び出せるようになり、フィールド値をセット可能になります。
1
2
3
4
5
6
<select id="selectAddress2" resultType="mybatis.test.model.Address">
SELECT postal_code AS postalCode
, street_name AS streetName
FROM address.addresses
WHERE postal_code = #{postalCode};
</select>
  1. map-underscore-to-camel-case: true を設定する
    • application.yml に設定を追記して、スネークケース ⇒ キャメルケースに自動変換。
    • これにより、対応するJavaオブジェクトの Setter が呼び出され、フィールド値をセットできます。
1
2
3
mybatis:
  configuration:
    map-underscore-to-camel-case: true

「仕様3 - 例外的に値がセットされるケース」について

「仕様2」の条件に合致していなくても、引数付きコンストラクタを呼び出せる場合は、インスタンス生成時にフィールドに値がセットできます。

引数付きコンストラクタで、フィールド値を初期化
引数付きコンストラクタで、フィールド値を初期化
初期化されたフィールド値で処理が進行
初期化されたフィールド値で処理が進行

ただし「仕様1」で紹介したように、デフォルトコンストラクタが存在するとそちらが優先で使われるので、注意が必要です。

また、MyBatisのマッピングがうまく機能せず、RDBから取得したデータを Javaオブジェクトに正しく格納できない場合もあります。
例えば、以下のソースコードでは、コンストラクタの引数の順番を逆にしてみました。

コンストラクタの引数の順番を入れ替え
コンストラクタの引数の順番を入れ替え

本来なら postal_codepostalCode とマッピングされるハズです。
しかし…何故か postal_codestreetName とセットされる、想定外の動作を引き起こしてしまいました。

postalCode と streetName が逆になっている!
postalCode と streetName が逆になっている!

この結果から、RDBから取得してきた順番で、フィールド値を順次セットしていることが分かります。

今回はフィールド値が両方とも String だったので、エラーは発生せず、値が逆にセットされるという異常現象が起きました。
String と int の組み合わせなど、複数の型をフィールド値に指定した場合は、ここでエラーが発生します。


「仕様4 - Getter は無関係」について

Address クラスの Getterメソッドにブレークポイントを置いて、デバッグモードで調査しましたが…Getterを通過する処理は1つもありませんでした。
このことから、MyBatis が Getterを一切使用していない ことが分かります。


学び・今後の課題など

冒頭でご紹介した ↓ のQiita情報は、「仕様2」の条件を満たすを場合、正しいと言える ことが、今回の調査で分かりました。

値のセットは、 Setter メソッドがあればそれ経由で、なければフィールドに直接行われる。

引用:MyBatis 使い方メモ - Qiita

MyBatis を利用する場合、仕様を正しく理解した上で、RDB のカラム名・Javaオブジェクトのフィールド名を、正しくマッピングさせる必要があります。

マッピングの際には、基本的にresultMap を使えば問題が少ない です。
<result>要素を指定することで、あらゆるJavaオブジェクトに対して柔軟に対応できます。記述量が多くなりがちですが、安定を取るならコッチです。

一方、resultType は使い勝手が悪い ので、プリミティブ型やラッパークラス、Stringなどの決まった型を扱う程度に留めておくのが無難だと思われます。

これまでの自分は、ネット上の情報や経験則を頼りに、何となくMyBatis を使っていました。
以前は「スネークケース ⇒ キャメルケースの変換なども、MyBatisが自動的にやってくれるのだろう」と思い込んでいましたが…実際は明示的に設定しないと機能しません。
また、resultMapresultType の違いもよく分かっていなかったのですが、今回の調査で、実際の仕様に関する知見を深めることができました。
今後、MyBatisを使って何らかの実装をするときの参考にしたいと思います!


余談

MyBatis は Javaリフレクションで privateフィールドを操作している?

ここからは余談です。調査の過程で、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 フィールドの操作を試みました。


実際にリフレクションを試す

Country クラス (Model)

まず、privateなフィールド値 countryName を持つ Country クラスを作成しました。

1
2
3
4
5
6
7
package com.example.demo.model;

public class Country {

private String countryName = "日本";

}

クラス作成後、試しに外部パッケージから参照してみようとしたところ、不可視の状態のため、エラーとなりました。
ちゃんと private 修飾子が活きていますね。

countryName は private なので、外部から参照できない
countryName は private なので、外部から参照できない

ReflectionTestRunner クラス (実行クラス)

Country クラスを操作するための実行クラスです。
Javaリフレクションを利用して、private なフィールドから値を get/set して、コンソール上に表示できるか試します。

  • Field:フィールド操作用の専用クラス。
  • getDeclaredField() で、フィールドを呼び出し。
  • setAccessible(true) で、privateフィールドへのアクセス許可を得ます。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package com.example.demo.runner;

import java.lang.reflect.Field;

import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

import com.example.demo.model.Country;

@Component
public class ReflectionTestRunner implements CommandLineRunner {

	@Override
	public void run(String... args) {

		Country country = new Country();

		try {

			Field field = Country.class.getDeclaredField("countryName");
			field.setAccessible(true);

			System.out.println(field.get(country));

			field.set(country, "アメリカ");

			System.out.println(field.get(country));

		} catch (Exception e) {
			e.printStackTrace();
		}

	}

}

上記ソースコードを実行したところ、以下の結果が得られました。
countryName の初期値「日本」と、その後 Field.set() でセットした「アメリカ」の文字列が、順番に出力されています。

private なフィールド値を get/setできた
private なフィールド値を get/setできた

この検証から、Javaリフレクションの機能を利用すると、private フィールドの値を外部から get/set できる ことが分かりました。

公式ドキュメントに言及が無いので何とも言えませんが… おそらく MyBatis は、このJavaリフレクション機能を活用して、値のセットを行っているのでしょう。また1つ賢くなりました!


参考リンク