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 方法,接收 SumRequestSumReply 两个参数,求和后将结果放回到 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 来调用 HandlerServeHTTP(ResponseWriter, *Request) 方法来处理,对于 GoRPC 包来说,则可以实现该方法来处理 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

参考