Guiceで簡易的なプラグインシステムを構築するには

Java界隈にはOSGiといういかついダイナミックモジュールシステムがあるのですけども、これは解決しようとしている問題の量が非常に多いのでちょっとしたものを作るために使おうとすると非常に辛い。

そもそも、アプリケーションのブートストラップ部分から全部OSGiベースで作りこまないと良い感じに動いてくれません。

そこで、OSGiを前提としたアーキテクチャ設計が必要になるのですけども、ちょっと便利なツール作りたいだけなのに膨大な時間をかけてOSGiを勉強するかと言われると、しませんよね。

という訳で、Javaにおいて簡易的なプラグインシステムをGuiceで作ってみましょうという話です。

尚、Spring界隈で実現する方法については@makingさんのエントリをどうぞ。

コードの場所

必要に応じてエントリ内にコードはありますが、実際に動作するものはGitHub上にあります。

制限事項

ここで作るプラグインシステムでは幾つかの明確な制限事項があります。

  • 単一のプロセスで動作する
    • 複数のプロセスでプラグインを自律的に共有することはできない
  • 単一のクラスローダを前提に動作する
    • プラグインシステムとプラグインが同一のクラスローダ上にいるので、クラスパス次第ではクラス定義の乗っ取りが発生する可能性がある、つまりセキュアではない
  • プラグインの追加や削除する際にプロセスを再起動しなければならない
  • プラグインシステム及びプラグインの自動的なバージョンアップはできない
  • プラグインシステムで定義した拡張ポイントにだけプラグインできる
    • 追加されたプラグインが更に新しい拡張点を宣言することはできない、つまり、プラグインをプラグインできる仕組みではない
  • 追加したプラグインの数に比例してプラグインシステムの起動時間が伸びる
    • プラグインの探索を遅延評価することはできない
  • GUIアプリケーションを想定しない
  • plugin.xmlのような自己記述的設定ファイルを持たない

もし、これらの制限事項を乗り越えたいのであれば、OSGiを学習すべきです。

ServiceLoaderをちゃんと使う

Javaの標準APIにはjava.util.ServiceLoaderというクラスローダをプラグインシステムっぽく使うための便利クラスが定義されています。それを更に便利にしたAPIがjavax.imageio.spi.ServiceRegistryなんですけども、余り知られていないように思います。

最初はServiceLoaderを使ってアプリケーション起動時に機能を拡張する仕組みを作ってみましょう。これを少しづつ改善しながらプラグインシステムを構築していきます。

サンプルコードは、

です。

拡張ポイントの定義

プラグインを表すインターフェースを定義します。

package serviceloader;

public interface Plugin {

    String name();

    void initialize();
}

拡張ポイントを集約する

ServiceLoaderでインスタンス化するコードは以下のようになります。

ここでは、名前でプラグインを検索できるようにしてみました。

package serviceloader;

import java.util.*;

public class PluginRegistry {

    Map<String, Plugin> plugins = new HashMap<>();

    public PluginRegistry() {
        for (Plugin plugin : ServiceLoader.load(Plugin.class)) {
            plugin.initialize();
            this.plugins.put(plugin.name(), plugin);
        }
    }

    public Optional<Plugin> find(String name) {
        return Optional.ofNullable(this.plugins.get(name));
    }
}

このServiceLoaderでPluginをインスタンス化するには、META-INF/services/serviceloader.Pluginというファイルの中に実装クラスのFQNを記述します。

serviceloader.FirstPlugin
# コメントもかける
serviceloader.SecondPlugin

ここでのポイントは、このプロバイダ構成ファイルには改行区切りで複数のクラス名を記述できる事です。

プラグインの実装コード

プロバイダ構成ファイルには2つのクラスを書きましたが、その片方だけ例示しておきます。

package serviceloader;

public class FirstPlugin implements Plugin {

    boolean initialized = false;

    @Override
    public String name() {
        return "first-plugin";
    }

    @Override
    public void initialize() {
        this.initialized = true;
    }
}

テストコード

これを使ったテストコードを書いてみましょう。

package serviceloader;

import static org.junit.Assert.*;

import java.util.Optional;

import org.junit.Test;

public class PluginRegistryTest {

    @Test
    public void test() {
        PluginRegistry registry = new PluginRegistry();
        assertEquals(2, registry.plugins.keySet().size());

        Optional<Plugin> found = registry.find("first-plugin");
        assertEquals(true, found.isPresent());
        Plugin plugin = found.get();
        assertEquals(FirstPlugin.class, plugin.getClass());

        FirstPlugin tp = (FirstPlugin) plugin;
        assertTrue(tp.initialized);
    }
}

