Language Server Protocol(LSP)的概念
Language Server Protocol (语言服务器协议,简称 LSP)是微软于 2016 年提出的一套统一的通讯协议方案。该方案定义了一套编辑器或 IDE 与语言服务器之间使用的协议,该语言服务器提供自动完成、转到定义、查找所有引用等语言功能。
使用LSP的意义
每个开发IDE,都要为语言实现类如自动补全,转动定义,悬停在单词上提供文档的功能,传统上,需要个IDE根据自己的API实现上述工作,即使是相同的功能,也要根据不同IDE实现一遍重复的功能,代码却不同。
一个语言服务器旨在提供某个语言的智能化,并通过支持进程间通信的协议与开发工具进行通信。
LSP设计的目标是使该语言服务器和开发工具进行标准化的通信,这个语言服务可以在多个开发工具中重复使用,从而以最小的改动支持多种语言。语言服务器后端可以用PHP,Python或Java编写,LSP可以轻松地将其集成到各种工具中,该协议提供通用抽象级别的协议,以便工具可以提供丰富的语言服务,从而无需完全理解特定于底层域模型的细微差别。
提出LSP之前,各个编辑器(VSCode, Vim, Atom, Sublime…)各自为战,编辑器内部实现的特性和协议都不同。每换一个编辑器,就有可能要给该编辑器中支持的每门语言写一个对应的 Language Server,也就是说假设有 n 门语言,m 个编辑器,那全部编辑器适配所有语言的开发成本和复杂度为 n * m。
但有了LSP,就让语言的「静态分析服务」和「编辑器 / IDE」分离开来,这样上述情景下开发成本和复杂度就可以降低为线性的 n + m。
LSP对于语言提供商和工具的提供商都是双赢。
LSP如何工作
语言服务器会作为单独的进程运行,同时开发工具使用基于JSON-RPC的语言协议与服务器进行通信。
为了规范,Language Server Protocol 中的交互一般需要遵循如下生命周期:
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打开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 则是根据编辑行为,维护和调整上述虚拟文件对象的数据结构,进而做出响应。
3.编辑文件(textDocument/didChange)
编辑文件总是发生在打开事件之后。
根据 LSP 规范,Language Server 允许的编辑操作的更新方式有三种:不更新、全量更新、增量更新。但大部分 Language Server 一般采用增量更新模式,即发送编辑产生的 “diff” 而非更新后的整体内容。
例如,在代码中新增一行”a”,客户端会产生如下请求:
{
"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 的内容,更新内部的数据结构,决定是否产生某些 “行为”(比如代码诊断等)。
当用户需要跳转到指定函数或者变量定义时,IDE与语言服务器也通过这样的交互实现。
示例如下:
请求
{
"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
},
// ...其他参数
},
}
响应
{
"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 }
}
},
}
4.关闭文件 (textDocument/didClose)
按照规范内容,关闭的文件一般对应着一个已经由客户端打开的文件对象。
当文档在客户端关闭时,文档关闭通知从客户端发送到服务器。 文档的主文件现在存在于文档的 URI 指向的位置(例如,如果文档的 URI 是文件 URI,则主文件现在存在于磁盘上)。 与打开通知一样,关闭通知是关于管理文档内容的。 关闭通知需要发送先前的打开通知。服务器满足请求的能力与文本文档是打开还是关闭无关。
其它
1.语言服务器会访问文件系统中的文件么?
是的。Language Server 还是有可能读取文件系统中未被编辑器打开的文件。
协议中仅仅规定,textDocument/didOpen 仅是不允许 Language Server 去打开“客户端已经打开的” 对应 URI 文件的内容,但允许 Language Server 读取工作区和已打开文件上下文中其他「未打开的文件」。
例如,import 其他库的时候的代码补全功能,Language Server 就需要访问文件系统以获取索引信息。
2.代码诊断如何实现?(需要再深入了解)
通过建立抽象语法树,做语法分析检查语法错误。
一些插件或者代码诊断工具,如 ESLint,可以在语法规范的 AST 中的节点中遍历访问,找出更多的 Lint 警告/错误。
3.代码补全如何实现?(需要再深入了解)
根据 LSP 中的规定,代码补全由客户端根据事件发起请求,遵循如下触发类型:
- 用户输入某个标识符(大部分情况下编辑器会自动执行这个事件)或敲击 Ctrl/Cmd + Space
- 用户正在输入某个关键字符(比如 “.”)
- 补全列表不完整,需要重新触发一次
之后,服务端会根据当前 输入光标的所在位置 以及 文件的上下文信息 来判断如何做代码补全。