Language Server Protocol (语言服务器协议,简称 LSP)是微软于 2016 年提出的一套统一的通讯协议方案。该方案定义了一套编辑器或 IDE 与语言服务器之间使用的协议,该语言服务器提供自动完成、转到定义、查找所有引用等语言功能。
同学们可能对语言服务器(Language Server)不是很了解。举个例子,我们在使用在线编程工具的时候,是不是也有代码提示、代码错误诊断等功能?其实背后是跑着一个对应这门语言的 language server 进程实例(也有开发者工具本身和 Language Server 耦合在一起的,比如 Eclipse),这个 Language Server 实例负责分析你当前打开的代码文件。
市面上的 编辑器 / IDE,本质上提供给用户的代码编辑(如打开文件、编辑文集、查找引用、打开工作区等)以及编辑器的响应行为(如补全提示、代码诊断等)其实都大同小异,可能在个别功能上实现不一样,但是逃不开上述内容。或者说,上述这些功能都可以抽象为一系列的「行为事件」。
微软提出 LSP 的目的是,之前各个编辑器(VSCode, Vim, Atom, Sublime…)各自为战,编辑器内部实现的特性和协议都不同。每换一个编辑器,就有可能要给该编辑器中支持的每门语言写一个对应的 Language Server,也就是说假设有 $n$ 门语言,$m$ 个编辑器,那全部编辑器适配所有语言的开发成本和复杂度为 $n * m$。
能不能在中间层做一个抽象,让语言的「静态分析服务」和「编辑器 / IDE」分离开来?这样上述情景下开发成本和复杂度就可以降低为线性的 $n + m$。
例如,每个编辑器(客户端)都在用户产生某些通用的行为时(比如点击跳转到定义)负责生成标准中的行为事件,然后以 JSON-RPC 的形式去调用 Language Server 的接口方法。Language Server 相对应地,也必须实现全部 LSP 规范(或者至少实现其中关键部分)定义的接口。
这么做的好处在于,对于某门编程语言,一个编辑器工具不需要再去关心怎么去做代码分析,而是只需要关注如何在界面上发起或响应 LSP 规定的 RPC 事件。而在语言服务器这边也是同理,只需要关注协议本身的事件并响应 & 发起事件即可。
这种中间层分离的思想非常常见,例如编译器就分为前端和后端,前端生产中间语言 IR,后端负责把中间语言再翻译为 CPU 特定的指令集。典型的代表如 JVM 字节码、 LLVM IR 等
另外,由于编辑器和 Language Server 是两个进程,所以如果 Language Server 挂了,编辑器进程本身也还会存在,用户不用担心还没修改好的代码因此丢失的问题。
有没有缺点?肯定有,那就是市面上所有的 编辑器 和 Language Server 的 maintainer 都需要花时间和精力去兼容这个协议,并且这个协议本身也会随着自身版本更新而要求服务端 / 客户端响应新的协议行为。但是总体来说,利大于弊。
LSP 的运作机制 #
LSP 是一个「双工协议」。不只是开发者工具(客户端)会主动向 Language Server (服务端)通信,服务端也可能主动向开发者工具发起 RPC 请求(比如代码诊断事件 textDocument/Diagnostics
,只能从服务端向客户端主动发送)。在
LSP 规范定义文档 中,每个 RPC 事件会标注可能的发起方以及是否需要对方做出响应。
例子
- 例如一个客户端发起,且要求服务端返回的请求事件(小标题的括号中有一个从左至右然后转弯的箭头):
- 例如一个服务端发起,且要求客户端返回的请求事件(小标题的括号中有一个从右至左然后转弯的箭头):
- 也有单方面发送,不需要响应的(分别为工具向服务端单方面发送 / 服务端向工具单方面发送):
我们以 Goto Type Definition Request 为例,具象化地理解一下整个流程。这个 RPC 请求的发起可能是来自 VSCode 中用户右键菜单中点击 跳转到类型定义 (Goto Type Definition) 这个事件:
VSCode 会向 Language Server 进程以 IPC 形式发送如下信息(仅举例,实际参数结构比较复杂):
{
"jsonrpc": "2.0",
"id": 24,
"method": "textDocument/typeDefinition",
"params": {
"textDocument": {
"uri": "file:///User/bytedance/java-hello/src/main/java/Main.java"
},
"position": {
"line": 3,
"character": 13
},
// ...其他参数
},
}
然后 Language Server 拿到这条指令,会执行如下动作:
- 调用的方法是 textDocument/typeDefinition,也就是分析一个符号的类型定义信息。
- 根据参数,指令的来源文件是 Main.java 第 3 行第 13 个字符 —— 分析后可知是 foo 这个符号。
- Server 寻找 foo 的符号对应的类型 Foo 所在位置。找到之后,同样通过 IPC 返回结果 JSON-RPC:
{
"jsonrpc": "2.0",
// Request 中的 id 为 24,因此 Server 端对应的 Response id 也必须为 24
"id": 24,
"result": {
"uri": "file:///User/bytedance/java-hello/src/main/java/Main.java",
"range": {
"start": { "line": 7, "character": 25 },
"end": { "line": 7, "character": 28 }
}
},
}
只有客户端根据返回值中的参数,让当前用户的编辑光标跳转到指定位置。
LSP 的生命周期 #
上一节中的例子只是 Language Server 和开发者工具之间通信的其中一个特例场景。在编辑代码的整个过程中,Language Server / 开发者工具双方会持续不断地通过各式各样的请求体通信。
为了规范,Language Server Protocol 中的交互一般需要遵循如下生命周期。
用户在打开一个项目或者代码文件后,开发者工具就需要视情况启动一个 Language Server 子进程并建立通信。在 Language Server 开始接收消息后,一般从客户端发出初始化请求开始。
1. 初始化 (Initialize) #
由于 Language Server 启动后,并不知道当前编辑器的状态。因此,所有符合 LSP 规范的开发者工具在和符合 LSP 规范的 Language Server 建立连接后,第一个 RPC 请求永远是 initialize 指令。initialize 指令的结构体比较复杂,主要是告知 Language Server 当前的工作区在哪里、客户端提供的能力(capacities)有哪些等等。
Server 根据编辑器工具请求体内的配置信息初始化完成后,会响应 InitializeResult 结构体作为结果,同时告知客户端当前 Server 具有哪些能力。
由于不同编辑器的功能实现不一,因此 LSP 中大部分的服务端/客户端能力都是可选的:比如有的客户端不提供 codeLens 功能,有的服务端不提供代码补全功能等。双方是否具备这些能力都会在初始化阶段互相告知,以避免后续产生某些无效的功能请求。
按照 LSP 规范,客户端对 textDocument/didOpen、textDocument/didChange 和 textDocument/didClose 通知的支持是强制性的,客户端不能选择不支持它们。
2. 打开文件 (textDocument/didOpen) #
然后,每当开发者工具侧的用户在打开(或者在 Language Server 初始化前已经打开)了某个文件,开发者工具会向 Language Server 发出 textDocument/didOpen 通知,告知 Language Server 某个文件被打开。
「文档打开通知」从客户端发送到服务器,以表示新打开的文本文档。文档的内容现在由客户端管理,语言服务器不得尝试使用文档的 Uri 读取文档的内容。 从这个意义上说,「打开」意味着它由客户端「管理」。 这并不一定表示其内容会显示在编辑器中。在没有相应的「关闭通知」之前发送的情况下,客户端不能多次发送打开通知 —— 也就是说,打开和关闭通知必须一一匹配,并且特定 textDocument 的最大打开计数为 1。 请注意,服务器满足请求的能力,与文本文档是打开还是关闭无关。
举个例子,我们通过 VSCode 打开 /workspace 下的 main.go 文件:
package main
import (
"fmt"
)
func main() {
fmt.Println("Hello World go!")
}
会发送的 textDocument/didOpen 通知结构体为:
{
"jsonrpc": "2.0",
"method": "textDocument/didOpen",
"params": {
"textDocument": {
"uri": "file:///workspace/main.go",
"languageId": "go",
"version": 2,
// 这里的文件内容为 Language Server 中虚拟文件的内容初始状态
"text": "package main\n\nimport (\n\t"fmt"\n)\n\nfunc main() {\n fmt.Println("Hello World go!")\n}"
}
}
}
整体流程图如下:
我们注意到,Language Server 在得知文件被打开后,会试图维护一个"虚拟"的文件结构体,而不会去读取文件系统中对应文件的实际内容。后续的保存文件等操作是交由开发者工具直接写入文件系统完成的,Language Server 不负责同步文件内容。
之后用户的编辑行为,都会通过事件通知的形式告知 Language Server。而 Language Server 则是根据编辑行为,维护和调整上述虚拟文件对象的数据结构,进而做出响应。
当然,大家也不要产生误解,Language Server 仍有可能访问文件系统。
3. 编辑文件 (textDocument/didChange) #
编辑文件总是发生在打开事件之后。
根据 LSP 规范,Language Server 允许的编辑操作的更新方式有三种:不更新、全量更新、增量更新。但大部分 Language Server 一般采用增量更新模式,即发送编辑产生的 “diff” 而非更新后的整体内容。举例来说,我们在代码中新增一行 “a”:
package main
import (
"fmt"
)
func main() {
fmt.Println("Hello World go!")
+ a
}
客户端会产生如下的 JSON-RPC 请求:
{
"jsonrpc":"2.0",
"method":"textDocument/didChange",
"params": {
"textDocument": {
"uri": "file:///workspace/main.go",
"version": 37 // 这个版本号用于确认 change 的先后顺序
},
"contentChanges": [{
"range": {
"start": {
"line":8,
"character":4
},
"end": {
"line": 8,
"character": 4
}
},
"rangeLength": 0,
"text": "a"
}]
}
}
然后,服务端根据当前 change 的内容,更新内部的数据结构,决定是否产生某些 “行为”(比如代码诊断等)。
4. 关闭文件 (textDocument/didClose) #
按照规范内容,关闭的文件一般对应着一个已经由客户端打开的文件对象。这里不再赘述。
当文档在客户端关闭时,文档关闭通知从客户端发送到服务器。 文档的主文件现在存在于文档的 URI 指向的位置(例如,如果文档的 URI 是文件 URI,则主文件现在存在于磁盘上)。 与打开通知一样,关闭通知是关于管理文档内容的。 收到关闭通知并不意味着该文档之前曾在编辑器中打开过。 关闭通知需要发送先前的打开通知。 请注意,服务器满足请求的能力与文本文档是打开还是关闭无关。
关于 LSP 的常见问题 #
1. 语言服务器不会访问文件系统中的文件么? #
不,Language Server 还是有可能读取文件系统中未被编辑器打开的文件。
协议中仅仅规定,textDocument/didOpen 仅是不允许 Language Server 去打开 “客户端已经打开的” 对应 URI 文件的内容,但允许 Language Server 读取工作区和已打开文件上下文中其他「未打开的文件」。
例如,import 其他库的时候的代码补全功能,Language Server 就需要访问文件系统以获取索引信息。
2. 代码诊断是怎么实现的? #
一般是通过建立抽象语法树,做语法分析检查语法错误。
一些插件或者代码诊断工具,如 ESLint,可以在语法规范的 AST 中的节点中遍历访问,找出更多的 Lint 警告/错误。
3. 代码补全是怎么实现的? #
根据 LSP 中的规定,代码补全由客户端根据事件发起请求,遵循如下触发类型:
- 用户输入某个标识符(大部分情况下编辑器会自动执行这个事件)或敲击 Ctrl/Cmd + Space
- 用户正在输入某个关键字符(比如 “.")
- 补全列表不完整,需要重新触发一次
之后,服务端会根据当前 输入光标的所在位置 以及 文件的上下文信息 来判断如何做代码补全。这一块背后的原理相对比较复杂,后续可以单独列一篇文章讲述。