IK.AM

@making's tech note


DIコンテナで実現する簡易プラグイン機構

🗃 {Programming/Java/org/springframework}
🏷 Java 🏷 Spring 
🗓 Updated at 2015-04-13T17:46:04Z  🗓 Created at 2015-04-13T17:46:04Z   🌎 English Page

先日のJJUG CCCの『Embulk』に見るモダンJavaの実践的テクニック
 ∼並列分散処理システムの実装手法∼を受付からちら見していたとき、 DIコンテナにGuiceを使っており、プラグイン機構にはService Loaderを使っていると聞きました。

断片的に聞いており、

とつぶやいたら、発表者のnahiさんやtokuhiromさんから反応があったので、一応ブログに書いておきます。

自分が思っていたことはそんな難しいものではなく、簡易プラグイン機構はDIで普通にできるよねって話です。 Service Loaderでも最低限のことはできるけど、これはDI使いたくない人向けみたいなものだし、DIコンテナ使っているのならDIコンテナに任せればいいのでは?という思っています。

ここでいう簡易プラグインとは、

  • 特定のインタフェースを持っている
  • 複数登録できる
  • 起動中に動的に変更することはない
  • マルチバージョン管理はしない

といったものです。OSGIとか使う必要のないシーンを想定しています。

基本編

まずは基本パターンから説明します。

package demo;

public interface MyPlugin {
    String action(String input);
}

こんなインタフェースがあったとして、このプラグインを使う製品が

package demo;

import java.util.List;

public class MyProduct {
    List<MyPlugin> plugins;

    public void execute(String input) {
        System.out.println(plugins);
        String result = input;
        for (MyPlugin plugin : plugins) {
            result = plugin.action(result); // プラグインの結果を次々に適用していく(普通はこんなことしないか・・)
        }
        System.out.println(result);
    }
}

こんな感じだとします。この製品を使うユーザーは自由にプラグインを追加できるケース。

このプラグイン群をいかにして引っ張りあげるかがポイントですが、 確かにJava SE 6で追加されたServiceLoader実現できます。 DIコンテナを使わない条件であれば、ServiceLoaderで十分だけど、DIコンテナ使っているんならこのくらいDIコンテナでやったほうがいいんじゃない?という率直な感想が今回のツイートの発端となっています。

Spring Frameworkの場合は、DIコンテナに登録されたBeanが複数の同じインタフェースを持っていたら、ListMapで引っこ抜くことは簡単です。

例えば、次のようなBean定義があるとします(ここではJavaConfigでBean定義する)

package demo;

import aaa.AaaPlugin;
import bbb.BbbPlugin;
import ccc.CccPlugin;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AppConfig {
    // XML定義でいうところの<bean id="aaaPlugin" clas="aaa.AaaPlugin" />に相当する
    @Bean
    AaaPlugin aaaPlugin() {
        return new AaaPlugin();
    }

    @Bean
    BbbPlugin bbbPlugin() {
        return new BbbPlugin();
    }

    @Bean
    CccPlugin cccPlugin() {
        return new CccPlugin();
    }

    @Bean
    MyProduct myProduct() {
        return new MyProduct();
    }
}

3つのプラグインは次のような実装です。

package aaa;

import demo.MyPlugin;

public class AaaPlugin implements MyPlugin {
    @Override
    public String action(String input) {
        return input.replace("a", "◯");
    }
}

package bbb;

import demo.MyPlugin;

public class BbbPlugin implements MyPlugin {
    @Override
    public String action(String input) {
        return input.replace("b", "●");
    }
}

package ccc;

import demo.MyPlugin;

public class CccPlugin implements MyPlugin {
    @Override
    public String action(String input) {
        return input.replace("c", "◎");
    }
}

製品コードであるMyProductには List<MyPlugin>をそのままインジェクションできます。

package demo;

import org.springframework.beans.factory.annotation.Autowired;

import java.util.List;

public class MyProduct {
    @Autowired // ここではオートワイヤリングを使ってインジェクション
    List<MyPlugin> plugins;

    public void execute(String input) {
        System.out.println(plugins);
        String result = input;
        for (MyPlugin plugin : plugins) {
            result = plugin.action(result);
        }
        System.out.println(result);
    }
}

このMyProductを実行するエントリポイントクラスは次のように書きます。

package demo;

import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.support.GenericApplicationContext;

