腳本化技術
我喜歡在 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<Plugin> plist;
private static TodoPluginManager instance;
public static TodoPluginManager getInstance() {
if (instance == null) {
instance = new TodoPluginManager();
}
return instance;
}
private TodoPluginManager() {
plist = new ArrayList<Plugin>(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<Plugin> 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<String, Object> context;
private ScriptEngine sengine;
private Invocable engine;
public TodoPlugin(String file, String name, String desc) {
this.name = name;
this.desc = desc;
context = new HashMap<String, Object>();
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<String> 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;
}
/**
* put value to plug-in context, and then put it into engine context
*/
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<Plugin> 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. 第一個腳本
import Package(java.awt, java.awt.event)
import Package(Packages.javax.swing)
import Class(java.lang.System)
import Class(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<pluginList.size();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;
}
//this function will be invoked from java code, MainFrame...
function _customizeMenuBar_(menuBar){
menuBar.add(buildPluginMenu());
}
我們在腳本中創建一個菜單項,名稱為 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 那樣。不過使用腳本更輕量級一些。
本文配套源碼