Advertisement

一文吃透 Go 内置 RPC 原理

阅读量:

各位读者好!
我是小楼(我的名字),这篇文章属于《Go底层原理剖析》系列的第三篇。
接下来我会继续深入探讨Http模块的工作原理。
今天我们将聚焦于一种非常特别的技术——
它就是在编程语言领域中最被低估的存在,
它就是... 哈哈,
其实说实在话,
它就是... 真的好厉害!

从一个 Demo 入手

为了迅速达到工作状态,我们先搭建一个 Demo 项目,并基于 Go 源码 src/net/rpc/server.go 做了些许改动。

  • 首先定义请求的入参和出参:
复制代码
    package common
    
    type Args struct {
    	A, B int
    }
    
    type Quotient struct {
    	Quo, Rem int
    }
  • 接着在定义一个对象,并给这个对象写两个方法
复制代码
    type Arith struct{}
    
    func (t *Arith) Multiply(args *common.Args, reply *int) error {
    	*reply = args.A * args.B
    	return nil
    }
    
    func (t *Arith) Divide(args *common.Args, quo *common.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 server:
复制代码
    func main() {
    	arith := new(Arith)
    	rpc.Register(arith)
    	rpc.HandleHTTP()
    	l, e := net.Listen("tcp", ":9876")
    	if e != nil {
    		panic(e)
    	}
    
    	go http.Serve(l, nil)
    
    	var wg sync.WaitGroup
    	wg.Add(1)
    	wg.Wait()
    }
  • 最后初始化 RPC Client,并发起调用:
复制代码
    func main() {
    	client, err := rpc.DialHTTP("tcp", "127.0.0.1:9876")
    	if err != nil {
    		panic(err)
    	}
    
    	args := common.Args{A: 7, B: 8}
    	var reply int
      // 同步调用
    	err = client.Call("Arith.Multiply", &args, &reply)
    	if err != nil {
    		panic(err)
    	}
    	fmt.Printf("Call Arith: %d * %d = %d\n", args.A, args.B, reply)
    
      // 异步调用
    	quotient := new(common.Quotient)
    	divCall := client.Go("Arith.Divide", args, quotient, nil)
    	replyCall := <-divCall.Done
    
    	fmt.Printf("Go Divide: %d divide %d = %+v %+v\n", args.A, args.B, replyCall.Reply, quotient)
    }

如果不出意外,RPC 调用成功

这 RPC 吗

在剖析原理之前,我们先想想什么是 RPC?

RPC即为RemoteProcedureCall这一术语的缩写形式,在技术文献中常被常见译名为"远程程序调用"。个人认为这一译名略显晦涩不易理解。那么'过程'具体指什么呢?若查阅相关术语解释,则会发现原来Process一词在程序设计中特指应用程序或子系统等实体。

因此,在翻译后就是执行远程程序的操作;简单来说就是该方法无法直接在本地机器上运行或访问;必须依赖于远程通信机制进行操作;也就是说,在本地系统中无法直接获取该方法的入口或资源定位信息;必须依靠特定的远程调用协议或工具来进行实现。

常见RPC框架的作用在于简化远程服务的使用流程,在于使调用远程服务的过程与本地服务一致且便捷。具体而言,RPC框架通过整合复杂的一一映射编码/解码逻辑以及通信机制,使得客户端能够以更直观的方式编写代码。

到此为止我认为有些话难以认同网上的一些文章会提到虽然HTTP作为一种基础协议已经非常成熟但为何还需要引入RPC这项技术从技术实现的角度来看RPC与HTTP之间确实存在互补关系两者并非完全对立

扯远了,我们回头看一下上述的例子是否符合我们对 RPC 的定义。

  • 首先部署了一个服务器,并将其绑定在9876端口上随后客户端与之进行通信从而实现了两个程序间的协同工作无论网络是否畅通都能保持正常运行
  • 它严格遵循了远程方法调用的基本流程即通过参数传递给被调用方而未涉及编码解码或数据传输过程这与其说它遵循了某种模式不如说它借鉴了Dubbo的技术架构这种设计思路使得实现更加简洁而易于扩展

综上两点,这很 RPC。

下面我会分为两个部分来详细阐述Go内置RPC服务器与客户端的工作原理,并深入分析其实现机制。

RPC Server 原理

注册服务

这里的服务特指一个提供公开接口的对象,在 Demo 中定义的一个类 Arith 为例,请问能否通过调用 Register 即可完成注册过程。

复制代码
    rpc.Register(arith)

注册完成了以下动作:

  • 通过反射机制获取该对象的类别信息及其名称和实例值,并访问其公共方法。
    • 将此对象封装成服务对象并存入服务器的服务映射表中。
      • 其中服务映射表的键默认基于类名。
        • 比如这里是Arith。
        • 还可以通过另一个注册函数 RegisterName 来指定自定义名称。

注册 Http Handle

或许你可能会好奇为何RPC会选择使用Http协议来实现数据交互。实际上,在Go语言中,默认配置已经将RPC通信集成到Http框架下,并因此需要进行额外的配置以确保正常运行。例如,在Go语言中,默认配置已经将RPC通信集成到Http框架下,并因此需要进行额外的配置以确保正常运行。

复制代码
    rpc.HandleHTTP()

该接口采用了 Http 的 Handle 方法这一核心功能模块进行处理,并基于其底层逻辑完成了一系列业务流程的执行。如需进一步了解相关内容,请参考我之前的文章《一文读懂 Go Http Server 原理》

该系统注册了两个特殊的路径:/_goRPC_/debug/rpc。其中一个是用于调试的路径;当然还可以根据需要进行定制。

逻辑处理

在注册过程中注入了一个RPC服务器实例,在这之后该实例必须被配置为实现该Handler类中的ServeHTTP接口,并且RPC处理逻辑将在此处进行。

复制代码
    type Handler interface {
    	ServeHTTP(ResponseWriter, *Request)
    }

我们看 RPC Server 是如何实现这个接口的:

复制代码
    // 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)
    }

