mybatis-spring-boot 2.1.0で追加されるmybatis.lazy-initializationについて

令和１発目の投稿です :grin:
Spring Boot 2.2(投稿時点では2.2.0.M2がリリース済み)より、DIコンテナで管理するBeanを[遅延初期化対象にするための新しいプロパティ(spring.main.lazy-initialization)](https://spring.io/blog/2019/03/14/lazy-initialization-in-spring-boot-2-2)が追加されます。この仕組みを有効にすると、mybatis-springを利用して生成するBeanにも適用されるのですが・・・他のMapperのステートメント、キャッシュ、結果マッピング、SQLフラグメントへの参照があると[SQL実行時にエラーになってしまう](https://github.com/mybatis/spring/issues/376)ことがあるのです・・・


# 改訂履歴

* 2019-05-01 : `@DependsOn`を利用したエラー回避方法を追加

# なぜSQL実行時にエラーになるのか？

mybatis-springは、Bean初期化時にMapperの情報（アノテーションやMapper XMLの情報）を読み込む仕組みになっており、DIコンテナの初期化完了時にはMyBatisの初期化も全て完了していることを前提として動作します。そのため、Beanの遅延初期化を有効にすると、アプリケーションやテストで直接利用しないMapperの情報が読み込まれていない状態になるため、読み込まれていないMapperに依存しているステートメントを呼び出すとSQL実行時に必要な情報が見つからなずにエラーになってしまうのです・・・

具体的には・・・・以下のような構成でテストを実行するとエラーになります。

```java
@Mapper
public interface GoodsMapper {
  @Select("select * from goods where id=#{goodsId}")
  @Results({
      @Result(id = true, column = "id", property = "id"),
      @Result(column = "name", property = "name"),
      @Result(column = "vendor_id", property = "vendor",
          // 他のMapperのステートメントに依存
          one = @One(select = "com.example.mapper.VendorMapper.getVendor"))
  })
  Goods getGoods(@Param("goodsId") Integer goodsId);

}
```

```java
@Mapper
public interface VendorMapper {

  @Select("select * from vendor where id=#{vendorId}")
  Vendor getVendor(@Param("vendorId") String vendorId);

}
```

```java
@RunWith(SpringRunner.class)
@SpringBootTest
@TestPropertySource(properties =
    {
        "spring.main.lazy-initialization=true" // Spring Bootの遅延初期化機能を有効化
    })
public class GoodsMapperTests {

  @Autowired
  GoodsMapper target; // テスト対象のMapper(GoodsMapper)のみインジェクション → VendorMapperは初期化されない

  @Test
  public void contextLoads() {
    Goods goods = target.getGoods(1);
    Assert.assertEquals("GOODS 1", goods.getName());
    Assert.assertEquals("VENDOR 1", goods.getVendor().getName());
  }

}
```

```
org.mybatis.spring.MyBatisSystemException: nested exception is org.apache.ibatis.exceptions.PersistenceException: 
### Error querying database.  Cause: java.lang.IllegalArgumentException: Mapped Statements collection does not contain value for com.example.mapper.VendorMapper.getVendor
### The error may exist in com/example/mapper/GoodsMapper.java (best guess)
### The error may involve com.example..mapper.GoodsMapper.getGoods
### The error occurred while handling results
### SQL: select * from goods where id=?
### Cause: java.lang.IllegalArgumentException: Mapped Statements collection does not contain value for com.example.mapper.VendorMapper.getVendor

	at org.mybatis.spring.MyBatisExceptionTranslator.translateExceptionIfPossible(MyBatisExceptionTranslator.java:78)
	at org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor.invoke(SqlSessionTemplate.java:440)
	at com.sun.proxy.$Proxy59.selectOne(Unknown Source)
	at org.mybatis.spring.SqlSessionTemplate.selectOne(SqlSessionTemplate.java:159)
	at org.apache.ibatis.binding.MapperMethod.execute(MapperMethod.java:87)
	at org.apache.ibatis.binding.MapperProxy.invoke(MapperProxy.java:58)
	at com.sun.proxy.$Proxy61.getGoods(Unknown Source)
	at com.example.mapper.GoodsMapperTests.contextLoads(GoodsMapperTests.java:31)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)
	at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
	at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
	at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
	at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
	at org.springframework.test.context.junit4.statements.RunBeforeTestExecutionCallbacks.evaluate(RunBeforeTestExecutionCallbacks.java:74)
	at org.springframework.test.context.junit4.statements.RunAfterTestExecutionCallbacks.evaluate(RunAfterTestExecutionCallbacks.java:84)
	at org.springframework.test.context.junit4.statements.RunBeforeTestMethodCallbacks.evaluate(RunBeforeTestMethodCallbacks.java:75)
	at org.springframework.test.context.junit4.statements.RunAfterTestMethodCallbacks.evaluate(RunAfterTestMethodCallbacks.java:86)
	at org.springframework.test.context.junit4.statements.SpringRepeat.evaluate(SpringRepeat.java:84)
	at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
	at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.runChild(SpringJUnit4ClassRunner.java:251)
	at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.runChild(SpringJUnit4ClassRunner.java:97)
	at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
	at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
	at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
	at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
	at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
	at org.springframework.test.context.junit4.statements.RunBeforeTestClassCallbacks.evaluate(RunBeforeTestClassCallbacks.java:61)
	at org.springframework.test.context.junit4.statements.RunAfterTestClassCallbacks.evaluate(RunAfterTestClassCallbacks.java:70)
	at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
	at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.run(SpringJUnit4ClassRunner.java:190)
	at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
	at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:68)
	at com.intellij.rt.execution.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:47)
	at com.intellij.rt.execution.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:242)
	at com.intellij.rt.execution.junit.JUnitStarter.main(JUnitStarter.java:70)
Caused by: org.apache.ibatis.exceptions.PersistenceException: 
### Error querying database.  Cause: java.lang.IllegalArgumentException: Mapped Statements collection does not contain value for com.example.mapper.VendorMapper.getVendor
### The error may exist in com/example/mapper/GoodsMapper.java (best guess)
### The error may involve com.example.mapper.GoodsMapper.getGoods
### The error occurred while handling results
### SQL: select * from goods where id=?
### Cause: java.lang.IllegalArgumentException: Mapped Statements collection does not contain value for com.example.mapper.VendorMapper.getVendor
	at org.apache.ibatis.exceptions.ExceptionFactory.wrapException(ExceptionFactory.java:30)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:149)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:140)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.selectOne(DefaultSqlSession.java:76)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)
	at org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor.invoke(SqlSessionTemplate.java:426)
	... 36 more
Caused by: java.lang.IllegalArgumentException: Mapped Statements collection does not contain value for com.example.mapper.VendorMapper.getVendor
	at org.apache.ibatis.session.Configuration$StrictMap.get(Configuration.java:946)
	at org.apache.ibatis.session.Configuration.getMappedStatement(Configuration.java:739)
	at org.apache.ibatis.session.Configuration.getMappedStatement(Configuration.java:732)
	at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.getNestedQueryMappingValue(DefaultResultSetHandler.java:743)
	at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.getPropertyMappingValue(DefaultResultSetHandler.java:465)
	at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.applyPropertyMappings(DefaultResultSetHandler.java:441)
	at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.getRowValue(DefaultResultSetHandler.java:404)
	at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.handleRowValuesForSimpleResultMap(DefaultResultSetHandler.java:354)
	at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.handleRowValues(DefaultResultSetHandler.java:328)
	at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.handleResultSet(DefaultResultSetHandler.java:301)
	at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.handleResultSets(DefaultResultSetHandler.java:194)
	at org.apache.ibatis.executor.statement.PreparedStatementHandler.query(PreparedStatementHandler.java:65)
	at org.apache.ibatis.executor.statement.RoutingStatementHandler.query(RoutingStatementHandler.java:79)
	at org.apache.ibatis.executor.SimpleExecutor.doQuery(SimpleExecutor.java:63)
	at org.apache.ibatis.executor.BaseExecutor.queryFromDatabase(BaseExecutor.java:324)
	at org.apache.ibatis.executor.BaseExecutor.query(BaseExecutor.java:156)
	at org.apache.ibatis.executor.CachingExecutor.query(CachingExecutor.java:109)
	at org.apache.ibatis.executor.CachingExecutor.query(CachingExecutor.java:83)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:147)
	... 43 more
```

# エラーを回避するには？

## `@DependsOn`を指定する

依存するMapperのBean名を`@DependsOn`を使って指定すると、依存するBeanも一緒に初期化され、エラーを回避することができます。

```java
@Mapper
@DependsOn("vendorMapper") // 追加
public interface GoodsMapper {
  // ...
}
```

## 依存Mapperをインジェクションする

テストから直接利用しないけどテスト対象のMapperが依存しているMapper(VendorMapper)もインジェクションするようにしておけば・・・インジェクション時にMapperが初期化される（＝Mapper情報が読み取られる)ため、エラーを回避することができます。

