MIT 6.824 - Lab 1 (4): Go RPC

Lab 1 虽然是个单机器多线程、多进程的程序,但主节点和工作节点的交互依然通过 RPC 实现,Go 本身也提供了开箱即用的 RPC 功能,下面将通过一个简单的求和服务来了解在 Go 中如何实现一个 RPC 服务。

定义请求体和响应体

请求体和响应体都非常简单,SumRequest 中包含要求和的两个数字,SumReply 中存放求和的结果:

1
2
3
4
5
6
7
8
9
10
package pb

type SumRequest struct {
A int
B int
}

type SumReply struct {
Result int
}

服务端

首先定义服务类 SumService 和提供的方法:

1
2
3
4
5
6
7
8
type SumService struct {
}

func (sumService *SumService) Sum(sumRequest *pb.SumRequest, sumReplay *pb.SumReply) error {
sumReplay.Result = sumRequest.A + sumRequest.B

return nil
}

SumService 只有一个 Sum 方法,接收 SumRequest 和 SumReply 两个参数,求和后将结果放回到 SumReply 中(Sum 的方法签名必须是这样的形式,即两个入参和一个 error 类型的出参,具体规则见下文描述)。

然后进行服务注册:

1
2
3
sumService := &SumService{}
rpc.Register(sumService)
rpc.HandleHTTP()

通过 rpc.Register 这个方法可以知道,一个服务类及其提供的方法必须满足以下条件才能注册成功:

  1. 服务类必须是公共的
  2. 服务类提供的方法必须是公共的
  3. 服务类提供的方法入参必须是两个,一个表示请求,一个表示响应(从编码的角度来说方法入参是两个,但是实际代码是判断是否等于3个,因为在这种场景下定义的方法的第一个入参类似于 Java 中的 this)
  4. 服务类提供的方法的第一个参数类型必须是公共的或者是 Go 内置的数据类型
  5. 服务类提供的方法的第二个参数类型也必须是公共的或者是 Go 内置的数据类型,且必须是指针类型
  6. 服务类提供的方法的出参个数只能是1个
  7. 服务类提供的方法的出参类型必须是 error

而 rpc.HandleHTTP() 表示通过 HTTP 作为客户端和服务端间的通信协议,当客户端发起一个 RPC 调用时,本质上是将要调用的方法和参数包装成一个 HTTP 请求,服务端收到 HTTP 请求后,解码出要调用的本地方法名称和入参,然后调用本地方法,在本地方法调用完成后再将结果写入到 HTTP 响应中,客户端收到响应后,再解析出远程调用的结果。

rpc.HandleHTTP() 本质上是个 HTTP 路由注册,实际上是调用 Handle(pattern string, handler Handler) 方法,当请求路由匹配 pattern 时,会调用对应的 handler 执行,对于 Go RPC 来说,固定路由路径是 /_goRPC_。

所以,在完成 HTTP 路由注册后,还需要配合开启一个 HTTP 服务,这样才能接受远程服务调用:

1
2
3
4
5
6
7
8
listener, err := net.Listen("tcp", ":1234")

if err != nil {
log.Fatal("listen error:", err)
}

fmt.Println("Listening on port 1234")
http.Serve(listener, nil)

http.Serve 方法中对于每一个客户端的连接,最终会分配一个 goroutine 来调用 Handler 的 ServeHTTP(ResponseWriter, *Request) 方法来处理,对于 Go 的 RPC 包来说,则可以实现该方法来处理 RPC 请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ServeHTTP implements an http.Handler that answers RPC requests.
func (server *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) {
if req.Method != "CONNECT" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusMethodNotAllowed)
io.WriteString(w, "405 must CONNECT\n")
return
}
conn, _, err := w.(http.Hijacker).Hijack()
if err != nil {
log.Print("rpc hijacking ", req.RemoteAddr, ": ", err.Error())
return
}
io.WriteString(conn, "HTTP/1.0 "+connected+"\n\n")
server.ServeConn(conn)
}

客户端

对于客户端来说,发起远程方法调用前需要先和服务端建立连接:

1
2
3
4
5
6
7
client, err := rpc.DialHTTP("tcp", ":1234")

if err != nil {
log.Fatal("dialing:", err)
}

defer client.Close()

该方法同时返回了一个 RPC 客户端类,内部同时负责对 RPC 请求的编码和解码。

然后通过 client.Call 来发起远程调用:

1
2
3
4
5
6
7
8
9
10
11
12
sumRequest := &pb.SumRequest{
A: 1,
B: 2,
}
sumReply := &pb.SumReply{}
err = client.Call("SumService.Sum", sumRequest, sumReply)

if err != nil {
log.Fatal("call error:", err)
}

fmt.Println("Result:", sumReply.Result)

这里的调用一共有三个参数,第一个是被调用的远程方法名,需要是 类名.方法名 的形式,后两个则是远程方法的约定入参。

完整的代码可参考 go-rpc-demo。

参考