谭浩的博客

Simple is beauty.

一个简单例子——Go RPC 客户端和服务端【译】

本文译An Example of Go RPC Client and Server

RPC hello world 架构

net/rpc给了我们一个让一段代码通过网络与另一段代码进行通信的方式,这里我们搭建一个”Hello World”的 client-server 架构。

该项目的文件结构如下,项目源代码访问e0c9/simple-rpc-go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.
|--- Makefile # 构建和运行项目的指令
|
|
|--- client # rpc 客户端
| |___ client.go # 提供执行远程方法的接口
|
|--- core # 功能分享
| |___ core.go #- 实现在服务器中执行的实际处理程序
| #- 提供客户端与服务端消息交换的格式
|
|--- main.go
|
|___ server # rpc 服务端 提供了供客户端调用的接口
|___ server.go

这允许我们保持项目非常简单,因为main 允许我们基于标志(-server)执行服务器或客户端代码,并且其余所有的功能都是独立实现的。

创建一个客户端和服务端,你将能够进行通信。接下来,让我们实现他们。

示意图

创建一个 Go RPC 处理器

一个 RPC 服务端的主要部分是客户端调用时执行的方法,RPC 中的 P 就是这些方法。

对于习惯 HTTP 协议的人来说,一个请求(POST/say-hello)就等价于 RPC 中一个服务名(HelloSayer.Say)。Go 构造了一个 map 存储这些服务,每一个服务就是一个导出结构,这些结构有一系列导出方法让他们可以被调用。

自然的,这些导出方法必须遵循一个特定的接口来让net/rpc能够序列化以及反序列化请求和响应对象,并正确地执行方法。

  • 这些方法必须带有两个参数并返回一个 error 对象
  • 第一个参数为请求对象,第二个参数为返回对象
  • 第二个参数必须是一个指针-如果发生故障,需要返回 error 且将该参数设置为 nil

通过查看 Go 的源代码(src/net/rpc/server.go),我们可以理解其底层的实现。

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
26
27
28
29
30
31
32
33
// Register 在服务器中发布满足以下条件的接收器的方法集:
// - 导出类型的导出方法
// - 带有两个参数且都是导出类型
// - 第二个参数是一个指针
// - 一个类型为 error 的返回值
// 如果接收方不是导出类型或没有合适的方法则返回错误,并使用日志包记录错误日志。客户端使用
// "Type.Method"的格式访问每个方法,其中Type为接收者的具体类型。
func (server *Server) Register(rcvr interface{}) error {
return server.register(rcvr, "", false)
}

// `register` 完成了获取结构然后分析其方法并注册他们的艰苦工作。
//
// 最后,`net/rpc`构造了一个map,它将端点名称映射到实际的实现上去,并执行相应的序列化与
// 反序列化方法,以便正确地调用这些方法。
func (server *Server) register(rcvr interface{}, name string, useName bool) error {
s := new(service)
s.typ = reflect.TypeOf(rcvr)
s.rcvr = reflect.ValueOf(rcvr)
s.name = reflect.Indirect(s.rcvr).Type().Name()

// Install the methods
// 检查方法并验证每个方法是否遵循 `net/rpc` 期望的接口。
s.method = suitableMethods(s.typ, true)

// 使用给定的服务名称存储服务
if _, dup := server.serviceMap.LoadOrStore(s.name, s); dup {
return errors.New(
"rpc: service already defined: " +
sname)
}
return nil
}

因此,对于该 Hello World 程序 ,我最终得到下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type Response struct {
Message string
}

type Request struct {
Name string
Ok bool
}

type Handler struct {}

func (h *Handler) Execute(req Request, res *Response) (err error) {
if req.Name == "" {
err = errors.New("A name must be specified")
return
}
res.Message = "Hello " + req.Name
return
}

以上代码遵循了rpc/net所期待的接口并且对比我们例子来说足够简单。

创建Go RPC 服务端和客户端

一旦处理器被定义好了,创建服务端则是简单的(和 HTTP TCP 服务端创建类似)。唯一的区别在于执行监听获取连接之前,你必须调用rpc.Register并传入一个结构的实例,该实例包含你定义的需要导出的方法。

1
2
3
4
5
6
7
// 发布我们的处理器方法
rpc.Register(&core.Handler{})
// 创建一个TCP监听器
listener, _ = net.Listen("tcp", ":" + strconv.Itoa(Port))
defer listener.Close()
// 等待客户端的连接
rpc.Accept(listener)

在客户端是几乎相同的建立 TCP 连接的过程:

1
2
3
4
5
6
7
8
9
10
11
12
var (
addr = "127.0.0.1:" + strconv.Itoa(Port)
request = &core.Request{Name: Request}
response = new(core.Response)
)

// 建立与服务端的连接
client, _ = rpc.Dial("tcp", addr)
defer c.client.Close()

_ = c.client.Call(core.HandlerName, request, response)
fmt.Println(response.Message)

让我们看看其实际是如何工作的。

Go RPC 实践

鉴于创建客户端和服务器的简单性,我对底层通信的工作原理感到好奇。

Go RPC 机制默认使用encoding/gob序列化和反序列化从net/rpc得到的消息。这意味着我们无法简单通过检查通信获得传递的内容。尽管如此,net/rpc是可插拔的其他的编码机制也可以被采用,例如官方的 jsonrpc 则更加容易检查内容。

8080端口抓包

传递内容

首先,一个连接被建立,然后一个请求被发送:

1
2
3
4
5
6
7
8
9
{
"id": 0,
"method": "Handler.Execute",
"params": [
{
"Name": "e0c9"
}
]
}

当没有故障出现时,响应返回 error: null和执行结果:

1
2
3
4
5
6
7
8
{
"error": null,
"id": 0,
"result": {
"Message": "Hello e0c9"
"Ok": true
}
}