一、为什么我要自己写一个 LSP 客户端?
作为一名前端开发者,我一直对在线代码编辑器非常感兴趣。Monaco Editor 是 VS Code 的核心组件,功能强大,但默认只提供基础语法高亮和简单的智能提示。为了实现更高级的语言服务(如类型推断、错误诊断、跳转定义、自动补全等),就需要引入 Language Server Protocol(LSP)的支持。
市面上虽然有一些封装好的库,比如 monaco-languageclient,但我一直想尝试从头实现一个轻量级的 LSP 客户端,一是为了加深理解,二是为了在特定场景下拥有更高的自由度和可控性。
于是,我写了 SimpleLspClient
这个类 —— 一个基于 WebSocket 和 JSON-RPC 协议的简单 LSP 客户端,用于连接语言服务器并与 Monaco Editor 集成。
接下来,我会从自己的角度出发,带你一步步回顾这个类的诞生过程。
二、第一步:确定通信方式与协议
1. 选择 WebSocket 作为传输层
LSP 支持多种通信方式,包括 stdin/stdout、TCP、WebSocket 等。考虑到 Web 环境的限制,WebSocket 是最自然的选择。它可以在浏览器中使用,并且适合长连接、双向通信的场景。
此外,我希望客户端具备断线重连的能力,因此选用了 ReconnectingWebSocket 这个库,它在原生 WebSocket 的基础上增加了自动重连机制,非常适合用在生产环境。
2. 使用 JSON-RPC 2.0 协议进行消息封装
LSP 基于 JSON-RPC 协议进行交互,而 JSON-RPC 2.0 是目前最广泛使用的版本。为了简化请求和响应的处理,我引入了 json-rpc-2.0 这个库,它提供了清晰的 API 来发送请求 (request
) 和通知 (notify
),并且支持异步操作。
三、第二步:设计类结构
我决定将整个 LSP 客户端封装为一个类 SimpleLspClient
,这样便于管理状态和调用方法。类的主要职责是:
- 维护 WebSocket 连接
- 封装 LSP 请求与通知
- 处理来自语言服务器的消息
因此,我设计了如下几个核心成员变量:
private ws: ReconnectingWebSocket;
private client: JSONRPCClient;
private readyPromise: Promise<void>;
ws
:WebSocket 实例client
:JSON-RPC 客户端实例readyPromise
:确保 WebSocket 已连接后再发送请求
四、第三步:构造函数逻辑详解
构造函数是我花时间最多的地方之一,因为它需要完成初始化连接、处理异步等待、设置回调等一系列操作。
constructor(wsUrl: string) {
this.ws = new ReconnectingWebSocket(wsUrl, []);
this.readyPromise = new Promise((resolve) => {
this.ws.onopen = () => resolve();
});
this.client = new JSONRPCClient((json) => {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(json));
return Promise.resolve();
} else {
return Promise.reject("WebSocket not open");
}
});
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.id !== undefined) {
this.client.receive(data);
}
// 这里可以处理 LSP 的通知,比如 diagnostics
};
}
1. 初始化 WebSocket
首先创建 WebSocket 实例,并传入一个空数组作为第二个参数。这一步是为了避免某些浏览器插件或框架的兼容性问题。
this.ws = new ReconnectingWebSocket(wsUrl, []);
2. 创建 readyPromise 确保连接建立
为了避免在 WebSocket 尚未打开时就发送请求,我创建了一个 readyPromise
,只有当 onopen
事件触发后才 resolve。
this.readyPromise = new Promise((resolve) => {
this.ws.onopen = () => resolve();
});
3. 初始化 JSON-RPC 客户端
这是整个类的核心部分。我将一个发送函数传给 JSONRPCClient
,该函数负责把 JSON 数据通过 WebSocket 发送出去。
this.client = new JSONRPCClient((json) => {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(json));
return Promise.resolve();
} else {
return Promise.reject("WebSocket not open");
}
});
如果 WebSocket 没有准备好,就返回 reject,防止无效请求。
4. 监听服务器消息
最后监听 WebSocket 的 onmessage
事件,解析收到的数据并传递给 client.receive()
方法处理响应。
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.id !== undefined) {
this.client.receive(data);
}
// 这里可以处理 LSP 的通知,比如 diagnostics
};
当前只是处理了带 id 的响应请求,后续还可以在这里添加对通知(如 publishDiagnostics)的处理逻辑。
五、第四步:实现关键方法
1. initialize 方法:初始化连接
LSP 协议要求客户端先发送 initialize
请求,然后发送 initialized
通知。这部分逻辑我在 initialize()
方法中实现。
public async initialize() {
await this.readyPromise; // 确保连接已建立
const result = await this.client.request("initialize", initialize);
this.client.notify("initialized", {});
return result;
}
- 先等待 WebSocket 就绪
- 发送
initialize
请求,传入配置对象 - 发送
initialized
通知表示握手完成 - 返回结果供外部使用
这里的 initialize
是从本地文件导入的配置对象,通常包含客户端能力声明、根路径、工作区设置等信息。
2. sendRequest / sendNotification:通用接口
这两个方法是对 client.request
和 client.notify
的封装,用于向语言服务器发送任意请求或通知。
public async sendRequest(method: string, params: any) {
return this.client.request(method, params);
}
public sendNotification(method: string, params: any) {
this.client.notify(method, params);
}
它们构成了客户端与服务器交互的基础,例如:
- 获取悬停信息:
textDocument/hover
- 获取补全建议:
textDocument/completion
- 提交文档变化:
textDocument/didChange
3. close 方法:关闭连接
public close() {
this.ws.close();
}
这是一个可选的方法,但在某些场景下(如页面卸载、用户退出编辑)很有用。
六、第五步:反思与优化空间
虽然 SimpleLspClient
功能完整,但从工程化角度看,还有一些值得改进的地方:
1. 状态管理不够完善
当前没有记录语言服务器的状态,例如是否已经完成初始化。如果多次调用 initialize()
可能会引发重复初始化的问题。可以考虑增加一个 _isInitialized
标志位。
2. 错误处理不够集中
虽然请求失败会 reject,但没有统一的错误处理入口。未来可以添加一个全局的 onError
回调或者日志系统。
3. 对通知的支持不足
目前 onmessage
中只处理了带 id 的响应,对于像 publishDiagnostics
这样的通知没有具体处理逻辑。应该在这里加入相应的回调,将诊断信息反馈到 Monaco 编辑器中。
4. WebSocket 参数不可配置
目前硬编码了 WebSocket 的连接参数。更好的做法是允许用户传入选项,如最大重试次数、心跳间隔等。
5. 缺乏超时控制
长时间未响应的请求可能会导致阻塞,应考虑加入超时机制,提升用户体验。
七、第六步:整合到 Monaco Editor 中
写完客户端之后,下一步就是把它和 Monaco Editor 结合起来,实现真正的语言服务。这里我简单介绍一下我的整合策略:
1. 注册语言模式与语法高亮
monaco.languages.register({ id: 'python' });
monaco.editor.setMonarchTokensProvider('python', pythonLanguage.configuration);
2. 监听文档变化并同步到语言服务器
editor.getModel().onDidChangeContent(() => {
lspClient.sendNotification('textDocument/didChange', {
textDocument: { uri: 'file:///main.py' },
contentChanges: [{ text: editor.getValue() }]
});
});
3. 实现 Hover、Completion 等功能
monaco.languages.registerHoverProvider('python', {
provideHover: async (model, position) => {
const hoverResult = await lspClient.sendRequest('textDocument/hover', {
textDocument: { uri: model.uri.toString() },
position
});
return {
contents: hoverResult.contents
};
}
});
类似地,还可以实现 Definition、References、Signature Help 等功能。
写 SimpleLspClient
的初衷是学习和探索,而不是替代成熟的库。但它确实帮助我深入理解了 LSP 协议的工作原理,也让我掌握了如何在 Web 环境中搭建完整的语言服务。