由于前一篇文章感觉代码文字比超过了70%,因此在最后一天来一篇干活,保证不水代码。
RPC入门
RPC 是远程过程调用的简称,是分布式系统中不同节点间流行的通信方式。在互联网时代,RPC 已经和 IPC 一样成为一个不可或缺的基础构件。因此 Go 语言的标准库也提供了一个简单的 RPC 实现,我们将以此为入口学习 RPC 的各种用法。
远程过程调用带来的新问题
在远程调用add函数时时,我们需要执行的函数体是在远程的机器上的,也就是说,add是在另一个进程中执行的(进程地址空间不同了,就无法像调用本地函数时将变量压栈等等操作)。这就带来了几个新问题:
- Call ID映射。我们怎么告诉远程机器我们要调用add,而不是sub或者Foo呢?在本地调用中,函数体是直接通过函数指针来指定的,我们调用add,编译器就自动帮我们调用它相应的函数指针。但是在远程调用中,函数指针是不行的,因为两个进程的地址空间是完全不一样的。所以,在RPC中,所有的函数都必须有自己的一个ID。这个ID在所有进程中都是唯一确定的。客户端在做远程过程调用时,必须附上这个ID。然后我们还需要在客户端和服务端分别维护一个 {函数 <–> Call ID} 的对应表。两者的表不一定需要完全相同,但相同的函数对应的Call ID必须相同。当客户端需要进行远程调用时,它就查一下这个表,找出相应的Call ID,然后把它传给服务端,服务端也通过查表,来确定客户端需要调用的函数,然后执行相应函数的代码。
- 序列化和反序列化。客户端怎么把参数值传给远程的函数呢?在本地调用中,我们只需要把参数压到栈里,然后让函数自己去栈里读就行。但是在远程过程调用时,客户端跟服务端是不同的进程,不能通过内存来传递参数。甚至有时候客户端和服务端使用的都不是同一种语言(比如服务端用C++,客户端用Java或者Python)。这时候就需要客户端把参数先转成一个字节流,传给服务端后,再把字节流转成自己能读取的格式。这个过程叫序列化和反序列化。同理,从服务端返回的值也需要序列化反序列化的过程。
- 网络传输。远程调用往往用在网络上,客户端和服务端是通过网络连接的。所有的数据都需要通过网络传输,因此就需要有一个网络传输层。网络传输层需要把Call ID和序列化后的参数字节流传给服务端,然后再把序列化后的调用结果传回客户端。只要能完成这两者的,都可以作为传输层使用。因此,它所使用的协议其实是不限的,能完成传输就行。尽管大部分RPC框架都使用TCP协议,但其实UDP也可以,而gRPC干脆就用了HTTP2。Java的Netty也属于这层的东西。
在后面RPC的代码实现方面我们也能看到,这三者在代码实现上是必不可少的,我们在代码中在客户端和服务端必须有对应的函数名称(Call ID映射可以直接使用函数字符串,也可以使用整数ID。映射表一般就是一个哈希表),我们使用的序列化和反序列化协议为JSON,当然在后面学习gRPC时我们会使用性能更高的protobuf(常用的有json,xml,yaml,protobuf等等),而在网络传输方面我们的第一个例子会选择TCP协议!
实际上真正的开发过程中,除了上面的基本功能以外还需要更多的细节:网络错误、流量控制、超时和重试等
先让我们看看官方文档对rpc包的一个简述:
包rpc通过网络或其他I/O连接提供对对象的导出方法的访问。server注册一个对象,使其作为具有其对象类型名称的服务而变得可见。注册后,可以远程访问对象的导出方法。服务器可以注册多个不同类型的对象(服务),但注册多个相同类型的对象是错误的。
只有满足这些标准的方法才能用于远程访问;其他方法将被忽略:
- 方法的类型被导出
- 方法被导出
- 该方法有两个参数,都是导出(或内置)类型
- 该方法的第二个参数是指针
- 该方法必须有error这个返回类型
RPC原理图(http通信、socket通信都可):
RPC开发的四大要素
RPC在架构设计上由四部分组成,分别是:客户端、客户端存根(client stub)、服务端、服务端存根(server stub)
- 客户端
- 客户端存根(client stub):该程序运行在客户端所在的机器上,主要用来存储要调用的服务器的地址,其次,该程序还负责将客户端请求远端服务器程序的数据信息打包成数据包,通过网络发送给服务端stub,其次还要接受服务端stub发送的调用结果数据包,并解析给客户端
- 服务端
- 服务端存根:接受客户端stub程序通过网络发送的请求消息数据包,并调用服务端中真正的程序功能方法,完成功能调用;其次,将服务端执行调用的结果用数据处理打包发送给客户端stub
另外,一个好的RPC框架必须使用动态代理技术,在下面我们在客户端进行封装的过程我们会产生疑问,你服务端一个函数就封装这么多,我服务端100个函数你难道要封装一百次?于是动态代理就是用来解决这个问题的,其会自动生成一段程序,在我们后面学习gRPC的时候我们也会发现,在我们编写玩proto文件编译后会自动生成代码!这样就省去了我们进行封装的麻烦!
RPC版”Hello World”
我们新建两个文件,一个server.go,一个client.go
`` Golang
// server.go
package main
import (
"log"
"net"
"net/rpc"
)
type HelloService struct{}
func (p *HelloService) Hello(request string, reply *string) error {
*reply = "hello:" + request
return nil
}
func main() {
_ = rpc.RegisterName("HelloService", new(HelloService))
listener, err := net.Listen("tcp", ":1234")
if err != nil {
log.Fatal("ListenTCP error:", err)
}
//go http.Serve(listener, nil)
conn, err := listener.Accept()
if err != nil {
log.Fatal("Accept error:", err)
}
rpc.ServeConn(conn)
}
在 server.go 中,我们新建了一个结构体(类型) HelloService ,并且为这个结构体实现了一个Hello 方法(当然,我们可以实现多个方法,这个结构体的多个方法都可以被rpc远程调用),Hello 方法接受两个参数,request 作为我们的输入参数,reply 是一个指针作为我们的输出参数,我们在这个方法里的逻辑就是传入一个 request 并拼接到 Hello: 后面,并将结果赋给 reply ,在后面我们会让 request 等于 World ,这样 reply 就等于Hello:World了,我们会再打印 reply!
其中 rpc.Register 函数调用会将对象类型中所有满足 RPC 规则的对象方法注册为 RPC 函数,所有注册的方法会放在 “HelloService” 服务空间之下。然后我们通过net.Listen监听 TCP 连接,并且通过listener.Accept创建一个套接字,然后rpc.ServerConn会接管这个连接从而在该TCP 连接上为对方提供 RPC 服务!
再回过头来看看我们之前说的RPC调用的三个问题这里有没有一一体现!
首先就是Caller ID映射,我们的rpc.Register完成了这一点。
其次就是序列化和反序列化,点开我们的ServerConn源码,发现其默认使用的gob格式!因此序列化和反序列也在此次体现!
最后就是我们的网络传输,显然我们这里的TCP连接有所体现!
注意到我们定义的Hello方法是严格遵守上面rpc包的要求的哦:两个参数,第二个参数是指针,返回类型为error
//client.go
package main
import (
"fmt"
"log"
"net/rpc"
)
func main() {
client, err := rpc.Dial("tcp", "localhost:1234")
if err != nil {
log.Fatal("dialing:", err)
}
var reply string
err = client.Call("HelloService.Hello", "Wrold", &reply)
if err != nil {
log.Fatal(err)
}
fmt.Println(reply)
}
首先是通过 rpc.Dial 拨号 RPC 服务,然后通过 client.Call 调用具体的 RPC 方法。在调用 client.Call 时,第一个参数是用点号连接的 RPC 服务名字和方法名字,第二和第三个参数分别我们定义 RPC 方法的两个参数。
我们先后运行server.go和client.go会得到输出结果:Hello:World
更安全的RPC接口
上面我们已经基本实现了简单的RPC例子,但是上面的代码封装性不好,也就是说我们在调用RPC函数时没有得到一种像调用本地函数的感觉,我们下面将上面的代码进行封装和解耦!
// server.go
package main
import (
"log"
"net"
"net/rpc"
)
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)
}
type HelloService struct{}
func (p *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)
}
for {
conn, err := listener.Accept()
if err != nil {
log.Fatal("Accept error:", err)
}
go rpc.ServeConn(conn)
}
}
在上面的代码中,我们创建了HelloServiceInterface接口,并用HelloService实现了这个接口,以及我们创建了RegisterHelloService方法,参数类型为HelloServiceInterface接口,这样做的好处一方面是封装了我们注册RPC的步骤,另一方面通过创建接口实现了横向扩展!并且注意到一个小细节,我们定义了 const 常量 HelloServiceName,为了避免名字冲突,我们在 RPC 服务的名字中增加了包路径前缀(这个是 RPC 服务抽象的包路径,并非完全等价 Go 语言的包路径)
在新的 RPC 服务端实现中,我们用 RegisterHelloService 函数来注册函数,这样不仅可以避免命名服务名称的工作,同时也保证了传入的服务对象满足了 RPC 接口的定义。最后我们新的服务改为支持多个 TCP 连接(通过while循环的方式),然后为每个 TCP 连接提供 RPC 服务。
// client.go
package main
import (
"fmt"
"log"
"net/rpc"
)
const HelloServiceName = "path/to/pkg.HelloService"
type HelloServiceClient struct {
*rpc.Client
}
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)
}
func main() {
client, err := DialHelloService("tcp", "localhost:1234")
if err != nil {
log.Fatal("dialing:", err)
}
var reply string
client.Hello("world", &reply)
//err = client.Call(HelloServiceName+".Hello", "Wrold", &reply)
if err != nil {
log.Fatal(err)
}
fmt.Println(reply)
}
在客户端的代码,我们同样的对 Dial 的过程进行了封装,以及对调用服务端函数的过程进行了封装,这样在我们调用的时候就不用我们手动敲RPC函数名了(客户端用户不用再担心 RPC 方法名字或参数类型不匹配等低级错误的发生。),并且我们通过client.Hello的方式是不是像极了本地的函数调用呢?(尽管我们的封装过程可能有点麻烦,但调用起来还是很爽的)
跨语言的RPC
在RPC版"Hello World"一节中我们已经看到标准库的 RPC 默认采用 Go 语言特有的 gob 编码,因此从其它语言调用 Go 语言实现的 RPC 服务将比较困难。在互联网的微服务时代,每个 RPC 以及服务的使用者都可能采用不同的编程语言,因此跨语言是互联网时代 RPC 的一个首要条件。得益于 RPC 的框架设计,Go 语言的 RPC 其实也是很容易实现跨语言支持的。
Go 语言的 RPC 框架有两个比较有特色的设计:一个是 RPC 数据打包时可以通过插件实现自定义的编码和解码;另一个是 RPC 建立在抽象的 io.ReadWriteCloser 接口之上的,我们可以将 RPC 架设在不同的通讯协议之上。这里我们将尝试通过官方自带的 net/rpc/jsonrpc 扩展实现一个跨语言的 RPC。
下面我们就将gob编码换成json编码,并用python发送请求并得到结果实现跨语言!
首先我们将server.go改一下实现json编码,我们唯一改动的地方就在rpc.ServeCodec(jsonrpc.NewServerCodec(conn))
下面这三个代码段就不放完整代码了,担心代码文字比又超过 70% 呜呜,具体代码可以在参考链接的文章中查看到!
// server.go
// client.go
// test_rpc_client.py
开启服务端后,python客户端的结果:
可见,我们成功实现了跨语言调用!
http上的RPC
前面我们用的是TCP连接,这里我们改用http连接,毕竟http对我们而言更方便以及更熟悉
Go 语言内在的 RPC 框架已经支持在 Http 协议上提供 RPC 服务。但是框架的 http 服务同样采用了内置的 gob 协议,并且没有提供采用其它协议的接口,因此从其它语言依然无法访问的。在前面的例子中,我们已经实现了在 TCP 协议之上运行 jsonrpc 服务。现在我们尝试在 http 协议上提供 jsonrpc 服务。
我们将服务端代码和客户端代码简单修改如下便可得到想要的结果!
下面这两个代码段就不放完整代码了,担心代码文字比又超过 70% 呜呜,具体代码可以在参考链接的文章中查看到!
// server.go
package main
# client.py
import requests
request = {
"id":0,
"params":["world"],
"method":"HelloService.Hello"
}
rsp = requests.post("http://localhost:1234/jsonrpc",json=request)
print(rsp.text)
Protobuf入门
protoc编译命令
下面几种命令都是将当前文件夹下的helloworld.proto文件进行编译,生成的pb.go文件放到当前文件夹!
protoc -I . helloworld.proto --go_out=D:\Goland\Learn-rpc\proto
这种方式中:-I表示输入的意思,-I后面的. helloworld.proto表示当前目录下的helloworld.proto文件,所以-I .表示输入为当前目录下的helloworld.proto文件,–go_out表示输出的go文件的位置,我们这里指定了绝对位置(其实也是当前目录)
protoc --go_out=. helloworld.proto
这种方式中: -I .被省略了则默认输入为当前目录,--go_out=.表示输出为当前目录, 最好的helloworld.proto表示输入为当前目录的helloworld.proto文件
protoc -I=D:\Goland\Learn-rpc\proto --go_out=D:\Goland\Learn-rpc\proto D:\Goland\Learn-rpc\proto\helloworld.proto
这种方式就是第一和第二种方式的结合,只不过路径都用绝对路径
protoc --go-grpc_out=:. helloworld.proto
这种方式与第二种方式唯一不同之处在于–go_out变成–go-grpc_out,这意味着我们会生成一个xxx_grpc.pb.go的文件,而上面的方法是生成xxx.pb.go文件,通常情况下两者都需要,因此后面我们会将二者结合
protoc -I . helloworld.proto --go-grpc_out=:.
与第一种方式相同
protoc --proto_path=. --go_out=. --go-grpc_out=require_unimplemented_servers=false:. helloworld.proto
这最后一种则是我们所推荐的方式,输入路径为当前目录,生成helloworld_grpc.pb.go与helloworld.pb.go两个文件,并且要加上require_unimplemented_servers=false,否则你在实现接口的时候会出问题!
protobuf的基本类型和默认值
解析消息后,如果经过编码的消息不包含特定单数元素,则解析对象中的相应字段将设置为该字段的默认值。这些默认值因类型而异:
- 对于字符串,默认值为空字符串。
- 对于字节,默认值为空字节。
- 对于布尔值,默认值为 false。
- 对于数值类型,默认值为零。
- 对于枚举,默认值为第一个定义的枚举值,必须为 0。
- 对于消息字段,系统不会设置此字段。其确切值取决于语言。
option_package的作用
package有两种
package xxx;
option go_package="./common;proto";
第一种指明你这个proto文件的package,比如这里定义为xxx的话,你别的proto文件想要引用这个proto文件里的东西就得用xxx.something!
第二种用分号隔开,分号前面指明你生成的pb文件的路径,比如这里为./common表明生成的pb文件放在当前文件夹的common文件夹下(如果没有则自动生成)。分号后面指定包名,与第一种是一样的!
因此我们推荐只写第二种即可!
proto文件中import另一个文件
在一个proto文件中import另一个文件只需要直接import “那个文件名”即可,不过还需要装个插件,在Goland中,只需要把鼠标放在import报红的地方,点击它提示的装插件即可!需要注意的是,你的新的proto文件里面的message和service不能和老的proto文件有相同的名字!
在下面的例子中,我们在helloworld.proto中定义了一个message为Empty也确实没有任何元素,仅作占位符用,而在test.proto中导入helloworld.proto后便可以使用Empty这个message了!
// helloworld.proto
syntax = "proto3";
package proto;
option go_package="./common;proto";
message String {
string value = 1;
}
service HelloService {
rpc Hello (String) returns (String);
rpc Channel (stream String) returns (stream String);
}
service PubsubService {
rpc Publish (String) returns (String);
rpc Subscribe (String) returns (stream String);
}
message Empty{}
// test.proto
syntax = "proto3";
package proto;
option go_package="./common;proto";
import "helloworld.proto";
service gg {
rpc Ping(Empty) returns (Pong);
}
message Pong{
string id = 1;
}
事实上上面的Empty占位符我们可以用protobuf自带的,在调用时只需要带上google.protobuf前缀即可,并且还有一些其他自带的类型:
syntax = "proto3";
package proto;
option go_package="./common;proto";
import "google/protobuf/empty.proto";
service gg {
rpc Ping(google.protobuf.Empty) returns (Pong);
}
message Pong{
string id = 1;
}
青训营的最后一篇文章了,希望能顺利拿到结营证书呜呜呜~