seraphyの日記

日記というよりは過去を振り返るときのための単なる備忘録

JAVAにスクリプトエンジンを組み込んでみる

Rhinoを使ってみよう、と思い立つ。
ずっと以前にJythonやGroovyを試して以来、RhinoというJavaScript実装もあるという話は聞いていた気がするが、そのうち試そうと思いつつ、あっという間(?)に2年以上も経過していた。
気が付けば、JavaScriptは、そこらじゅうにある状況になっていた。これは、もうRhinoをつかえ、ということなのか。
ということで、Rhinoを使ってみることにする。*1

Rhinoを使ってみる

package jp.seraphyware;

import java.io.PrintWriter;
import java.io.Reader;
import java.io.StringReader;
import java.io.StringWriter;

import org.mozilla.javascript.Context;
import org.mozilla.javascript.Script;
import org.mozilla.javascript.ScriptableObject;

public final class RhinoTest {

    /**
     * JavaScriptを読み込む。
     * 関数fの定義と、JAVAクラスメソッドの呼び出しが定義されている。
     * @return JavaScriptのReader
     */
    protected static Reader getScript() {
        final StringWriter sw = new StringWriter();
        final PrintWriter pw = new PrintWriter(sw);
        pw.println("java.lang.System.out.println('hello, world!');");
        pw.println("Packages.jp.seraphyware.RhinoTest.func('123.4');");
        pw.println("function f(p) {");
        pw.println("var a = 1;");
        pw.println("var b = 2;");
        pw.println("var c = (a + b) + p;");
        pw.println("return c;");
        pw.println("}");
        return new StringReader(sw.toString());
    }

    /**
     * JavaScriptから呼び出すテスト用
     * @param value
     */
    public static void func(final double value) {
        System.out.println(value);
    }

    /**
     * エントリポイント
     * @param args 引数(意味無し)
     * @throws Exception 失敗
     */
    public static void main(final String[] args) throws Exception {
        // コンテキストの作成
        final Context ctx = new Context();

        // スクリプトのコンパイル
        final Script scr;
        Context.enter(ctx);
        try {
            System.out.println("compile:");
            final Reader rd = getScript();
            try {
                scr = ctx.compileReader(rd, "<code1>", 1, null);
            }
            finally {
                rd.close();
            }
        }
        finally {
            // スレッドからコンテキストを切り離す
            Context.exit();
        }

        // ルートオブジェクトの作成
        // JavaScriptのグローバルにアクセスできるオブジェクトなどが初期設定される。
        final ScriptableObject root = ctx.initStandardObjects();

        // スクリプトの実行処理
        final Runnable[] jobs = new Runnable[] {
                // スクリプトを実行し、ルートオブジェクトに新しい関数fを定義する
                new Runnable() {
                    public void run() {
                        Context.enter(ctx); // コンテキストをスレッドに関連づける
                        try {
                            System.out.println("execute1:");
                            final Object result = scr.exec(ctx, root);
                            System.out.println("result:" + result);
                        }
                        finally {
                            Context.exit();
                        }
                    }
                },
                // ルートオブジェクトに定義された関数fをスクリプトから呼び出す
                new Runnable() {
                    public void run() {
                        Context.enter(ctx);
                        try {
                            System.out.println("execute2:");
                            final Object result = ctx.evaluateString(root,
                                    "f(123);", "<code2>", 1, null);
                            System.out.println("result:" + result);
                        }
                        finally {
                            Context.exit();
                        }
                    }
                },
                // ルートオブジェクトを直接使って定義された関数fを呼び出す
                new Runnable() {
                    public void run() {
                        Context.enter(ctx);
                        try {
                            System.out.println("execute3:");
                            final Object result = ScriptableObject.callMethod(root,
                                    "f", new Object[] {"ABC"});
                            System.out.println("result:" + result);
                        }
                        finally {
                            Context.exit();
                        }
                    }
                }
            };
        
        // コンテキストとルートオブジェクトをスレッドを渡歩きながら実行してみる。
        final ThreadGroup tgroup = new ThreadGroup("jobs") {
            @Override
            public void uncaughtException(Thread v_t, Throwable v_e) {
                v_e.printStackTrace();
                System.exit(1);
            }
        };
        for (final Runnable job: jobs) {
            final Thread thread = new Thread(tgroup, job);
            thread.start();
            thread.join();
        }

        System.out.println("done.");
    }
}

