最終更新日:2022/02/26 原本2021-04-07

プラグインで拡張できるJavaプログラムを作る

プラグイン方式によるプログラムの機能拡張

プラグインを使って機能を拡張するJavaプログラムはどのように作成すればよいのでしょうか。簡単なサンプルを作成しながらその基本について説明します。

はじめに

 最近のプログラムの多くは、最初から機能がすべて確定したものではなく、後からプログラマが拡張していけるような形をとるようになってきています。この種のプログラムでよく用いられるのが「プラグイン」でしょう。仕様にそって作成すれば誰でもプラグインを作り機能を拡張していくことができるというのはなかなか魅力的です。そこで、Javaプログラムでこうした「プラグインによる機能拡張」を実装する方法について考えてみることにしましょう。

対象読者

  • Javaを使ったプログラム作成を行っている中級レベルのプログラマ。
  • プラグイン型のプログラム拡張に興味がある方。

プラグインによるプログラム拡張とは?

 多くのプログラムでは、プラグインと呼ばれるプログラムを作成することで機能を拡張することができます。このプラグインというのは、大抵の場合、次のような働きをします。

  • あらかじめ用意されている仕様に沿ってプログラムを作成するだけでいい。本体側でどんな処理がなされているかなど知る必要がない。
  • 指定されたディレクトリなどにファイルを入れておくだけでプログラムが自動認識し、プログラム本体に組み込まれる。
  • 複数のプラグインを個別に認識し、随時切り替えるなどして利用することができる。

 こういうプラグイン方式による機能拡張というのはなかなか便利です。自分で、あるいは自社でプログラムを作成するとき、こうした仕組みを用意して、あわよくば超人気のプラグインなどが登場してくれれば……などと虫のいいことを考えたこと、誰しもあるはずですね、ハイ。

 が、実際にプログラムを作成しようとすると、どのような形でプラグインの仕組みを組み込めばいいのか、うまくイメージできないということもあるのではないでしょうか。そこで、プラグインの仕組みはどうなっているか、考えてみましょう。

サンプルとなる計算プログラム

 では、最初にベースとなる簡単なプログラムを用意しましょう。これは、ごく単純な計算をするアプリケーションです。入力フィールドに書かれた数字に1.05をかけた答えを別のフィールドに表示する、という、要するに「消費税の税込価格を計算するプログラム」です。

基本プログラム
package jp.tuyano.codezine;

import java.awt.*;
import java.awt.event.*;

import javax.swing.*;

public class SamplePluginApp extends JFrame {
    private static final long serialVersionUID = 1L;
    
    private JTextField result,input;
    private JPanel panel;

    public static void main(String[] args) {
        new SamplePluginApp().setVisible(true);
    }
    
    public SamplePluginApp(){
        result = new JTextField();
        this.add(result,BorderLayout.NORTH);
        panel = new JPanel();
        panel.setLayout(null);
        input = new JTextField();
        input.setBounds(new Rectangle(25,25,50,20));
        panel.add(input);
        this.add(panel,BorderLayout.CENTER);
        JButton button = new JButton("calc");
        button.addActionListener(new buttonAction());
        this.add(button,BorderLayout.SOUTH);
        this.setSize(new Dimension(200,150));
        this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    }
    
    class buttonAction implements ActionListener {
        public void actionPerformed(ActionEvent ev){
            try {
                int n = Integer.parseInt(input.getText());
                result.setText(Double.toString(n * 1.05));
            } catch (NumberFormatException e) {
                e.printStackTrace();
            }
        }
    }
}
ベースとなるプログラム。中央のフィールドに数字を入れてクリックすると1.05倍した値が上のフィールドに表示される。
ベースとなるプログラム。中央のフィールドに数字を入れてクリックすると1.05倍した値が上のフィールドに表示される。

 これ自体は、取り立ててどうということもないものです。画面中央にあるフィールドに数値を入れてボタンをクリックすると、それを1.05倍した値を上のフィールドに表示するというものです。

 このプログラムでは、入力した値を1.05倍するという計算をして結果を表示しています。この「与えられた値を元に計算した結果を返す」という部分を、プラグインとして入れ替え可能にしてみましょう。

