vue-codemirror + Java Compiler實現Java Web IDE

背景

最近同事告訴我一個很有趣的需求:讓用戶(應用場景中,一般為其他開發者)自己填入Java程式碼片段,程式碼片段的內容為已經規定好的模板類的繼承類,實現模板類定義的方法。我們的項目要實現動態編譯程式碼片段,存儲程式碼片段和用戶操作記錄的映射關係,並能夠在業務中載入程式碼片段執行。

這有點像我們提供一個模板模式的架構,只不過模板類的實現類由外部介面填入程式碼片段動態實現。相較讓其他開發者直接參与項目開發,無疑:

  1. 降低了侵入風險
  2. 向其他開發者隱藏了大部分實現
  3. 降低操作難度和開發門檻
  4. 便於管理

……
這相當於要實現一個簡單的在線Java開發環境,提供基礎的程式碼填寫、編譯和保存的功能。

效果演示

切換主題

聯動填寫類名

測試編譯

基於vue-codemirrorJava Compiler的動態編譯,實現了上述需求,目前完成的Web端IDE主要功能點包括:

  • 頁面展示Java程式碼塊(程式碼高亮,有行號、可自動補全括弧等)
  • 從服務端獲取模板類程式碼,並提供示例
  • 實時動態編譯並獲取編譯結果(通過/失敗 todo:返回編譯錯誤資訊)
  • 將輸入字元串載入成Java Class
    以及小的功能點:自動縮進、補全括弧、切換主題、聯動填寫類名等等。
    下面給出涉及到的技術和實現方法。

CodeMirror

CodeMirror是一個JS庫,可以支援實現有豐富的附加功能和多種語言支援。我們項目的前端使用Vue框架,可以很方便地集成並使用CodeMirror提供的插件,實現我們的在線IDE多種特性。
參考:CodeMirror官網

引入

安裝依賴: "vue-codemirror": "^4.0.6"
src目錄下的main.js中引入:

import VueCodeMirror from 'vue-codemirror'
import 'codemirror/lib/codemirror.css'
Vue.use(VueCodeMirror)

使用

新建組件JavaIDE.vue