さすがに、もともとPureJavaブラウザのために作られただけあって、柔軟性が高い感じがする。

Rhinoの基本的なスタイルとしては、

Context ctx = Context.enter();
try {
 ....
}
finally {
    Context.exit();
}

というパターンとなるのだが、ぱっと見、ContextのstaticメソッドenterでContextを取得しておきながら、取得したContextオブジェクトではなくて、そのファクトリであるContext自身のstaticメソッドexitを呼び出して「後始末」する、というように見えて、ものすごく違和感があった。
しかし、ドキュメントを読むと、そうではなくて、enterは現在のスレッドにコンテキストを結びつけることであって、引数なしのenterはコンビニエンスメソッドだということが分かる。(結びつける機能とコンテキストを作成する機能が混ざっているから違和感があるのも無理はない。)
現在のスレッドから切り離すためにexitを呼び出すのだが、これはコンテキストのオブジェクトとは関係ないのでContextオブジェクトのメソッドでないことも合理である。
たぶん、よく使うパターンをコンパクトに書くためによく吟味されたものなのであろうが、コードの後ろで何が起きているのかをメソッド名やオブジェクトの動きからは想像つかないのは、ちょっと難点かもしれない。

JDK6のRhino

RhinoはJSE6からは標準で使えるようになっている。
このJSR223の機能は、そういえば数年前のJavaWorldの記事で、そんな話も見たような気がしないこともない。(Groovyの特集あたりだろうか。)
ようやく使える状況になったようなので試してみることにした。

この新しいAPIでは、ScriptEngineManagerを構築することで、META-INF/services配下の定義から利用可能なScriptEngineFactoryの実装を取得し、スクリプトの名前、もしくは拡張子、あるいはMIMEタイプからScriptEngineを取得することで、どのスクリプト言語を利用するか決定することからはじめる。
JDK6(beta2)にはRhinoだけが定義されているが、JSR223のリファレンス実装にはPHPなどのエンジンも含まれており、また、scripting.dev.java.netCSVリポジトリを覗いてみると、Jython用など、そのほかの言語のScriptEngineFactoryなどの実装があることが分かる。
どうゆう形でRhino以外のスクリプトエンジンが提供されることになるのか、よく分からないのだが、このJSR223の仕組みとしてはスクリプト言語に依存しない形で統一的にJAVAからスクリプト言語を扱うことが出来るようにしようとしていることが分かる。

そこで、JDK6の標準のメカニズムでJavaScriptを扱うには、どうなるのか、ということを試してみることにした。
その前に、前述のようにRhino以外のスクリプトエンジンもある可能性があり、どのスクリプトエンジンを使うかは名前で索引する必要があるが、では、Rhinoは何と言うな名前で登録されているのであろうか? まずは、それを知る必要があるだろう。
とりあえず、ScriptEngineManagerを構築して、すべてのスクリプトエンジンの列挙と、それがサポートしている「名前」を調べてみた。

engineFactoryClass: com.sun.script.javascript.RhinoScriptEngineFactory
engine-name: Mozilla Rhino
language: ECMAScript
language version: 1.6
mime-type: application/javascript
mime-type: application/ecmascript
mime-type: text/javascript
mime-type: text/ecmascript
name: js
name: rhino
name: JavaScript
name: javascript
name: ECMAScript
name: ecmascript
class: com.sun.script.javascript.RhinoScriptEngine@1100d7a

