最近闲来无事,对于之前放置不理的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 = 5036const 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 : undefinedconsole.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 = 0process.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 = 0for(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 + 1console.log(`Send: ${json}`)socket.send(json)}}}data = data.substring(end)last = data.length - endstart -= 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 monaco-editor@0.30.0 --save
npm install monaco-editor-webpack-plugin@6.0.0 --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,创建modelipcRenderer.on('open', (event, filePath) => {let first = !this.modellet model = monaco.editor.getModel(monaco.Uri.file(filePath))if (!model) {model = this.createModel(filePath)}this.model = model//第一次打开的话,要创建编辑器,链接到language serverif(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 modeawait 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 developmentwin.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 + Qif (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 Devtoolstry {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主页表注的对应关系表:

按照这个表来说,最新版本应该是可以的,我也没太搞明白,经过反复实验,最后选定了monaco-editor@0.30.0monaco-editor-webpack-plugin@6.0.0,解决了上述的问题。

另外,反复使用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的源代码,参考 这儿.

做一个简单的java编辑器相关推荐

  1. 做一个简单的java小游戏--单机版五子棋

    做一个简单的java小游戏–单机版五子棋 学了java有一段时间了,今天就来搞一个简单的单机版五子棋游戏. 实现功能:那必须能进行基础的输赢判断.还有重新开始的功能,悔棋的功能,先手设置的功能和退出的 ...

  2. 做一个简单的java小游戏--贪吃蛇

    做一个简单的java小游戏–贪吃蛇 贪吃蛇游戏博客链接:(方法一样,语言不一样) c++贪吃蛇:https://blog.csdn.net/weixin_46791942/article/detail ...

  3. Java实现一个简单的文本编辑器(简易版)

    (用Java做了一个简单的文本编辑器,其中看了很多博主的教学和代码,在这里感谢:@Mark7758.@Kingsly_Liang.@佐敦不下雨.再次感谢!) 1.功能说明: 文件菜单:打开.保存.新建 ...

  4. 如何复制java卡,使用java做一个简单的集卡程序

    使用java做一个简单的集卡程序 本次设想的是要集齐4张卡,每张卡的概率都是25%,如果每个用户集齐需要多少次才能集合完毕 public class Test { public static void ...

  5. java如何做网页_java怎么做一个简单网页?网页包括什么?

    学了java程序之后,大家就可以将这些运用到生活中去,比如做一个简单的网页.正好也可以检测自己学了怎么样,那么接下来,我们就来给大家讲解一下这方面的内容. 用Java语言编写实现一个简单的WEB浏览器 ...

  6. Java怎么做一个简单网页呢?

    学java的同学在接触到Java web开发之后都跃跃欲试想要尝试自己开发一个页面,那么应该如何操作呢?都需要使用到哪些技术呢?下面小千就来告诉你. 需要使用到的技术 java 语言知识, jsp 知 ...

  7. java Swing 做一个简单的输入文本框

    java Swing做一个简单的文本输入框, 新建一个SwingDemo类: // //java swing做一个简单的文本框 //Created by lee_1310 on 2019.03.29 ...

  8. 用java做一个简单记事本_用记事本写一个简单的java程序

    用记事本写一个简单的java程序 第一步: 安装好jdk,并设置好环境变量. 桌面-计算机(右键)-属性-高级系统设置-环境变量-path-在变量值后加上:和jdk安装路径加上(路径即为C:\Prog ...

  9. 程序猿修仙之路--数据结构之你是否真的懂数组? c#socket TCP同步网络通信 用lambda表达式树替代反射 ASP.NET MVC如何做一个简单的非法登录拦截...

    程序猿修仙之路--数据结构之你是否真的懂数组? 数据结构 但凡IT江湖侠士,算法与数据结构为必修之课.早有前辈已经明确指出:程序=算法+数据结构  .要想在之后的江湖历练中通关,数据结构必不可少.数据 ...

最新文章

  1. Docker+Tomcat+geoserver+shp发布地图服务
  2. c++类指针赋值表达式必须是可修改的左值_C++进阶教程系列:全面理解C++中的类...
  3. php mysql 值是否存在_php检测mysql表是否存在的方法小结
  4. 获取class文件对象三种方式
  5. 运行时动态调用子程序的例子
  6. sql语句练习(二):Demand
  7. 量化交易,量化分析推荐书单
  8. SAP云平台上的Low Code Development(低代码开发)解决方案
  9. C#将运算字符串直接转换成表达式且计算结果
  10. 不同于NLP,数据驱动、机器学习无法攻克NLU,原因有三
  11. JavaScript高级程序设计读书笔记(第5章引用类型之Array类型)
  12. redis 缓存目标
  13. SpringAOP 通知(adivce)- methodIntercepor
  14. UE4之cmd调用函数
  15. 码支付如何对接网站_做“刷脸支付”怎么推广?怎么办理刷脸支付POS机?
  16. 以一定概率执行某段代码(Python实现)
  17. PropertyGrid类别排序
  18. LM2596S-ADJ DC-DC降压芯片使用
  19. ZOJ 3880 Demacia of the Ancients(水题)
  20. 叮,你有一份光线追踪技术合集待查收 | IMG2020

热门文章

  1. 我也来做功放! 仿制拜亚动力A1
  2. IT男频繁猝死背后的心理探秘
  3. 基于大数据的社会治理与决策支持
  4. react-native系列(6)组件篇: ScrollView滚屏及滚屏加载
  5. 网页源代码与开发者工具里打开的代码有何区别?在爬取网页是我们该如何进行选择?(网页源代码,框架源代码,开发者工具三者的联系)
  6. Unity中如何制作预制件(prefab)
  7. Master of Both
  8. 【git】warning: adding embedded git repository
  9. 计算机安全小品剧本,5分钟安全五人小品剧本
  10. ADA卡尔达诺价值社区再次跨越,EMURGO正式加入PCTA