プラグインの仕様を考える

 Javaの場合、最も利用しやすいファイル形式は「Jarファイル」でしょう。プラグインも、作成したクラスファイルなどをJarファイルとしてまとめたものを指定のフォルダに入れておくような形にするのが、最もスマートだろうと思います。Jarなら複数のファイルを1つにまとめられますし、必要な情報をマニフェストファイルに入れて読み込ませることもできます。そこで、今回は次のような形でプラグインを作成することにしました。

  • プラグイン用にインターフェイスを用意する。このインターフェイスで、値の設定と計算結果の取得、プラグイン用GUIオブジェクト作成などのメソッドを定義しておく。
  • プラグインクラスは、必ず上記のインターフェイスを実装したものとして設計する。
  • 作成したプラグインクラスは、Jarファイルとしてまとめる。
  • Jarファイルには、プラグインクラス名を記したマニフェストファイルを用意する。アプリケーション側では、Jarからマニフェストファイルを読み込み、その値を元にクラスを読み込むようにする。
  • 作成したプラグインのJarファイルは、アプリケーションと同じ階層に用意された「plugins」フォルダの中に保管する。アプリケーションは、起動時にこのフォルダ内を調べ、プラグインのJarをすべて読み込むようにする。
  • 便宜上、今回は読み込んだプラグイン・クラスのtoString()で得られたものをプラグインの名前として利用する形にしておく。

 基本的な形は次のようになります。では、プラグイン用のインターフェイスを用意しましょう。ここでは、SamplePluginAppPluginという名前で次のように用意することにします。

インターフェイス
package jp.tuyano.codezine;

import javax.swing.JPanel;

public interface SamplePluginAppPlugin {
    public String getResult();              // 計算結果の取得
    public void setInputData(String input); // データのSetter
    public String getInputData();           // データのGetter
    public JPanel getPanel();               // GUIとなるJPanelを返す
}

 プラグイン・クラスでは、setInputでString値が渡され、getResultで結果が返されます。まぁ、一つにまとめて引数としてStringを渡したら結果を返すようにしても良いのですが、後々拡張することを考えて、データとなる値を保管するinputDataプロパティを用意し、そのSetter/Getterと計算結果を得るgetResultの3つに分けておくことにしました。また、値は数値ではなくStringとしてやりとりするようにしてあります。

 今回は、計算に必要な情報を入力するためのGUIもプラグインにもたせることにしました。getPanelでプラグインが使用するGUIとなるJPanelを取得できるようにしてあります。JPanelの内部はどのようにデザインされていてもかまいません。結果と現在のデータの値がgetResultなどで取得できれば、内部がどうなっているか知る必要はないのですから。

プラグイン対応アプリケーションの作成

 では、アプリケーション側のクラスを作成しましょう。ここでは、メインクラスとなるSamplePluginAppには計算のための処理は用意せず、サンプルとしてプラグインクラスを1つ標準で用意しておき、それをデフォルトで組み込んでおく形にしておきました。

メインクラス
package jp.tuyano.codezine;

import java.awt.*;
import java.awt.event.*;
import java.io.File;
import java.net.*;
import java.util.*;
import java.util.jar.*;

import javax.swing.*;

public class SamplePluginApp extends JFrame {
    private static final long serialVersionUID = 1L;
    
    private JTextField result;
    private JComboBox pluginsCombo;
    private JPanel panel;
    
    private ArrayList <SamplePluginAppPlugin>plugins;
    private SamplePluginAppPlugin selectedPlugin;
    private JPanel selectedPluginPanel;

    public static void main(String[] args) {
        new SamplePluginApp().setVisible(true);
    }
    
    // コンストラクタ。基本GUIの作成
    public SamplePluginApp(){
        result = new JTextField();
        this.add(result,BorderLayout.NORTH);
        panel = new JPanel();
        panel.setLayout(new BorderLayout());
        pluginsCombo = this.getPluginComboBox();
        pluginsCombo.setBounds(new Rectangle(25,25,100,20));
        pluginsCombo.addActionListener(new pluginComboAction());
        panel.add(pluginsCombo,BorderLayout.NORTH);
        panel.add(selectedPlugin.getPanel(),BorderLayout.CENTER);
        this.add(panel,BorderLayout.CENTER);
        JButton button = new JButton("calc");
        button.addActionListener(new buttonAction());
        this.add(button,BorderLayout.SOUTH);
        this.setSize(new Dimension(200,200));
        this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    }
    
