许多软件都提供了扩展机制,即使软件以二进制形式发布,用户仍然可以对其进行定制和扩展。从 JDK 6 开始,Java 集成了对脚本语言的支持,这允许用户通过在应用程序中集成脚本语言来提供更好的扩展性和定制性。本文通过开发一个集成了 JavaScript 的小型 Java 应用,展示了该技术 。
脚本化技术
我喜欢在 vim 或者 emacs 编辑环境中进行文档,代码以及邮件等的编写,她们都提供了良好的命令和快捷键,但是这些都不足以使得她们被誉为 world-class 编辑器,她们的强大的真正来源,正是脚本技术。使用脚本,您可以将您的 vim 或者 emacs 配置得无所不能,甚至有人通过脚本来 让 emacs 煮咖啡。
什么是脚本化
脚本化可以使 宿主 程序具有 脚本 所描述的能力,比如流行在 DHTML 页面中的 JavaScript 技术,JavaScript 可以让原本是静态的 HTML 代码的页面“活”起来,具有动画,局部刷新等更高级的功能。应用程序一般是以二进制的形式发布的,用户很难根据自己的需求对其进行定制,当然,修改配置文 件是一种方式,但是不够灵活。而脚本化则是通过用户自己设计脚本(程序代码 ),然后将其 注入 到应用中,使得应用的行为得到改变。
如何脚本化您的应用
通常的做法是,将 宿主 程序的一部分组件暴露给脚本,以方便脚本对其定制,这些组件的作用范围是全局的(可以通过公开接口暴露,也可以将组件实例设置到脚本上下文 (context)中),脚本可以在其中添加,修改一些子组件,从而实现定制的目的。本文将通过一个实例来对这个过程以说明,在文章的最后,我们可以得到 一个可以运行的小应用出来,如果您对其有不满意之处,可以任意的扩展它。
JDK 6 中,添加了对脚本的支持,并实现了一些常见的脚本语言与 Java 的交互,比如 Python(Jython)、 JavaScript(rhino)等语言,完整的列表请参考 此处。文中使用的脚本语言为 JavaScript,宿主语言为 Java。(JavaScript 在 DHTML 中应用很广泛,同时,也是我最喜欢的一门编程语言)
一个小的 todo 管理器
在文中,我们会先实现一个小型的应用:一个简单的 todo(待办事项)管理器,然后开发一个插件(脚本)框架,最后将使用这个框架对 todo 管理器进行脚本化。
图 1. sTodo 主界面
这是一个简单的 todo 管理器,可以对待办事项(todo item)进行增删改查等操作,并且可以将这些事项通过邮件发送给指定邮箱等。这个项目目前托管在 Google,项目名为 sTodo。
图 2. sTodo 右键菜单
设计和实现
sTodo 是用纯 Java 的 Swing 工具包开发的,其中包含一个嵌入式的数据库 sqlite,整个应用非常简单,我们现在考虑为其增加脚本框架,并为其开发两个脚本,扩展其部分功能。完整的代码可以从 示 例代码 中获得。由于 sTodo 为一个开源项目,并且主要由本文开发和维护,所以可以自由的对其进行修改、扩展,使其成为一个真实可用的应用。
在开始之前,读者可以在 sTodo 的项目主页上下载未经过脚本化的初始版本的源代码,然后根据文中的步骤自己逐步给 sTodo 加入插件机制。
编写脚本框架
sTodo 中除了主界面之外,还包含其他一些窗口,如用户配置设置(preference)、新建待办事项窗口、发送邮件窗口等,这些窗口的实现与脚本化无关,我们 主要来看看脚本框架的设计与实现。(如果您恰好对 swing 开发感兴趣,可以参考 sTodo 的源码。)
设计和实现
JDK 6 之后,对脚本的支持是对脚本引擎(Script Engine)的抽象,JDK 提供的框架设计得非常好,我们在此只是对其进行一个浅包装。具体的功能需要代理到 JDK 的实现上。
下面是插件框架的类图:
图 3. 插件框架类图
我们现在有了对插件的描述的接口(Plugin),以及对插件管理的接口(PluginManager),并且有了具体的实现类,下面就分别 描述一下:
插件接口:
定义一个插件所具备的基本功能,包括获取插件名称、获取插件描述、以及将键值对插入到插件的上下文、执行插件公开的功能等方法。
插件管理器接口:
定义管理所有插件的管理器,包括安装插件、卸载插件、激活插件、按名称获取插件等方法。
好了,这个简单的框架基本满足我们的需求。在实现中,我们可以比较简单地将 JDK 6 提供的脚本引擎做一个包装。
由于插件管理器(PluginManager)的作用范围是全局的,所以我们将其实现为一个单例的对象:
代码 1. sTodo 插件管理器
public class TodoPluginManager implements PluginManager { private List plist; private static TodoPluginManager instance;
public static TodoPluginManager getInstance() { if (instance == null) { instance = new TodoPluginManager(); } return instance; }
private TodoPluginManager() { plist = new ArrayList(1); }
public void activate(Plugin plugin) {
}
public void deactivate(Plugin plugin) {
}
public Plugin getPlugin(String name) { for (Plugin p : plist) { if (p.getName().equals(name)) { return p; } } return null; }
public void install(Plugin plugin) { plist.add(plugin); }
public List listPlugins() { return plist; }
public void removePlugin(String name) { for (int i = 0; i < plist.size(); i++) { plist.get(i).getName().equals(name); plist.remove(i); break; } }
public void uninstall(Plugin plugin) { plist.remove(plugin); }
public int getPluginNumber() { return plist.size(); }
}
|
插件本身比较容易实现,包含一个名为 context 的 Map,以及一些 getter/setter:
代码 2. sTodo 插件实现
public class TodoPlugin implements Plugin { private String name; private String desc;
private Map context;
private ScriptEngine sengine; private Invocable engine;
public TodoPlugin(String file, String name, String desc) { this.name = name; this.desc = desc;
context = new HashMap(); sengine = RuntimeEnv.getScriptEngine(); engine = RuntimeEnv.getInvocableEngine(); try { sengine.eval(new java.io.FileReader(file)); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (ScriptException e) { e.printStackTrace(); } }
public TodoPlugin(URL url) {
}
public Object execute(String function, Object... objects) { Object result = null; try { result = engine.invokeFunction(function, objects); } catch (ScriptException e) { e.printStackTrace(); } catch (NoSuchMethodException e) { e.printStackTrace(); }
return result; }
public List getAvailiableFunctions() { return null; }
public String getDescription() { return desc; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public void setDescription(String desc) { this.desc = desc; }
public void putValueToContext(String key, Object obj) { context.put(key, obj); sengine.put(key, obj); }
}
|
对执行环境的包装,主要是对 JDK 提供的 Script Engine 的封装:
代码 3. 运行时环境的实现
public class RuntimeEnv {
private static ScriptEngineManager manager;
private static ScriptEngine engine;
static {
manager = new ScriptEngineManager();
engine = manager.getEngineByName("JavaScript");
}
public static ScriptEngine getScriptEngine() {
return engine;
}
public static Invocable getInvocableEngine() {
return (Invocable) engine;
}
}
脚本化 stodo
好了,基础框架我们已经有了,如何脚本化具体的应用呢?如前所述,通常的步骤是这样的:
- 公开宿主程序中的组件(Component),可以通过两种方式:提供 get 方法;将 Component 的实例放进脚本的上下文中,脚本引擎会建立两者的联系。
- 在脚本中使用宿主公开的组件,对其进行修改,达到脚本化的目的,比如宿主中公开了 toolbar 组件,我们可以向其上添加一些有用的按钮,并定制改按钮的事件处理器。
公开宿主程序中必要的组件
首先,我们为 sTodo 的入口类 sTodo.java 添加一个方法:
代码 4. 给 sTodo 添加 initEnv 方法
public void initEnv(){ PluginManager pManager = TodoPluginManager.getInstance(); Plugin menuBar = new TodoPlugin("menubar.js", "menubar", "menubar plguin"); pManager.install(menuBar); List plist = pManager.listPlugins(); menuBar.putValueToContext("pluginList", plist); }
|
在 initEnv 中,我们创建一个新的插件,这个插件负责加载 menubar.js 脚本,然后将这个插件安装在管理器上,最后我们将一个名为 pluginList 的 List 对象放到这个插件的上下文中。
然后,我们来到 MainFrame.java 这个类中,在 initUI() 方法中,我们将 menubar 的实例 mBar 公开给脚本:
代码 5. 公开 JMenuBar 实例
Plugin pMenuBar = TodoPluginManager.getInstance().getPlugin("menubar"); pMenuBar.execute("_customizeMenuBar_", mbar);
|
好了,我们来看下一步:
提供第一个脚本
我们提供的第一个脚本很简单,为宿主程序添加一个菜单项,然后通过此菜单的事件处理器,我们让该脚本弹出一个新的窗口,这个窗口显示目前被加 载到应用中的插件的列表。
代码 6. 第一个脚本
importPackage(java.awt, java.awt.event) importPackage(Packages.javax.swing) importClass(java.lang.System) importClass(java.lang.reflect.Constructor)
function buildPluginMenu(){ var menuPlugin = new JMenu(); menuPlugin.setText("Plugin"); var menuItemListPlugin = new JMenuItem(); menuItemListPlugin.setText("list plugins"); menuItemListPlugin.addActionListener( new JavaAdapter( ActionListener, { actionPerformed : function(event){ var plFrame = new JFrame("plugins list"); var epNote = new JEditorPane(); var s = ""; for(var i = 0; i var pi = pluginList.get(i); s += pi.getName()+":"+pi.getDescription()+"\n"; } epNote.setText(s); epNote.setEditable(false); plFrame.add(epNote, BorderLayout.CENTER); plFrame.setSize(200,200); plFrame.setLocationRelativeTo(null); plFrame.setVisible(true); } } ) ); menuPlugin.add(menuItemListPlugin); return menuPlugin; }
|
我们在脚本中创建一个菜单项,名称为 plugin,这个菜单项中有一个名为 list plugins 的项目,点击之后会弹出一个对话框,显示目前被应用到 sTodo 中的插件(脚本):
图 4. 点击 list plugins
图 5. 显示目前被应用到 sTodo 中的插件(脚本)
为了保证 list plugins 的功能,我在 initEnv() 方法中加入了另一个插件 style.js。因此我们可以看到,弹出的窗口正确的显示了目前被加载的插件,这些信息均来自于宿主程序!
提供第二个脚本
通常情况下,您可能已经有了一个写的比较好的应用模块,而想要在另一个应用中使用这个模块,比如您有一个 org.free.something 的包,里边已经包含了您写的某个面板,其中包含版权信息声明等。现在您开发出了另一个应用,如果把两者集成那就最好了。
我们开发的第二个插件就是涉及如何引用外部包的问题:
比如,我们已经有了一个良好的 Help 界面,定义如下:
代码 7. 一个已有的 Dialog
public class HelpDialog extends JDialog{ private static final long serialVersionUID = -146997705470075999L; private JFrame parent; public HelpDialog(JFrame parent, String title){ super(parent, title, true); this.parent = parent; initComponents(); } private void initComponents(){ setSize(200, 200); add(new JLabel("Here is the help content..."), BorderLayout.NORTH); JButton button = new JButton("Click to close help."); button.addActionListener(new ActionListener(){ public void actionPerformed(ActionEvent e) { HelpDialog.this.setVisible(false); } }); add(button); setDefaultCloseOperation(HIDE_ON_CLOSE); setLocationRelativeTo(null); setResizable(false); setVisible(true); } public static void main(String[] args){ new HelpDialog(null, "This is help"); } }
|
注意,这个类是定义在另一个包中!然后我们在第一个脚本中添加一个 Javascript 方法:
代码 8. 扩展脚本一
function buildHelpMenu() { var menuHelp = new JMenu(); menuHelp.setText("Help"); var menuItemHelp = new JMenuItem(); menuItemHelp.setText("Help"); menuItemHelp.addActionListener( new JavaAdapter( ActionListener, { actionPerformed : function(event){ importPackage(Packages.org.someone.dialog); var hDialog = new HelpDialog(null, "This is Help"); } } ) ); menuHelp.add(menuItemHelp); return menuHelp; }
|
通过脚本引擎,我们导入这个包:
代码 9. 导入一个外部 jar 包中的类文件
importPackage(Packages.org.someone.dialog);
|
然后,在不需要修改 Java 代码的情况下,我们将
function _customizeMenuBar_(menuBar) { menuBar.add(buildPluginMenu()); }
|
改为:
代码 10. 修改脚本的入口
function _customizeMenuBar_(menuBar){ menuBar.add(buildPluginMenu()); menuBar.add(buildHelpMenu()); }
|
然后运行 sTodo:
图 6. 点击 Help
图 7. 运行 Help
结束语
事实上,几乎所有的东西都是可以定制的,您的应用只需要提供一个基本而稳健的框架,剩余的事情全部可以交给脚本来完成,那样,您可以在不对应 用做任何调整的情况下,使其彻底的改头换面,比如将一个简单的编辑器定制成一个强大的 IDE,正如 Eclipse 那样。不过使用脚本更轻量级一些。
下载本文代码