RPC
介绍rpc
之前,我们来回顾一下IPC
的概念:
IPC
(Inter-Process Communication) 指的是进程间通信
。其实现囊括了一系列的技术:管道
、消息队列
、共享内存
等。
IPC 可以在同一台计算机上的不同进程
之间进行通信,也可以在网络上的不同计算机之间
进行通信。
而RPC
(Remote Procedure Call)是一种特定的 IPC 模型
,它隐藏了底层通信细节,目标是使远程计算机上的程序调用就像本地调用一样。
所以,IPC 是一个更广泛的概念
,用于描述不同进程之间的通信,而 RPC 是 IPC 的一种实现方式
,专注于实现远程过程调用
。

目前,RPC 是分布式系统中不同节点间
流行的通信方式
。Go 语言的标准库也提供了一个简单的 RPC 实现,我们将以此为入口学习 RPC 的各种用法。
“Hello, World!”
Go 语言的 RPC 包的路径为 net/rpc。既然放在 net 包目录,我们就有理由猜测该 RPC 包是建立在 net 包基础之上
的。
我们尝试基于golang标准库中的 rpc 包实现一个 rpc 服务,并编写客户端调用这个 rpc 接口。
rpc-server
首先构造一个 HelloService 类型,包含一个 Hello 方法:
1
2
3
4
5
6
|
type HelloService struct {}
func (h *HelloService) Hello(request string, reply *string) error {
*reply = "hello:" + request
return nil
}
|
其中 Hello 方法必须满足 Go 语言的 RPC 规则:
然后就可以将 HelloService 类型的对象注册为一个 RPC 服务:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
func main() {
// 将对象类型中所有满足 RPC 规则的对象方法注册为 RPC 函数
// 注册的方法放在 “HelloService” 服务空间之下
rpc.RegisterName("HelloService", new(HelloService))
// 建立 TCP 连接
listener, err := net.Listen("tcp", ":1234")
if err != nil {
log.Fatal("ListenTCP error:", err)
}
// 开始监听 如果有请求过来,则返回建立的TCP连接
conn, err := listener.Accept()
if err != nil {
log.Fatal("Accept error:", err)
}
// 通过 rpc.ServeConn 函数在 TCP 连接上为对方提供 RPC 服务。
rpc.ServeConn(conn)
}
|
rpc-client
下面是请求 HelloService 服务的客户端
代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
func main() {
// 通过 rpc.Dial 拨号 RPC 服务
client, err := rpc.Dial("tcp", "localhost:1234")
if err != nil {
log.Fatal("dialing:", err)
}
var reply string
// 通过 client.Call 调用具体的 RPC 方法
err = client.Call("HelloService.Hello", "小新", &reply)
if err != nil {
log.Fatal(err)
}
fmt.Println(reply)
}
|
可以看出,利用go标准库中的rpc包来构建rpc服务非常简单。
更安全的 RPC 接口
在涉及 RPC 的应用中,开发人员一般至少有三种角色:
服务端
提供 RPC 方法的开发人员
;
客户端
调用 RPC 方法的开发人员
;
- 制定服务端和客户端 RPC
接口规范的设计人员
(最重要)。
前面的例子中将以上几种角色的工作全部放到了一起,以简化说明。虽然看似实现简单,但是不利于后期的维护和工作的切割。
重构rpc-server
我们来尝试重构 HelloService 服务
,将 RPC 服务的接口规范
分为三个部分
:
服务名
;
- 服务要实现的
详细方法列表
(接口);
- 注册该类型服务的函数。
1
2
3
4
5
6
7
8
9
10
11
12
|
// 服务名
const HelloServiceName = "path/to/pkg.HelloService"
// 服务提供的接口方法
type HelloServiceInterface interface {
Hello(request string, reply *string) error
}
// 注册服务的函数
func RegisterHelloService(svc HelloServiceInterface) error {
return rpc.RegisterName(HelloServiceName, svc)
}
|
为了避免命名冲突,我们在 RPC 服务的名字中增加了包路径前缀
(这个是 RPC 服务抽象的包路径,并非完全等价 Go 语言的包路径)。
而使用 RegisterHelloService 注册服务
时,编译器会要求传入的对象必须实现 HelloServiceInterface 接口,否则无法编译通过。等于将不规范的接口问题提前暴露,而不是等到服务端启动,客户端调用时候才报错。
基于重构之后的接口规范编写的服务端代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
type HelloService struct {}
func (h *HelloService) Hello(request string, reply *string) error {
*reply = "hello:" + request
return nil
}
func main() {
RegisterHelloService(new(HelloService))
listener, err := net.Listen("tcp", ":1234")
if err != nil {
log.Fatal("ListenTCP error:", err)
}
// 支持多个 TCP 连接,并为每个 TCP 连接提供 RPC 服务
for {
conn, err := listener.Accept()
if err != nil {
log.Fatal("Accept error:", err)
}
go rpc.ServeConn(conn)
}
}
|
重构rpc-client
上一小节我们定义了 RPC 服务的接口规范
,并基于接口规范重构了 rpc 服务端的代码。我们并没有改变服务提供的形式,所以你仍然可以使用上一章实现的客户端代码来远程调用 rpc 接口。
然而通过client.Call
函数调用 RPC 方法依然比较繁琐(需要传入服务及方法名),同时参数的类型依然无法得到编译器提供的安全保障
。
我们希望这一小节重构之后的 client,至少能够实现这些目标:
一个服务对应一个客户端
,建立一个客户端,可以直接通过方法名
调用对应的接口处理;
- 假如入参类型错误,例如 &reply 错传成 reply,可以在编译期间就暴露出来。
因此,我们对客户端进行如下包装:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
type HelloServiceClient struct {
*rpc.Client
}
var _ HelloServiceInterface = (*HelloServiceClient)(nil)
// 提供 DialHelloService 方法,直接拨号 HelloService 服务
func DialHelloService(network, address string) (*HelloServiceClient, error) {
c, err := rpc.Dial(network, address)
if err != nil {
return nil, err
}
return &HelloServiceClient{Client: c}, nil
}
func (p *HelloServiceClient) Hello(request string, reply *string) error {
return p.Client.Call(HelloServiceName+".Hello", request, reply)
}
|
新增 HelloServiceClient 类型,该类型也实现了服务规范中的 HelloServiceInterface 接口。这样一来,客户端调用rpc服务的过程就被简化
成了:
- 通过
DialXXX
方法直接创建某个 rpc 服务对应的 client;
- 通过该服务的 client,直接调用服务提供的方法(例如
Hello
)
重构后的完整代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
func main() {
// 建立客户端连接
client, err := DialHelloService("tcp", "localhost:1234")
if err != nil {
log.Fatal("dialing:", err)
}
var reply string
// 直接调用接口方法
err = client.Hello("小新", &reply)
if err != nil {
log.Fatal(err)
}
fmt.Println(reply)
}
|
现在客户端用户不用再担心 RPC 方法名字或参数类型不匹配等低级错误的发生。
跨语言 RPC
标准库的 RPC 默认采用 Go 特有的 gob 编码
,因此从其它语言调用 Go 实现的 RPC 服务会比较困难。
在互联网的微服务时代,每个 RPC 以及服务的使用者都可能采用不同的编程语言,因此跨语言
是互联网时代 RPC 的一个首要条件。得益于 RPC 的框架设计,Go 实现的 RPC 其实也是很容易实现跨语言支持的。
Go 语言的 RPC 框架有两个比较有特色的设计:
- RPC 数据打包时可以
通过插件实现自定义的编码和解码
;
- RPC 建立在抽象的
io.ReadWriteCloser
接口之上,我们可以将 RPC 架设在不同的通讯协议之上。
这里我们尝试通过官方自带的 net/rpc/jsonrpc 扩展,实现一个基于json编码的、支持跨语言的 RPC。
基于 json 编码的RPC Server
json是一种流行的数据传输格式,易于被JavaScript解析,并对人类阅读友好。我们先基于 json 编码重新实现 RPC 服务:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
func main() {
rpc.RegisterName("HelloService", new(HelloService))
listener, err := net.Listen("tcp", ":1234")
if err != nil {
log.Fatal("ListenTCP error:", err)
}
for {
conn, err := listener.Accept()
if err != nil {
log.Fatal("Accept error:", err)
}
// 用 rpc.ServeCodec 函数替代了 rpc.ServeConn 函数,传入的参数是针对服务端的 json 编解码器
go rpc.ServeCodec(jsonrpc.NewServerCodec(conn))
}
}
|
然后是实现 json 版本的客户端:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
func main() {
conn, err := net.Dial("tcp", "localhost:1234")
if err != nil {
log.Fatal("net.Dial:", err)
}
// 基于该连接建立针对客户端的 json 编解码器
client := rpc.NewClientWithCodec(jsonrpc.NewClientCodec(conn))
var reply string
err = client.Call("HelloService.Hello", "hello", &reply)
if err != nil {
log.Fatal(err)
}
fmt.Println(reply)
}
|
nc 工具验证
上一小节我们实现了基于json编码的服务端及客户端代码,为了验证改动之后的服务端代码是否支持跨语言
调用,我们可以这样做:
- 获取go客户端调用rpc服务时传输的数据格式;
- 采用其他的客户端,例如其他语言编写的客户端,或者本节使用的nc工具传输
相同的数据
,如果go语言实现的RPC服务器能够正常处理返回,则说明支持跨语言调用。
我们先来获取go客户端调用rpc服务时传输的数据格式,通过 nc 命令
启动一个TCP服务
:
接着执行一次 RPC 调用,会发现 nc 输出了以下的信息:
1
|
{"method":"HelloService.Hello","params":["hello"],"id":0}
|
很明显,客户端调用RPC服务时,发送的是一个 json 编码的数据:
method
对应要调用的 rpc 服务和方法组合成的名字;
params
的第一个元素为参数;
id
是由调用端
维护的一个唯一的调用编号。
请求的 json 数据对象在内部对应两个结构体:客户端是 clientRequest,服务端是 serverRequest。二者内容基本一致:
1
2
3
4
5
6
7
8
9
10
11
|
type clientRequest struct {
Method string `json:"method"`
Params [1]interface{} `json:"params"`
Id uint64 `json:"id"`
}
type serverRequest struct {
Method string `json:"method"`
Params *json.RawMessage `json:"params"`
Id *json.RawMessage `json:"id"`
}
|
获取到 RPC 调用发送的 json 数据后,我们可以直接向架设了 RPC 服务的 TCP 服务器
发送 json 数据模拟 RPC 方法调用:
1
|
$ echo -e '{"method":"HelloService.Hello","params":["小新"],"id":1}' | nc localhost 1234
|
可以发现正常返回了,结果也是一个 json 格式的数据:
1
|
{"id":1,"result":"hello:小新","error":null}
|
主要构成有:
id
对应输入的 id 参数;
result
为返回的结果;
error
在出问题时表示错误信息。
对于顺序调用
来说,id 不是必须的。但是 Go 语言的 RPC 框架支持异步调用
,当返回结果的顺序和调用的顺序不一致时,可以通过 id 来识别对应的调用。
返回的 json 数据也是对应内部的两个结构体:客户端是 clientResponse,服务端是 serverResponse。二者的内容也是类似的:
1
2
3
4
5
6
7
8
9
10
11
|
type clientResponse struct {
Id uint64 `json:"id"`
Result *json.RawMessage `json:"result"`
Error interface{} `json:"error"`
}
type serverResponse struct {
Id *json.RawMessage `json:"id"`
Result interface{} `json:"result"`
Error interface{} `json:"error"`
}
|
因此无论采用何种语言,只要遵循同样的 json 结构
,以同样的流程就可以和 Go 语言编写的 RPC 服务进行通信。
也就是说,我们实现了跨语言的 RPC。
Http上的 RPC
Go 语言内在的 RPC 框架已经支持在 Http 协议上提供 RPC 服务。但是框架的 http 服务同样采用了内置的 gob 协议
,并且没有提供采用其它协议的接口,因此其它语言依然无法访问。
在前面的例子中,我们已经实现了在 TCP 协议之上运行 jsonrpc 服务,并且通过 nc 命令行工具验证了 RPC 方法调用。现在我们尝试在 http 协议上提供 jsonrpc 服务。
新的 RPC 服务其实是一个类似 REST 规范的接口,接收请求并采用相应处理流程:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
func main() {
rpc.RegisterName("HelloService", new(HelloService))
http.HandleFunc("/jsonrpc", func(w http.ResponseWriter, r *http.Request) {
var conn io.ReadWriteCloser = struct {
io.Writer
io.ReadCloser
}{
ReadCloser: r.Body,
Writer: w,
}
rpc.ServeRequest(jsonrpc.NewServerCodec(conn))
})
http.ListenAndServe(":1234", nil)
}
|
RPC 的服务架设在 “/jsonrpc” 路径,在处理函数中基于 http.ResponseWriter 和 http.Request 类型的参数构造一个 io.ReadWriteCloser 类型的 conn 通道。然后基于 conn 构建针对服务端的 json 编码解码器。最后通过 rpc.ServeRequest 函数为每次请求处理一次 RPC 方法调用。
模拟一次 RPC 调用的过程就是向该连接发送一个 json 字符串:
1
2
|
$ curl localhost:1234/jsonrpc -X POST \
--data '{"method":"HelloService.Hello","params":["hello"],"id":0}'
|
返回的结果依然是 json 字符串:
1
|
{"id":0,"result":"hello:hello","error":null}
|
这样就可以从不同语言中访问 RPC 服务了。