我对这段代码标了号,逐一看:

  • ①:强制要求请求中的Method字段只能设置为CONNECT值;如果未设置该值,则服务器将立即返回错误信息。
    为什么要这样做?通过查看Method字段的注释信息可清楚理解其背后的逻辑。
    在Go语言中使用的Http客户端库不具备发送CONNECT方法的能力。
复制代码
    type Request struct {
    	// Method specifies the HTTP method (GET, POST, PUT, etc.).
    	// For client requests, an empty string means GET.
    	//
    	// Go's HTTP client does not support sending a request with
    	// the CONNECT method. See the documentation on Transport for
    	// details.
    	Method string
    }
  • ②:Hijack 是抓取 Http 连接的行为,在此之后需自行管理连接的终止过程;其目的是复用通道
复制代码
    "HTTP/1.0 200 Connected to Go RPC \n\n"

④:开始真正的处理,这里段比较长,大致做了如下几点事情:

  • 准备好了数据源和编码解码组件。
  • 在主处理循环中依次接收每个请求。
    • 解析每个 incoming 请求信息。
    • 使用异步方式动态获取并调用所需服务的方法。
    • 将计算结果经过编码后返回给客户端连接。

说到这里,代码中有个对象池的设计挺巧妙,这里展开说说。

在高并发场景下,服务器端的Request与Response对象会在短时间内频繁生成,并通过队列机制实现了请求池管理。详细阐述Request对象池的工作原理时,在Server对象中设置了Request指针字段,并为每个Request对象都包含指向下一个请求的对象引用字段(next pointer)。

复制代码
    type Server struct {
    	...
    	freeReq    *Request
    	..
    }
    
    type Request struct {
    	ServiceMethod string 
    	Seq           uint64
    	next          *Request
    }

当处理一个读取请求时,若资源池中不存在可用资源,则生成一个新的实例提供给客户端使用;如果有现成的对象,则获取该对象,并将服务器端的引用指向下一个对象。

复制代码
    func (server *Server) getRequest() *Request {
    	server.reqLock.Lock()
    	req := server.freeReq
    	if req == nil {
    		req = new(Request)
    	} else {
    		server.freeReq = req.next
    		*req = Request{}
    	}
    	server.reqLock.Unlock()
    	return req
    }

请求处理完成时,释放这个对象,插入到链表的头部

复制代码
    func (server *Server) freeRequest(req *Request) {
    	server.reqLock.Lock()
    	req.next = server.freeReq
    	server.freeReq = req
    	server.reqLock.Unlock()
    }

画个图整体感受下:

