最小的语言服务器实现
为了更好的理解语言服务器协议,我们不使用任何第三方库实现一个语言服务器客户端和服务端,并尽可能最小。
语言服务器可以使用多种通信方式,我们采用最常用的一种: stdio
, 即基于标准输入输出进行通信
我们创建一个项目,名为 min-lsp-example
的 js 语言项目。
mkdir min-lsp-example
cd min-lsp-example
npm init -y
创建一个 client.js
文件, 并写入以下内容:
const { spawn } = require('child_process');
const ls = spawn('node', ['server.js']);
ls.stdin.write("client init message")
ls.stdout.on('data', (data) => {
console.log(`stdout: ${data}`);
});
我们在 client.js
中使用 server.js
创建了一个子进程,并向子进程的标准输入中写入了一些字符,并接受来自子进程的输出,然后在收到的消息前加上 stdout:
后将其输出到了控制台。当然,server.js
并不存在,因此,创建一个 server.js
文件,并写入以下内容:
process.stdin.on("data", data => {
data = data.toString().toUpperCase()
process.stdout.write(data)
})
我们在 server.js
中我们接受来自标准输入的内容,并将其转换为大写后,写入标准输出。
现在我们运行它:
node client.js
我们看到控制台输出了如下信息:
stdout: CLIENT INIT MESSAGE
这表明,我们成功创建了一个进程,并使用标准输入输出与其通信,下面,我们将他们分别改造为语言服务器的客户端和服务端。
客户端和服务端必须的能力
-
客户端和服务器都必须支持生命周期消息:
initialize
请求,initialized
通知,shutdown
请求,exit
通知 -
客户端必须实现
textDocument/didOpen
、textDocument/didChange
和textDocument/didClose
通知
我们首先创建一个基础协议模块来处理,请求、响应和通知,创建 protocol.js
文件,并写入以下内容:
let id = 0;
function getId() {
return id;
}
function getMessage(data) {
let json = JSON.stringify(data);
let length = json.length;
return `Content-Length: ${length}\r\n\r\n${json}`;
}
function getRequest(method, params) {
id++;
return getMessage({
jsonrpc: "2.0",
id,
method,
params
});
}
function getResponse(id, result, error) {
return getMessage({
jsonrpc: "2.0",
id,
result,
error
})
}
function getNotification(method, params) {
return getMessage({
jsonrpc: "2.0",
method,
params
})
}
module.exports = {
getId,
getRequest,
getResponse,
getNotification,
}
客户端向服务器发送 initialize
请求,根据 initialize 请求 中的 请求(Request) 定义,并统一处理发送数据和处理数据,对 client.js
进行修改:
const { spawn } = require('child_process');
+const protocol = require('./protocol');
const ls = spawn('node', ['server.js']);
-ls.stdin.write("client init message")
+send(protocol.getRequest("initialize", {
+ processId: process.pid,
+ rootUri: null,
+ capabilities: {}
+}))
ls.stdout.on('data', (data) => {
- console.log(`stdout: ${data}`);
+ dealMessage(data.toString())
});
+
+function send(data) {
+ console.log("===>", data.split("\r\n\r\n")[1]);
+ ls.stdin.write(data)
+}
+
+function dealMessage(data) {
+ console.log(data)
+}
根据 响应(Response) 的定义,对 server.js
进行修改,并且考虑不同客户端的实现导致消息标头和内容不连续的可能:
+const protocol = require('./protocol');
+
+let length = 0
+
process.stdin.on("data", data => {
- data = data.toString().toUpperCase()
- process.stdout.write(data)
+ data = data.toString()
+ let arr = data.split("Content-Length: ")
+ if (arr[0] && arr[0].length === length) {
+ dealMessage(arr[0])
+ }
+
+ for (let i = 1; i < arr.length; i++) {
+ let value = arr[i].split("\r\n\r\n")[1]
+ if (value) {
+ dealMessage(value)
+ } else {
+ length = Number(arr[i].split("\r\n\r\n")[0])
+ }
+ }
})
+
+function dealMessage(data) {
+ let message = JSON.parse(data)
+ if (message.method === 'initialize') {
+ process.stdout.write(protocol.getResponse(message.id, {
+ capabilities: {}
+ }))
+ }
+}
现在启动客户端:
node client.js
我们可以看下以下输出:
===> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":49654,"rootUri":null,"capabilities":{}}}
Content-Length: 53
{"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}
这是包括发送的 initialize
请求和从服务器返回的对 initialize
请求的响应消息。
根据协议,客户端在收到 initialize
请求的结果之后,向服务器发送 initialized
通知,同样考虑不同服务端的实现导致消息标头和内容不连续的可能,修改 client.js
:
const { spawn } = require('child_process');
const protocol = require("./protocol")
const ls = spawn('node', ['server1.js']);
send(protocol.getRequest("initialize", {
processId: process.pid,
rootUri: null,
capabilities: {}
}))
+let length = 0
+
ls.stdout.on('data', (data) => {
- dealMessage(data.toString())
+ data = data.toString()
+ let arr = data.split("Content-Length: ")
+ if (arr[0] && arr[0].length === length) {
+ dealMessage(arr[0])
+ }
+
+ for (let i = 1; i < arr.length; i++) {
+ let value = arr[i].split("\r\n\r\n")[1]
+ if (value) {
+ dealMessage(value)
+ } else {
+ length = Number(arr[i].split("\r\n\r\n")[0])
+ }
+ }
});
function send(data) {
console.log("===>", data.split("\r\n\r\n")[1]);
ls.stdin.write(data)
}
function dealMessage(data) {
- console.log(data);
+ let response = JSON.parse(data)
+ console.log("<===", data);
+ if (response.id === 1) {
+ send(protocol.getNotification("initialized", {}))
+ }
}
现在,启动客户端,我们可以看到:
===> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":49975,"rootUri":null,"capabilities":{}}}
<=== {"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}
===> {"jsonrpc":"2.0","method":"initialized","params":{}}
客户端收到 initialize
的响应之后向服务器发送了 initialized
通知。我们成功完成了客户端和服务器的初始化。
这里,我们客户端不需要做任何操作,因此,我们在等待 1s 后发送 shutdown
请求,修改 client.js
:
function dealMessage(data) {
let response = JSON.parse(data)
console.log("<===", data);
if (response.id === 1) {
send(protocol.getNotification("initialized", {}))
+ setTimeout(() => {
+ send(protocol.getRequest("shutdown"))
+ }, 1000);
}
}
在服务端接收 shutdown
请求,并设置 shutdown
状态,修改 server.js
:
+let shutdown = false;
function dealMessage(data) {
let message = JSON.parse(data)
if (message.method === 'initialize') {
process.stdout.write(protocol.getResponse(message.id, {
capabilities: {}
}))
+ } else if (message.method === 'shutdown') {
+ shutdown = true;
+ process.stdout.write(protocol.getResponse(message.id, null))
}
}
客户端收到 shutdown
请求的响应之后,发送 exit
通知,使服务器退出,修改 client.js
:
+ls.on('close', (code) => {
+ console.log(`child process exited with code ${code}`);
+});
+
+let shutdownId = 0;
+
function dealMessage(data) {
let response = JSON.parse(data)
console.log("<===", data);
if (response.id === 1) {
send(protocol.getNotification("initialized", {}))
setTimeout(() => {
send(protocol.getRequest("shutdown"))
+ shutdownId = protocol.getId();
}, 1000);
+ } else if (response.id === shutdownId) {
+ send(protocol.getNotification("exit"))
}
}
服务器接收到 exit
通知后退出,修改 server.js
:
function dealMessage(data) {
let message = JSON.parse(data)
if (message.method === 'initialize') {
process.stdout.write(protocol.getResponse(message.id, {
capabilities: {}
}))
} else if (message.method === 'shutdown') {
shutdown = true;
process.stdout.write(protocol.getResponse(message.id, null))
+ } else if (message.method === 'exit') {
+ if (shutdown) {
+ process.exit(0)
+ } else {
+ process.exit(1)
+ }
}
}
最终代码如下:
client.js
const { spawn } = require('child_process');
const protocol = require("./protocol")
const ls = spawn('node', ['server.js']);
send(protocol.getRequest("initialize", {
processId: process.pid,
rootUri: null,
capabilities: {}
}))
let length = 0
ls.stdout.on('data', (data) => {
data = data.toString()
let arr = data.split("Content-Length: ")
if (arr[0] && arr[0].length === length) {
dealMessage(arr[0])
}
for (let i = 1; i < arr.length; i++) {
let value = arr[i].split("\r\n\r\n")[1]
if (value) {
dealMessage(value)
} else {
length = Number(arr[i].split("\r\n\r\n")[0])
}
}
});
ls.on('close', (code) => {
console.log(`child process exited with code ${code}`);
});
function send(data) {
console.log("===>", data.split("\r\n\r\n")[1]);
ls.stdin.write(data)
}
let shutdownId = 0;
function dealMessage(data) {
let response = JSON.parse(data)
console.log("<===", data);
if (response.id === 1) {
send(protocol.getNotification("initialized", {}))
setTimeout(() => {
send(protocol.getRequest("shutdown"))
shutdownId = protocol.getId();
}, 1000);
} else if (response.id === shutdownId) {
send(protocol.getNotification("exit"))
}
}
server.js
const protocol = require("./protocol");
let length = 0
process.stdin.on("data", data => {
data = data.toString()
let arr = data.split("Content-Length: ")
if (arr[0] && arr[0].length === length) {
dealMessage(arr[0])
}
for (let i = 1; i < arr.length; i++) {
let value = arr[i].split("\r\n\r\n")[1]
if (value) {
dealMessage(value)
} else {
length = Number(arr[i].split("\r\n\r\n")[0])
}
}
})
let shutdown = false;
function dealMessage(data) {
let message = JSON.parse(data)
if (message.method === 'initialize') {
process.stdout.write(protocol.getResponse(message.id, {
capabilities: {}
}))
} else if (message.method === 'shutdown') {
shutdown = true;
process.stdout.write(protocol.getResponse(message.id, null))
} else if (message.method === 'exit') {
if (shutdown) {
process.exit(0)
} else {
process.exit(1)
}
}
}
现在,我们运行它,会显示如下消息:
===> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":51807,"rootUri":null,"capabilities":{}}}
<=== {"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}
===> {"jsonrpc":"2.0","method":"initialized","params":{}}
===> {"jsonrpc":"2.0","id":2,"method":"shutdown"}
<=== {"jsonrpc":"2.0","id":2,"result":null}
===> {"jsonrpc":"2.0","method":"exit"}
child process exited with code 0
到这里(忽略客户端对 textDocument/*
的处理),我们成功实现了一个语言服务器的客户端和服务端,尽管它们没有任何能力。
这里,特别感谢 connie1451 的帮助!