观前提示:本文基于Go语言,基于语言的原生能力初步讨论了微服务的开发过程
RPC的概念
这里不想谈RPC微服务和单体服务的优缺点,网络上有很多很长的文章。这里假定读者同学对RPC有一个最基本的概念,只是分享一下我我对RPC的想法。 RPC是远程过程调用的简写(Remote Procedure Call),这个名词看起来有些抽象,我们一点点来分析。
过程调用
这里的RPC概念中的“过程调用”相信同学们并不陌生,在任何一个语言中def 一个或者func 一个 或者function 一个函数之后,用函数名后面跟小括号,就完成了一次过程调用。而远程过程调用就是将函数定义过程和调用过程分开(远程的含义),利用网络协议进行传输
和REST的区别
以上就是RPC最朴素的解释,而和REST的区别,也是讨论RPC时最为津津乐道的话题,个人认为,REST作为一种为url制定的标准,现在得到较为主流的使用,更注重目的,通过对HTTP的请求方式映射成对资源的增删改查的操作,尝试从数据库的视角描述操作的目的,本身并不关心背后是怎么实现的。而RPC更注重过程,尝试基于给定的函数名和函数参数,完成函数的调用过程。当然这是它们从设计的角度来说最主要的一个区别,从这个区别的描述中,我们也可以看出以下两个区别
- 网络协议
- REST是url(http协议)的一种标准
- RPC并不强调协议,事实上有很多种协议方式
- 序列化与反序列化
- REST是基于http协议,自然跟着http协议的序列化与反序列化的方式走
- RPC也有很多协议方式,可以自定义,如Go原生的Gob、gRPC的Protobuf、包括之前的json和xml都可以实现
RPC的核心概念
三个核心概念:通过以上的讨论,我们可以发现RPC最重要的就是实现一个远程的【过程调用】,也就是调用方在不感知网络协议和序列化和反序列化方式的情况下,可以根据函数名和参数进行过程调用,就像在文件上面定义了这个函数那样进行调用。那么我们就很容易总结出RPC中比较关键的几个方面。
- 传输中的网络协议
- 传输中的序列化和反序列化的方式
- 根据函数名在目标的地方找到对应的函数
我们也可以换一种方式理解这三个概念,想象一下【调用方】需要远方的一箱货,【被调用方】如何将这一箱货物给到【调用方】呢
- 首先需要一辆车能够按照指定路线到达(网络协议)
- 其次货物能够被装起来,也能够从箱子里面拿出来(序列化和反序列化)
- 最后也是最重要的就是【被调用方】得根据【调用方】的简单描述,知道是哪一箱货物(根据函数名在目标的地方找到对应的函数)
四个核心过程:对于客户端来说比较容易,以下我们只讨论对于服务端来说(也就是定义函数的地方)的过程,主要有四个:【定义函数】,【定义服务端】,【绑定函数】,【启动服务】。听起来有点抽象是不是,别着急,让我们用刚才【一箱货物】来举例:
- 备货(定义函数)
- 备车(定义服务端)
- 装货(绑定函数)
- 发车(启动服务)
其他讨论:在远程调用中由于网络的不确定性和传输较慢的影响,还需要考虑:失败时的重试,积压时的限流,高负载时的负载均衡等问题。本篇文章我们不做过多讨论。
极简RPC的实现
在具体代码实现这里,主要用了net/rpc包和net包,前者实现了【网络协议】,后者实现了【根据函数名在远程定位函数】,至于序列化方式,可以用rpc包实现,也可以通过其他方式实现
Server
import "net"
import "net/rpc"
type Hello struct{}
func (ho *Hello) Call (req string, resp *string){
*resp = "RPC Hello " + req
return nil
}
func main(){
listener, _ := net.Listen("tcp", ":8888")
_ = rpc.RegisterName("Hello", &Hello{})
for {
conn, _ := listener.Accept()
rpc.ServeConn(conn)
}
}
以上过程解释两个地方,一个是函数定义时接收的参数,必须是2个请求 string,响应 *string,比较好理解,因为在Go中接收响应的方法是通过传递指针,让函数内部改变外部变量指针指向的位置。这是Go中很典型的操作(json的反序列化或者接收命令行输入)个人理解,这种直接该地址的方式要比返回值的方式效率高。另一个是序列化和反序列化的方式,因为我们没有额外指定这个方式,所以用的是Go的默认Gob这个方式
Client
import "net/rpc"
import "fmt"
func main(){
client, _ := rpc.Dial("tcp", ":8888")
var resp string
_ = client.Call("Hello.Call", "World", &resp)
fmt.Println(resp)
}
client端相对简单,就不多说了,此时输出,“RPC Hello World”
RPC改造
1. 换方式为json
Server端
刚才我们提到此时RPC利用的序列化和反序列化的方式是 Gob,一种Go特有的方式,但是这样,其他的语言就无法调用了,我们手动换一下方式,比如json
import "net"
import "net/rpc"
type Hello struct{}
func (ho *Hello) Call (request string, reply *string){
*reply = "RPC Hello " + request
return nil
}
func main(){
listener, _ := net.Listen("tcp", ":8888")
_ = rpc.RegisterName("Hello", &Hello{})
conn, _ := listener.Accept()
rpc.ServeCodec(jsonrpc.NewServerCodec(conn))
}
以上,我们只改了最后一行,在启动服务之后追加了json的反序列化的方式
Client端
import "net/rpc"
import "fmt"
func main(){
conn, _ := net.Dial("tcp", ":8888")
client := rpc.NewClientWithCodec(jsonrpc.NewClientCodec(conn))
var resp string
_ := client.Call("Hello.Call", "World", &resp)
fmt.Println(resp)
}
此时client端就以json的格式发送数据,那到底是什么样的数据呢,别的语言能否发送数据呢。答案当然是肯定的,格式如下
{"method": "Hello.Call", "params": ["hello"], "id": 0}
明显这种序列化方式效率不高,真正有用的信息,还不到一半,但胜在人类可读,至于想验证其他语言能否调用这个服务端的小伙伴可以自己试一下,注意:是TCP而不是HTTP
2. 换协议为http
刚才提到协议是TCP,我们可以使用HTTP吗
Server端
import "net/http"
import "net/rpc"
type Hello struct{}
func (ho *Hello) Call (request string, reply *string){
*reply = "RPC Hello " + request
return nil
}
func main(){
_ = rpc.RegisterName("Hello", &Hello{})
http.HandleFunc("/httpjsonrpc", 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)
}
这次代码的改动有些大,这是因为http.HandleFunc需要将一个url绑定到一个处理函数上,这就要求这个处理函数中做更多的东西。不过还是可以看出,之前分析的那几个步骤还在。此时的请求就是一个标准的http的请求了,client端也就是标准的http的请求的写法,就不再赘述了
总结
本文由浅入深的介绍了RPC最重要的三部分组成,请求到函数的映射,网络协议和序列化/反序列化的方式,并且基于Go原生的能力实现了三版RPC的服务端和客户端,帮助读者同学理解RPC调用
深挖的话,还有很多不足,比如用字符串定位的方式,客户端如何知道服务端都有什么能力,以及之前提到的RPC由于网络传输带来的具有挑战的其他要求。实际开发过程中更多的是利用现在已有的开发框架gRPC,gRPC会帮我们处理好这些问题,也有着完善的生态,将在之后的文章中介绍
|