    // JComboBoxの作成
    public JComboBox getPluginComboBox(){
        JComboBox combo = new JComboBox();
        plugins = getPlugins();
        // デフォルトプラグインの準備
        selectedPlugin = new DefaultPlugin();
        selectedPluginPanel = selectedPlugin.getPanel();
        combo.addItem(selectedPlugin);
        
        for(int i = 0;i < plugins.size();i++){
            SamplePluginAppPlugin plugin = plugins.get(i);
            combo.addItem(plugin);
        }
        return combo;
    }
    
    // JComboBoxのActionListener
    class pluginComboAction implements ActionListener {
        public void actionPerformed(ActionEvent ev){
            panel.removeAll();
            panel.add(pluginsCombo,BorderLayout.NORTH);
            selectedPlugin = (SamplePluginAppPlugin)
                    pluginsCombo.getSelectedItem();
            selectedPluginPanel = selectedPlugin.getPanel();
            panel.add(selectedPluginPanel);
            panel.updateUI();
        }
    }
    
    // JButtonのActionListener
    class buttonAction implements ActionListener {
        public void actionPerformed(ActionEvent ev){
            result.setText(selectedPlugin.getResult());
        }
    }
    
    // プラグインクラスのインスタンスをArrayListにまとめて返す
    public ArrayList<SamplePluginAppPlugin> getPlugins() {
        ArrayList <SamplePluginAppPlugin>plugins =
                new ArrayList<SamplePluginAppPlugin>();
        String cpath = System.getProperty("user.dir") +
                File.separator + "plugins";
        try {
            File f = new File(cpath);
            String[] files = f.list();
            for (int i = 0; i < files.length; i++) {
                if (files[i].endsWith(".jar")) {
                    File file = new File(cpath + File.separator +
                            files[i]);
                    JarFile jar = new JarFile(file);
                    Manifest mf = jar.getManifest();
                    Attributes att = mf.getMainAttributes();
                    String cname = att.getValue("Plugin-Class");
                    URL url = file.getCanonicalFile().toURI().toURL();
                    URLClassLoader loader = new URLClassLoader(
                            new URL[] { url });
                    Class cobj = loader.loadClass(cname);
                    Class[] ifnames = cobj.getInterfaces();
                    for (int j = 0; j < ifnames.length; j++) {
                        if (ifnames[j] == SamplePluginAppPlugin.class) {
                            System.out.println("load..... " + cname);
                            SamplePluginAppPlugin plugin =
                                (SamplePluginAppPlugin)cobj.newInstance();
                            plugins.add(plugin);
                            break;
                        }
                    }
                }
            }
        } catch (Exception ex) {
            ex.printStackTrace();
        }
        return plugins;
    }
}

// サンプルプラグインのクラス
class DefaultPlugin implements SamplePluginAppPlugin {
    private JTextField input;
    private JPanel currentPanel;
    
    public String toString(){
        return "Default(tax)";
    }

    public String getResult() {
        String inputData = input.getText();
        if (inputData == null) inputData = "0";
        int n;
        try {
            n = Integer.parseInt(inputData);
        } catch (NumberFormatException e) {
            e.printStackTrace();
            n = 0;
        }
        double res = ((int)(n * 1.05 * 100)) / 100;
        return Double.toString(res);
    }

    public void setInputData(String str) {
        input.setText(str);
    }
    
    public String getInputData(){
        return input.getText();
    }

    public JPanel getPanel() {
        currentPanel = new JPanel();
        currentPanel.setLayout(null);
        input = new JTextField();
        input.setBounds(new Rectangle(25,25,50,20));
        currentPanel.add(input);
        return currentPanel;
    }
    
}
プラグイン方式に改良されたプログラム。デフォルトでプラグインが1つ組み込まれている。
プラグイン方式に改良されたプログラム。デフォルトでプラグインが1つ組み込まれている。