```java
@RunWith(SpringRunner.class)
@SpringBootTest
@TestPropertySource(properties =
    {
        "spring.main.lazy-initialization=true"
    })
public class GoodsMapperTests {

  @Autowired
  GoodsMapper target;

  @Autowired
  VendorMapper vendorMapper; // VendorMapperは初期化するためのインジェクション

  @Test
  public void contextLoads() {
    Goods goods = target.getGoods(1);
    Assert.assertEquals("GOODS 1", goods.getName());
    Assert.assertEquals("VENDOR 1", goods.getVendor().getName());
  }

}
```

# `mybatis.lazy-initialization` をなぜ追加するのか？


エラーを回避するだけであれば・・・`@DependsOn`を使用したり、依存するMapperもインジェクションするようにすれば良いのですが、MyBatis(+mybatis-spring)のアーキテクチャとしてMapperおよびMapper Beanの遅延初期化(依存Mapperの遅延初期化)をサポートしているわけではないので、現時点ではSpring Bootの遅延初期化機能と連動しない方が良いだろう（無難だろう）と考えました。とはいえ・・・開発時（テスト時）には遅延初期化の仕組みは有効だと思うので、MyBatis(mybatis-spring & mybatis-spring-boot-starter)独自にMapperを遅延初期化することができる仕組みをサポートすることにしました。

