Language Server 架构细节:JSON-RPC

816 阅读5分钟

什么是 JSON-RPC

在之前的 2 篇文章中,我们时不时会提到一个关键词:JSON-RPC。官方定义是这样的:

JSON-RPC是一个无状态且轻量级的远程过程调用(RPC)协议…它允许运行在基于socket,http等诸多不同消息传输环境的同一进程中。其使用 JSON 作为数据格式。 熟悉 RPC 的同学都知道,不熟悉也没关系,通俗地讲,其作为一种协议,支持客户端在不知道调用细节,不了解底层网络技术的情况下,如同调用本地应用程序中的对象一样,调用存在于远程计算机中的某个对象。

在日常的工作中,我们常常会在某些团队的工作内容中,听到或者看到类似 Thrift、gRPC 等词语,这些都是 RPC 协议的具体实现方式。本质上,JSON-RPC 也是一种 RPC 协议的一种实践,其特点在于极致的简单。

RPC 规范

我们可以简单地过一遍JSON-RPC 规范,看看其到底简单在何处。规范开篇对请求对象和响应对象做了明确的约定。首先,就请求对象来说,其一般包含三到四个字段,比如:

{
	"jsonrpc": "2.0",
	"method": "add",
	"params": [1, 2],
	"id": 1
}

其中,jsonrpc 字段指定 JSON-RPC 协议版本,一般我们写死为 2.0;接下来两个字段表示调用远程计算机中的 add 方法,且参数为 1 和 2;最后的 id 字段用于标识请求,与响应对象的 id 字段相匹配,一般为整数或字符串。

值得注意的是,并非所有的请求都需要 id 字段。综合前文该字段的作用,若是请求不需要响应,那配置该字段也没有意义了。通常情况下,这类特殊的请求,在 JSON-RPC 协议中被定义为通知(Notification)。

再来看参数,我们发现上文例子中,params 传入的值是一个数组,其还存在另一重约束,即数组内元素的顺序与远程计算机中声明的函数参数顺序一致。

由此,我们可能会产生一个小小的问题,关于参数,是否可以以更为显式(Verbose)的方式指定?答案是肯定的,假设我们在远程计算机定义了一个函数,其签名如下所示:

export function add(lhs: number, rhs: number): number;

与之对应,使用 JSON-RPC 协议的请求对象可以如下所示:

{
	"jsonrpc": "2.0",
	"method": "add",
	"params": {"lhs": 1, "rhs": 2},
	"id": 1
}

此时,我们需要保证的是参数的正确性,包括参数名称和类型的正确性,不需要考虑参数的先后顺序。

接下来,我们看一下响应对象,其一般也仅包含三个字段,其中两个与请求对象一致,即 jsonrpcid 字段。举个例子:

{
	"jsonrpc": "2.0",
	"id": 1,
	"result": 3
}

若是请求成功,则响应对象内部的 result 字段表示执行结果;若是请求失败,则将报错信息存在 error 字段,其数据结构如下所示:

interface Error {
	code: number;
	message: string;
}

常见的报错代码和报错消息如下所示:

CodeMessageMeaning
-32700Parse ErrorJSON 解析错误
-32600Invalid Request请求对象无效
-32601Method Not Found调用方法不存在
-32602Invalid Params方法参数无效
-32603Internal ErrorJSON-RPC 内部错误
-32000 — -32099Server Error自定义服务器错误

到这里,我们已经掌握了 JSON-RPC 协议的核心内容,寥寥数百字便可囊括,就是这么简单。感兴趣的同学可以根据规范自行实现一个 JSON-RPC 调用框架,也可以了解更多开源框架,比如 jaysonvscode-jsonrpc 等。

LSP 规范中的 JSON-RPC

前续章节在介绍 LSP 规范时,我们跳过了其中有关 JSON-RPC 协议的部分。在学习前文之后,我们已然可以回过头细看其中相关的内容。

其中,LSP 规范开篇介绍了请求对象与响应对象的接口类型,现在看来应当十分眼熟,特别是请求对象,基本与 JSON-RPC 协议规范一致:

interface Message {
	jsonrpc: string;
}


interface RequestMessage extends Message {
	/**
	 * The request id.
	 */
	id: integer | string;

	/**
	 * The method to be invoked.
	 */
	method: string;

	/**
	 * The method's params.
	 */
	params?: array | object;
}

至于响应对象,VSCode 团队对参数类型做了更加细致的约定,也就是其中的两个特殊字段,即resultcode

interface ResponseMessage extends Message {
	/**
	 * The request id.
	 */
	id: integer | string | null;

	/**
	 * The result of a request. This member is REQUIRED on success.
	 * This member MUST NOT exist if there was an error invoking the method.
	 */
	result?: LSPAny;

	/**
	 * The error object in case a request fails.
	 */
	error?: ResponseError;
}

至于具体的类型,感兴趣的同学可以去看 LSP 规范。不过,当我们真正尝试去开发特定的 Language Server 时,这些内容细节实际上都已经被相应的框架所隐藏。也就是说,我们的全部精力,最后都落在具体的接口逻辑实现上。

总结

回顾 Language Server 架构,我们发现,那些看似神秘的功能,比如声明跳转,其本质就是:

  • 代码编辑器(Client)通过某种连接方式(比如 IPC),向语言服务器发送一个 JSON-RPC 请求对象,要求调用其中的某个方法函数;
  • 其所调用的方法函数,将在工作区文件中寻找变量 / 函数声明的位置;
  • 语言服务器收到请求,将执行结果封装成响应对象,返回给代码编辑器;
  • 后代码编辑器根据响应对象中的位置信息,自动跳转到响应位置。

想来经过一番了解与学习,我们发现 LSP 已经不是那么神秘。好学的我们,仍然会好奇如何在实践中为一门现实的编程语言,开发一个真正可用的语言服务器。好了,枯燥无聊的理论到此为止,接下来我们将以开发者的身份继续上路,一步步构建起我们自己的 Language Server。