我是如何写出 SimpleLspClient 这个 LSP 客户端类的 —— 一次从零开始构建 Monaco 编辑器语言服务的实践

108 阅读6分钟

一、为什么我要自己写一个 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.requestclient.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 环境中搭建完整的语言服务。