本文作为Grpc的开篇,通过文档先了解一下rpc。
个人网站:https://linzyblog.netlify.app/
示例代码已经上传到github:点击跳转
RPC(Remote Procedure Call 远程过程调用)
是一种软件通信协议,一个程序可以使用该协议向位于网络上另一台计算机中的程序请求服务,而无需了解网络的详细信息。RPC 用于调用远程系统上的其他进程,如本地系统。过程调用有时也称为 函数调用或 子程序调用。
RPC是一种客户端-服务器交互形式(调用者是客户端,执行者是服务器),通常通过请求-响应消息传递系统实现。与本地过程调用一样,RPC 是一种 同步
操作,需要阻塞
请求程序,直到返回远程过程的结果。但是,使用共享相同地址空间的轻量级进程或 线程 可以同时执行多个 RPC。
通俗的解释:
客户端在不知道调用细节的情况下,调用存在于远程计算机上的某个对象,就像调用本地应用程序中的对象一样。
接口定义语言(IDL)——用于描述软件组件的应用程序编程接口(API)的规范语言——通常用于远程过程调用软件。在这种情况下,IDL 在链路两端的机器之间提供了一座桥梁,这些机器可能使用不同的操作系统 (OS) 和计算机语言。
实际场景:
有两台服务器,分别是服务器 A、服务器 B。在 服务器 A 上的应用 想要调用服务器 B 上的应用,它们可以直接本地调用吗?
答案是不能的,但走 RPC 的话,十分方便。因此常有人称使用 RPC,就跟本地调用一个函数一样简单。
RPC是一种方法,而HTTP是一种协议。两者都常用于实现服务,在这个层面最本质的区别是RPC服务主要工作在TCP协议之上(也可以在HTTP协议),而HTTP服务工作在HTTP协议之上。由于HTTP协议基于TCP协议,所以RPC服务天然比HTTP更轻量,效率更胜一筹。
两者都是基于网络实现的,从这一点上,都是基于Client/Server架构。
RPC是远端过程调用,其调用协议通常包含:传输协议
和 序列化协议
。
HTTP服务工作在HTTP协议之上,而且HTTP协议基于TCP协议。
当调用 RPC 时,调用环境被挂起,过程参数通过网络传送到过程执行的环境,然后在该环境中执行过程。
当过程完成时,结果将被传送回调用环境,在那里继续执行,就像从常规过程调用返回一样。
在 RPC 期间,将执行以下步骤:
编组
。解编组
。Client (客户端):服务调用方。
Server(服务端):服务提供方。
Client Stub(客户端存根):存放服务端的地址消息,负责将客户端的请求参数打包成网络消息,然后通过网络发送给服务提供方。
Server Stub(服务端存根):接收客户端发送的消息,再将客户端请求参数打包成网络消息,然后通过网络远程发送给服务方。
尽管它拥有广泛的好处,但使用 RPC 的人肯定应该注意一些陷阱。
RPC 为开发人员和应用程序管理人员提供的一些优势:
另一方面,RPC 的一些缺点包括:
Go语言标准包(net/rpc
)已经提供了对RPC的支持,而且支持三个级别的RPC:TCP、HTTP和JSONRPC
。但Go语言的RPC包是独一无二的RPC,它和传统的RPC系统不同,它只支持Go语言开发的服务器与客户端之间的交互,因为在内部,它们采用了Gob
来编码。
我们先构造一个 HelloService 类型,其中的 SayHi方法用于实现打印功能:
type HelloService struct{}
func (h *HelloService) SayHi(request string, response *string) error {
format := time.Now().Format("2006-01-02 15:04:05")
*response = "hi " + request + "---" + format
return nil
}
Go 语言的 RPC 规则:方法只能有两个可序列化的参数,其中第二个参数是指针类型,并且返回一个 error 类型,同时必须是公开的方法。
将 HelloService 类型的对象注册为一个 RPC 服务:
func main() {
//注册服务
_ = rpc.RegisterName("HiLinzy", new(HelloService))
//监听接口
lis, err := net.Listen("tcp", ":8888")
if err != nil {
log.Fatal(err)
return
}
for {
//监听请求
accept, err := lis.Accept()
if err != nil {
log.Fatalf("Accept Error: %s", err)
}
//用goroutine为每个TCP连接提供RPC服务
go rpc.ServeConn(accept)
}
}
RegisterName类似于Register,但使用提供的名称作为类型,
Register
函数调用会将对象类型中所有满足 RPC 规则的对象方法注册为 RPC 函数,所有注册的方法会放在 “HelloService” 服务空间之下。
rpc.ServeConn 函数在该 TCP 连接上为对方提供 RPC 服务。
我们的服务支持多个 TCP 连接,然后为每个 TCP 连接提供 RPC 服务。
在客户端请求 HelloService 服务的代码:
func main() {
//建立连接
dial, err := rpc.Dial("tcp", "127.0.0.1:8888")
if err != nil {
log.Fatal("Dial error ", err)
}
var result string
for i := 0; i < 5; i++ {
//发起请求
_ = dial.Call("HiLinzy.SayHi", "linzy", &result)
fmt.Println("rpc service result:", result)
time.Sleep(time.Second)
}
}
rpc.Dial 拨号 RPC 服务,然后通过 dial.Call 调用具体的 RPC 方法。
在调用 dial.Call 时,第一个参数是用点号连接的 RPC 服务名字和方法名字,第二和第三个参数分别我们定义 RPC 方法的两个参数,第一个是客服端传递的消息,第二个是由服务端产生返回的结果。
# 启动服务
➜ go run server.go
API server listening at: 127.0.0.1:54096
# 启动客户端
➜ go run client.go
API server listening at: 127.0.0.1:54100
rpc service result: hi linzy---2022-10-30 15:52:39
rpc service result: hi linzy---2022-10-30 15:52:40
rpc service result: hi linzy---2022-10-30 15:52:41
rpc service result: hi linzy---2022-10-30 15:52:42
rpc service result: hi linzy---2022-10-30 15:52:43
在涉及 RPC 的应用中,作为开发人员一般至少有三种角色:首先是服务端实现 RPC 方法的开发人员,其次是客户端调用 RPC 方法的人员,最后也是最重要的是制定服务端和客户端 RPC 接口规范的设计人员。在前面的例子中我们为了简化将以上几种角色的工作全部放到了一起,虽然看似实现简单,但是不利于后期的维护和工作的切割。
如果要重构 HelloService 服务,第一步需要明确服务的名字和接口:
const HelloServiceName = "server/tcp-server/server.HiLinzy"
type HelloServiceInterface interface {
SayHi(request string, response *string) error
}
//封装Register
func RegisterHelloService(svc HelloServiceInterface) error {
return rpc.RegisterName(HelloServiceName, svc)
}
我们将 RPC 服务的接口规范分为三个部分:首先是服务的名字,然后是服务要实现的详细方法列表,最后是注册该类型服务的函数。
为了避免名字冲突,我们在 RPC 服务的名字中增加了包路径前缀(这个是 RPC 服务抽象的包路径,并非完全等价 Go 语言的包路径)。
RegisterHelloService 注册服务时,编译器会要求传入的对象满足 HelloServiceInterface 接口。
基于 RPC 接口规范编写真实的服务端代码:
type HelloService struct{}
func (h *HelloService) SayHi(request string, response *string) error {
format := time.Now().Format("2006-01-02 15:04:05")
*response = "hi " + request + "---" + format
return nil
}
func main() {
//注册服务
//_ = rpc.RegisterName("HiLinzy", new(HelloService))
RegisterHelloService(new(HelloService))
//监听接口
lis, err := net.Listen("tcp", "127.0.0.1:8888")
if err != nil {
log.Fatal(err)
return
}
for {
//监听请求
accept, err := lis.Accept()
if err != nil {
log.Fatalf("Accept Error: %s", err)
}
go rpc.ServeConn(accept)
}
}
为了简化客户端用户调用 RPC 函数,我们在可以在接口规范部分增加对客户端的简单包装:
const HelloServiceName = "server/tcp-server/server.HiLinzy"
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 (h *HelloServiceClient) SayHi(request string, response *string) error {
//client.Call 的第一个参数用 HelloServiceName+".SayHi" 代替了 "HiLinzy.SayHi"。
return h.Client.Call(HelloServiceName+".SayHi", request, &response)
}
提供了一个 DialHelloService 方法,直接拨号 HelloService 服务。
基于新的客户端接口,我们可以简化客户端用户的代码:
func main() {
//建立连接
//dial, err := rpc.Dial("tcp", "127.0.0.1:8888")
client, err := DialHelloService("tcp", "127.0.0.1:8888")
if err != nil {
log.Fatal("dialing:", err)
}
var result string
for i := 0; i < 5; i++ {
//发起请求
//_ = dial.Call("HiLinzy.SayHi", "linzy", &result)
err = client.SayHi("linzy", &result)
if err != nil {
log.Fatal(err)
}
fmt.Println("rpc service result:", result)
time.Sleep(time.Second)
}
}
现在客户端用户不用再担心 RPC 方法名字或参数类型不匹配等低级错误的发生。
# 启动服务
➜ go run server.go
API server listening at: 127.0.0.1:56990
# 启动客户端
➜ go run client.go
API server listening at: 127.0.0.1:57188
rpc service result: hi linzy---2022-10-30 16:55:12
rpc service result: hi linzy---2022-10-30 16:55:13
rpc service result: hi linzy---2022-10-30 16:55:14
rpc service result: hi linzy---2022-10-30 16:55:15
rpc service result: hi linzy---2022-10-30 16:55:16
在新的 RPC 服务端实现中,我们用 RegisterHelloService 函数来注册函数,这样不仅可以避免命名服务名称的工作,同时也保证了传入的服务对象满足了 RPC 接口的定义。
标准库的RPC默认采用 Go 语言特有的 gob 编码,因此从其他语言调用 Go 语言实现的 RPC 服务将比较困难。在互联网的微服务时代,每个 RPC 以及服务的使用者都可能采用不同的编程语言,因此跨语言是互联网时代 RPC 的一个首要条件。得益于 RPC 的框架设计,Go 语言的 RPC 其实也是很容易实现跨语言支持的。
Go 语言的 RPC 框架有两个比较有特色的设计:
这里我们使用Go官方自带的 net/rpc/jsonrpc
扩展实现一个跨语言的rpc。
首先是基于 json 编码重新实现 RPC 服务:
func main() {
//注册服务
//_ = rpc.RegisterName("HiLinzy", new(HelloService))
RegisterHelloService(new(HelloService))
//监听接口
lis, err := net.Listen("tcp", "127.0.0.1:8888")
if err != nil {
log.Fatal(err)
return
}
for {
//监听请求
accept, err := lis.Accept()
if err != nil {
log.Fatalf("Accept Error: %s", err)
}
//go rpc.ServeConn(accept)
go rpc.ServeCodec(jsonrpc.NewServerCodec(accept))
}
}
代码中最大的变化是用 rpc.ServeCodec 函数替代了 rpc.ServeConn 函数,传入的参数是针对服务端的 json 编解码器。
实现 json 版本的客户端:
func main() {
//建立TCP连接
conn, err := net.Dial("tcp", "127.0.0.1:8888")
if err != nil {
log.Fatal("net.Dial:", err)
}
//建立针对客户端的json编解码器
client := rpc.NewClientWithCodec(jsonrpc.NewClientCodec(conn))
var result string
for i := 0; i < 5; i++ {
//发起请求
//err = client.SayHi("linzy", &result)
client.Call(HelloServiceName+".SayHi", "linzy", &result)
if err != nil {
log.Fatal(err)
}
fmt.Println("rpc service result:", result)
time.Sleep(time.Second)
}
}
# 启动服务
➜ go run server.go
API server listening at: 127.0.0.1:59409
# 启动客户端
➜ go run client.go
API server listening at: 127.0.0.1:59514
rpc service result: hi linzy---2022-10-30 19:09:52
rpc service result: hi linzy---2022-10-30 19:09:53
rpc service result: hi linzy---2022-10-30 19:09:54
rpc service result: hi linzy---2022-10-30 19:09:55
rpc service result: hi linzy---2022-10-30 19:09:56
我们先手工调用 net.Dial 函数建立 TCP 连接,然后基于该连接建立针对客户端的 json 编解码器。
在确保客户端可以正常调用 RPC 服务的方法之后,我们用一个普通的 TCP 服务代替 Go 语言版本的 RPC 服务,这样可以查看客户端调用时发送的数据格式。
我们用Wireshark抓个包看看我们直接传递的数据格式:
这是一个 json 编码的数据,其中 method 部分对应要调用的 rpc 服务和方法组合成的名字,params 部分的第一个元素为参数,id 是由调用端维护的一个唯一的调用编号。
{"method":"server/tcp-server/server.HiLinzy.SayHi","params":["linzy"],"id":0}
请求的 json 数据对象在内部对应两个结构体:客户端是 clientRequest,服务端是 serverRequest。clientRequest 和 serverRequest 结构体的内容基本是一致的:
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"`
}
我们再来查看服务端响应的结果的数据结构:
返回的结果也是一个 json 格式的数据:
{"id":0,"result":"hilinzy---2022-10-30 19:09:52","error":null}.
其中 id 对应输入的 id 参数,result 为返回的结果,error 部分在出问题时表示错误信息。对于顺序调用来说,id 不是必须的。但是 Go 语言的 RPC 框架支持异步调用
,当返回结果的顺序和调用的顺序不一致时,可以通过 id 来识别对应的调用。
返回的 json 数据也是对应内部的两个结构体:客户端是 clientResponse,服务端是 serverResponse。两个结构体的内容同样也是类似的:
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。
Go 语言内在的 RPC 框架已经支持在 HTTP 协议上提供 RPC 服务。但是框架的 HTTP 服务同样采用了内置的 gob 协议,并且没有提供采用其它协议的接口,因此从其它语言依然无法访问的
。
在前面的例子中,我们已经实现了在 TCP 协议之上运行 jsonrpc 服务,并且通过Wireshark抓包分析传递的数据 json 数据格式。现在我们尝试在 http 协议上提供 jsonrpc 服务。
新的 RPC 服务其实是一个类似 REST 规范的接口,接收请求并采用相应处理流程:
const HelloServiceName = "server/tcp-server/server.HiLinzy"
type HelloService struct{}
func (h *HelloService) SayHi(request string, response *string) error {
format := time.Now().Format("2006-01-02 15:04:05")
*response = "hi " + request + "---" + format
return nil
}
func main() {
//注册服务
rpc.RegisterName(HelloServiceName, 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(":8888", nil)
}
RPC 的服务架设在 “/jsonrpc” 路径,在处理函数中基于 http.ResponseWriter 和 http.Request 类型的参数构造一个 io.ReadWriteCloser 类型的 conn 通道。
然后基于 conn 构建针对服务端的 json 编码解码器。最后通过 rpc.ServeRequest 函数为每次请求处理一次 RPC 方法调用。
用Postman模拟RPC调用过程,向连接localhost:8888/jsonrpc
发送一条 json 字符串
{"method":"server/tcp-server/server.HiLinzy.SayHi","params":["linzy"],"id":0}
这样我们就可用很方便的从不同的语言或者不同的方式来访问RPC服务了。
参考文章:
https://www.techtarget.com/searchapparchitecture/definition/Remote-Procedure-Call-RPC
https://chai2010.cn/advanced-go-programming-book/ch4-rpc/ch4-01-rpc-intro.html