問題点

このServiceLoaderを使ったプラグインシステムには、いくらか不満があります。

  • プラグインにはデフォルトのコンストラクタが必須
  • プラグインのライフサイクルモデルが単純過ぎる
  • 拡張点ごとにRegistryオブジェクトを定義したくない

これらの問題点を解決するためにGuiceを使ってみましょう。

Guiceのmultibindingsを使う

Guiceで簡易的なプラグインシステムを構築するためには、guice-multibindingsという拡張モジュールを利用します。

つまり、Guiceの本体とは別に依存ライブラリとして宣言しなければなりません。例えば、Gradleを使っている場合build.gradleは、このようになるでしょう。

apply plugin: 'java'

repositories.mavenCentral()

sourceCompatibility = targetCompatibility = 1.8
tasks.withType(AbstractCompile)*.options*.encoding = "UTF-8"

dependencies {
    compile 'com.google.inject.extensions:guice-multibindings:4.0+'
    testCompile 'junit:junit:4.+'
}

Multibinderを使う

ここでのサンプルコードは、

です。

GuiceのModuleでMultibinderを使うと単一のインターフェースに対して複数の実装を対応付けできます。
例えば、java.util.Set<Plugin>のようなインスタンスを依存性注入できます。

ここでは、コンストラクタインジェクションによってPluginConsumerPluginのインスタンスを複数設定しています。

package multibindings;

import java.util.*;
import java.util.stream.Collectors;

import javax.inject.Inject;

public class PluginConsumer {

    Set<Plugin> plugins;

    @Inject
    public PluginConsumer(Set<Plugin> plugins) {
        this.plugins = plugins;
    }

    public List<String> execute() {
        return this.plugins.stream().map(p -> ">> " + p.name())
                .collect(Collectors.toList());
    }
}

Moduleの定義

どのように利用されるのかを確認したのでMultibinderを使って2つのModuleを定義してみましょう。

package first;

import multibindings.Plugin;

import com.google.inject.*;
import com.google.inject.multibindings.Multibinder;

public class FirstModule implements Module {

    @Override
    public void configure(Binder binder) {
        Multibinder<Plugin> mb = Multibinder.newSetBinder(binder, Plugin.class);
        mb.addBinding().to(FirstPlugin.class);
        mb.addBinding().to(MoreFirstPlugin.class);
    }
}
package second;

import multibindings.Plugin;

import com.google.inject.*;
import com.google.inject.multibindings.Multibinder;

public class SecondModule implements Module {

    @Override
    public void configure(Binder binder) {
        Multibinder<Plugin> mb = Multibinder.newSetBinder(binder, Plugin.class);
        mb.addBinding().to(SecondPlugin.class);
    }
}

2つのModuleで合計3つの実装クラスがPluginに対応付けされましたね。

テストコード

それでは、いつも通りにInjectorを生成してみましょう。

ここでは、2つのテストメソッド用意しました。

staticLoadingでは読み込むモジュールを静的に指定しています。これでは、プラグインシステムとは言えませんね。

package multibindings;

import static org.junit.Assert.assertEquals;

import java.util.*;

import org.junit.Test;

import com.google.inject.*;

import first.FirstModule;

public class PluginTest {
    @Test
    public void staticLoading() {
        Injector injector = Guice.createInjector(new FirstModule());
        PluginConsumer instance = injector.getInstance(PluginConsumer.class);
        String string = instance.execute().get(0);
        assertEquals(">> first-plugin", string);
    }

    @Test
    public void dynamicLoading() throws Exception {
        ServiceLoader<Module> loader = ServiceLoader.load(Module.class);
        Injector injector = Guice.createInjector(loader);
        PluginConsumer instance = injector.getInstance(PluginConsumer.class);
        List<String> names = instance.execute();
        assertEquals(3, names.size());
        assertEquals(">> first-plugin", names.get(0));
        assertEquals(">> second-plugin", names.get(2));
    }
}

dynamicLoadingではServiceLoaderを使ってModuleを読みだしています。つまり、META-INF/services/com.google.inject.Moduleというプロバイダ構成ファイルにModuleのクラス名が以下のように列挙されています。

first.FirstModule
second.SecondModule

解決した問題

Multibinderを使うことでプラグインのインスタンスをGuiceで管理できるようになりました。これによってServiceLoaderによる単純な仕組みにあった不満点は概ね解消されています。

