-
Notifications
You must be signed in to change notification settings - Fork 214
lsp bridge架构设计
EAF补强了Emacs的多媒体生态后,Emacs离VSCode这种现代IDE最大的差距就是代码语法补全,VSCode通过LSP协议实现了绝大多数编程语言的智能语法补全,而且操作性能极佳。
虽然Emacs也有lsp-mode和eglot,但是受制于Emacs自身的单线程限制,如果LSP Server发送消息太多时,Emacs会因为处理不过来而卡住,使用体验很不好。
五一节闭关研究了一下LSP协议,为Emacs实现了一个全新的LSP客户端: lsp-bridge。
lsp-bridge通过Python多线程技术在Emacs和LSP Server之间建立一个缓冲桥梁,以实现完全不卡手的高性能LSP客户端。
下面,我们通过讲解lsp-bridge架构协议图来学习其设计思想和协议细节:
首先上图分为两种模型,上面的部分是单文件模型,下面半部分是项目模型。
单文件模型主要是用户打开一个文件直接写代码,这个文件不属于任何Git项目,lsp-bridge会根据文件的类型自动启动对应的LSP服务器来提供代码辅助服务,每个单文件对应一个LSP服务器,一般在脚本语言用单文件模型比较多,比如Python,用户会经常创建一个临时文件来实验新想法 (是否是单文件可以通过命令 git rev-parse --is-inside-work-tree
来判断)。
项目模型主要是针对同一个项目的源代码提供代码辅助服务,lsp-bridge会根据文件所属项目目录和文件类型来创建对应的LSP服务器,比如项目A中分别有三个文件x.py、 y.py和z.cpp, lsp-bridge会让x.py和y.py这两个文件共享一个LSP Python服务器, 给文件z.cpp启动一个LSP C++服务器。这样当你在同一个项目中编辑不同的文件都会有LSP服务器来提供代码辅助服务,用户完全不用进行任何设置(项目根目录可以通过命令 git rev-parse --show-toplevel
来实现)。
lsp-bridge内部主要实现了两个线程循环,一个是Request Thread,一个是Response Thread。
Request Thread主要用于接收Emacs发过来的请求,请求接收以后进入子线程循环中处理,不立即响应,以此来保证Emacs不会因为任何事件而等待。
Response Thread主要处理LSP Server返回的消息,接收到消息后放入子线程中进行计算,计算有结果后再push处理结果给Emacs,比如补全列表、重命名等信息。
lsp-bridge每次向LSP发送消息时,都会生成一个唯一的RequestID, 并缓存RequestID对应的文件路径、请求类型等信息,等lsp-bridge接收到服务器返回消息后,通过对比RequestID来判断返回的是什么类型的消息,整个消息流程都是完全无状态的线程模型,只通过RequestID来松散链接。
这样的设计好处是,Emacs只用向lsp-bridge发送LSP请求,lsp-bridge处理完LSP响应数据后向Emacs发送操作命令,双方完全不用等对方,从设计上就规避了LSP Server卡Emacs的可能性,即使LSP Server自身有bug卡住了,Emacs也不会卡顿一下。
在我们讲LSP协议之前,我们首先了解一下LSP的消息格式,不管Server还是Client都要用以下格式内容进行通讯:
Content-Length: 180\r\n
\r\n
{
"jsonrpc": "2.0",
"id": 1,
"method": "textDocument/didOpen",
"params": {
...
}
}
正常的解析过程是,第一行通过解析Content-Length字符串来提取消息的长度,接着读取一个空行扔掉,然后继续按照解析来的长度信息读取后续的消息内容,读取内容通过 json.load
来转换成JSON对象做进一步处理,样例代码如下:
first_line = self.process.stdout.readline().strip()
length = int(first_line[line.rfind(":") + 1:])
second_line = self.process.stdout.readline().strip()
message = self.process.stdout.readline(length).strip()
json.loads(message)
但是实践中,因为管道缓冲大小的原因,消息处理并不会这么完美,一般有三种意外情况:
-
消息字符串前后不干净导致
json.loads(message)
失败,这时候要找到消息开始{
和 末尾}
的位置,截取中间合法的消息内容再传递给json.loads
-
第二条消息的
Content-Length: number
会放到第一条消息的末尾,这种情况是每次读完消息后,检查一下第一条消息的行尾,看看是不是Content-Length: number
结尾, 是的话接下来操作应该是直接跳过下一行后,按照长度再读一行就可以知道第二条消息的准确内容 -
管道缓冲区不够时,
Content-Length: 1234
有可能会截断成两行Content-Length: 12
和34
,这时候需要在读取Content-Length: 12
以后再检查下一行的开头是不是数字, 是数字的话,还需要把这两行的数字拼接一下,拼接后的数字才是真正的下一条消息的长度,接着跳过下一行后,用矫正过的长度读取消息内容
注意LSP消息的解析一定要严谨,错过一行消息或者解析错误都会导致实践中发生丢LSP消息的情况,解析LSP消息是后面所有章节的基础,这一章错误,后面的逻辑都会混乱。
LSP客户端发送给服务器的消息一般有三种,分别是Request、 Notification和Response, 这三种消息的格式细节区别是:
- Notification消息不带id属性, Request的id属性是lsp-bridge生成的,方便服务器返回的时候带上id用于识别消息类型, Response的id属性一般是读取服务器返回消息的id后再发送回去,方便服务器根据id来处理客户端消息
- Request消息包括jsonrpc、 method、 params和id四个字段, Notification消息包括 jsonrpc、 method和params三个字段, Response消息包括jsonrpc、 id和result这三个字段
这一章的内容是最为关键的,一定要按照以下协议的顺序发送请求,否则LSP Server不会报错也不会有任何响应,当LSP Server没有任何响应时就很难进行调试。
要让LSP服务器正常工作的关键五个步骤依次是: 启动 -> initialize -> initialized -> workspace/didChangeConfiguration -> textDocument/didOpen:
以Python的LSP服务器pyright为例:
process = subprocess.Popen(["pyright-langserver", "--stdio"], bufsize=100000000, stdin=PIPE, stdout=PIPE, stderr=stderr)
process.stdin = io.TextIOWrapper(process.stdin, newline='', encoding="utf-8", write_through=True)
process.stdout = io.TextIOWrapper(process.stdout, newline='', encoding="utf-8")
- 第一行是启动pyright服务器,注意的坑是,必须通过
bufsize
参数调大管道缓冲值大小, 避免管道堵塞后就无法接收服务器消息 - 第二行和第三行,主要通过
TextIOWrapper
和newline
参数来避免Windows平台的换行符导致无法解析LSP服务器返回消息的问题
先给服务器打个招呼,告诉服务器有LSP客户端在进行初始化操作。
send_to_request("initialize", {
"processId": os.getpid(),
"rootPath": root_path,
"clientInfo": {
"name": "emacs",
"version": "GNU Emacs 28.1 (build 1, x86_64-pc-linux-gnu, GTK+ Version 3.24.33, cairo version 1.17.6)\n of 2022-04-04"
},
"rootUri": project_path,
"capabilities": {},
"initializationOptions": {}
},
initialize_id)
这里面需要注意的几个地方:
- processId是指当前进程的pid, LSP服务器会监听这个pid, 如果父进程退出以后, LSP服务器也会自动退出
- rootUri的内容根据工程模型那一章讲解的,如果是单文件模型就传入单文件的文件路径,如果是工程模型就传入工程的根目录
- capabilities和initializationOptions这两个参数, 除非LSP服务器明确需要配置才填写(比如volar), 否则默认都设置成空, 填写错误参数会导致LSP服务器不发送任何响应消息回来,我第一次研究LSP协议就是这里乱填,一直接不到服务器返回消息,卡了好久
- initialize_id用
abs(random.getrandbits(16))
来生成一个随机的唯一ID, 避免线程多了, RequestID冲突
接到LSP服务器对 initialize
请求的返回消息后, 发送 initialize
提醒消息给服务器,告诉服务器客户端已经确认准备好了,只有这个提醒发了以后LSP服务器才会响应后面的请求。
initialized
提醒内容最简单: send_to_notification("initialized", {})
这一步主要是在 initialized
提醒消息发送后,马上对LSP服务器进行初始化配置,以pyright为例:
send_to_notification("workspace/didChangeConfiguration",
"settings": {
"analysis": {
"autoImportCompletions": true,
"typeshedPaths": [],
"stubPath": "",
"useLibraryCodeForTypes": true,
"diagnosticMode": "openFilesOnly",
"typeCheckingMode": "basic",
"logLevel": "verbose",
"autoSearchPaths": true,
"extraPaths": []
},
"pythonPath": "/usr/bin/python",
"venvPath": ""
}
)
这一步的关键是,每个LSP服务器的初始化配置都不一样,填不对配置, LSP服务器也不理你,哈哈哈哈。
每个服务器对应的参数可以直接查看 lsp-bridge的配置
上面初始化和配置操作完成以后,需要马上调用 textDocument/didOpen 提醒消息,告诉LSP服务器打开文件的内容, 比如:
send_to_notification("textDocument/didOpen",
{
"textDocument": {
"uri": "file:///home/andy/foo.py"
"languageId": "python",
"version": 0,
"text": f.read()
}
})
一旦完成 textDocument/didOpen 消息发送后,后面就简单了,只用查看LSP协议文档,向服务器发送消息,接到消息后解析JSON内容就可以快速开发 lsp-bridge 高级功能。
textDocument/didOpen消息后,其他消息都可以发送一个接收一个,代码语法补全消息要稍微复杂一点。
首先要监听Emacs的内容变化钩子 after-change-functions
, Emacs编辑文件后都要发送 textDocument/didChange
提醒消息给LSP服务器,让服务器可以实时保持文本内容和编辑器是一样的, 这样做的原因是, 如果编辑器不保存文件, LSP服务器就不知道当前文件的最新内容,我们也不可能每次都发送全文件内容给服务器, 那样在处理大文件的时候会有性能问题, textDocument/didChange
提醒消息一般长这个样子:
send_to_notification("textDocument/didChange",
{
"textDocument": {
"uri": "file:///home/andy/foo.py"
"version": version
},
"contentChanges": [
{
"range": {
"start": {
"line": start_row - 1,
"character": start_character
},
"end": {
"line": end_row - 1,
"character": end_character
}
},
"rangeLength": range_length,
"text": text
}]
})
textDocument/didChange
消息应该是LSP里面最复杂的一条消息, 需要根据Emacs编辑文本的变动同步给LSP服务器, 主要针对start_row、 start_character、 end_row、 end_character、 range_length、 text六个参数进行详细设计。
- start_row: 变动之前起始光标的行
- start_character: 变动之前起始光标的列
- end_row: 变动之前末尾光标的行
- end_character: 变动之前末尾光标的列
- range_length: 变动后字符串的长度
- text: 变动后的字符串
变动之前的状态需要通过Emacs的before-change-functions
函数来跟踪,变动之后的状态需要通过Emacs的after-change-functions
函数来跟踪。
当你正确的处理 textDocument/didChange
消息后,就可以发送 textDocument/completion
请求给服务器来获取当前光标处的语法补全信息:
if char in self.trigger_characters:
self.send_to_request("textDocument/completion",
{
"textDocument": {
"uri": "file:///home/andy/foo.py"
},
"position": {
"line": row - 1,
"character": column
},
"context": {
"triggerKind": 2,
"triggerCharacter": char
}
},
request_id)
else:
self.send_to_request("textDocument/completion",
{
"textDocument": {
"uri": "file:///home/andy/foo.py"
},
"position": {
"line": row - 1,
"character": column
},
"context": {
"triggerKind": 3
}
},
request_id)
这里需要注意的是,如果字符是补全触发字符,比如 '.' '->' 这些字符, triggerKind的类型必须是2, 并且需要同步发送触发补全的字符, 如果不是触发补全字符, triggerKind类型必须是3。
触发字符在你发送 initialize
请求的时候, 服务器会返回对应的补全触发字符, 一般在JSON结构的这个位置 message["result"]["capabilities"]["completionProvider"]["triggerCharacters"]
lsp-bridge安装很简单, 为了保持最新的内容, 请大家直接查看 lsp-bridge 的README
注意: 开发者需要打开 (setq lsp-bridge-enable-logt t)
选项, 以实时查看服务器的返回消息 (用户不用打开这个选项,以提高性能)。
lsp-bridge发布的短短几天,已经有15位开发者加入我们的团队,贡献16种编程语言的语法补全代码,包括Java、 C、 C++、 Python、 Golang、 Rust、 Ruby、 Haskell、 Elixir、 Dart、 SCala、TypeScript、 JavaScript、 OCaml、 Erlang、 LaTeX等语言。
目前已完成的功能包括代码语法补全、定义跳转、引用查看和重命名, 欢迎加入我们开发更多的高级功能。
持续给lsp-bridge做贡献的理由是我们可以保证永远不卡Emacs, 实现行云流水的编程体验, 哈哈哈哈。