做一个简单的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 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,创建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主页表注的对应关系表:
在这里插入图片描述
按照这个表来说,最新版本应该是可以的,我也没太搞明白,经过反复实验,最后选定了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的源代码,参考 这儿.
  • 10
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 16
    评论
当然,我可以帮你写一个简单的文本编辑器。以下是一个Java编写的基本文本编辑器示例: ```java import javax.swing.*; import java.awt.*; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.io.*; public class TextEditor extends JFrame implements ActionListener { private JTextArea textArea; private JFileChooser fileChooser; public TextEditor() { setTitle("文本编辑器"); setSize(500, 400); setDefaultCloseOperation(EXIT_ON_CLOSE); textArea = new JTextArea(); JScrollPane scrollPane = new JScrollPane(textArea); add(scrollPane, BorderLayout.CENTER); JMenuBar menuBar = new JMenuBar(); JMenu fileMenu = new JMenu("文件"); JMenuItem openMenuItem = new JMenuItem("打开"); openMenuItem.addActionListener(this); fileMenu.add(openMenuItem); JMenuItem saveMenuItem = new JMenuItem("保存"); saveMenuItem.addActionListener(this); fileMenu.add(saveMenuItem); menuBar.add(fileMenu); setJMenuBar(menuBar); fileChooser = new JFileChooser(); setVisible(true); } public static void main(String[] args) { new TextEditor(); } @Override public void actionPerformed(ActionEvent e) { if (e.getActionCommand().equals("打开")) { int returnVal = fileChooser.showOpenDialog(this); if (returnVal == JFileChooser.APPROVE_OPTION) { File file = fileChooser.getSelectedFile(); openFile(file); } } else if (e.getActionCommand().equals("保存")) { int returnVal = fileChooser.showSaveDialog(this); if (returnVal == JFileChooser.APPROVE_OPTION) { File file = fileChooser.getSelectedFile(); saveFile(file); } } } private void openFile(File file) { try { BufferedReader reader = new BufferedReader(new FileReader(file)); textArea.setText(""); String line; while ((line = reader.readLine()) != null) { textArea.append(line + "\n"); } reader.close(); } catch (IOException e) { e.printStackTrace(); } } private void saveFile(File file) { try { BufferedWriter writer = new BufferedWriter(new FileWriter(file)); writer.write(textArea.getText()); writer.close(); } catch (IOException e) { e.printStackTrace(); } } } ``` 这个文本编辑器使用了Java的Swing库来创建GUI界面,并使用了文件选择器来打开和保存文件。通过菜单栏的"文件"菜单,你可以选择打开和保存文件。打开文件会将文件的内容显示到文本编辑器中,而保存文件会将文本编辑器中的内容保存到文件中。 请注意,这只是一个基本的示例,你可以根据自己的需求进行扩展和修改。希望对你有所帮助!

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 16
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值