メインコンテンツまでスキップ

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

Sato Taichi
yak shaver

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 の問題点を上手く回避できると言えます。

その他資料