public class App {
    public static void main(String[] args) {
        try (GenericApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class)) {
            // DIコンテナから登録されたBeanをルックアップ
            MyProduct product = context.getBean(MyProduct.class);

            String input = "blackberry";
            product.execute(input);
        }
    }
}

このmainメソッドを実行すると、以下のように出力されます。

[aaa.AaaPlugin@60438a68, bbb.BbbPlugin@140e5a13, ccc.CccPlugin@3439f68d]
●l◯◎k●erry

AaaPluginBbbPluginCccPluginの結果が全て適用されています。(こんな冪等にならなさそうなプラグインシステムは作らないでしょうねw)

ちなみにMapでインジェクションしてpirintlnすると、

@Autowired
Map<String, MyPlugin> pluginMap;

// ...
System.out.println(pluginMap);

出力結果は次のように、Bean ID(JavaConfigではデフォルトではメソッド名)とインスタンスのマッピングが表示されます。

{aaaPlugin=aaa.AaaPlugin@60438a68, bbbPlugin=bbb.BbbPlugin@140e5a13, cccPlugin=ccc.CccPlugin@3439f68d}

ここまでで、 ServiceLoader的なことを実現する方法はわかると思います。

次に検討ポイントとなるのは、プラグインの登録方法です。上記例では製品コードにプラグイン定義を書いたので現実的ではありません。

やりたいのはユーザーが書いたプラグインを含むjarを製品実行時のクラスパスに追加するとそのプラグインが読み込まれるというやり方。

一番簡単なのは、コンポーネントスキャンを使う方法です。

package demo;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;

@ComponentScan(basePackages = {"スキャン対象のプラグインパッケージ(トップの階層でOK)"},
        includeFilters = @ComponentScan.Filter(value = MyPlugin.class, type = FilterType.ASSIGNABLE_TYPE))
@Configuration
public class AppConfig {
    @Bean
    MyProduct myProduct() {
        return new MyProduct();
    }
}

この設定では特定のパッケージ配下のMyPluginインタフェースを実装したクラスを根こそぎDIコンテナに登録できます。スキャン対象が広いと起動が遅くなってしまうので、何かしらプラグインパッケージに規約をさだめるのが良いでしょう。

パッケージルールが嫌な場合は、ユーザーがBean定義とセットでjarを作るという方法があります。

package demo;

import org.springframework.context.annotation.*;

@ImportResource("classpath*:META-INF/plugins.xml")
@Configuration
public class AppConfig {
    @Bean
    MyProduct myProduct() {
        return new MyProduct();
    }
}

これはクラスパス配下の任意のMETA-INF/plugins.xmlを読み込む設定です。 プラグイン作成側は、プラグイン実装とMETA-INF/plugins.xmlに次のような設定を書いてjarを作れば良いです。

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
    <bean class="aaa.AaaPlugin"/>
</beans>

設定ファイルパスのルールを決めることでプラグインパッケージは自由に決めることができます。 これはServiceLoaderパターンとほとんど同じですね。

これすら嫌な場合、以下のようにプログラマティックにBeanを登録することができるので、自分でプラグイン定義方法を考えて、それを読み込む実装を行えば良いでしょう。

package demo;

import aaa.AaaPlugin;
import bbb.BbbPlugin;
import ccc.CccPlugin;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AppConfig {
    @Bean
    InitializingBean pluginRegisterer() {
        return new InitializingBean() {
            @Autowired
            AnnotationConfigApplicationContext context;

            @Override
            public void afterPropertiesSet() throws Exception {
                // プラグインクラス名をなんらかの形で取得する仕組みを作れば良い
                context.register(AaaPlugin.class, BbbPlugin.class, CccPlugin.class);
            }
        };
    }

    @Bean
    MyProduct myProduct() {
        return new MyProduct();
    }
}

ただ、このやり方はあまり見ない。

余談ですが、上記コードを実行するのに必要な依存関係は

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context</artifactId>
    <version>4.1.6.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>jcl-over-slf4j</artifactId>
    <version>1.7.8</version>
</dependency>
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-simple</artifactId>
    <version>1.7.8</version>
</dependency>

だけです。Spring使うと依存関係の定義が大変と思っている人も多いと思いますが、コアな部分に限定すると大したことありません。

ここまで基本的な話です。

Spring Plugin