# mybatis.lazy-initializationの利用要件

`mybatis.lazy-initialization` プロパティは、mybatis-spring-boot-starter 2.1+(投稿時点ではSNAPSHOT) & mybatis-spring 2.0.2+(投稿時点ではSNAPSHOT)の組み合わせで使わないと有効にならないという制約はありますが、利用可能なSpring Bootのバージョンには個別の制約はありません(とはいえ、mybatis-spring-boot-starter 2.1はSpring Boot 2.1+を前提にする予定ですがw)。

# mybatis.lazy-initializationの利用例

`mybatis.lazy-initialization` のデフォルト値は `false` (遅延初期化しない)なので、Mapperを遅延初期化したいテストケースなどで `mybatis.lazy-initialization=true`を指定してください。

```java
@TestPropertySource(properties =
    {
        "spring.main.lazy-initialization=true",
        "mybatis.lazy-initialization=true" // 追加
    })
public class GoodsMapperTests {
  // ...
}
```

なお、`mybatis.lazy-initialization`を利用しても、他のMapperのステートメント、キャッシュ、結果マッピング、SQLフラグメントへの参照があると、SQL実行時にエラーになることには変わりはありません。その際には、[エラーを回避するには？](#エラーを回避するには)と同様の対処が必要になります。

# `@MapperScan`での遅延初期化

`@MapperScan`を利用してMapperを読み込む際に遅延初期化したい場合は、 `lazyInitialization`属性に `true` を指定することで利用することができます。`lazyInitialization`属性には任意のプロパティプレースホルダを指定することができるので、テストの時だけ遅延初期化することも可能です。

```java
@MapperScan(basePackages = "com.example.mapper",
        lazyInitialization = "${mybatis.lazy-initialization:false}") // 遅延初期化の指定
public class MyBatisConfig {
  // ...
} 
```

> **NOTE:**
>
> `<mybatis:scan>`(XMLネームスペース)および`MapperScannerConfigurer`でも同様のオプションが用意されています。

# まとめ

大量のMapperがある場合には、遅延初期化の仕組みを利用することで、テスト時におけるMapperの初期化時間を短縮することができるようになるかもしれません。なお、Mapperの遅延初期化機能はmybatis-springから提供している機能なので、非Spring Bootユーザも利用することができます。
将来的には・・・依存関係のあるMapperの遅延初期化もサポートできるようにしたいな〜とは思っており、それがサポートできればSpring Bootの遅延初期化機能と連動させる方が良いでしょうね。

# 参考サイト

* https://spring.io/blog/2019/03/14/lazy-initialization-in-spring-boot-2-2