プラグイン読み込みの処理

 SamplePluginAppでは、getPluginsメソッドでプラグインをロードしArrayListにまとめる処理をしています。処理の流れを整理していきましょう。――まずは、アプリケーションのディレクトリ内にある「plugins」フォルダのパスを取得します。

String cpath = System.getProperty("user.dir") +
        File.separator + "plugins";

 アプリケーションも実行可能Jarとしてまとめる前提で考えています。この場合、System.getProperty("user.dir")でアプリケーションがおかれているディレクトリのパスが得られます。これにFile.separatorを使って"plugins"を付け足すことで「plugins」フォルダのパスが得られます。

File f = new File(cpath);
String[] files = f.list();

 「plugins」フォルダ内の全ファイルを取得します。これはFileインスタンスのlist()で得ることができます。こうして取得されたファイルの中から、forによる繰り返しを使って、この中からJarファイルだけをピックアップしていきます。

for (int i = 0; i < files.length; i++) {
    if (files[i].endsWith(".jar")) {

 ファイル名の末尾が".jar"で終わるものだけを処理していきます。まずFileインスタンスを作成し、それを元にJarFileインスタンスを作成します。

File file = new File(cpath + File.separator + files[i]);
JarFile jar = new JarFile(file);

 続いて、JarFileからManifestを取得します。これは名前の通り、マニフェストファイルを示すクラスです。そこからメインアトリビュート(属性)を取得し、その中にある"Plugin-Class"の値を取得します。

Manifest mf = jar.getManifest();
Attributes att = mf.getMainAttributes();
String cname = att.getValue("Plugin-Class");

 こうして得られたプラグインクラスをJarファイルから読み込みます。これには、JarファイルをURLとして取得し、そこからURLClassLoaderを作成します。

URL url = file.getCannicalFile().toURI().toURL();
URLClassLoader loader = new URLClassLoader(
        new URL[] { url });

 そしてこのURLClassLoaderloadClass()を使い、プラグインクラスのClassを取得し、そこからインターフェイスをすべて取り出します。

Class cobj = loader.loadClass(cname);
Class[] ifnames = cobj.getInterfaces();

 これでプラグイン・クラスにimplementsされている全インターフェイスのClassが得られました。後は、forを使い、この中にSamplePluginAppPluginが含まれていないかチェックすればいいわけです。

for (int j = 0; j < ifnames.length; j++) {
    if (ifnames[j] == SamplePluginAppPlugin.class) {

 もし含まれていた場合には、newInstance()メソッドでSamplePluginAppPluginインスタンスを作成し、これをArrayListに追加します。

System.out.println("load..... " + cname);
SamplePluginAppPlugin plugin =
    (SamplePluginAppPlugin)cobj.newInstance();
plugins.add(plugin);

 これで、JarからSamplePluginAppPluginをimplementsしてあるクラスのインスタンスを作成しArrayListにまとめる、という処理ができました。Jarファイルの作成、マニフェストの読み込み、属性の値の取得、インターフェイスの取得、クラスのインスタンスの作成――これらの流れが一通り理解できれば、Jarファイルからプラグインのクラスを読み込むことはほぼできるようになります。

コンボボックスの処理

 コンボボックスは、ArrayListをもとにプラグインを項目として追加しています。コンボボックスにコンポーネントなどを追加した場合、表示されるのはtoString()の値になりますので、toString()をプラグインクラスでオーバーライドすることでプラグイン名を表示させることができますね。

 このコンボボックスを選択すると、使用するプラグインが切り替わるようになっています。ここでは、まず全コンポーネントを取り除き、それから改めてコンボボックスを組み込みなおしています。

panel.removeAll();
panel.add(pluginsCombo,BorderLayout.NORTH);

 そして、選択されているプラグインを保管するselectedPluginと、そのGUI用パネルを保管するselectedPluginPanelにそれぞれインスタンスを設定します。

selectedPlugin = (SamplePluginAppPlugin)
        pluginsCombo.getSelectedItem();
selectedPluginPanel = selectedPlugin.getPanel();

 後は、selectedPluginPanelを改めてaddし、表示を更新するだけです。既にプラグインのインスタンスがひとまとめに用意されていれば、このように使用プラグインの設定などは比較的簡単に行えるでしょう。

プラグインクラスを追加する

 では、サンプルで用意したプラグインクラスはどうなっているでしょうか。implementsしてあるSamplePluginAppPluginのメソッドを一通り実装していることが分かりますね。そのうち、getInputData/setInputDataは、単純にprivateフィールドと値をやり取りしているだけです。

 toString()は、プラグイン名を返すようにしてあります。また肝心のgetResult()では、データを保管してあるprivateフィールドのStringを元に、計算を行い、その結果を返しています。プラグインの処理そのものは、それほど難しいものではないことが分かるでしょう。

 では、同様にしてプラグインのファイルを作成してみることにしましょう。ここでは、入力した数値の平均を計算するプラグインを作成してみましょう。まずはソースコードからです。ここでは「AveragePlugin.java」として作成します。

AveragePLuginクラス
package jp.tuyano.codezine;

import java.awt.Rectangle;
import javax.swing.*;

public class AveragePlugin implements SamplePluginAppPlugin {
    private JTextArea input = null;
    
    public String toString(){
        return "Average";
    }
    
    public String getResult() {
        String inputData = input.getText();
        if (inputData == null) inputData = "0";
        String[] arr = inputData.split("\n");
        int[] intarr = new int[arr.length];
        for (int i = 0; i < arr.length; i++) {
            try {
                intarr[i] = Integer.parseInt(arr[i]);
            } catch (NumberFormatException e) {
                e.printStackTrace();
            }
        }
        int total = 0;
        for (int i : intarr) {
            total += i;
        }
        double av = total / intarr.length;
        double res = ((int)(av * 100)) / 100;
        return Double.toString(res);
    }

    public String getInputData() {
        return input.getText();
    }
    
    public void setInputData(String str) {
        input.setText(str);
    }
    
    public JPanel getPanel() {
        JPanel p = new JPanel();
        p.setLayout(null);
        input = new JTextArea();
        input.setLineWrap(true);
        JScrollPane s = new JScrollPane(input);
        s.setBounds(new Rectangle(25,25,75,50));
        p.add(s);
        return p;
    }
}

 このプラグインでは、GUIのパネルにJTextAreaを配置し、改行して記述された1つ1つの値を合計し、平均を計算するように設計してみました。続いて、Jar用のマニフェストファイルを用意します。

AveragePluginのマニフェストファイル
Manifest-Version: 1.0
Plugin-Class: jp.tuyano.codezine.AveragePlugin

 ここでは、Plugin-Classにプラグインクラスを記述しておきます。こうして作成したら、プラグインクラスをコンパイルし、用意したマニフェストファイルを使ってJarファイル化しておきます。

 作成したJarファイルを「plugins」フォルダに入れて、アプリケーションを起動してみてください。追加したプラグインがコンボボックスに項目として表示されれば、プラグインを読み込んでいます。追加したプラグインを選ぶとコンボボックス下の表示が切り替わり、そこで数字を入力してボタンを押せば、平均がフィールドに書き出されます。入力のためのパネル部分と計算処理がプラグインのものに入れ替わっていることがよく分かるでしょう。

コンボボックスにプラグインが追加されている。これを選択するとGUIが変わり、クリックすると平均を計算するようになる。
コンボボックスにプラグインが追加されている。これを選択するとGUIが変わり、クリックすると平均を計算するようになる。

まとめ

 プラグイン方式によるプログラムの拡張は、「インターフェイスの設計」と、「どのような形でプラグインクラスを読み込ませるか」という設計部分の2つがポイントとなります。読み込み方法については、今回行ったものをベースにすればいろいろなやり方が考えられるようになるでしょう。実際に1~2度、簡単なプログラムを作ってみれば大体の流れは飲み込めるはずです。

 おそらく、最も難しいのは、インターフェイスの設計でしょう。これは、そのプラグインにどのような機能を持たせるか、どのような形でメインプログラムからアクセスするのが最もよいか、それによって大きく変わるからです。

 Javaは、Jarやマニフェストファイルなど、外部からプログラムを読み込むための仕組みが標準で備えられています。これらを使うことでプラグインの機能は比較的簡単に実装することができます。あなたのプログラムにも、一度プラグイン機能を追加してみてください。その後の機能追加などがずいぶんと楽になるはずですよ。