回到正题,在Client与Server之间仅存在一条通信通道的情况下,在采用异步执行模式时(即非阻塞方式),如何确保返回的数据准确无误?若一次性完成叙述后,则接下来的一节中的Client就无法继续参与讨论了。

RPC Client 原理

创建一个Client对象是Client应用的第一步,在这一步骤中暗中启动了一个协程以完成特定任务。该协程的主要功能是用于接收Server端的响应信息这也是开发者的常见做法。

每个客户端的请求都被封装成一个Call对象对象,并且该Call对象包含调用方法、参数设置、响应内容以及可能出现的错误信息和完成状态。

在 Client 对象中存在一个称为 pending 的映射表,在其内部定义了一个名为 key 的字段用于存储请求的唯一标识符。每当 Client发起一次调用操作时,系统会自动递增当前请求的编号,并将其Call对象记录到该pending映射表中;随后立即发送该请求至目标连接处以供处理完毕后查询结果。

表示为两个部分依次是Request和参数。也可视为header和body,在此过程中Request包含Client的请求自增序号。

当服务器端发送响应时,将该序号传输回;客户端接收到响应后解析返回的数据;然后,在pending map中查找与之对应的请求;最后,将此请求指派给相应的阻塞 coroutine。

该方法能将请求与响应串联起来吗?这一策略在许多RPC框架中得到了应用。

Client和Server流程全部完成。但未对编解码过程进行深入处理。Go RPC协议通常会采用 gob 格式进行编码与解码。同时简要介绍gob编码器的相关知识。

Client和Server流程全部完成。但未对编解码过程进行深入处理。Go RPC协议通常会采用 gob 格式进行编码与解码。同时简要介绍gob编码器的相关知识。

gob 编解码

gob可视为协议的一个组成部分,该协议具备良好的Go亲和性,仅限于在Go语言环境中应用。对于Go Client RPC而言,其对编解码接口的规定如下:

复制代码
    type ClientCodec interface {
    	WriteRequest(*Request, interface{}) error
    	ReadResponseHeader(*Response) error
    	ReadResponseBody(interface{}) error
    
    	Close() error
    }

同理,Server 端也有一个定义:

复制代码
    type ServerCodec interface {
    	ReadRequestHeader(*Request) error
    	ReadRequestBody(interface{}) error
    	WriteResponse(*Response, interface{}) error
      
    	Close() error
    }

gob 是其一个实现,这里只看 Client:

复制代码
    func (c *gobClientCodec) WriteRequest(r *Request, body interface{}) (err error) {
    	if err = c.enc.Encode(r); err != nil {
    		return
    	}
    	if err = c.enc.Encode(body); err != nil {
    		return
    	}
    	return c.encBuf.Flush()
    }
    
    func (c *gobClientCodec) ReadResponseHeader(r *Response) error {
    	return c.dec.Decode(r)
    }
    
    func (c *gobClientCodec) ReadResponseBody(body interface{}) error {
    	return c.dec.Decode(body)
    }

深入底层查看的是编码器中的EncodeValue和DecodeValue方法。我对其中的Encode细节不做深入解析是因为这部分内容对我来说较为复杂且不感兴趣。其核心作用就是将结构体转换为二进制数据,并在此基础上触发writeMessage函数。

总结

本文阐述了Go语言中内建的RPC客户端与服务器端机制。通过这一内容的学习,你大致了解RPC的一些设计理念。如果你希望实现一个RPC功能是否有一些参考依据呢?

最初在草稿上录入了大量代码。然而认为这样解析内容难以深入理解。因此反复删除了这些代码。

不过还有一点是我本想探讨却未涉及的内容。文章主要介绍了Go语言内置RPC的基本概念及其实现方法。关于其优缺点以及能否在实际生产环境中应用,则是本文未曾触及的问题。而文章并未深入探讨这一主题的相关细节与应用场景。计划在未来一期的文章中详细介绍这一主题。如需更多内容,请持续关注我们的更新。期待下期与您分享更多内容。欢迎转发、收藏、点赞。

往期回顾

加入关注微信公众号"捉虫大师"后账号即可获得深度分享,
通过本平台可以获得后端技术相关内容的分享与交流,
涵盖架构设计、系统性能优化、源码学习等内容,
还有各种问题解决经验总结以及踩过的坑和心得分享。

全部评论 (0)

还没有任何评论哟~