JSR223实现

JSR223是一种Java标准,它为JVM上的非Java语言(主要是脚本语言)提供了一组标准接口。

目前几乎所有JVM脚本语言都会提供一个JSR223的实现,甚至对于groovy这种编译到字节码的语言也做了一套实现(虽说groovy确实就是脚本语言)。使用该实现最大的好处即:它是标准。

这套接口其实非常普通,如果你是一种JVM语言的编写者,并且想做一个REPL程序,那么一定会设计出类似的接口。所以与其自己设计一套API让别人适配,还不如自己适配这套接口,这样他人使用也会更加方便。

下面我会从我的视角说一说这套接口。刚才提到,这套接口在做REPL程序时会被用到,所以,首先谈谈何为REPL。

REPL

何谓REPL?Read Eval Print Loop,即:读取输入,解析运行,打印结果,继续循环。
现代编程语言通常都会提供一个REPL,例如scala、groovy、haskell、nodejs、python等。

使用REPL可以非常方便的执行一些语句,以此来尝试一些没有用过的功能,而不必单独开一个文件编写代码。Java在9版本时也会推出jshell,提供一个java的REPL环境。

通常REPL会读入一行或多行代码,在输入完成后立即编译或者解析执行这段代码,然后将执行结果打印在屏幕上,最后继续等待用户输入新的代码。在这过程中,老代码的执行结果将被保存在上下文中,例如变量的赋值、方法的定义等。在后续的代码中可以读取或调用之前定义的变量或方法。

此外,REPL还可能提供一些更高级的功能,例如显示一个变量的类型(例如scala、haskell的:t);或者提供一些只有REPL中才可能出现的功能,例如退出REPL、重置REPL的上下文等。

例如scala的REPL:

1
2
3
4
5
6
7
8
9
10
11
12
13
Welcome to Scala 2.12.1 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_121).
Type in expressions for evaluation. Or try :help.

scala> val a = 3
a: Int = 3

scala> a + 4
res0: Int = 7

scala> :t a
Int

scala> :q

接口设计

最基本的,REPL需要能够编译,或者解析源代码,所以至少会有一个接口,专门处理解析相关的工作,我们不妨称之为Evaluator。

Evaluator要做的事情即:输入源代码、编译/解析、执行、返回结果。所以,如果用Java来写,可能会是这样:

1
2
3
4
// Evaluator 版本1
interface Evaluator {
Object eval(String source);
}

其实,如果实现了这个接口,那么REPL的最基础部分已经完成了。加上一堆循环、输入、输出的控制代码,REPL就出炉了。但是这么搞肯定不会有人去使用,毕竟功能太单一了,别说高级功能,甚至连上下文都没办法保存,打了半天字才定义的一个方法,后续连使用都不行。

所以,我们至少还得加上上下文存储。

由于我们写的是JVM语言,那么上下文中的对象应当能够被Java读取(不一定要能任意使用,但是至少要能够正常的取出来,正常的赋值和传递),所以我们需要一个接口定义上下文。

通常来说,语言会有一些作用域,无论是js的函数链式作用域,还是c系的块式作用域,还是let expression,总之通常都会有作用域这一说。特别是对于脚本语言,会有一大堆“pre defined”的对象或函数,例如nodejs中的module、php中的$GLOBALS等。

对于编译型的语言,这种pre defined会相对少一些,因为设计和实现编译型的语言工作量相比脚本语言稍高一些,也会尽可能让语言自洽。例如scala的println,实际上是自动的在所有源代码中,对某个标注库中的类做了import static

既然有作用域的区分,那么我们可以定义Scope接口,来存储本作用域下的变量和函数。

1
2
3
4
5
6
interface Scope {
Object get(String name);
Object put(String name, Object o);
boolean contains(String name);
List<String> keys(); // 列举所有变量
}

