go官方RPC库
go官方rpc库:net/rpc
包rpc提供了通过网络访问一个对象的输出方法的能力。
服务器需要注册对象, 通过对象的类型名暴露这个服务。注册后这个对象的输出方法就可以远程调用,这个库封装了底层传输的细节,包括序列化(默认GOB序列化器)。
服务器可以注册多个不同类型的对象,但是注册相同类型的多个对象的时候会出错。
同时,如果对象的方法要能远程访问,它们必须满足一定的条件,否则这个对象的方法会被忽略。
这些条件是:
- 方法的类型是可输出的
- 方法本身也是可输出的
- 方法必须有两个参数,必须是输出类型或者是内建类型
- 方法的第二个参数必须是指针类型
- 方法返回类型为error
所以一个输出方法的格式如下:
func (t *T) MethodName(argType T1, replyType *T2) error
这里的T、T1、T2能够被encoding/gob序列化,即使使用其它的序列化框架,将来的这个需求可能会被弱化,
这个方法的第一个参数代表调用者(client)提供的参数,第二个参数代表要返回给调用者的计算结果。
如果返回error,则reply参数不会返回给调用者。
服务器通过调用ServeConn在一个连接上处理请求,更典型地, 它可以创建一个network listener然后accept请求。
对于HTTP listener来说,可以调用 HandleHTTP 和 http.Serve。细节会在下面介绍。
客户端可以调用Dial和DialHTTP建立连接。 客户端有两个方法调用服务: Call 和 Go,可以同步地或者异步地调用服务。
当然,调用的时候,需要把服务名、方法名和参数传递给服务器。异步方法调用Go 通过 Done channel通知调用结果返回。
除非显示的设置codec,否则这个库默认使用包encoding/gob作为序列化框架。
简单例子
首选介绍一个简单的例子, 这个例子摘自官方标准库,是一个非常简单的服务。
这个例子中提供了对两个数相乘和相除的两个方法。
第一步你需要定义传入参数和返回参数的数据结构:
package server
type Args struct {
A, B int
}
type Quotient struct {
Quo, Rem int
}
第二步定义一个服务对象,这个服务对象可以很简单, 比如类型是int或者是interface{},重要的是它输出的方法。
这里我们定义一个算术类型Arith,其实它是一个int类型,但是这个int的值我们在后面方法的实现中也没用到,所以它基本上就起一个辅助的作用。
type Arith int
第三步实现这个类型的两个方法乘法和除法
func (a *Arith) Multiply(args *Args, reply *int) error {
*reply = args.A * args.B
return nil
}
func (a *Arith) Divide(args *Args, quo *Quotient) error {
if args.B == 0 {
return errors.New("divide by zero")
}
quo.Quo = args.A / args.B
quo.Rem = args.A % args.B
return nil
}
目前为止,我们的准备工作已经完成,喝口茶继续下面的步骤。
第四步实现RPC服务器
func main() {
// 实例化一个算数服务
arith := new(Arith)
// 注册服务
_ = rpc.Register(arith)
rpc.HandleHTTP()
l, e := net.Listen("tcp", ":8000")
if e != nil {
log.Fatalln("net.Listen Fail", e)
}
go func() {
_ = http.Serve(l, nil)
}()
select {
}
}
这里我们生成了一个Arith对象,并使用rpc.Register注册这个服务,然后通过HTTP暴露出来。
客户端可以看到服务Arith以及它的两个方法Arith.Multiply和Arith.Divide。
第五步创建一个客户端,建立客户端和服务器端的连接:
func main() {
client, err := rpc.DialHTTP("tcp", ":8000")
if err != nil {
log.Fatalln("rpc.DialHTTP Fail:", err)
}
}
然后客户端就可以进行远程调用了,比如同步的方式调用:
args := &Args{A: 9, B: 2}
var reply int
err = client.Call("Arith.Multiply", args, &reply)
if err != nil {
log.Println("client.Call 【Arith.Multiply fail】:", err)
}
fmt.Printf("Arith:%d*%d=%d", args.A, args.B, reply)
或者异步的方式:
args := &Args{A: 9, B: 2}
reply := new(Quotient)
multiplyDone := client.Go("Arith.Divide", args, reply, nil)
replyCall := <- multiplyDone.Done
replay := replyCall.Reply.(*Quotient)
fmt.Printf("Arith:%d/%d =%d,%d\n", args.A, args.B, replay.Quo, replay.Rem)
服务器代码分析
首先, net/rpc定义了一个缺省的Server,所以Server的很多方法你可以直接调用,这对于一个简单的Server的实现更方便,但是你如果需要配置不同的Server,
比如不同的监听地址或端口,就需要自己生成Server:
var DefaultServer = NewServer()
Server有多种Socket监听的方式:
func (server *Server) Accept(lis net.Listener)
func (server *Server) HandleHTTP(rpcPath, debugPath string)
func (server *Server) ServeCodec(codec ServerCodec)
func (server *Server) ServeConn(conn io.ReadWriteCloser)
func (server *Server) ServeHTTP(w http.ResponseWriter, req *http.Request)
func (server *Server) ServeRequest(codec ServerCodec) error
其中, ServeHTTP 实现了处理 http请求的业务逻辑, 它首先处理http的 CONNECT请求, 接收后就Hijacker这个连接conn, 然后调用ServeConn在这个连接上 处理这个客户端的请求。
它其实是实现了 http.Handler接口,我们一般不直接调用这个方法。
`Server.HandleHTTP`设置rpc的上下文路径,`rpc.HandleHTTP`使用默认的上下文路径`DefaultRPCPath`、 DefaultDebugPath。
这样,当你启动一个http server的时候 `http.ListenAndServe`,上面设置的上下文将用作RPC传输,这个上下文的请求会教给ServeHTTP来处理。
以上是RPC over http的实现,可以看出net/rpc只是利用http CONNECT建立链接,这和普通的 RESTful api还是不一样的。
Accept
用来处理一个监听器,一直在监听客户端链接,一旦监听器收到了一个连接,则还是交给ServeConn交给另外一个goroutine去处理:
func (server *Server) Accept(lis net.Listener) {
for {
conn, err := lis.Accept()
if err != nil {
log.Print("rpc.Serve: accept:", err.Error())
return
}
go server.ServeConn(conn)
}
}
可以看出很重的一个方法是ServeConn
:
func (server *Server) ServeConn(conn io.ReadWriteCloser) {
buf := bufio.NewWriter(conn)
srv := &gobServerCodec{
rwc: conn,
dec: gob.NewDecoder(conn),
enc: gob.NewEncoder(buf),
encBuf: buf,
}
server.ServeCodec(srv)
}
链接其实是交给一个ServeCodec去处理,这里默认使用gobServerCodec去处理,这是一个默认的编解码器,你也可以使用其它的编解码器,我们下面在介绍,这里我们看看ServeCodec是怎么实现的:
// ServeCodec is like ServeConn but uses the specified codec to
// decode requests and encode responses.
func (server *Server) ServeCodec(codec ServerCodec) {
sending := new(sync.Mutex)
wg := new(sync.WaitGroup)
for {
service, mtype, req, argv, replyv, keepReading, err := server.readRequest(codec)
if err != nil {
if debugLog && err != io.EOF {
log.Println("rpc:", err)
}
if !keepReading {
break
}
// send a response if we actually managed to read a header.
if req != nil {
server.sendResponse(sending, req, invalidRequest, codec, err.Error())
server.freeRequest(req)
}
continue
}
wg.Add(1)
go service.call(server, sending, wg, mtype, req, argv, replyv, codec)
}
// We've seen that there are no more requests.
// Wait for responses to be sent before closing codec.
wg.Wait()
codec.Close()
}
它其实一直从连接中读取请求,然后调用go service.call
,在另外的goroutine中处理服务调用。
我们从中可以学到
- 对象重用。 Request和Response都是可重用的,通过Lock处理竞争。这在大并发的情况下很有效。 有兴趣的读者可以参考fasthttp的实现。
- 使用了大量的goroutine。 和Java中的线程不同,你可以创建非常多的goroutine, 并发处理非常好。 如果使用一定数量的goutine作为worker池去处理这个case,可能还会有些性能的提升,但是更复杂了。使用goroutine已经获得了非常好的性能。
- 业务处理是异步的, 服务的执行不会阻塞其它消息的读取。
注意一个codec实例必然和一个connnection相关,因为它需要从connection中读取request和发送response。
go的rpc官方库的消息(request和response)的定义很简单, 就是消息头(header)+内容体(body)。
请求的消息头的定义如下,包括服务的名称和序列号:
type Request struct {
ServiceMethod string // format: "Service.Method"
Seq uint64 // sequence number chosen by client
next *Request // for free list in Server
}
消息体就是传入的参数。
返回的消息头的定义如下:
type Response struct {
ServiceMethod string // echoes that of the Request
Seq uint64 // echoes that of the request
Error string // error, if any.
next *Response // for free list in Server
}
消息体是reply类型的序列化后的值。
Server还提供了两个注册服务的方法:
// Register publishes the receiver's methods in the DefaultServer.
func Register(rcvr any) error { return DefaultServer.Register(rcvr) }
// RegisterName is like Register but uses the provided name for the type
// instead of the receiver's concrete type.
func RegisterName(name string, rcvr any) error {
return DefaultServer.RegisterName(name, rcvr)
}
第二个方法为服务起一个别名,否则服务名已它的类型命名,它们俩底层调用register进行服务的注册。
func (server *Server) register(rcvr any, name string, useName bool) error
受限于Go语言的特点, 我们不可能在接到客户端的请求的时候,根据反射动态的创建一个对象,就是Java那样,
因此在Go语言中,我们需要预先创建一个服务map这是在编译的时候完成的:
// serviceMap sync.Map // map[string]*service
server.serviceMap = make(map[string]*service)
同时每个服务还有一个方法map: map[string]*methodType,通过suitableMethods建立:
func suitableMethods(typ reflect.Type, logErr bool) map[string]*methodType
这样rpc在读取请求header,通过查找这两个map,就可以得到要调用的服务及它对应的方法了,方法的调用:
func (s *service) call(server *Server, sending *sync.Mutex, wg *sync.WaitGroup, mtype *methodType, req *Request, argv, replyv reflect.Value, codec ServerCodec) {
if wg != nil {
defer wg.Done()
}
mtype.Lock()
mtype.numCalls++
mtype.Unlock()
function := mtype.method.Func
// Invoke the method, providing a new value for the reply.
returnValues := function.Call([]reflect.Value{s.rcvr, argv, replyv})
// The return value for the method is an error.
errInter := returnValues[0].Interface()
errmsg := ""
if errInter != nil {
errmsg = errInter.(error).Error()
}
server.sendResponse(sending, req, replyv.Interface(), codec, errmsg)
server.freeRequest(req)
}
客户端源码分析
客户端要建立和服务器的连接,可以有以下几种方式:
func Dial(network, address string) (*Client, error)
func DialHTTP(network, address string) (*Client, error)
func DialHTTPPath(network, address, path string) (*Client, error)
func NewClient(conn io.ReadWriteCloser) *Client
func NewClientWithCodec(codec ClientCodec) *Client
DialHTTP 和 DialHTTPPath是通过HTTP的方式和服务器建立连接,他俩的区别之在于是否设置上下文路径:
// DialHTTPPath connects to an HTTP RPC server
// at the specified network address and path.
func DialHTTPPath(network, address, path string) (*Client, error) {
conn, err := net.Dial(network, address)
if err != nil {
return nil, err
}
io.WriteString(conn, "CONNECT "+path+" HTTP/1.0\n\n")
// Require successful HTTP response
// before switching to RPC protocol.
resp, err := http.ReadResponse(bufio.NewReader(conn), &http.Request{Method: "CONNECT"})
if err == nil && resp.Status == connected {
return NewClient(conn), nil
}
if err == nil {
err = errors.New("unexpected HTTP response: " + resp.Status)
}
conn.Close()
return nil, &net.OpError{
Op: "dial-http",
Net: network + " " + address,
Addr: nil,
Err: err,
}
}
首先发送 CONNECT 请求,如果连接成功则通过NewClient(conn)创建client。
而Dial则通过TCP直接连接服务器:
func Dial(network, address string) (*Client, error) {
conn, err := net.Dial(network, address)
if err != nil {
return nil, err
}
return NewClient(conn), nil
}
根据服务是over HTTP还是 over TCP选择合适的连接方式。
NewClient则创建一个缺省codec为gob序列化库的客户端:
func NewClient(conn io.ReadWriteCloser) *Client {
encBuf := bufio.NewWriter(conn)
client := &gobClientCodec{conn, gob.NewDecoder(conn), gob.NewEncoder(encBuf), encBuf}
return NewClientWithCodec(client)
}
如果你想用其它的序列化库,你可以调用NewClientWithCodec方法<:></:>
func NewClientWithCodec(codec ClientCodec) *Client {
client := &Client{
codec: codec,
pending: make(map[uint64]*Call),
}
go client.input()
return client
}
重要的是input方法,它以一个死循环的方式不断地从连接中读取response,然后调用map中读取等待的Call.Done channel通知完成。
消息的结构和服务器一致,都是Header+Body的方式。
客户端的调用有两个方法: Go 和 Call。 Go方法是异步的,它返回一个 Call指针对象, 它的Done是一个channel,如果服务返回,
Done就可以得到返回的对象(实际是Call对象,包含Reply和error信息)。 Call是同步的方式调用,它实际是调用Go实现的,
我们可以看看它是怎么实现的,可以了解一下异步变同步的方式:
// Call invokes the named function, waits for it to complete, and returns its error status.
func (client *Client) Call(serviceMethod string, args any, reply any) error {
call := <-client.Go(serviceMethod, args, reply, make(chan *Call, 1)).Done
return call.Error
}
从一个Channel中读取对象会被阻塞住,直到有对象可以读取,这种实现很简单,也很方便。
其实从服务器端的代码和客户端的代码实现我们还可以学到锁Lock的一种实用方式,也就是尽快的释放锁,而不是defer mu.Unlock直到函数执行到最后才释放,那样锁占用的时间太长了。
codec/序列化框架
前面我们介绍了rpc框架默认使用gob序列化库,很多情况下我们追求更好的效率的情况下,或者追求更通用的序列化格式,我们可能采用其它的序列化方式, 比如protobuf, json, xml等。
gob序列化库有个要求,就是对于接口类型的值,你需要注册具体的实现类型:
func Register(value interface{})
func RegisterName(name string, value interface{})
初次使用rpc的人容易犯这个错误,导致序列化不成功。
Go官方库实现了JSON-RPC 1.0。JSON-RPC是一个通过JSON格式进行消息传输的RPC规范,因此可以进行跨语言的调用。
Go的net/rpc/jsonrpc库可以将JSON-RPC的请求转换成自己内部的格式,比如request header的处理: