(rpc系列)01 什么是rpc?

什么是rpc?

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 规则:

  • 方法只能有两个可序列化的参数;

    把变量从内存中变成可存储可传输的过程,称之为序列化。换言之,序列化之后,就可以把内容写入磁盘,或者通过网络传输到别的机器上。

    反之,把变量内容从序列化的对象重新读到内存里,称之为反序列化

  • 第二个参数是指针类型

  • 方法返回一个 error 类型

  • 必须是公开的方法。

然后就可以将 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. 注册该类型服务的函数。
 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,至少能够实现这些目标:

  1. 一个服务对应一个客户端,建立一个客户端,可以直接通过方法名调用对应的接口处理;
  2. 假如入参类型错误,例如 &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服务的过程就被简化成了:

  1. 通过DialXXX方法直接创建某个 rpc 服务对应的 client;
  2. 通过该服务的 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 框架有两个比较有特色的设计:

  1. RPC 数据打包时可以通过插件实现自定义的编码和解码
  2. 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编码的服务端及客户端代码,为了验证改动之后的服务端代码是否支持跨语言调用,我们可以这样做:

  1. 获取go客户端调用rpc服务时传输的数据格式;
  2. 采用其他的客户端,例如其他语言编写的客户端,或者本节使用的nc工具传输相同的数据,如果go语言实现的RPC服务器能够正常处理返回,则说明支持跨语言调用。

我们先来获取go客户端调用rpc服务时传输的数据格式,通过 nc 命令启动一个TCP服务

1
nc -l 1234

接着执行一次 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 服务了。

Licensed under CC BY-NC-SA 4.0
我的玫瑰,种在繁星中的一颗~
Built with Hugo
主题 StackJimmy 设计