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的处理:

参考文档

posted @ 2022-09-22 15:55  专职  阅读(228)  评论(0编辑  收藏  举报