前言
在构建微服务时,为了追求极致的效率,服务间一般会使用 RPC(Remote Procedure Call)来进行通信。本文通过 Node.js 来实践一下。
Node.js 朴素 RPC
首先我们来构建一下 server
:
1 | // server.js |
我们新建了一个 TCP 的服务,并监听来自客户端的数据,注意这里我们通过一个 MessageBuffer
类来对数据进行解析(至于为什么这么做可参考考文末补充内容:关于 TCP “粘包”问题说明),将 TCP 数据流解析成我们的消息体。然后调用服务端预先配置好的方法,最后将返回值返回给客户端。
客户端相对比较简单,将函数调用相关数据按照事先规定好的格式发送给服务端即可:
1 | const net = require('net') |
这样,一个非常简单的 RPC 雏形就出来了,不过目前这种方式还不是 RPC。所谓的 RPC,就是客户端必须像调用本地方法一样来调用远端的方法,而不是还需要自己组装消息体,并监听事件获取返回值。理想中的方式应该像这样:
1 | const result = await client.add(1, 2, 3) |
我们来改造一下。首先,我们定义一份配置文件,用来描述我们的 services
:
1 | // services/index.js |
services
描述文件中包含了类以及它拥有的方法,方法参数(类型,是否可选),返回值类型等信息。为了简单一点,我们先不校验参数和返回值的类型。
然后就是我们的 server
:
1 | const net = require('net') |
server
中会监听 client
的连接,一旦有 client
进来,就根据 services
配置文件为其实例化所有 services
。之后开始接受 client
的数据,并根据 client
的消息调用相应的 service
中的方法,并返回结果。
注意到消息体中有个 seqId
,用来标识包的序号,必须将其返回给 client
,这样 client
才能知道返回的结果是跟哪个请求对应的。
最后就是我们的 client
:
1 | const net = require('net') |
初始化一个 client
时,会解析 services
,并在当前 client
实例上添加 services
的方法。方法中会将函数调用封装成消息发送给服务端并返回 Promise
对象,同时将 Promise
对象的 resolve
方法缓存在 resResolve
这个 Map
中,此时 Promise
对象还处于 pending
状态。
当 server
返回相应的 seqId
的结果时,resResolve
中对应的 resolve
方法会调用,从而将 Promise
对象状态设为 fulfilled
,此时 client
则可以获取到结果。
这样我们就实现了一个非常朴素的 RPC 框架。接下来我们简单看看业界常用的 RPC 框架是怎么做的吧,这里以 Thrift 为例。
Thrift RPC Demo
我们先准备一个 calculator.thrift
文件,用来描述 service
:
1 | service Calculator { |
由于 thrift
文件是语言无关的,所以我们需要通过它生成对应 Calculator.js
文件:
1 | thrift -r --gen js:node calculator.thrift |
这个文件包含 server
端和 client
相关的代码,在 client
端负责将函数调用转为消息发送给 server
,在 server
端负责读取消息,调用方法,返回结果给 client
。
然后 server
和 client
分别按照如下方式进行使用即可:
1 | // server.js |
下面,我们通过 Wireshark
来看看 thrift
通信的过程。
打开 Wireshark
,选择 Capturing from Loopback: lo0
,然后在 filter 中输入 tcp.port == 9090
。分别运行上面的 server
和 client
,则可抓包到如下内容:
我们先来看看第五行,可以看到 Wireshark
自动识别了 thrift
协议,并解析出这是一个 CALL
类型的消息,调用的方法为 add
。接下来我们再仔细看看 thrift
协议:
thrift
协议格式如上图所示,这里是一个参数的场景,如果有多个参数的话则可以在 Data -> List
后面继续添加,比如我们给 add
方法增加第二个参数,表示是否打印日志:
1 | i32 add(1:list<i32> arr, 2:bool printLog) |
抓包得到的内容如下:
返回的消息格式也类似,这里就不赘述了。
关于 RPC 的内容就先介绍到这,后面计划基于 Nest.js 再实战一下。
补充内容
关于 TCP “粘包”问题说明
首先声明一下,所谓的 TCP “粘包问题”其实并不是一个问题。
先看一个简单的例子:
1 | // server.js |
启动 server
后再运行 client
,则 server
有可能会打印如下日志:
1 | ------------------- |
如上所示,客户端调用了两次 write
,但是服务端却只打印了一次。也就是说,两次发送的数据在服务端被一次性取出来了。即,使用方层面的两个包“粘在”了一起。原因在于 TCP 是面向字节流的,并没有包的概念,所以开发者需要对 data
事件获取到的数据进行解析。