一文吃透Go的内置RPC原理

 更新时间:2023年03月03日 08:43:56   作者:捉虫大师  
这篇文章主要为大家详细介绍了Go语言中内置RPC的原理。说起 RPC 大家想到的一般是框架,Go 作为编程语言竟然还内置了 RPC,着实让我有些吃鲸,本文就来一起聊聊吧

从一个 Demo 入手

为了快速进入状态,我们先搞一个 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 是 Remote Procedure Call 的缩写,一般翻译为远程过程调用,不过我觉得这个翻译有点难懂,啥叫过程?如果查一下 Procedure,就能发现它就是应用程序的意思。

所以翻译过来应该是调用远程程序,说人话就是调用的方法不在本地,不能通过内存寻址找到,只能通过远程通信来调用。

一般来说 RPC 框架存在的意义是让你调用远程方法像调用本地方法一样方便,也就是将复杂的编解码、通信过程都封装起来,让代码写起来更简单。

说到这里其实我想吐槽一下,网上经常有文章说,既然有 Http,为什么还要有 RPC?如果你理解 RPC,我相信你不会问出这样的问题,他们是两个维度的东西,RPC 关注的是远程调用的封装,Http 是一种协议,RPC 没有规定通信协议,RPC 也可以使用 Http,这不矛盾。这种问法就好像在问既然有了苹果手机,为什么还要有中国移动?

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

  • 首先是远程调用,我们是开了一个 Server,监听了9876端口,然后 Client 与之通信,将这两个程序部署在两台机器上,只要网络是通的,照样可以正常工作
  • 其次它符合调用远程方法像调用本地方法一样方便,代码中没有处理编解码,也没有处理通信,只不过方法名以参数的形式传入,和一般的 RPC 稍有不同,倒是很像 Dubbo 的泛化调用

综上两点,这很 RPC。

下面我将用两段内容分别剖析 Go 内置的 RPC Server 与 Client 的原理,来看看 Go 是如何实现一个 RPC 的。

RPC Server 原理

注册服务

这里的服务指的是一个具有公开方法的对象,比如上面 Demo 中的 Arith,只需要调用 Register 就能注册

rpc.Register(arith)

注册完成了以下动作:

  • 利用反射获取这个对象的类型、类名、值、以及公开方法
  • 将其包装为 service 对象,并存在 server 的 serviceMap 中,serviceMap 的 key 默认为类名,比如这里是Arith,也可以调用另一个注册方法 RegisterName 来自定义名称

注册 Http Handle

这里你可能会问,为啥 RPC 要注册 Http Handle。没错,Go 内置的 RPC 通信是基于 Http 协议的,所以需要注册。只需要一行代码:

rpc.HandleHTTP()

它调用的是 Http 的 Handle 方法,也就是 HandleFunc 的底层实现,这块如果不清楚,可以看我之前的文章《一文读懂 Go Http Server 原理》

它注册了两个特殊的 Path:/_goRPC_ 和 /debug/rpc,其中有一个是 Debug 专用,当然也可以自定义。

逻辑处理

注册时传入了 RPC 的 server 对象,这个对象必须实现 Handler 的 ServeHTTP 接口,也就是 RPC 的处理逻辑入口在这个 ServeHTTP 中:

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 Client 是发不出 CONNECT 的请求,也就是 RPC 的 Server 是没办法通过 Go 的 Http Client 访问,限制必须得使用 RPC Client

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"

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

准备好数据、编解码器

在一个大循环里处理每一个请求,处理流程是:

  • 读出请求,包括要调用的service,参数等
  • 通过反射异步地调用对应的方法
  • 将执行结果编码写回连接

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

在高并发下,Server 端的 Request 对象和 Response 对象会频繁地创建,这里用了队列来实现了对象池。以 Request 对象池做个介绍,在 Server 对象中有一个 Request 指针,Request 中有个 next 指针

type Server struct {
	...
	freeReq    *Request
	..
}

type Request struct {
	ServiceMethod string 
	Seq           uint64
	next          *Request
}

在读取请求时需要这个对象,如果池中没有对象,则 new 一个出来,有的话就拿到,并将 Server 中的指针指向 next:

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 使用第一步是 New 一个 Client 对象,在这一步,它偷偷起了一个协程,干什么呢?用来读取 Server 端的返回,这也是 Go 惯用的伎俩。