まず、ServiceLoaderでインスタンス化するオブジェクトをGuiceのModuleに変更したのでPluginの実装クラスはGuiceが管理しているオブジェクトを好きなように依存性注入して貰えますので、デフォルトのコンストラクタが必要なくなりました。

同様にGuiceによってPluginの実装クラスが管理されるようになったので、例えばAssistedInjectのような高度なインスタンス生成方法を選択できるようになりました。

また、PluginRegistryのようなPluginの管理コンテナとなるオブジェクトを一々定義する必要はなくなりました。

更なる問題点

最初の仕組みでは、名前によって各インスタンスを区別できていましたが、Multibinderを使ったプラグインシステムでは、Pluginのインスタンスを利用する側がそれぞれのインスタンスについて一切区別出来なくなってしまいました。

例えば、ロード済みのプラグインを設定ファイルから利用するような場合、これでは期待したように動作しません。

MapBinderを使う

ここでのサンプルコードは、

です。

MapBinderを使うと一意なキーによって一つのインターフェースに対応付けられた実装クラスのインスタンスを区別できます。
例えば、java.util.Map<String, Plugin>のようなインスタンスを依存性注入できます。

つまり、最初に書いたようなPluginRegistryはGuiceのMapBinderによって以下のように置き換えられます。

package multibindings;

import java.util.*;

public class PluginRegistry {

    Map<String, Plugin> plugins = new HashMap<>();

    @javax.inject.Inject;
    public PluginRegistry(Map<String, Plugin> plugins) {
        this.plugins = plugins;
    }

    public Optional<Plugin> find(String name) {
        return Optional.ofNullable(this.plugins.get(name));
    }

    public static <PLUGIN extends Plugin> MapBinder<String, PLUGIN> newBinder(
            Binder binder, Class<PLUGIN> type) {
        return MapBinder.newMapBinder(binder, String.class, type)
                .permitDuplicates();
    }
}

Moduleの定義

では、MapBinderを使ってModuleを定義してみましょう。

addBindingメソッドを呼び出す際にキーとなる情報を指定するのがポイントです。

package first;

import multibindings.*;

import com.google.inject.*;
import com.google.inject.multibindings.MapBinder;

public class FirstModule implements Module {

    @Override
    public void configure(Binder binder) {
        MapBinder<String, InputPlugin> inputs = PluginRegistry.newBinder(
                binder, InputPlugin.class);
        inputs.addBinding("file").to(FileInputPlugin.class);

        MapBinder<String, OutputPlugin> outputs = PluginRegistry.newBinder(
                binder, OutputPlugin.class);
        outputs.addBinding("file").to(FileOutputPlugin.class);
    }
}

PluginRegistryを汎用的にする

PluginRegistryはこのままでは、ほとんど使い物になりませんので、より現実的に使える形に置き換えてみましょう。

例えば、プラグインのインターフェースを増やしてもPluginRegistryの内部を修正するだけで対応できるようにしてみます。

package multibindings;

import java.util.*;

import javax.inject.Inject;

import com.google.inject.*;
import com.google.inject.multibindings.MapBinder;

public class EagerLoadingPluginRegistry {

    Map<Class<? extends Plugin>, Map<String, ? extends Plugin>> plugins = new HashMap<>();

    @Inject
    public EagerLoadingPluginRegistry(Injector injector) {
        register(injector, InputPlugin.class,
                new TypeLiteral<Map<String, InputPlugin>>() {
                });
        register(injector, OutputPlugin.class,
                new TypeLiteral<Map<String, OutputPlugin>>() {
                });
    }

    <T extends Plugin> void register(Injector injector, Class<T> clazz,
            TypeLiteral<Map<String, T>> tl) {
        Key<Map<String, T>> key = Key.get(tl);
        Binding<Map<String, T>> binding = injector.getBinding(key);
        Map<String, T> map = binding.getProvider().get(); // !!!!
        this.plugins.put(clazz, map);
    }

    @SuppressWarnings("unchecked")
    public <T extends Plugin> Optional<T> newPlugin(Class<T> type, String name) {
        Map<String, ? extends Plugin> map = this.plugins.get(type);
        if (map == null) {
            throw new IllegalStateException("unsupported plugin type " + type);
        }
        return (Optional<T>) Optional.ofNullable(map.get(name)).map(p -> {
            p.initialize();
            return p;
        });
    }

