• 43
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

黒魔術(バイトコードをいじること)なしに、Javaで動的にMixinします。

Background

ミドルウェアパターンの実装などにおいて、とあるミドルウェアを追加したら、リクエストオブジェクトにメソッドを追加したい、ということがあります。

これを多重継承のできないJavaで実現しようとすると、最終的に必要となるメソッドを全部実装したクラス(またはその親子関係)が必要になります。

必要なメソッドだけ、必要なときに足したいですよね。Mixin! Mixin!

インタフェースのデフォルト実装

JavaでMixinを実装したいと思っていた人たちには、Java8でインタフェースにデフォルト実装を持てるようになったのは歓迎すべき出来事だったようです。

こんな感じのデフォルト実装を持っておけば、

public interface Traceable {
    Logger LOG = LoggerFactory.getLogger(Traceable.class);

    default void writeLog() {
        LOG.info(toString());
    }
}

それをimplementsに加えるだけで、ログ出力メソッドが使えるようになるわけです。

class RequestImpl implements Request, Traceable {
    @Override
    public String toString() {
        return getParameters().toString();
    }
}

interface Request {
    Map<String, String> getParameters();
}

Request req = new RequestImpl();
((Traceable) req).writeLog();

ただし状態は持つことができないので、

interface SessionAvailable {
    private Session session;
    default void setSession(Session session) {
        this.session = session;
    }
}

みたいなことはできません。

スレッドセーフを考えなくてもよい対象のオブジェクトであれば、以下のように状態のコンテナの機能を作っておけば、デフォルト実装で状態を扱う操作も可能にはなります。

public interface Extendable {
    Object getExtension(String name);
    void setExtension(String name, Object extension);
}
public interface SessionAvailable extends Extendable {
    default void setSession(Session session) {
        setExtensions("session", session);
    }
}

動的にMixinする

デフォルト実装のおかげで、implementsにインタフェースを並べるだけで、その実装が使えるようになるわけですが、「このミドルウェアを使うときは、このインタフェースが実装されていなければならない」のような制約は、ミドルウェアパターンで実装されたフレームワークを使う側にとっては、難しい制約になりかねません。

そこで、ミドルウェア側で勝手に、必要なときに必要な実装をリクエストオブジェクトに足してハンドリングできるよう、動的Mixinの機能を考えます。

以下は、ミドルウェアの実装例ですが、使う側でインタフェースをMixinして、そのメソッドを使うようすると、事前にRequestがSessionAvailableを実装している必要がありません。

    @Override
    public RES handle(REQ req, MiddlewareChain next) {
        final REQ request = MixinUtils.mixin(req, SessionAvailable.class);
        ((SessionAvailable) request).getSession();
        // .....
    }

Javaで動的にインタフェースを足すにはどうしたらよいのでしょうか? 限りなく黒魔術に近い白魔術Proxyを使えば可能です。

Proxy.newProxyInstance(
    classloader,
    new Class[]{ Request.class, SessionAvailable.class },
    new MixinProxyHandler());

のように、Proxyを作れば2番めの引数で追加したインタフェースのメソッドが、3番めの引数で渡したInvocationHandlerで処理されるようになります。

MixinProxyHandler
class MixinProxyHandler {
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if (method.getDeclaringClass().isAssignableFrom(original.getClass())) {
            // 元の
            return method.invoke(original, args);
        } else {
            // インタフェース側のメソッドにディスパッチ
            return getMethodHandle(method)
                   .bindTo(proxy)
                   .invokeWithArguments(args);
        }
    }
}

MethodHandle

さてここで問題になるのは、インタフェースのデフォルト実装をリフレクションを使って実行しようとしたときに、元のオブジェクトは追加したインタフェースを実装していないので、invokeできないのです。

これは従来のリフレクションでなく、MethodHandleを使うと、実装してなくてもインタフェースのデフォルト実装を呼ぶことができます。

MethodHandles.lookup()
    .in(declaringClass)
    .unreflectSpecial(method, declaringClass)
    .bindTo(proxy)
    .invokeWithArguments(args);

デフォルト実装は、invokeSpecialで呼べます。実際使うには、Accessibilityを変更しないとlookupに失敗したりするので、もう少し複雑にはなります。全体のコードは以下を見てください。

https://github.com/kawasima/enkan/blob/master/enkan-core%2Fsrc%2Fmain%2Fjava%2Fenkan%2Futil%2FMixinUtils.java

こうしてMixinのユーティリティを作っておけば、動的にインタフェースを追加して、その実装を呼び出せることになります。便利ですね!

public class MixinUtilsTest {
    @Test
    public void mixin() {
        Money m1 = new MoneyImpl(5);
        m1 = MixinUtils.mixin(m1, ComparableMoney.class);

        assertTrue(ComparableMoney.class.cast(m1).isBigger(new MoneyImpl(3)));
    }

    public static class MoneyImpl implements Money {
        private int amount;

        public MoneyImpl(int amount) {
            this.amount = amount;
        }

        @Override
        public int getAmount() {
            return amount;
        }

        @Override
        public String toString() {
            return Integer.toString(amount);
        }
    }

    public interface Money {
        int getAmount();
    }

    public interface ComparableMoney extends Money {
        default boolean isBigger(Money other) {
            return getAmount() > other.getAmount();
        }
    }
}

注意

現時点のJava8 JVM実装ではMethodHandleによるinvokeSpecialの呼び出しは非常に低速です。実測でふつうのリフレクションの10倍〜100倍の実行時間がかかります。
なので本番運用では静的Mixinにするなどの工夫は必要そうです。