Tuesday, April 21, 2015

Nashorn has some ... "problems"


Nashorn is the new javascript engine bundled with jdk 8. It's billed as a complete (and significantly faster) rewrite of the scripting api, and replaces the older Mozilla Rhino library. For the most part that's true, but after trying to work through a fairly involved use case I've found there are some things it should do that just can't be done.

First, the use case - our application allows users to post a package of data and some processing instructions in the form of a bit of free-form javascript. We're processing this on the JVM, so typically the data is some form of java Map that has to get injected into the javascript context. Ideally we'd like to be able to just put the map into the javascript context, or into a javascript compatible wrapper.

Unfortunately in Nashorn java objects passed in to the javascript context don't behave like proper javascript objects, and there's no way to make them.

For example, suppose you inject a map into a javascript context:

HashMap<String,Object> h = new HashMap<>();
h.put("A",1);

If you turned this into json you'd get
{"A":1}
so ideally we'd like these 2 things to be logically equivalent - our injected object should behave identically to the javascript object.

It sort of does. You do have to tweak it a bit, to guarantee that it behaves like a proper javascript object you have to define that behavior by extending the jdk.nashorn.api.scripting.JSObject interface. That's a well-defined requirement, so far, so good. So you might create some sort of custom object to wrap your map, something like

public class MyJsMap extends Map<String,Object> implements JSObject {
 ... implement lots of methods here.

And run some processing code inside Nashorn:

MyJsMap m = new MyJsMap();
m.put("A",1);
ScriptEngine engine = new NashornScriptEngineFactory().getScriptEngine();
Bindings bindings = engine.createBindings();
bindings.put("mymap", m );
engine.eval("print(mymap.A);", bindings);

This, by the way, works fine. mymap.A actually calls the method on the JSObject interface getMember("A"), which is exactly what you'd expect it to do. So far so good.

Now, suppose you'd like to write
 engine.eval("print(mymap.hasOwnProperty("A");", bindings);

This does not work. For some reason this actually calls getMember("hasOwnProperty"). Since your map doesn't have one of those, this throws an exception because you're trying to invoke a function call on "hasOwnProperty", which nashorn has decided doesn't exist. You can get around this easily enough, though it's a kludge - just return have the JSObject return a function:

@Override
  public Object getMember(String arg0) { 
    if ("hasOwnProperty".equals(arg0)) {
      return (Function<String, Boolean>) this::hasMember;
    }
    return base.get(arg0);//this returns the actual map values.
  }

Now, suppose in your script you want to do this:
engine.eval("mymap.b={'x':1};", bindings);

This invokes the setMember on your JsObject:

@Override
public void setMember(String arg0, Object arg1) {...

Unfortunately, what's being set as arg1 is not a JSObject. It's a thing that's actually a jdk.nashorn.internal.scripts.JO4 which is a subclass of jdk.nashorn.internal.runtime.ScriptObject. This does not implement the interface that your other objects do, so if you want to do something with this value inside your object, you'll have to allow for the possibility that it miggghht be an internal ScriptObject, or it miggghtt be a JSObject, but they don't share an interface in common so you'll have to write different branches of code to handle the nearly, but not quite, identical things that happen in one case or the other. That might have been necessary for the implementation, but it certainly is annoying.
  
Here's a few more things you can't do:
- engine.eval("print(JSON.stringify(mymap));", bindings);

nope. JSON.stringify only works for native objects. It seems like it would have been easy enough to put a hook for this in the shared interface. As far as I can tell there's no way to make this work.


-Lists and arrays won't understand things like "map". Neither will your JSObjects. For example:

Native object:
jjs> [1,2,3].map(function(x){ return x+x;})
jjs> 2,4,6

Java object:
jjs> var x =new java.util.ArrayList()
jjs> x.add(1)
jjs> x.map(function(x){ return x+x;})
<shell>:1 TypeError: [1] has no such function "map"

I assume this will also break for things like "eval" , but I don't really have the appetite to test anymore.

- There's no "console.log". Instead you'd call "print". This is just annoying if you want to test your javascript code in, say, node, since node doesn't understand "print" and jjs doesn't understand "console.log".

-calling .hasOwnProperty on a java object passed into Nashorn doesn't give you a sane value (like, a map key if you pass in a map or an index from  a passed in list - seriously, how hard would this have been to implement) or even better, values pulled from reflection, or even just returning null and let you go on your way, but ... throws a NullPointer. That's just a pointless gotcha.