RPC是什么
RPC(Remote Procedure Call,远程过程调用)是一种计算机通信协议,用于使一个计算机程序能够调用远程计算机上的服务或函数,就像调用本地函数一样。它允许不同的进程或计算机之间进行通信和交互,将远程计算机上的函数调用封装为本地函数调用。
通过使用 RPC,开发人员可以将分布式系统中的不同组件连接在一起,使它们能够透明地进行通信和交互。RPC 隐藏了网络通信的复杂性,使开发人员能够专注于业务逻辑的实现,而不必过多关注底层通信细节。
RPC 可以使用不同的传输协议和序列化机制来实现,例如基于 HTTP 的 RESTful API、基于 TCP 的 gRPC、基于消息队列的 AMQP 等。每种实现方式都有其特点和适用场景,开发人员可以根据具体需求选择合适的 RPC 实现。
RPC简单例子
Go语言的RPC包的路径为net/rpc,也就是放在了net包目录下面。下面我们将尝试基于rpc实现一个类似的例子。
我们先创建一个服务端,创建一个UserService类型,和一个Say()方法:
golang
type UserService struct {
}
func (s *UserService) Say(request string, reply *string) error {
*reply = "hi," + request
return nil
}
其中Say()方法必须满足Go语言的RPC规则:方法只能有两个可序列化的参数,其中第二个参数是指针类型,并且返回一个error类型,同时必须是公开的方法。
然后就可以将HelloService类型的对象注册为一个RPC服务:
golang
func main() {
var err error
err = rpc.RegisterName("UserService", new(UserService))
if err != nil {
panic(err)
}
listener, err := net.Listen("tcp", ":8011")
if err != nil {
panic(err)
}
conn, err := listener.Accept()
if err != nil {
panic(err)
}
rpc.ServeConn(conn)
}
其中rpc.RegisterName()函数调用会将对象类型中所有满足RPC规则的对象方法注册为RPC函数,所有注册的方法会放在HelloService服务的空间之下。然后建立一个唯一的TCP链接,并且通过rpc.ServeConn()函数在该TCP链接上为对方提供RPC服务。
下面是Client端代码:
golang
func main() {
client, err := rpc.Dial("tcp", "localhost:8011")
if err != nil {
panic(err)
}
var reply string
err = client.Call("UserService.Say", "hi", &reply)
if err != nil {
panic(err)
}
fmt.Println(reply)
}
更合理的RPC代码
在涉及RPC的应用中,作为开发人员一般至少有3种角色:首先是服务器端实现RPC方法的开发人员,其次是客户端调用RPC方法的人员,最后也是最重要的是制定服务器端和客户端RPC接口规范的设计人员。在前面的例子中,为了简化我们将以上几种角色的工作全部放到了一起,虽然看似实现简单,但是不利于后期的维护和工作的切割。
重构服务代码
go
const UserServiceName = "path/user.UserService"
type UserServiceInterface interface {
Say(request string, reply *string) error
}
func RegisterUserService(s UserServiceInterface) error {
return rpc.RegisterName(UserServiceName, s)
}
我们将RPC服务的接口规范分为3部分:首先是服务的名字,然后是服务要实现的详细方法列表,最后是注册该类型服务的函数。为了避免名字冲突,我们在RPC服务的名字中增加了包路径前缀(这个是RPC服务抽象的包路径,并非完全等价于Go语言的包路径)。RegisterUserService注册服务时,编译器会要求传入的对象满足UserServiceInterface接口。
服务端代码:
golang
type UserService struct {
}
func (s *UserService) Say(request string, reply *string) error {
*reply = "hi," + request
return nil
}
func main() {
err := RegisterUserService(new(UserService))
if err != nil {
panic(err)
}
listener, err := net.Listen("tcp", ":8012")
if err != nil {
panic(err)
}
for {
conn, err := listener.Accept()
if err != nil {
log.Fatal(err)
}
go rpc.ServeConn(conn)
}
}
在新的RPC服务器端实现中,我们用RegisterUserService()函数来注册函数,这样不仅可以避免命名服务名称的工作,同时也保证了传入的服务对象满足RPC接口的定义。最后新的服务改为支持多个TCP链接,然后为每个TCP链接提供RPC服务。
重构客户端代码
golang
type UserClient struct {
*rpc.Client
}
var _ UserServiceInterface = (*UserClient)(nil)
func GetUserClient(network, address string) (*UserClient, error) {
c, err := rpc.Dial(network, address)
if err != nil {
return nil, err
}
return &UserClient{Client: c}, nil
}
func (p *UserClient) Say(request string, reply *string) error {
return p.Client.Call(UserServiceName+".Say", request, reply)
}
我们在接口规范中针对客户端新增加了UserClient类型,该类型也必须满足UserServiceInterface接口,这样客户端用户就可以直接通过接口对应的方法调用RPC函数。同时提供了一个GetUserClient函数,直接拨号UserService服务。
客户端代码:
golang
func main() {
client, err := GetUserClient("tcp", "localhost:8012")
if err != nil {
panic(err)
}
var replay string
err = client.Say("hi", &replay)
if err != nil {
panic(err)
}
fmt.Println(replay)
}
直接用封装好的GetUserClient获取client,然后用client中的Say方法调用RPC。
RPC跨语言
标准库的RPC默认采用Go语言特有的Gob编码,因此从其他语言调用Go语言实现的RPC服务将比较困难。在互联网的微服务时代,每个RPC以及服务的使用者都可能采用不同的编程语言,因此跨语言是互联网时代RPC的一个首要条件。得益于RPC的框架设计,Go语言的RPC其实也是很容易实现跨语言支持的。
Go语言的RPC框架有两个比较有特色的设计:一个是RPC数据打包时可以通过插件实现自定义的编码和解码;另一个是RPC建立在抽象的io.ReadWriteCloser接口之上,我们可以将RPC架设在不同的通信协议之上。这里我们将尝试通过官方自带的net/rpc/jsonrpc扩展实现一个跨语言的RPC。
基于json的RPC服务:
golang
func main() {
rpc.RegisterName("UserService", new(UserService))
listener, err := net.Listen("tcp", ":8013")
if err != nil {
log.Fatal("ListenTCP error:", err)
}
for {
conn, err := listener.Accept()
if err != nil {
log.Fatal("Accept error:", err)
}
go rpc.ServeCodec(jsonrpc.NewServerCodec(conn))
}
}
代码中最大的变化是用rpc.ServeCodec()函数替代了rpc.ServeConn()函数,传入的参数是针对服务器端的JSON编解码器。
客户端:
golang
func main() {
conn, err := net.Dial("tcp", "localhost:8013")
if err != nil {
log.Fatal("net.Dial:", err)
}
client := rpc.NewClientWithCodec(jsonrpc.NewClientCodec(conn))
var reply string
err = client.Call("UserService.Say", "hi", &reply)
if err != nil {
log.Fatal(err)
}
fmt.Println(reply)
}
在确保客户端可以正常调用RPC服务的方法之后,我们用一个普通的TCP服务代替Go语言版本的RPC服务,这样可以查看客户端调用时发送的数据格式。例如,通过命令nc -l 8013在同样的端口启动一个TCP服务。然后再次执行一次RPC调用将会发现nc输出了以下的信息:
json
{"method":"UserService.Say","params":["hi"],"id":0}
这是一个JSON编码的数据,其中method部分对应要调用的由RPC服务和方法组合成的名字,params部分的第一个元素为参数,id是由调用方维护的唯一的调用编号。
请求的JSON数据对象在内部对应两个结构体:客户端是clientRequest,服务器端是serverRequest。clientRequest和serverRequest结构体的内容基本是一致的:
golang
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方法调用:
json
$ echo -e '{"method":"UserService.Say","params":["hi"],"id":1}' | nc localhost 8013
返回的结果也是JSON格式的数据:
json
{"id":1,"result":"hi,hi","error":null}
其中id对应输入的id参数,result为返回的结果,error部分在出问题时表示错误信息。对顺序调用来说,id不是必需的。但是Go语言的RPC框架支持异步调用,当返回结果的顺序和调用的顺序不一致时,可以通过id来识别对应的调用。
返回的JSON数据也对应内部的两个结构体:客户端是clientResponse,服务器端是serverResponse。两个结构体的内容同样也是类似的:
golang
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。