基于标准库实现跨语言 RPC 服务

2022年4月5日 21:54

RPC 版本的 “Hello, World”

Go 语言标准库提供了简单的 RPC 实现,包路径为 net/rpc,我们实现的 RPC 方法必须要满足 Go 语言 RPC 要求:

  • 方法只能有两个可序列化参数:string*string
  • 返回一个 error 类型
  • 必须是公开方法

标准库提供的几个关键方法,服务端相关的:

  • rpc.RegisterName(),将对象类型中所有满足 RPC 规则的对象方法注册为 RPC 函数
  • rpc.ServeConn(),在 TCP 连接上为客户端提供 RPC 服务

客户端相关的:

  • rpc.Dial(),拨号 RPC 服务,返回客户端对象 rpc.Clienterror 对象
  • client.Call(),调用具体的 RPC 方法

基于这几个方法,我们可以实现一个非常简单的 RPC 版本 "Hello, World":

服务端代码:

package main

import (
    "fmt"
    "net/rpc"
)

// 服务端代码
type HelloService struct {}
 // 满足 RPC 规则的对象方法
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)
    }
    conn, err := listener.Accept()
    if err != nil {
        log.Fatal("Accept error: ", err)
    }
    rpc.ServeConn(conn)
}

客户端代码

package main

import (
    "fmt"
    "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", "hello", &reply)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(reply)
}

更完善的 RPC

上面的示例简单演示了标准库 RPC 的几个基本接口的用法,但是服务端和客户端都关注了很多 RPC 的底层细节;在日常开发中,往往存在三方角色:服务端、客户端、RPC 框架设计者,其中 RPC 框架设计者需要为服务端和客户端提供 RPC SDK,我们可以将上面的示例进行改造,将服务端、客户端的业务逻辑和 RPC 相关代码做拆分。

服务端 SDK:

package rpcsdk

import "net/rpc"

// server sdk
const HelloServiceName = "HelloService"

type HelloServiceInterface = interface {
    Hello(request string, reply *string) error
}

func RegisterHelloService(svc HelloServiceInterface) error {
    return rpc.RegisterName(HelloServiceName, svc)
}

服务端业务代码:

package main

import (
    "log"
    "net"
    "net/rpc"
    "testRPC/rpcsdk"
)

type HelloService struct{}

// Go RPC 接口形式:两个可序列化参数,返回值 error,必须是公开方法
func (p *HelloService) Hello(request string, reply *string) error {
    *reply = "hello: " + request
    return nil
}

func main() {
    rpcsdk.RegisterHelloService(new(HelloService))
    listener, err := net.Listen("tpc", ":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)
    }
}

客户端 SDK

package rpcsdk

import "net/rpc"

// client sdk
type HelloServiceClient struct {
    *rpc.Client
}

// 确保已经实现了 Hello 方法,否则编译报错
var _ HelloServiceInterface = (*HelloServiceClient)(nil) 

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)
}

客户端业务代码

package main

import (
    "log"
    "testRPC/rpcsdk"
)

func main() {
    client, err := rpcsdk.DialHelloService("tcp", "localhost:1234")
    if err != nil {
        log.Fatal("dialing: ", err)
    }
    var reply string
    err = client.Hello("hello", &reply)
    if err != nil {
        log.Fatal(err)
    }
}

经过修改后,客户端和服务端的业务逻辑和 RPC 代码实现了一定程度的解耦:客户端用户不在需要担心 RPC 方法名或参数类型不匹配的问题;服务端避免了命名服务名称的工作。


跨语言 RPC

标准库 RPC 默认采用 Go 语言特有的 Gob 编码,因此其他语言嗲用 Go 语言实现的 RPC 服务会比较困难。在微服务时代,每个 RPC 及服务的使用者都可能采用不同的编程语言,因此支持跨语言的 RPC 服务是必要,这里简单演示基于net/rpc/jsonrpc 实现一个跨语言的 RPC 服务。

服务端代码:

package main

import (
    "fmt"
    "log"
    "net"
    "net/rpc"
    "net/rpc/jsonrpc"
)


func main() {
    // ...
    listener, err := net.Listen("tcp", "localhost: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(jsonrpc.NewServerCodec(conn))
    }
}

最大的变化是使用 rpc.ServeCodec() 代替了 rpc.Conn() ,传入的参数是针对服务端的 JSON 编解码器。

客户端代码:

func main() {
    //...
    conn, err := new.Dial("tcp", "localhost:1234")
    if err != nil {
        log.Fatal("net.Dial: ", err)
    }
    client := rpc.NewClientWithCodec(jsonrpc.NewClientCodec(conn))
    var reply string
    //...
}

首先建立 TCP 连接,然后基于该连接建立针对客户端的 JSON 编解码器。

结果验证

首先需要清楚客户端请求发出的数据格式是什么,为此我们可以在 1234 端口启动一个 TCP 服务:

nc -l 1234

客户端执行依次 RPC 调用,得到以下信息:

{"method":"HelloService.Hello","params":["hello"],"id":0}

id 为调用方维护的唯一调用编号

得到了发送数据的形式,我们就可以向 RPC 服务端模拟发送数据:

echo -e '{"method":"HelloService.Hello","params":["hello"],"id":1}' | nc localhost 1234

得到返回结果:

{"id":1,"result":"hello:hello","error":null}

无论使用哪种语言,只要遵循同样的 JSON 结构,以同样的流程就可以和 Go 语言编写的 RPC 服务进行通信。


参考资料:

  1. 《Go语言高级编程》——柴树杉/曹春晖