Closed wzhudev closed 2 years ago
看了vscode代码之后,发现server部分有点疑惑,源码和你的图有点不一样的,但是感觉应该是你图中这样的才合理。。。
描述如下: IPCServer继承自IChannelServer,IServerChannel是通过registerChannel存储在IChannelServer.channels上的,因此,所有IPCServer的实例共享这些channel,举例来说,这里的IPCServer如果是文件ipc.cp.ts(子进程)中的,那么一个子进程中可以实例化多个IPCServer,并且每个实例都可以register多个channel,所有的IPCServer共享所有的channel。
问题: 我咋感觉这种封装有点问题啊?不应该是把channel注册到每个IPCServer上吗?这样才能够隔离开来,并且registerChannel才会有意义。
看看这个?摘自 src/vs/base/parts/ipc/node/ipc.cp.ts
有的地方没有严格的继承自 ipc.ts 中的 IPCServer,可能是不需要路由,或者collectionHub等
图中多画了一层, 后面会修改一下
已修改
vscode 的架构中主要有四类进程:主进程、渲染进程、shared 进程和 host 进程,这四个进程之间会发生进程间调用(Inter Process Calling, IPC)。vscode 中有专门的 IPC 模块来实现 IPC 机制,这篇文章将会深入介绍 vscode IPC 模块的设计和原理。
IPC 原理
在我们开始学习 vscode 的 IPC 机制之前,不妨根据我们已经掌握的关于计算机网络的基本知识,来推演一下 IPC 有何要点:
可以看到 IPC 理念上是比较简单的,而 vscode IPC 模块的优点在于,它清楚地定义了 IPC 模块的各个层次,将客户端的调用过程封装得就像是在调用本地的一个异步方法一样,还让不同的跨进程环境——例如本地进程、基于网络的跨进程、web worker——都能够很容易地实现。
vscode IPC 机制概述
vscode IPC 分为基于 Channel 的和基于 RpcProtocol 的两种。
基于 Channel 的机制
我们通过一个例子开始对 Channel 机制的介绍。
在渲染进程初始化的时候,会创建一个
ElectronIPCMainProcessService
,然后以此创建一个LoggerChannelClient
,并以ILoggerService
为 key 添加到依赖注入系统当中:我们进一步看
LoggerChannelClient
的实现的话,就会发现它会调用channel
的call
方法,这里就就发起了一个 IPC:就是 channel IPC。
channel IPC 主要支持两种类型的调用,通过下面这个枚举类型可以看出:
我们这篇文章将会以基于 Promise 的调用为例,基于事件的调用大家可以自行了解。
channel IPC 主要有以下这些参与者,它们之间的关系如下图所示:
服务端
IServerChannel
,一个IServerChannel
和一种服务对应,它们提供了call
和listen
两个方法给ChannelServer
调用,然后调用它们对应的服务来具体执行各种业务逻辑,实际上是对服务的一种包裹IChannelServer
,它负责监听IMessagePassingProtocol
传过来的请求,然后根据请求中指定的 channelName 来找到IServerChannel
并进行调用,还能将执行结果返回给客户端IPCServer
,它提供了一组注册和获取IServerChannel
的方法,并能够通过路由机制选择要通讯的客户端IMessagePassingProtocol
,它负责传输Uint8Array
类型的二进制信息,并且在收到消息的时候通过事件通知上层客户端
IChannel
提供的方法来发起一个 IPCIChannel
,它们提供了call
和listen
两个方法给业务代码调用,用以发起 IPCIChannelClient
,它们是实际发起请求的地方,会将请求封装成一定的数据格式,在接收到响应的时候返回给业务代码IPCClient
,它提供一组注册和获取IChannel
的方法IMessagePassingProtocol
,和它在服务端的对等方的功能一致下面我们会具体讲解每个模块的机制。
IChannel 和 IServerChannel
阅读过本系列之前两篇关于依赖注入和服务化的读者,应该已经知道 vscode 中各种功能都是封装在服务当中的,所以 IPC 的执行过程中必须要找到某个能响应特定调用的服务,
IServerChannel
则是负责和服务一一对应,帮助它们接入 IPC 系统的,我们将称为实体。而在客户端一侧,业务代码不知道 IPC 机制的接口,因此不能直接发起请求,而是将
IChannel
作为一个能够帮助它发起请求的代理。IserverChannel
和IChannel
分别就是实体和代理的接口:一个
IChannel
像就这样(即 return 返回的对象):而一个
IServerChannel
会像是这样:在这个例子中
TestChannel
就是对ITestService
的一层封装。创建 IServerChannel 的方式有很多种,除了上面这样的直接实现,还可以借助
ProxyChannel
namespace 提供的方法。ProxyChannel
如果不需要为 service 做一些特殊处理,可以直接使用
ProxyChannel
namespace 下的fromService
方法将一个 service 包装成一个IServerChannel
:同样的,也可以通过
toService
将IChannel
封装成服务供业务代码调用,这样业务代码就不用自己去调用IChannel
的call
或者listen
方法。本质上是创建了一个 Proxy,将对 Proxy 属性的访问转换成对 channel 的
call
listen
方法的调用。IChannelServer
IChannelServer
的主要职责包括:protocol
接收消息IServerChannel
来处理请求IServerChannel
IChannelServer
直接监听protocol
的消息,然后调用自己的onRawMessage
方法处理请求。onRawMessge
会根据请求的类型来调用其他方法。以基于 Promise 的调用为例,可以看到它的核心逻辑就是调用IServerChannel
的call
方法。可以看到,这里通过
request
的channelName
获取到一个IServerChannel
,然后调用了它的call
方法,并将结果通过this.sendResponse
发送给客户端。显然,这里this.channels
需要注册IServerChannel
,而IChannelServer
提供了这样的方法:IChannelClient
IChannelClient
的逻辑比较简单,它只提供了一个接口,即getChannel
,它返回了一个IChannel
,实际上就是通过闭包保存了channelName
,然后在业务方调用的时候调用requestPromise
等发起请求。消息传输
我们已经看到了
IChannelServer
和IChannelClient
之间会互发数据,这里简单讲解一下消息传输的机制。首先消息传输需要约定好请求和响应的结构。
IPC 请求的字段如下:
type
,表明这是一种什么类型的调用id
,请求的唯一标识符,与请求相对应的响应会有相同的 idchannelName
,调用的 channel 的名称name
,如果是基于 Promise 的调用,就是方法的名称,如果是基于事件的监听,就是事件的名称arg
,参数IPC 响应的字段如下:
type
,表明是什么类型的响应id
,响应的唯一标识符data
,返回的数据请求和响应在被发送之前,都会通过
VSBuffer
进行序列化,在接收之后则会进行反序列化。需要一定的机制来将请求和响应对应起来。这在服务端比较容易,因为服务端的处理在顺序上处于 IPC 的中间环节,可以很自然的通过作用域来对应请求和响应。而在客户端,则需要一些机制来匹配请求和响应。
IChannelClient
在sendRequest
之前,会通过id
来在自身的handlers
Map 上绑定一个 handler而在收到消息的时候,就会通过这里
id
调用相应的handler
,从而 resolve 客户端IChannel
的调用。IMessagePassingProtocol
IChannelServer
和IChannelClient
之间会通过 protocol 传输数据。对于上层,它提供二进制数据流传输服务(用VSBuffer
进行了封装),并能够在有新消息到达的时候通知上层。其接口非常简单:
send
通过下层信道发送Uint8Array
格式的消息onMessage
则在下层信道收到消息时触发上层的回调函数不同的通讯端有不同的信道,因此
IMessagePassingProtocol
也有多种实现,大致有以下几种:IPCClient
它用于在客户端管理
IChannel
,它同时实现了IChannelClient
和IChannelServer
,所以它实际上可以发起也可以响应 IPC:可以看到它仅有一个
IMessagePassingProtocol
,换句话说,就是只能跟一方进行通讯,这也是它跟IPCServer
最大的区别。IPCServer
它一共实现了三个接口:
IRoutingChannelClient
说明它可以根据一定的条件选择向哪个IChannelServer
发起调用,即对调用进行路由IConnectionHub
则说明它可以管理客户端连接我们来看
IPCServer
的构造方法:可以看到,在对方发来第一条消息时,
IPCServer
会创建:ChannelServer
ChannelClient
Connection
,这个Connection
就是来描述连接的,它的接口如下注意这里的
ctx
属性,它是客户端的标识符,将用在请求路由的过程中。它的getChannel
和ChannelClient
的getChannel
有很大不同:可以看到,在调用
getChannel
的时候如果传入了routerOrClientFilter
,则会在connections
中选择一个。Routing
选择
Connection
的方法,可以是一个简单的 filter 函数,也可以是通过IClientRouter
提供的routeCall
或者routeEvent
方法。我们以StaticRouter
为例:实际上在
getChannel
调用他的时候,会通过fn
来选择一个IConnectionHub
中的Connection
。到这里,整个基于 channel 的 IPC 机制我们就介绍完毕了。
基于 RpcProtocol 的机制
vscode IPC 的第二种机制基于
RpcProtocol
,用于渲染进程和 extension host 进程通讯(如果 vscode 的运行环境是浏览器,那么就是主线程和 extension host web worker 之间进行通讯)。举个例子,在 host 进程初始化时如果发生了错误,它会告知渲染进程,代码如下:
在调用
mainThreadExtensions
或mainThreadError
的方法的时候,即发生了 IPC。该机制如下图所示:
下面介绍其原理。
shape
客户端怎么知道
mainThreadExtensions
上有一个$onExtensionRuntimeError
方法可以调用呢?显然,这里需要定义一个接口,这个接口就是
MainThreadExtensionServiceShape
,定义在 extHost.protocol.ts 文件中。vscode 对于每一个可以调用的实体,都定义了一个以Shape
为后缀的接口,服务端的实体必须要实现该接口,这样客户端在编写代码的时候就知道有哪些方法可以调用了。identifier
客户端如何获取到服务端的实体在本地的代理,也就是
mainThreadExtensions
呢?换个问法,mainThreadExtensions
是如何跟mainThreadErrors
相区别的呢?代码中我们可以看到
mainThreadExtensions
是通过rpcProtocol.getProxy(MainContext.MainThreadExtensionService)
获得的,MainContext.MainThreadExtensionService
在这里就起到了一个标识符的作用,它将每一个实体-代理的对子区别开。MainContext.MainThreadExtensionService
定义在 extHost.protocol.ts 当中:而
createMainId
就是用于创建标识符的方法,本质上是创建了一个ProxyIdentifier
对象并存在到一个数组当中:每个标识符有三个字段:
context
我们如何知道另外一个进程中,有哪些实体可以被调用?
extHost.protocol.ts 文件中定义了 MainContext 和 ExtHostContext 两个文件。前者定义了渲染进程中可被调用的实体,后者定义了 host 进程中可被调用的实体。这里也可以看出,在 RpcProtocol 机制下,渲染进程和 host 进程是可以互相调用的。
customer
可被调用的实体是如何注册的?
host 进程调用
mainThreadExtensions
方法的时候,渲染进程必须要有类提供这个方法,而且它还需要注册到这个 RpcProtocol 的机制上。通过查找实现了MainThreadExtensionServiceShape
的类,不难发现 mainThreadExtensionService.ts 中存在这样一段代码:注意这里装饰器的调用,我们探究其实现:
可以发现它是将
id
,也就是MainContext.MainThreadExtensionService
和MainThreadExtensionService
绑定起来,而在 extension host 初始化的时候实例化它:注册的最后一步就是调用
RpcProtocol.set
方法注册可被调用的实体。RpcProtocol 的通讯原理
到这里我们基本了解了 RpcProtocol 的接口了,下面来了解一下它的内部逻辑。
首先来看
getProxy
,我们知道客户端要通过这个方法获取可调用的代理:可以看到它的核心逻辑就是创建一个 Proxy 对象,当对象上的属性被访问时,所有以 $ 开头的属性都会被包装为一个对
this._remoteCall
进行调用的方法。_remoteCall
的核心逻辑则主要是下面几行(这里主要省略了取消请求相关的逻辑):可以看到一个请求主要有以下这些信息:
type
,请求的类型,由一个枚举 MessageType 所定义req
,请求的序号,是一个自增的数字rpcId
,identifier 的字符串 id,表明是哪个实体-代理之间的请求method
,指定要调用实体的哪个方法argsBuff
,序列化的参数最终这些参数都会被封装为一个
VSBuffer
并通过 protocol 发送,而这些而这里的 protocol,这是我们的老朋友IMessagePassingProtocol
。 所以我们可以看到 RpcProtocol 机制也是分层的设计,可以在不同的环境中使用。当服务端接收到一个请求时,会回调
_receiveOneMessage
方法进行处理:即根据
type
来调用不同的方法对请求进行处理,这里来看_receiveRequest
方法:核心就是调用
_invokeHandler
然后将结果发送回去。注意到这一行
const actor = this._locals[rpcId];
获取了可被调用的实体,记得之前注册实体时调用的 set 方法吗:到这里,我们就了解了 RpcProtocol 的原理了。