理解 Language Server Protocol 的工作原理

avatar
FE @字节跳动

什么是 Language Server Protocol (LSP)?

首先根据官方解释 microsoft.github.io/language-se…

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。

image.png

例如,每个编辑器(客户端)都在用户产生某些通用的行为时(比如点击跳转到定义)负责生成标准中的行为事件,然后以 JSON-RPC 的形式去调用 Language Server 的接口方法。Language Server 相对应地,也必须实现全部 LSP 规范(或者至少实现其中关键部分)定义的接口。

这么做的好处在于,对于某门编程语言,一个编辑器工具不需要再去关心怎么去做代码分析,而是只需要关注如何在界面上发起或响应 LSP 规定的 RPC 事件。而在语言服务器这边也是同理,只需要关注协议本身的事件并响应 & 发起事件即可。

【P.S. 这种中间层分离的思想非常常见,例如编译器就分为前端和后端,前端生产中间语言 IR,后端负责把中间语言再翻译为 CPU 特定的指令集。典型的代表如 JVM 字节码、 LLVM IR 等】

另外,由于编辑器和 Language Server 是两个进程,所以如果 Language Server 挂了,编辑器进程本身也还会存在,用户不用担心还没修改好的代码因此丢失的问题。

有没有缺点?肯定有,那就是市面上所有的 编辑器 和 Language Server 的 maintainer 都需要花时间和精力去兼容这个协议,并且这个协议本身也会随着自身版本更新而要求服务端 / 客户端响应新的协议行为。但是总体来说,利大于弊。

LSP 的运作机制

首先大家需要知道,LSP 是一个「双工协议」。

不只是开发者工具(客户端)会主动向 Language Server (服务端)通信,服务端也可能主动向开发者工具发起 RPC 请求(比如代码诊断事件 textDocument/Diagnostics ,只能从服务端向客户端主动发送)。

LSP 规范定义文档 中,每个 RPC 事件会标注可能的发起方以及是否需要对方做出响应。

我们在这里给出两个例子:

  1. 例如一个客户端发起,且要求服务端返回的请求事件(小标题的括号中有一个从左至右然后转弯的箭头):

  1. 又例如一个服务端发起,且要求客户端返回的请求事件(小标题的括号中有一个从右至左然后转弯的箭头):

  1. 也有单方面发送,不需要响应的(分别为工具向服务端单方面发送 / 服务端向工具单方面发送):

我们以 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 拿到这条指令,会执行如下动作:

  1. 调用的方法是 textDocument/typeDefinition,也就是分析一个符号的类型定义信息。
  2. 根据参数,指令的来源文件是 Main.java 第 3 行第 13 个字符 —— 分析后可知是 foo 这个符号。
  3. 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 功能,有的服务端不提供代码补全功能等。双方是否具备这些能力都会在初始化阶段互相告知,以避免后续产生某些无效的功能请求。】

【注2:按照 LSP 规范,客户端对 textDocument/didOpen、textDocument/didChange 和 textDocument/didClose 通知的支持是强制性的,客户端不能选择不支持它们。】

  1. 打开文件 (textDocument/didOpen)

然后,每当开发者工具侧的用户在打开(或者在 Language Server 初始化前已经打开)了某个文件,开发者工具会向 Language Server 发出 textDocument/didOpen 通知,告知 Language Server 某个文件被打开。

按照协议规范的定义

The document open notification is sent from the client to the server to signal newly opened text documents. The document’s content is now managed by the client and the server must not try to read the document’s content using the document’s Uri. Open in this sense means it is managed by the client. It doesn’t necessarily mean that its content is presented in an editor. An open notification must not be sent more than once without a corresponding close notification send before. This means open and close notification must be balanced and the max open count for a particular textDocument is one. Note that a server’s ability to fulfill requests is independent of whether a text document is open or closed.

「文档打开通知」从客户端发送到服务器,以表示新打开的文本文档。文档的内容现在由客户端管理,语言服务器不得尝试使用文档的 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 仍有可能访问文件系统。

  1. 编辑文件 (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 的内容,更新内部的数据结构,决定是否产生某些 “行为”(比如代码诊断等)。

  1. 关闭文件 (textDocument/didClose)

按照规范内容,关闭的文件一般对应着一个已经由客户端打开的文件对象。这里不再赘述。

The document close notification is sent from the client to the server when the document got closed in the client. The document’s master now exists where the document’s Uri points to (e.g. if the document’s Uri is a file Uri the master now exists on disk). As with the open notification the close notification is about managing the document’s content. Receiving a close notification doesn’t mean that the document was open in an editor before. A close notification requires a previous open notification to be sent. Note that a server’s ability to fulfill requests is independent of whether a text document is open or closed.

当文档在客户端关闭时,文档关闭通知从客户端发送到服务器。 文档的主文件现在存在于文档的 URI 指向的位置(例如,如果文档的 URI 是文件 URI,则主文件现在存在于磁盘上)。 与打开通知一样,关闭通知是关于管理文档内容的。 收到关闭通知并不意味着该文档之前曾在编辑器中打开过。 关闭通知需要发送先前的打开通知。 请注意,服务器满足请求的能力与文本文档是打开还是关闭无关。

关于 LSP 的常见问题

  1. 语言服务器不会访问文件系统中的文件么?

不,Language Server 还是有可能读取文件系统中未被编辑器打开的文件

协议中仅仅规定,textDocument/didOpen 仅是不允许 Language Server 去打开“客户端已经打开的” 对应 URI 文件的内容,但允许 Language Server 读取工作区和已打开文件上下文中其他「未打开的文件」。

例如,import 其他库的时候的代码补全功能,Language Server 就需要访问文件系统以获取索引信息。

  1. 代码诊断是怎么实现的?

一般是通过建立抽象语法树,做语法分析检查语法错误。

一些插件或者代码诊断工具,如 ESLint,可以在语法规范的 AST 中的节点中遍历访问,找出更多的 Lint 警告/错误。

  1. 代码补全是怎么实现的?

根据 LSP 中的规定,代码补全由客户端根据事件发起请求,遵循如下触发类型

  1. 用户输入某个标识符(大部分情况下编辑器会自动执行这个事件)或敲击 Ctrl/Cmd + Space
  2. 用户正在输入某个关键字符(比如 ".")
  3. 补全列表不完整,需要重新触发一次

之后,服务端会根据当前 输入光标的所在位置 以及 文件的上下文信息 来判断如何做代码补全。这一块背后的原理相对比较复杂,后续可以单独列一篇文章讲述。