これがJDK6(beta2)の結果である。
スクリプトエンジンはRhinoのみ登録されている。
このRhinoECMAScriptとしての名前やHTMLのScriptタグに使うようなMIMEタイプでの索引も可能なように、幅広い名前で索引可能になっている。
しかし、これはJDK6での話しであって、JSR223のリファレンス実装によると同じRhinoでも、mime-typeが1つも定義されておらず、名前も「js」と「rhino」の2つだけであった。
MIMEは、なんとなく正式っぽい感じがあるし馴染み深い感じもするのだが、JythonやGroovyにはMIMEタイプは定義されていないし、実質的にJavaScript以外にはMIMEタイプは使えないと考えてよいだろう。
汎用的に使うならばスクリプト名からの索引となるであろうが、その場合でも、歴史的経緯があるようなので、とりあえず、ECMAScriptなどと形式ばらず「js」として索引しておくのが無難なのかもしれない。

    public static void main(final String[] args) throws Exception {
        // 現在のスレッドのコンテキストクラスローダからスクリプトエンジンマネージャを取得する。
        final ScriptEngineManager engMgr = new ScriptEngineManager();

        // スクリプトのルートオブジェクトを作成する。
        final ScriptContext ctx = new SimpleScriptContext();

        // 実行ジョブの定義
        final Runnable[] jobs = new Runnable[] {
                new Runnable() {
                    public void run() {
                        try {
                            final StringBuilder buf = new StringBuilder();
                            buf.append("function f() {\n");
                            buf.append("java.lang.System.out.print(");
                            buf.append("'hello, world!\\n'");
                            buf.append(");\n");
                            buf.append("}\n");
                            final ScriptEngine eng = engMgr.getEngineByName("js");
                            eng.setContext(ctx);
                            eng.eval(buf.toString());
                        }
                        catch (final ScriptException e) {
                            e.printStackTrace();
                        }
                    }
                },
                new Runnable() {
                    public void run() {
                        try {
                            final ScriptEngine eng2 = engMgr.getEngineByName("js");
                            eng2.eval("f();", ctx);
                        }
                        catch (final ScriptException e) {
                            e.printStackTrace();
                        }
                    }
                }
            };
        
        // 1つのコンテキストを異なるエンジン、異なるスレッドを渡歩いて実行する。
        for (final Runnable job: jobs) {
            final Thread t = new Thread(job);
            t.start();
            t.join();
        }

        System.out.println("done.");
    }

ソースコードの見た目は生Rhinoよりも可読性が高いと思う。
何をやっていて、どう関連しているのか、そのあたりが見える感じがする。

コンテキストがスクリプトのグローバル空間を表していて、これはスクリプトの実行によって変化しうるものである。コンテキストそのものはスクリプトエンジンとは独立しており、実行時に渡すことでスクリプトの実行環境に状態を与えて、また、その結果の状態を取得するものである。

たとえば、上記例であれば関数fがコンテキストの属性として保存されており、getAttributeによって取得することができる。(残念ながら、これを利用する手段がないようなのだが。)間違えでした。

果たして言語の異なるエンジンにまたがってコンテキストを使いまわすことが可能であるのか、そのあたりは不明である。(Rhinoしか実装がないので。)

上記の例のように、ソースコードは分かりやすくシンプルな感じであるが、柔軟性に欠ける点は否めないようである

スクリプトを実行するだけならば、このような単純な仕組みで十分かもしれないが、JAVAスクリプト言語の連携の緻密さとなると生Rhinoにはかなわない。むしろ、JSR223の仕様は、かなり不十分な印象である。

たとえば、ドキュメントを流し読みしてみたが、evalして構築したスクリプト定義のメソッドをJAVA側から利用するための方法が見つからない。もちろん、上記の例のようにスクリプトとしてevalすればいいわけだが、あくまでもスクリプトスクリプトから呼び出すだけの間接呼び出しであり、引数を渡す方法もないのでスクリプト言語の文字列として評価させるということぐらいしか思いつかない。
Invocationとか、それらしいインターフェイスはあるようだが、JAVADOCを眺めても、これを返したり取得したりするための方法がみつからない。スクリプトを評価した結果として、これを得る手段がない、つまり、つながってないのである。
スクリプトを何かのイベントに対するアクションとして実行する(ただし引数なし)という用途なら使えるかもしれないが、読み込んだスクリプトに対してJAVA側のイベントと連携して実行を共有しあうような連携が、ほとんど考慮されていない感じである。

(そんな基本的なニーズも叶えられないような、お粗末な仕組みとも思えないので、もしかすれば、うまい方法があるのかもしれないのだが、今日試した限りでは見つけられなかった。)

このあたりが必要ならば、現時点では、まよわず純粋にRhinoそのものを使ったほうが良いだろう。
間違えでした。詳細は、後日談を参照。

また、JDK6に定義されているクラスがリファレンス実装には存在しなかったりと実装に差異が結構あるようなので、現時点ではリファレンス実装を使ってJDK5と互換性のあるソースを書くのは無理な気がした。
今後、JDK5以前用のJSR223の正式な実装が出て、それがJDK6のScriptingのAPIと互換性があるものになるのであれば、この新しいAPIを使うのも手かと思うが、JDK5以前をサポートする必要があってJavaScript固有でよいのであれば、単純にRhinoそのものをjarファイルを使うのが確実だろう。

*1:ホントの理由は、仕事で使うことになったMayaaRhinoを使っていたからなんだけど。