    public static <PLUGIN extends Plugin> MapBinder<String, PLUGIN> newBinder(
            Binder binder, Class<PLUGIN> type) {
        return MapBinder.newMapBinder(binder, String.class, type)
                .permitDuplicates();
    }
}

TypeLiteralがコード上に現れるとGuiceの暗黒面に接しているように感じるのは僕だけではない筈です。

Injector#getBindingメソッドを使うと依存性注入するのに使われるProviderを得られます。
これによって得られたプラグインの名前とインスタンスの対応がなされたMapをnewPluginメソッドの中で検索し易いように格納しています。

テストコード

それではEagerLoadingPluginRegistryを使ってみましょう。

package multibindings;

import static org.junit.Assert.assertNotNull;
import java.util.ServiceLoader;
import org.junit.Test;
import com.google.inject.*;

public class PluginRegistryTest {

    @Test
    public void eagerLoading() {
        ServiceLoader<Module> loader = ServiceLoader.load(Module.class);
        Injector injector = Guice.createInjector(loader);

        EagerLoadingPluginRegistry registry = injector
                .getInstance(EagerLoadingPluginRegistry.class);
        assertNotNull(registry.newPlugin(InputPlugin.class, "file"));
        assertNotNull(registry.newPlugin(InputPlugin.class, "url"));
        assertNotNull(registry.newPlugin(OutputPlugin.class, "file"));
    }
}

この実装では、プラグインのインスタンスが複数回使いまわされる上に、それぞれinitializeメソッドが呼び出されるので、initializeメソッドの処理に冪等性が必要になります。

プラグインの実装時に注意点が多いと辛いので、更に改善してみましょう。

プラグインインスタンスの生成を遅延処理する

Guiceでは管理対象オブジェクトそれぞれに対して、Providerという型のファクトリオブジェクトを作ります。

ここでは、ファクトリオブジェクトの集合をプラグイン毎に取り出した上で、名前による検索と逐次的なインスタンス生成のためにFunctionオブジェクトを作っています。

ストレージとなるメンバ変数の型パラメータが大変な事になっていますが頑張って理解して下さい。

package multibindings;

import java.util.*;
import java.util.function.Function;

import javax.inject.*;

import com.google.inject.*;
import com.google.inject.multibindings.MapBinder;

public class PluginRegistry {

    Map<Class<? extends Plugin>, Function<String, Optional<Provider<? extends Plugin>>>> providers = new HashMap<>();

    @Inject
    public PluginRegistry(Injector injector) {
        register(injector, InputPlugin.class,
                new TypeLiteral<Map<String, Provider<InputPlugin>>>() {
                });
        register(injector, OutputPlugin.class,
                new TypeLiteral<Map<String, Provider<OutputPlugin>>>() {
                });
    }

    <T extends Plugin> void register(Injector injector, Class<T> clazz,
            TypeLiteral<Map<String, Provider<T>>> tl) {
        Key<Map<String, Provider<T>>> key = Key.get(tl);
        Binding<Map<String, Provider<T>>> binding = injector.getBinding(key);
        Map<String, Provider<T>> map = binding.getProvider().get();
        this.providers.put(clazz, name -> Optional.ofNullable(map.get(name)));
    }

    @SuppressWarnings("unchecked")
    public <T extends Plugin> Optional<T> newPlugin(Class<T> type, String name) {
        Function<String, Optional<Provider<? extends Plugin>>> fn = this.providers
                .get(type);
        if (fn == null) {
            throw new IllegalStateException("unsupported plugin type " + type);
        }
        Optional<? extends Plugin> plugin = fn.apply(name).map(Provider::get);
        return (Optional<T>) plugin.map(p -> {
            p.initialize();
            return p;
        });
    }

    public static <PLUGIN extends Plugin> MapBinder<String, PLUGIN> newBinder(
            Binder binder, Class<PLUGIN> type) {
        return MapBinder.newMapBinder(binder, String.class, type)
                .permitDuplicates();
    }
}

まとめ

GuiceのMapBinderは簡単なプラグインシステムを作るには非常に良く出来ているのですけども、全くドキュメントが無いので使い方をまとめてみました。

ServiceLoaderでロードできるリソースはJavaのオブジェクトに限定されている為、自己記述的な設定ファイルの類をOSGiのようにMETA-INF以下に配置したい場合には難しいと言えます。

一方で、GuiceはModuleが事実上の設定ファイルであるとみなせるのでServiceLoaderの問題点を上手く回避できると言えます。

その他資料