做一个简单的java编辑器

最近闲来无事,对于之前放置不理的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()}`)
    })
}


要注意的是:

  1. monaco-editor发送过来的信息和子进程需要的信息之间不太匹配需要处理,monaco-editor发送过来的是Buffer对象,没有content-length的信息,子进程输出的信息是Content-length和json数据,因此把信息写到子进程的输入时需要加上Conten-length信息,从子进程的输出读数据写到socket的时候需要过滤掉Conten-length信息。
  2. 另外数据很长的时候子进程的输出是一段一段的,需要拼接。

我们使用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的模块找不到的问题,我对前端不是太懂,反正好用了,就直接这么用了,有了解详情的欢迎大家分享,一起学习,共同进步。

源代码

  • java language sever的源代码,参考 这儿.
  • java editor的源代码,参考 这儿.

猜你喜欢

转载自blog.csdn.net/gao_grace/article/details/124388402