GroovyとRhinoのオブジェクト(クロージャも)を相互に変換

Groovy(SwingBuilderでGUIに強く、Javaの既存クラスを素直に使える)で実装されたホストアプリケーションに、スクリプト言語としてJavaScript(Ajaxアプリケーションとコードを共有できる)を組み込むとき、どうせなら両者のクロージャに互換性を持たせようというネタです。

Java的な正論としては、たぶん、独自に設計したインターフェースのメソッドを通して操作することになるんだろうけど、それってGroovy的じゃないですね。Groovyで[1,2,3]と書いた値は、RhinoでもArrayになって欲しいし、Rihnoで{a:1,b:2}と書いたら、Groovyでも[a:1,b:2]になって欲しい。

って、そのぐらいのオブジェクト変換なら簡単だけど、それだけじゃ両言語に共通する重要な要素が入ってないですね。そう、{ a -> ... } が function(a){ return ...; } と等価なものに変換できてこそ、ふたつの言語が真の威力を発揮できるはずです。

GroovyからRhinoを使うための共通タスク
import org.mozilla.javascript.*

ContextFactory.initGlobal(new ContextFactory())
cx = ContextFactory.getGlobal().enterContext()
scope = cx.initStandardObjects()

//TODO ここにコードを書く

cx.exit();

で、実行はこんな感じ。

groovy -cp js.jar your_script.groovy
JavaScritpの関数をGroovyでクロージャとして使う
def jsfuncToClosure(cx, scope, jsfunc) {
    return (jsfunc instanceof Function) ? { ... args -> 
         return jsfunc.call(cx, scope, null, args)
    } : null
}

cx.evaluateString(
    scope, "var jsfunc = function(a, b) { return a + b }",
    "<inline>", 1, null)
jsfunc = jsfuncToClosure(cx, scope, scope.get('jsfunc', null))
println jsfunc ? jsfunc(1, 2) : "jsfunc is not a function"

Rihnoの関数は、Functionを実装するScriptableObjectの派生クラスみたいなので、こんな感じになるんじゃないかと思います。

GroovyクロージャをJavaScriptで関数として使う
class JsFunctionAdapter extends FunctionObject {
    def closure
    def JsFunctionAdapter(scope, name, closure) {
        super(name, invokeMethod, scope)
        this.closure = closure
    }
    static def Object invoke(Context cx, Scriptable thisObj, Object[] args, Function funObj) {
        return (funObj as JsFunctionAdapter).closure(*args)
    }
    static def invokeMethod = JsFunctionAdapter.class.getMethod("invoke",
        Context.class, Scriptable.class, Object[].class, Function.class)
}

scope.put('groovyfunc', scope,
    new JsFunctionAdapter(scope, 'groovyfunc', { a, b -> a + b })
)
println cx.evaluateString(scope, "groovyfunc(1, 2);","<inline>", 1, null)

Rhinoでは、JavaScriptJava言語モデルの違いのために、オブジェクトから独立した関数を作成しにくい感じでした。


…で、ここまでで、Rhinoが関数の入出力で自動的に変換してくれる、文字列や数値が扱えるようになりました。ここまでの実験をふまえて、完全な値変換をやってみます。

相互に基本オブジェクトを変換

変換オブジェクトの実装はこんな感じです。

import org.mozilla.javascript.*;

public class JsValueTranlator {
    
    def cx, scope;
    
    def JsValueTranlator(cx, scope) {
        this.cx = cx
        this.scope = scope
    }
    
    def fromJS(obj, owner=null) {
        if(obj == cx.getUndefinedValue()) {
            return null
        }
        else if(obj instanceof ScriptableObject) {
            def proto = obj.getClassName()
            if(proto == "Date") {
                return new Date(scope.callMethod(obj, 'valueOf', []).toLong())
            }
            else if(proto == "Array") {
                return (0 .. obj.get('length', null) - 1).collect { fromJS(obj.get(it, null)) }
            }
            else if(obj instanceof Function) {
                return {...args ->
                    fromJS((obj as Function).call(cx, scope, owner, args.collect{ toJS(it) }.toArray() ))
                }
            }
            else {
                def r = [:]
                for(k in obj.getPropertyIds(obj)) {
                    r[k] = fromJS(scope.getProperty(obj, k), obj)
                }
                return r
            }
        }
        else {
            return obj
        }
    }
    
    def toJS(obj) {
        if(obj instanceof Closure) {
            return new JsFunctionAdaptor(cx, scope, obj.toString(), {...args ->
                obj(*args.collect{ fromJS(it) })
            })
        }
        else if(obj instanceof Map) {
            def o = cx.newObject(scope)
            for(k in obj.keySet()) {
                scope.put(k, o, toJS(obj[k]))
            }
            return o
        }
        else if(obj instanceof List) {
            def a = cx.newArray(scope, obj.size())
            for(i in 0 .. obj.size()-1) {
                scope.put(i, a, toJS(obj[i]))
            }
            return a
        }
        else if(obj instanceof Date) {
            return cx.newObject(scope, "Date", obj.getTime())
        }
        else {
            return Context.javaToJS(obj, scope)
        }
    }
}

class JsFunctionAdaptor extends FunctionObject {
    
    def closure, t
    
    def JsFunctionAdaptor(cx, scope, name, closure) {
        super(name, invokeMethod, scope)
        this.closure = closure
        this.t = new JsValueTranlator(cx, scope)
    }
    
    def call(args) {
        t.toJS(closure(*args.collect{ t.fromJS(it) }))
    }
    
    static def Object invoke(Context cx, Scriptable thisObj, Object[] args, Function funObj) {
        (funObj as JsFunctionAdaptor).call(args)
    }
    static def invokeMethod = JsFunctionAdaptor.class.getMethod("invoke",
        Context.class, Scriptable.class, Object[].class, Function.class)
}

やや冗長なコードですが、これで、

t = new JsValueTranlator(cx, scope)
scope.put('pricesum', scope, t.toJS({ a, tax ->
    a.collect{ (it.price * (1 + tax)) * it.count }.sum()
}))
println cx.evaluateString(scope, """
    var result = {
        total:pricesum([
            {name:"orange", price:30, count:2},
            {name:"apple",  price:70, count:1},
        ], 0.05),
        date:new Date()
    };
""", "<inline>", 1, null)
println t.fromJS(scope.get('result', null))

とか、

t = new JsValueTranlator(cx, scope)
cx.evaluateString(scope, """
    function pricesum(a, tax) {
        var total = 0;
        for(var i = 0; i < a.length; i++) {
            total += (a[i].price * (1 + tax)) * a[i].count;
        }
        return total;
    }
""", "<inline>", 1, null)
pricesum = t.fromJS(scope.get('pricesum', null))
println [
    total:pricesum([
        [name:"orange", price:30, count:2],
        [name:"apple",  price:70, count:1],
    ], 0.05),
    date:new Date()
]

みたいな、複雑な入出力を伴うコードでも大丈夫。

変換時には毎回新しいインスタンスができてしまうので、行き来しながら一つのオブジェクトを書き換え続けるような操作はできません。また、関数は変換のたびにラップされるので、出し入れしているとラッパーのネストがすごいことになります。