每一次 Client 的调用都被封装为一个 Call 对象,包含了调用的方法、参数、响应、错误、是否完成。

同时 Client 对象有一个 pending map,key 为请求的递增序号,当 Client 发起调用时,将序号自增,并把当前的 Call 对象放到 pending map 中,然后再向连接写入请求。

写入的请求先后分别为 Request 和参数,可以理解为 header 和 body,其中 Request 就包含了 Client 的请求自增序号。

Server 端响应时把这个序号带回去,Client 接收响应时读出返回数据,再去 pending map 里找到对应的请求,通知给对应的阻塞协程。

这不就能把请求和响应串到一起了吗?这一招很多 RPC 框架也是这么玩的。

Client 、Server 流程都走完,但我们忽略了编解码细节,Go RPC 默认使用 gob 编解码器,这里也稍微介绍下 gob。

gob 编解码

gob 是 Go 实现的一个 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)
}

追踪到底层就是 Encoder 的 EncodeValue 和 DecodeValue 方法,Encode 的细节我不打算写,因为我也不想看这一块,最终结果就是把结构体编码成了二进制数据,调用 writeMessage。

总结

本文介绍了 Go 内置的 RPC Client 和 Server 端原理,能窥探出一点点 RPC 的设计,如果让你实现一个 RPC 是不是有些可以参考呢?

到此这篇关于一文吃透Go的内置RPC原理的文章就介绍到这了,更多相关Go RPC内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • Go语言中你所不知道的位操作用法

    Go语言中你所不知道的位操作用法

    位运算可能在平常的编程中使用的并不多,但涉及到底层优化,一些算法及源码可能会经常遇见。下面这篇文章主要给大家介绍了关于Go语言中你所不知道的位操作用法的相关资料,文中通过示例代码介绍的非常详细,需要的朋友可以参考下。
    2017-12-12
  • Go语言程序开发gRPC服务

    Go语言程序开发gRPC服务

    这篇文章主要为大家介绍了Go语言程序开发gRPC服务,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-06-06
  • Go路由注册方法详解

    Go路由注册方法详解

    Go语言中,http.NewServeMux()和http.HandleFunc()是两种不同的路由注册方式,前者创建独立的ServeMux实例,适合模块化和分层路由,灵活性高,但启动服务器时需要显式指定,后者使用全局默认的http.DefaultServeMux,适合简单场景,感兴趣的朋友跟随小编一起看看吧
    2025-02-02
  • go项目实现mysql接入及web api的操作方法

    go项目实现mysql接入及web api的操作方法

    这篇文章主要介绍了go项目实现mysql接入以及web api,本文通过实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
    2023-08-08
  • Golang使用Swagger文档教程详解

    Golang使用Swagger文档教程详解

    这篇文章主要为大家详细介绍Golang使用Swagger文档教程的相关知识,文中通过示例代码讲解详细,具有一定的借鉴价值,感兴趣的小伙伴可以学习一下
    2023-12-12
  • Golang WebView跨平台的桌面应用库的使用

    Golang WebView跨平台的桌面应用库的使用

    Golang WebView是一个强大的桌面应用库,本文介绍了Golang WebView的特点和使用方法,并列举示例详细的介绍了其在实际项目中的应用,具有一定的参考价值,感兴趣的可以了解一下
    2024-03-03
  • Go语言版本管理module以及go.sum详解

    Go语言版本管理module以及go.sum详解

    本文介绍了Go语言版本管理go module以及go.sum详解,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2024-08-08
  • Golang 编译成DLL文件的操作

    Golang 编译成DLL文件的操作

    这篇文章主要介绍了Golang 编译成DLL文件的操作方式,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧
    2021-05-05
  • goland远程调试k8s上容器的实现

    goland远程调试k8s上容器的实现

    本文主要介绍了goland远程调试k8s上容器的实现,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2023-02-02
  • golang中的net/http库基本使用详解

    golang中的net/http库基本使用详解

    今天给大家分享golang中的net/http库基本使用方法,文章开头给大家详细介绍了标准库net/http如何处理一个请求,结合实例代码给大家介绍的非常详细,感兴趣的朋友跟随小编一起看看吧
    2024-04-04

最新评论