このような簡易プラグイン機構をSpringでより簡単に扱うためにSpring Pluginというプロジェクトがあります。

Spring Pluginでは以下のようなPluginインタフェースが用意されており、

package org.springframework.plugin.core;

public interface Plugin<S> {
    // なんらかのデリミタ(たとえば入力値そのもの)に対して、このプラグインが対応しているかどうかを返す
    boolean supports(S delimiter); 
}

これを拡張して、自分のプラグインを作ります。前述の例に合わせると、以下のような感じになります。

package demo;

import org.springframework.plugin.core.Plugin;

public interface MyPlugin extends Plugin<String> {

    String action(String input);
}

これを実装したプラグインは、先ほどとほぼ同じで次のように実装できます。

package aaa;

import demo.MyPlugin;

public class AaaPlugin implements MyPlugin {

    @Override
    public boolean supports(String s) {
        return s != null;
    }

    @Override
    public String action(String input) {
        return input.replace("a", "◯");
    }

}

BbbPluginCccPluginも同様です。

これらのプラグインをさっきと同じように、何らかのやり方でBean定義するのですが、 Spring PluinではDIコンテナに登録されたプラグインを管理するためのorg.springframework.plugin.core.PluginRegistryインタフェースが用意されています。

このPluginRegistryを通じて、登録されたプラグインを取得することができます。MyProductのコードは次のようになります。

package product;

import demo.MyPlugin;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.plugin.core.PluginRegistry;

import java.util.List;

public class MyProduct {
    @Autowired
    PluginRegistry<MyPlugin, String> pluginRegistry;

    public void execute(String input) {
        System.out.println(pluginRegistry.getPlugins());
        String result = input;
        List<MyPlugin> plugins = pluginRegistry.getPluginsFor(input);
        for (MyPlugin plugin : plugins) {
            result = plugin.action(result);
        }
        System.out.println(result);
    }
}

MyPlugin用のPluginRegistryをつくるためのBean定義は以下のように行えます。

package product;

import demo.MyPlugin;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportResource;
import org.springframework.plugin.core.config.EnablePluginRegistries;

@Configuration
@EnablePluginRegistries(MyPlugin.class)
@ImportResource("classpath*:META-INF/plugins.xml") // コンポーネントスキャンでも可
public class AppConfig {

    @Bean
    MyProduct product() {
        return new MyProduct();
    }
}

あとはユーザーにAaaPluginの実装とそれを定義したMETA-INF/plugins.xmlを含むjarを作ってもらい、MyProduct実行時にクラスパスに追加しておけば読み込まれます。

この製品のエントリポイントは基本編と同じで、

package product;

import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.support.GenericApplicationContext;

public class App {
    public static void main(String[] args) {
        try (GenericApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class)) {
            MyProduct product = context.getBean(MyProduct.class);
            String input = "blackberry";
            product.execute(input);
        }
    }
}

です。これを実行すると、以下のように表示されます。

[aaa.AaaPlugin@58134517, bbb.BbbPlugin@4450d156, ccc.CccPlugin@4461c7e3]
●l◯◎k●erry

Spring Pluginの使い方は

<dependency>
    <groupId>org.springframework.plugin</groupId>
    <artifactId>spring-plugin-core</artifactId>
    <version>1.2.0.RELEASE</version>
</dependency>

だけでOKです。

Spring Pluginでは他にもプラグインにメタデータを加える機構も用意されており、 プラグインシステムを作るのであればやりそうなことがサポートされています。

ただ、シンプルな実装であり、更新は年に1回くらいしか行われていないように見えます。(Springのバージョンアップくらい)


同様のことはたぶん他のDIフレームワークでも実現できると思います。Guiceだとこの辺ですかね。

DIコンテナにプラグインを管理させると、

  • インスタンスのスコープを制御できる(singletonとかprototypeとか)
  • インスタンスのライフサイクルイベントにフックできる(@PostConstructとか@PreDestroyとか。Springだとprototypeのときは効かないけど・・)
  • AOPをかけられる(共通ログとかキャッシュとか)
  • 製品側のインフラストラクチャーコードをインジェクションできる

などのメリットがあります。 この辺のメリットが不要な場合はServiceLoadaerでもいいかなーと思います。

本記事で扱ったサンプルコードはこちらです。


✒️️ Edit  ⏰ History  🗑 Delete