最近闲来无事,对于之前放置不理的language server又起了兴趣,研究了一下,搞了一个简单的java编辑器,因为心血来潮,功能比较简单,只支持打开单个java文件,编辑(错误提示,自动补全等都有)和保存。主要使用了monaco-editor,monaco-languageclient,electron,vue和eclipse.jdt.ls。在网上没找到多少中文的相关内容,在这里简单记录一些自己的心得。
什么是language server protocol
Adding features like auto complete, go to definition, or documentation on hover for a programming language takes significant effort. Traditionally this work had to be repeated for each development tool, as each tool provides different APIs for implementing the same feature.
A Language Server is meant to provide the language-specific smarts and communicate with development tools over a protocol that enables inter-process communication.
The idea behind the Language Server Protocol (LSP) is to standardize the protocol for how such servers and development tools communicate. This way, a single Language Server can be re-used in multiple development tools, which in turn can support multiple languages with minimal effort.
LSP is a win for both language providers and tooling vendors!
这里引用一些微软的官方解释,简单总结一下,语言服务器协议 (LSP) 的想法是标准化此类服务器和开发工具如何通信的协议。 这样,单个语言服务器可以在多个开发工具中重复使用,从而可以轻松地支持多种语言。
我从微软的语言服务器实现文档中找到了java的服务器实现,从中选择了eclipse.jdt.ls作为我们app选用的java语言服务器。
启动java language server
下载eclipse.jdt.ls
进入eclipse.jdt.ls的git仓库,参考readme即可。功能很强大,可以看到支持单独文件,也支持maven项目,我们这里只使用了单独文件的功能。
我选择了最新的snapshot版本,进入下载页面下载,然后将压缩包解压到/opt/jdt-language-server文件夹下面,文件夹里面的内容如下。
命令行启动
然后按照文档的指引启动即可,这里面./plugins/org.eclipse.equinox.launcher_1.6.400.v20210924-0641.jar
要替换成你自己的jar文件,我下载的版本是这个,-configuration ./config_mac \
这个因为我是mac系统,所以配置成这样,除此之外还有config_win和config_linux。
java \
-Declipse.application=org.eclipse.jdt.ls.core.id1 \
-Dosgi.bundles.defaultStartLevel=4 \
-Declipse.product=org.eclipse.jdt.ls.core.product \
-Dlog.level=ALL \
-Xmx1G \
--add-modules=ALL-SYSTEM \
--add-opens java.base/java.util=ALL-UNNAMED \
--add-opens java.base/java.lang=ALL-UNNAMED \
-jar ./plugins/org.eclipse.equinox.launcher_1.6.400.v20210924-0641.jar \
-configuration ./config_mac \
但是,这样启动的language server只支持标准输入和标准输出,我们在命令行启动的这个server并没有办法应用于网络环境。
搭建一个node服务器
官方文档说可以配置环境变量CLIENT_PORT
启用socket,我失败了,没有找到解决方案。最后反复查找,受到Aaaaash的启发,最后决定使用node搭建一个服务器。大概思路是使用node的子进程启动这个java进程,然后监听socket,写到java子进程,并将子进程的输出写到socket。我本来打算直接抄他的服务器代码的,emmm,不太好用,自己改了改,我nodejs不太擅长,勉强看看吧,具体代码如下。
const cp = require("child_process")
const express = require("express")
const glob = require("glob")
const WebSocket = require("ws").WebSocket
const url = require("url")
const CONFIG_DIR = process.platform === 'darwin' ? 'config_mac' : process.platform === 'linux' ? 'config_linux' : 'config_win'
const BASE_URI = '/opt/jdt-language-server'
const PORT = 5036
const launchersFound = glob.sync('**/plugins/org.eclipse.equinox.launcher_*.jar', {
cwd: `${
BASE_URI}`})
if (launchersFound.length === 0 || !launchersFound) {
throw new Error('**/plugins/org.eclipse.equinox.launcher_*.jar Not Found!')
}
const params =
[
'-Xmx1G',
'-Xms1G',
'-Declipse.application=org.eclipse.jdt.ls.core.id1',
'-Dosgi.bundles.defaultStartLevel=4',
'-Dlog.level=ALL',
'-Declipse.product=org.eclipse.jdt.ls.core.product',
'-jar',
`${
BASE_URI}/${
launchersFound[0]}`,
'-configuration',
`${
BASE_URI}/${
CONFIG_DIR}`
]
let app = express()
let server = app.listen(PORT)
let ws = new WebSocket.Server({
noServer: true,
perMessageDeflate: false
})
server.on('upgrade', function (request, socket, head) {
let pathname = request.url ? url.parse(request.url).pathname : undefined
console.log(pathname)
if (pathname === '/java-lsp') {
ws.handleUpgrade(request, socket, head, function (webSocket) {
let lspSocket = {
send: function (content) {
return webSocket.send(content, function (error) {
if (error) {
throw error
}
})
},
onMessage: function (cb) {
return webSocket.on('message', cb)
},
onError: function (cb) {
return webSocket.on('error', cb)
},
onClose: function (cb) {
return webSocket.on('close', cb)
},
dispose: function () {
return webSocket.close()
}
}
if (webSocket.readyState === webSocket.OPEN) {
launch(lspSocket)
} else {
webSocket.on('open', function () {
return launch(lspSocket)
})
}
})
}
})
function launch(socket) {
let process = cp.spawn('java', params)
let data = ''
let left = 0, start = 0, last = 0
process.stdin.setEncoding('utf-8')
socket.onMessage(function (data) {
console.log(`Receive:${
data.toString()}`)
process.stdin.write('Content-Length: ' + data.length + '\n\n')
process.stdin.write(data.toString())
})
socket.onClose(function () {
console.log('Socket Closed')
process.kill()
})
process.stdout.on('data', function (respose) {
data += respose.toString()
let end = 0
for(let i = last; i < data.length; i++) {
if(data.charAt(i) == '{') {
if(left == 0) {
start = i
}
left++
} else if(data.charAt(i) == '}') {
left--
if(left == 0) {
let json = data.substring(start, i + 1)
end = i + 1
console.log(`Send: ${
json}`)
socket.send(json)
}
}
}
data = data.substring(end)
last = data.length - end
start -= end
})
process.stderr.on('data', function (respose) {
console.error(`Error: ${
respose.toString()}`)
})
}
要注意的是:
- monaco-editor发送过来的信息和子进程需要的信息之间不太匹配需要处理,monaco-editor发送过来的是Buffer对象,没有content-length的信息,子进程输出的信息是Content-length和json数据,因此把信息写到子进程的输入时需要加上Conten-length信息,从子进程的输出读数据写到socket的时候需要过滤掉Conten-length信息。
- 另外数据很长的时候子进程的输出是一段一段的,需要拼接。
我们使用node index.js
启动这个node进程,就得到了一个可以处理socket链接的java language server。
创建一个java编辑器
创建一个vue项目
vue create java-editor
添加monaco编辑器相关依赖
npm install [email protected] --save
npm install [email protected] --save-dev
npm install monaco-languageclient --save
npm install @codingame/monaco-jsonrpc --save
添加electron-builder
vue add electron-builder
electron-builder install-app-deps
然后在vue.config.js文件里面添加plugin:
configureWebpack: {
plugins: [
new MonacoWebpackPlugin({
languages: ['javascript', 'css', 'html', 'typescript', 'json', 'java']
})
]
}
创建Editor
参考monaco-languageclient的使用样例我们在components里面添加一个Editor.vue文件。
<template>
<div style="width: 100%;height:100%;">
<div class="hello" ref="main" style="width: 100%;height:100%;text-align: left" v-show="model">
</div>
<div v-show="!model" style="width: 100%;height:100%;position: relative">
<span style="font-size: 30px;display: block;position:absolute;left: 50%; top: 50%;transform: translate(-50%, -50%)">
Please Open A Java File</span>
</div>
</div>
</template>
<script>
const {
ipcRenderer} = window.require('electron')
import {
listen } from "@codingame/monaco-jsonrpc"
import * as monaco from 'monaco-editor/esm/vs/editor/editor.main.js'
import 'monaco-editor/esm/vs/basic-languages/java/java.contribution'
const {
MonacoLanguageClient, CloseAction, ErrorAction, MonacoServices, createConnection } = require('monaco-languageclient')
export default {
name: 'JavaEditor',
data() {
return {
editor: null,
websocket: null,
model: null
}
},
methods: {
createLanguageClient(connection) {
return new MonacoLanguageClient({
name: "Java LSP client",
clientOptions: {
documentSelector: ['java'],
errorHandler: {
error: () => ErrorAction.Continue,
closed: () => CloseAction.DoNotRestart
}
},
connectionProvider: {
get: (errorHandler, closeHandler) => {
return Promise.resolve(createConnection(connection, errorHandler, closeHandler))
}
}
})
},
createModel (filePath) {
let fileContent = window.require('fs').readFileSync(filePath, 'utf-8').toString()
return monaco.editor.createModel(fileContent, 'java', monaco.Uri.file(filePath))
}
},
mounted() {
let self = this
//注册 Monaco language client 的服务
MonacoServices.install(monaco)
//监听打开文件的event,创建model
ipcRenderer.on('open', (event, filePath) => {
let first = !this.model
let model = monaco.editor.getModel(monaco.Uri.file(filePath))
if (!model) {
model = this.createModel(filePath)
}
this.model = model
//第一次打开的话,要创建编辑器,链接到language server
if(first) {
this.$nextTick(() => {
this.editor = monaco.editor.create(this.$refs.main, {
model: model
})
//这里这个url是之前启动的java language server的地址
const url = 'ws://127.0.0.1:5036/java-lsp'
this.websocket = new WebSocket(url)
listen({
webSocket: self.websocket,
onConnection: connection => {
console.log("connect")
const client = self.createLanguageClient(connection);
const disposable = client.start()
connection.onClose(() => disposable.dispose());
console.log(`Connected to "${
url}" and started the language client.`);
}
})
})
} else {
this.editor.setModel(model)
}
})
//监听save事件,保存文件
ipcRenderer.on('save', () => {
if(this.model) {
window.require('fs').writeFileSync(this.model.uri.fsPath, this.model.getValue())
}
})
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h3 {
margin: 40px 0 0;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: inline-block;
margin: 0 10px;
}
a {
color: #42b983;
}
</style>
修改App.vue文件,把Editor加入App.vue文件。
<template>
<div id="app">
<div style="background: black; height: 40px; width: 100%;color: white;text-align: left">
<span style="display: inline-block;padding: 5px;font-weight: bold">A Simple Jave Editor</span>
</div>
<div style="width: 100%; height: calc(100vh - 60px); padding: 10px">
<Editor/>
</div>
</div>
</template>
<script>
import Editor from './components/Editor.vue'
export default {
name: 'App',
components: {
Editor
}
}
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
body {
margin: 0;
}
</style>
配置electron菜单
修改background.js文件,这是之前electron-builder添加的electron的主进程,加入menu配置,主要是添加打开文件,保存文件的菜单。
'use strict'
import {
app, protocol, BrowserWindow, Menu, dialog } from 'electron'
import {
createProtocol } from 'vue-cli-plugin-electron-builder/lib'
import installExtension, {
VUEJS_DEVTOOLS } from 'electron-devtools-installer'
const isDevelopment = process.env.NODE_ENV !== 'production'
// Scheme must be registered before the app is ready
protocol.registerSchemesAsPrivileged([
{
scheme: 'app', privileges: {
secure: true, standard: true } }
])
async function createWindow() {
// Create the browser window.
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
enableRemoteModule: true
}
})
if (process.env.WEBPACK_DEV_SERVER_URL) {
// Load the url of the dev server if in development mode
await win.loadURL(process.env.WEBPACK_DEV_SERVER_URL)
if (!process.env.IS_TEST) win.webContents.openDevTools()
} else {
createProtocol('app')
// Load the index.html when not in development
win.loadURL('app://./index.html')
}
const isMac = process.platform === 'darwin'
const template = [
...(isMac ? [{
label: app.name,
submenu: [
{
role: 'about' },
{
type: 'separator' },
{
role: 'services' },
{
type: 'separator' },
{
role: 'hide' },
{
role: 'hideOthers' },
{
role: 'unhide' },
{
type: 'separator' },
{
role: 'quit' }
]
}] : []),
{
label: 'File',
//打开文件和保存文件的menu定义
submenu: [
{
label: 'Open File', accelerator: "CmdOrCtrl+O", click: () => {
if (win && !win.isDestroyed()) {
dialog.showOpenDialog(win, {
properties: ['openFile'],
filters: [{
name: 'Java', extensions: ['java']},]
}).then(result => {
if (!result.canceled) {
win.webContents.send('open', result.filePaths[0])
}
}).catch(err => {
console.log(err)
})
}
}
},
{
label: 'Save File', accelerator: "CmdOrCtrl+S", click: () => {
if(win && !win.isDestroyed()) {
win.webContents.send('save')
}
}},
isMac ? {
role: 'close' } : {
role: 'quit' }
]
},
{
label: 'Edit',
submenu: [
{
role: 'undo' },
{
role: 'redo' },
{
type: 'separator' },
{
role: 'cut' },
{
role: 'copy' },
{
role: 'paste' },
...(isMac ? [
{
role: 'pasteAndMatchStyle' },
{
role: 'delete' },
{
role: 'selectAll' },
{
type: 'separator' },
{
label: 'Speech',
submenu: [
{
role: 'startSpeaking' },
{
role: 'stopSpeaking' }
]
}
] : [
{
role: 'delete' },
{
type: 'separator' },
{
role: 'selectAll' }
])
]
},
{
label: 'Window',
submenu: [
{
role: 'minimize' },
{
role: 'zoom' },
...(isMac ? [
{
type: 'separator' },
{
role: 'front' },
{
type: 'separator' },
{
role: 'window' }
] : [
{
role: 'close' }
])
]
},
{
role: 'help',
submenu: [
{
label: 'Learn More',
click: async () => {
const {
shell } = require('electron')
await shell.openExternal('https://electronjs.org')
}
}
]
}
]
const menu = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(menu)
}
// Quit when all windows are closed.
app.on('window-all-closed', () => {
// On macOS it is common for applications and their menu bar
// to stay active until the user quits explicitly with Cmd + Q
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('activate', () => {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on('ready', async () => {
if (isDevelopment && !process.env.IS_TEST) {
// Install Vue Devtools
try {
await installExtension(VUEJS_DEVTOOLS)
} catch (e) {
console.error('Vue Devtools failed to install:', e.toString())
}
}
createWindow()
})
// Exit cleanly on request from parent process in development mode.
if (isDevelopment) {
if (process.platform === 'win32') {
process.on('message', (data) => {
if (data === 'graceful-exit') {
app.quit()
}
})
} else {
process.on('SIGTERM', () => {
app.quit()
})
}
}
启动运行
我们的editor就搭建好了,然后启动构建运行即可。
#启动
npm run electron:serve
#构建
npm run electron:build
启动之后界面如下:
打开一个本地java文件之后:
总结
最后,总结一下过程中遇到的问题
1.版本问题
monaco-editor和monaco-editor-webpack-plugin的版本是有对应关系的,刚开始由于默认使用最新版本0.33.0和7.0.1导致出现了很多错误,各种改版本,遇到了大概如下问题:
Error: Cannot find module 'vs/editor/contrib/gotoSymbol/goToCommands'
Error: Cannot find module 'monaco-editor/esm/vs/editor/contrib/bracketMatching/bracketMatching'
Error: Cannot find module 'vs/editor/contrib/anchorSelect/anchorSelect'
ERROR in ./node_modules/monaco-editor/esm/vs/language/css/monaco.contribution.js 29:15 Module parse failed: Unexpected token (29:15)
You may need an appropriate loader to handle this file type.
这是monaco-editor-webpack-plugin主页表注的对应关系表:
按照这个表来说,最新版本应该是可以的,我也没太搞明白,经过反复实验,最后选定了[email protected]
和[email protected]
,解决了上述的问题。
另外,反复使用npm install更新版本遇到了下面的问题
Error: Cyclic dependency toposort/index.js:53:9)
Uncaught TypeError: Converting circular structure to JSON
删除node_modules文件夹,重新install就好了。
2.monaco-languageclient使用问题
按照官网的指示使用monaco-languageclient时,遇到了如下问题:
Uncaught Error: Cannot find module 'vscode'
__dirname is not defined
参考官网的changelog,要在vue.config.js里面添加alias:
configureWebpack: {
resolve: {
alias : {
'vscode': require.resolve('monaco-languageclient/lib/vscode-compatibility')
}
}
}
另外,MonacoServices.install
的使用根据版本不同改过很多次,要根据具体版本决定怎么用,我之前用错了,发生过以下问题:
TypeError: model.getLanguageId is not a function
TypeError: Cannot read property 'getModels' of undefined
具体可以参考官网的changelog。
3.electron的问题
我之前使用electron-vue都是直接使用模板创建的,但是,vue更新了,模板已经很多年没有更新了,这回先创建vue然后添加的electron,就遇到了奇怪的问题:
Uncaught ReferenceError: __dirname is not defined
查找资料让我改创建window时候的webPreferences里面的参数,改成如下的样子。
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
enableRemoteModule: true
}
})
然后,出现了新的问题。
TypeError: fs.existsSync is not a function(anonymous function)
node_modules/electron/index.js:6
细心的小伙伴可能发现了,我上面代码里面的引用很多使用的window.require
而不是require
,使用window.require
可以解决node的模块找不到的问题,我对前端不是太懂,反正好用了,就直接这么用了,有了解详情的欢迎大家分享,一起学习,共同进步。