MyBatisでMapperに引数を経由せずにオブジェクトを渡す方法

記事投稿時点で見つけきらなかったので調べてみました。

# やりたかったこと
* フィーチャートグルの状態で発行するSQLを一部変更したい
* でも、フィーチャートグルを使うかどうかはマッパー内部に閉じたい（引数にしたくない）

# 結論だけみたい人へ

[サンプル](https://github.com/e-yamane/MyBatisImplicitParameter)はこちらにあります。
[このソースコード](https://github.com/e-yamane/MyBatisImplicitParameter/blob/main/src/main/java/mybatis_implicit_parameter/config/CustomSqlSessionConfig.java)の66行目のaddParameterメソッドを書き換えて渡したいオブジェクトを指定すれば引数で渡したのと同様の方法でアクセスできるようになります。

# コードの説明

## 全体像

まずは該当のソースコードを以下に示します。

<details><summary>ソースコード</summary><div>

```CustomSqlSessionConfig.java
package mybatis_implicit_parameter.config;

import lombok.experimental.Delegate;
import org.apache.ibatis.binding.MapperMethod;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.lang.reflect.Proxy;
import java.util.Map;

@Configuration
public class CustomSqlSessionConfig {
    @Bean
    public SqlSessionTemplate sqlSession(SqlSessionFactory sqlSessionFactory) throws Exception {
        return new SqlSessionTemplateExt(sqlSessionFactory);
    }

    static class SqlSessionTemplateExt extends SqlSessionTemplate {
        public SqlSessionTemplateExt(SqlSessionFactory sqlSessionFactory) {
            super(sqlSessionFactory);
        }

        Object invokeParentMethod(Method method, Object[] args) throws Throwable {
            return makeParentMethodHandle(method).bindTo(this).invokeWithArguments(args);
        }

        MethodHandle makeParentMethodHandle(Method method) throws Exception {
            return MethodHandles.lookup().findSpecial(SqlSessionTemplate.class, method.getName(),
                    MethodType.methodType(method.getReturnType(), method.getParameterTypes()), SqlSessionTemplateExt.class);
        }

        @Delegate
        final SqlSession proxy = (SqlSession)Proxy.newProxyInstance(SqlSessionTemplateExt.class.getClassLoader(),
                new Class[]{SqlSession.class}, new InvocationHandler() {
                    @Override
                    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                        if(hasParameter(method)) {
                            args[1] = addParameter(args[1]);
                        }
                        return SqlSessionTemplateExt.this.invokeParentMethod(method, args);
                    }

                    boolean hasParameter(Method method) {
                        Parameter[] params = method.getParameters();
                        return (params.length >= 2 && params[1].getType().equals(Object.class));
                    }

                    Object addParameter(Object parameter) {
                        parameter = (parameter == null) ? new MapperMethod.ParamMap() : parameter;
                        if (parameter instanceof Map) {
                            return addParameter((Map) parameter);
                        } else {
                            throw new RuntimeException("想定してないパターンここに来るなら教えて〜:" + parameter.getClass().getName());
                        }
                    }

                    Object addParameter(Map map) {
                        //暗黙のパラメーターはここでセットする
                        map.put("insertValue", "foo");
                        map.put("updateValue", "bar");
                        return map;
                    }
                });
    }
}
```

</div></details>

## やってることをかいつまんで説明すると、、、

MyBatisは [SqlSession](https://mybatis.org/mybatis-3/apidocs/org/apache/ibatis/session/SqlSession.html)クラス のinsert/select/update/deleteという名の第１引数にString、第２引数にObjectを持つメソッドを経由してMapperの内容を解析してSQLを発行します。
CustomSqlSessionConfigでは、```SQLSessionTemplate```を拡張してそれぞれのメソッドをオーバーライドして引数経由で渡すのが不適切だと思われるオブジェクトを第２引数のパラメーター（実体はMap)に追加して実行するようにしています。

## こだわりポイント：lombokの@Delegateとダイナミックプロキシを組み合わせる

このコードは自己満足的なコードで本当はもっとシンプルに実現できます。
上に書いたことを愚直に実装しようとすればこのようなコードではなくても、↓のように対象のメソッドをオーバーライドすれば実現できます。

<details><summary>愚直なコード</summary><div>

```CustomSqlSessionConfig.java
(省略)
@Override 
public int insert(String statement,Object parameter) {
    return super.insert(statement, addParameter(parameter));
}
(省略)
```

</div><details>

でも、同じことを何度も書くのが嫌だってのでちょっと拘ってみました。

ダイナミックプロキシは、Javaの古いバージョンから実装されてる機能で、引数で指定されたinterface（群）のメソッドを持つオブジェクトを動的に作成し、指定した```InvocationHandler```に処理を集約することができる機能です。

一方lombokの@Delegateはアノテートされたフィールドの全てのpublicなメソッドをそのフィールドを保持しているクラスのメソッドとして展開するアノテーションです(細かく調整もできます)。ぱっとイメージが付きにくいかも知れませが、[このサイト](https://projectlombok.org/features/Delegate.html)を見ていただけるとイメージがつき易いかと思います。

この2つを組み合わせると、自身のクラスの任意のメソッド(今回はinsertだったりselectだったり)をひとまとめに処理する事が出来るんです。
ちょっと黒魔術的なコードにはなりがちですが、好きな人は溜まらなく好きなはず！知らんけどw

この方法の欠点は、InvocationHandlerから親クラスの同一メソッドを呼び出す（super.xxx的な呼び出し）のが困難になる点です。InvocationHandlerはMethodオブジェクトを引数として受け取るので、```method.invoke(...)```と出来ると良いのですが、@Delegateと組み合わせると、StackOverflowを引き起こしてしまいます。これは、親クラスを指定して同一のメソッドシグネチャを持つMethodオブジェクトを取得してきても同じ結果になります。これは、Method#invokeの[APIリファレンス](https://docs.oracle.com/en/java/javase/13/docs/api/java.base/java/lang/reflect/Method.html#invoke(java.lang.Object,java.lang.Object...))に書かれてるとおりメソッド呼び出しをinvokeVirtualで実現しているためです。

> If the underlying method is an instance method, it is invoked using dynamic method lookup as documented in The Java Language Specification, section 15.12.4.4; in particular, overriding based on the runtime type of the target object may occur.
> (基礎となるメソッドがインスタンス・メソッドの場合、Java言語仕様セクションに記載されている動的メソッド参照を使用して起動され、特に、ターゲット・オブジェクトの実行時のタイプに基づいてオーバーライドされます。)

super.xxxをリフレクションで実現するためには

```Java
MethodHandles.lookup().findSpecial(SqlSessionTemplate.class, method.getName(),
    MethodType.methodType(method.getReturnType(), 
    method.getParameterTypes()), SqlSessionTemplateExt.class);
```

子クラス内で、```findSpecial```で親クラスのメソッドの```MethodHandle```を取得する必要があります。
MethodHandleはこれまでのJavaのリフレクションと異なり、どのクラスで実効しているかで結果が異なります。```InvocationHandler```実装クラス内でこの処理を実行してもエラーが発生します。

## あやしいポイント

* パラメーターを追加するメソッドかどうかの判定は若干怪しい。
* パラメーターオブジェクトの型がMap以外のケースがあると動かない。Map以外のケース(nullの場合がある事は確認済み）が来ると動作しません。Map以外が来るかはちゃんと追い切れてません。

## 動作確認済みライブラリバージョン

|ライブラリ名|バージョン|
|---|---|
|Spring-Boot|2.3.4.RELEASE|
|mybatis-spring-boot-starter|2.1.3|
|lombok|5.1.0|

## サンプルコードについて

サンプルコードは好きに使っていただいて構いませんが、その結果においてはいかなる責任も負いませーん。