<template>
    <codemirror ref="codeMirrorEditor" :value="code" :options="cmOptions" @changes="onChange">
  </codemirror>
  </template>  
  <script>
      import codemirror from "codemirror/lib/codemirror";
      require("codemirror/mode/clike/clike.js");
      require("codemirror/addon/edit/closebrackets.js");
      components: {
          codemirror;
      }
      export default{
          data(){
              return{
                code: "",
                cmOptions:{
                    mode: "text/x-java",  //Java語言
                    theme: "darcula", // 默認主題
                    autofocus: true,  
                    lineNumbers: true,   //顯示行號
                    smartIndent: true, // 自動縮進
                    autoCloseBrackets: true// 自動補全括弧
                }
              }
          }
  </script>

組件化地使用它,我們可以方便地操作它綁定的值(code)和其他附加選項(cmOption)。
在組件創建時為code賦值,即可實現載入模板程式碼。

根據官網,我們可以直接使用CodeMirror的默認構造函數,也可以提供一個textarea DOM元素作為構造CodeMirror對象的參數。

可以使用readOnly參數將程式碼塊設置為只讀。

聯動填寫類名功能

希望實現:在上面頂欄中填寫類名,在程式碼中聯動填寫。
實現方式: 使用正則匹配替換程式碼片段,再進行替換
使用相同的方法,也可以實現動態補全類名等功能

參考更多JavaScript的正則表達式

為輸入框加上監聽函數@input="changeClassName"

 changeClassName(className) {
    var reg = new RegExp(/public class .*? extends ActionParamBuilder/);
    this.code = this.code.replace(reg,
                    "public class " + className + " extends ActionParamBuilder"
   );
 }

切換主題

引入主題css樣式文件

 import "codemirror/theme/eclipse.css";
 import "codemirror/theme/darcula.css";
 import "codemirror/theme/blackboard.css";

使用String數組定義支援的主題,並使用 Element-UI提供的Select組件支援主題切換:

<el-select v-model="cmOptions.theme" placeholder="切換主題" @change="changeTheme">
          <span slot="prefix">
             <el-tooltip content="更換主題">
              <a-icon type="skin" style="fontSize:16px;line-height=50px;"/>
      </el-tooltip>
      </span>
 <el-option v-for="(item,index) in supportThemes" :key="index" :label="item" :value="item">
   </el-option>
</el-select>
  • 使用slot實現在選擇器中嵌入圖標,並支援tooltip功能,使工具欄更加緊湊。 slot意為插槽,是封裝好的組件預留的可以自定義的空間,我們可以使用slot = ""把DOM元素置入到組件內部,非常靈活。

樣式覆寫

使用!important關鍵字覆蓋原有CodeMirror樣式。注意,將該樣式放在全局而不是局部scoped樣式表中。

.CodeMirror {
     height: 500px !important;
 }

JavaCompiler

不用將傳入的程式碼保存成.java文件寫入磁碟,直接就可以使用JavaCompiler工具對字元串進行編譯。

為了實現實時動態編譯功能,我搜索了關於如何將字元串編譯成class的方法,還看了一些動態代理的實現思路。後來看到這一篇:
Java運行時動態生成class的方法
,發現這就是我想要的!

使用Java SDK(since 1.6)提供的JavaCompiler工具。該工具提供編譯方法:

  CompilationTask getTask(Writer out,
                            JavaFileManager fileManager,
                            DiagnosticListener<? super JavaFileObject> diagnosticListener,
                            Iterable<String> options,
                            Iterable<String> classes,
                            Iterable<? extends JavaFileObject> compilationUnits);
  • JavaFileManager
    自定義MemoryJavaFileManager,繼承ForwardingJavaFileManager<JavaFileManager>,實現從記憶體字元串中讀取JavaFileObject
    重點是下面這個方法:
	JavaFileObject makeStringSource(String name, String code) {
		return new MemoryInputJavaFileObject(name, code);
	}
	static class MemoryInputJavaFileObject extends SimpleJavaFileObject {
		final String code;
		MemoryInputJavaFileObject(String name, String code) {
			super(URI.create("string:///" + name), Kind.SOURCE);
			this.code = code;
		}
		@Override
		public CharBuffer getCharContent(boolean ignoreEncodingErrors) {
			return CharBuffer.wrap(code);
		}
	}
  • options,可選參數列表,可以增加外部Jar包依賴
    因為我們所需要編譯的程式碼里依賴的類來源於外部的Jar包,所以需要將這些Jar包使用option將這些依賴加進去。這一步踩了坑,因為之前沒用過,不知道怎麼寫……最後終於找到了正確的寫法:
    List<String> optionList =Arrays.asList("-extdirs",extLib);
    extLib是外部jar包的路徑(目錄地址)。可以使用路徑分隔符填入多個路徑。
  • DiagnosticListener 診斷資訊監聽
    加入診斷資訊監聽器,我們可以拿到編譯錯誤資訊,把這些資訊回饋給前端,實現實時編譯並報錯的功能。
    DiagnosticCollector diagnosticCollector = new DiagnosticCollector();
  • JavaFileObject 待編譯的Java對象,調用自定義類MemoryJavaFileManagermakeStringSource方法。可以傳入一組編譯單元。
    完整方法如下:
public Map<String, byte[]> compile(String fileName, String source,String extLib) throws IOException {
		try (MemoryJavaFileManager manager = new MemoryJavaFileManager(stdManager)) {
			JavaFileObject javaFileObject = manager.makeStringSource(fileName, source);    
            // 傳入診斷監聽器 size和傳入的javaObject相同
            DiagnosticCollector diagnosticCollector = new DiagnosticCollector();
			List<String> optionList =Arrays.asList("-extdirs",extLib);
			CompilationTask task = compiler.getTask(null, manager,diagnosticCollector, optionList, null, Arrays.asList(javaFileObject));
			Boolean result = task.call();
			if (result == null || !result.booleanValue()) {
				throw new RuntimeException("Compilation failed.");
			}
			return manager.getClassBytes();
		}
	}

調用程式碼:

 Map<String, byte[]> results = javaStringCompiler.compile(className + ".java", CODE_TO_COMPILE, libDir);

自定義ClassLoader

參考《Java編程的邏輯》中24.5中內容,我們可以使用自定義的ClassLoader來載入用戶程式碼片段,成為可調用的Class對象。

  • 繼承URLClassLoader
  • 重寫findClass方法
class MemoryClassLoader extends URLClassLoader {

	// class name to class bytes:
	Map<String, byte[]> classBytes = new HashMap<String, byte[]>();

	public MemoryClassLoader(Map<String, byte[]> classBytes) {
		super(new URL[0], MemoryClassLoader.class.getClassLoader());
		this.classBytes.putAll(classBytes);
	}

	@Override
	protected Class<?> findClass(String name) throws ClassNotFoundException {
		byte[] buf = classBytes.get(name);
		if (buf == null) {
			return super.findClass(name);
		}
		classBytes.remove(name);
		return defineClass(name, buf, 0, buf.length);
	}

}

自定義類載入器有如下好處:

  • 可以自定義讀取class文件位元組碼方法和形式,如:從記憶體中、指定jar包中,或從資料庫/網路讀取等
  • 實現隔離,可以實現使用同一個類的不同版本
  • 實現熱部署,動態更新類的內容

總結

本篇中主要涉及知識點:

  • vue-codemirror集成和使用
  • JavaCompiler的使用
  • JavaScript正則和Vue中的插槽(slot
  • 自定義ClassLoader實現動態載入