前面提到了作用域的层级。那么,若要让作用域分层,通常有两种做法:

  1. 在Scope实现中保存父级作用域的引用,当做取操作时,首先检查本作用域中是否存在该变量,若没有,则返回父级作用域中的该变量。若已经是顶级作用域(没有父级作用域了),那么再返回null,或其他表示不存在的值。

  2. 在更高的一个层级而非Scope本身做这件事情:用数字表示作用域级别,数字越大则作用域级别越高。在寻找时从低级别的作用域开始,向高级别作用域进行查询,直到找到值,或者全部作用域都搜过为止。

由于接口没有办法决定实现,且第一种的实现,说实话确实代码量有点多,所以综合考虑,使用第二种方式定义接口。我们可以将拥有“管理作用域”功能的实例称为上下文。

1
2
3
4
5
6
7
interface Context {
Scope getScope(int lvl);
void setScope(int lvl, Scope scope);
List<Integer> scopes();
Object get(Strign name);
Object put(String name, Object o);
}

上述接口,定义了获取、设置、列举作用域的方法,以及用于获取和存放值的方法。


接下来我们把上下文塞进Evaluator里面。

1
2
3
4
5
6
// Evaluator 版本2
interface Evaluator {
void setContext(Context ctx);
Context getContext();
Object eval(String src);
}

如此一来,我们的Evaluator就有了保存上下文的能力,在REPL程序中,也可以针对上下文中保存的内容实现一些高级指令。

到这里,实现一个REPL需要的接口已经全部定好了。不过,repl很可能需要提供重置上下文的功能,这里最简单的做法是:使用setContext替换掉当前上下文。不过对于支持多线程的语言来说,若REPL的E步骤正在进行,直接替换上下文很可能会使其状态不一致。

所以最好重新生成一个新的Evaluator。重新生成时可以直接new,但是初始化过程就要再写一遍代码。其实我们可以写个Factory,用来生成Evaluator,在每次需要新Evaluator时直接调用Factory的函数即可。

1
2
3
interface EvaluatorFactory {
Evaluator getEvaluator();
}

到此,一个维护性比较好的接口已经出炉了。当然,对于一个REPL程序来说可能用不到这样的设计,但是,不管是像上面这样做到职责单一,还是把所有东西混在一起,这几个接口包含的内容,是一个拿的出手的REPL必须全部实现的、比较基础的功能。上面这样的接口设计,也是比较直观,功能清晰,且易于实现的。

JSR 223

JSR 223作为一个调用脚本语言的接口框架,自然要比“实现REPL”支持更多的功能、定义更多的规则。

上面的Evaluator,即脚本的执行者,在JSR 223中被称作ScriptEngineEvaluatorFactory,即用于创建脚本执行者的工具,被称作ScriptEngineFactory。定义上下文的接口,被称作ScriptContext,定义作用域的接口,被称作Bindings(没错,末尾有个s)。

JSR 223的想法是,需要将脚本语言中的对象,和Java能够获取的对象,做一个绑定,所以在我的思路中的作用域,被称作了Bindings。
由于JSR 223是一个框架,所以除了接口之外,还有一些做实事的类,例如ScriptEngineManager,用于加载脚本语言、获取和初始化ScriptEngine等事情。

JSR 223 规范的具体文档可以在JCP官网下载:这个链接,规范比较清晰,且整体结构和我思路中的Evaluator极其相似,所以本文不再对其做过多说明。

Latte-lang JSR 223 支持

Latte-lang是我之前写的一个编译型语言,它也支持在运行时编译代码并执行,所以我也做了一份JSR223实现。

之前没有想过要做完善的脚本支持(毕竟它是编译型的),所以只提供了一套非常简易的脚本执行库。直到阅读JSR223规范后,才发现,JSR223做的事情竟然和我之前实现的Evaluator做的事情完全一致。正好,之前的Evaluator实现得有点乱,于是我用JSR223的接口重写了Evaluator,顺便也提供了标准支持。

源码在这里

在Java中调用Latte-lang可以这么写:

1
2
3
ScriptEngine engine = new ScriptEngineManager().getEngineByName("Latte-lang");
Object result = engine.eval("a = ['key': 'value']");
System.out.println(engine.get("a"));

看起来还是挺方便的~