在分布式计算,远程过程调用(Remote Procedure Call,缩写为 RPC)是一个计算机通信协议。该协议允许运行于一台计算机的程序调用另一个地址空间(通常为一个开放网络的一台计算机)的子程序,而程序员就像调用本地程序一样,无需额外地为这个交互作用编程(无需关注细节)。RPC是一种服务器-客户端(Client/Server)模式,经典实现是一个通过发送请求-接受回应进行信息交互的系统。(from Wikipedia)
在通常的使用场景中,我更愿意把RPC称为“远程函数调用”。这是一个标准的C-S模型,服务的提供方将自己的服务注册,由各类框架解析后,客户端再通过较方便的方式来调用各种服务。
这是一个令许多人迷惑的问题。我们明明已经有了HTTP这种方便的方式来调取其他服务,那我们为什么还要使用RPC呢?这难道不是过分设计吗?
我们首先抛出两个需求:
在微信公众号开发上,各种自动回复功能和菜单点击等需要不同的处理逻辑,这些通常是互相无相关性的。 业务越来越庞大,我们在个人信息的接口中需要回复的东西越来越多,这些通常不属于一个模块,需要一个方便的功能来拉取其他模块的个人信息。
我们如果把所有的自动回复这些放到一个服务中,通常大部分的业务是这么做的,那么不可避免地就会提高耦合性。当业务越来越庞大,我们需要增加、删除、或者修改一个自动回复的功能需要修改整份代码。这个时候我们便有了一种简单的、低耦合的解决方案:RPC
服务的提供:
package main
import (
"github.com/MashiroC/begonia-rpc/entity"
"github.com/MashiroC/begonia-sdk"
)
type MathService struct {
}
type HelloService struct {
}
func (s *MathService) Sum(a, b int) (res int) {
return a + b
}
func (h *HelloService) Hello(name string) (res string) {
return "Hello " + name
}
func main() {
cli := begonia.Default(":8080")
cli.Sign("Hello", &HelloService{})
cli.Sign("Math",&MathService{})
cli.KeepConnect()
}
调用一个远程函数:
package main
import (
"fmt"
"github.com/MashiroC/begonia-rpc/entity"
"github.com/MashiroC/begonia-sdk"
)
func main() {
// get a begonia client
cli := begonia.Default(":8080")
// get a service
helloService := cli.Service("Hello")
// get a sync Function
hello := helloService.FunSync("Hello")
// call it!
res, err := hello("MashiroC")
fmt.Println(res, err)
// Hello Mashiroc <nil>
// get a async function
helloAsync := helloService.FunAsync("Hello")
// call it too!
helloAsync(func(res interface{}, err error) {
fmt.Println(res,err)
}, "MashiroC")
}
可能上面的代码会让你迷惑,因为这不是你所见过的任何一个RPC框架的写法。这里所使用的是begonia-rpc,是我自己动手花了一段功夫写的一个轻量级RPC框架。接下来我会从如何使用说起,一直到如何自己动手写一个RPC框架。
现在,我们将分别以使用者、设计者和开发者的角度来分别讲述关于一个RPC框架的落地。
作为一名使用者,我要关注的无非就是三点:
绝大部分的开发者只是作为使用者,关注的是以上三点,当然也会有一部分的开发者想要去了解你的代码与实现,才会去深入了解你所用的技术、技巧。
基于我自己的体验,我设计了一套如上的API。
首先我们需要使用begonia.Default(addr string)
连接到begonia-RPC的服务中心,这个服务中心的功能和设计思路我们下面说
我们使用一个Client.Service(name string)
获得到一个服务,然后再使用Service.FunSync(name string)
获得一个同步远程函数。这里我们获得的远程函数我们可以直接调用,这里的调用使本地命名空间的函数和远程地址的函数的调用不再割裂,就真的是“远程函数”。
我们调用远程函数hello()
之后,返回的有两个值,interface{}
和error
,第一个返回值是我们远程调用的结果,第二个是调用中可能出现的错误。返回值有以下几种情况:
func Add(a, b int) (res int) {
return a + b
}
最简单的返回值类型,只带有一个结果,这个结果将作为本地调用的第一个返回值。
func Divide(a, b int) (res int, err error) {
if b == 0 {
err = errors.New("Divided cannot be zero")
return
}
res = a / b
return
}
最常见的返回类型,返回一个结果并且伴有可能发生的error
。如果在调用过程中没有发生错误,那么这个远程调用的两个返回值将作为本地调用的结果。
func Mod(a ,b int) (res int, m int) {
res = a / b
m = a % b
}
当一个函数的返回值除了error外拥有一个以上返回值后,我们将除了最后一个error
外的所有返回值作为一个[]interface{}
传递给函数的返回值。
对于golang的sdk,begonia-rpc可以传递整形、浮点数、字符串、数组、切片、结构体、map。
如果一个远程函数的返回值是结构体,本地的反序列化会将它转为一个map[string]interface{}
,为了能够方便的提供结构体的支持,这里有一个API可以将函数的返回值绑定到一个结构体上:
假设我们有一个结构体叫Person
,另外还有一个配置好的远程函数TestPerson()
,这个函数会获取一个Person的实例。
var per Person
if err := begonia.Result(TestPerson()).Bind(&per) ; err != nil {
log.Fatal("rpc call error!", err.Error())
}
实际上这一套如上的API是我设计的第二套API了,第一套API实在是丑并且毫无亮点,这里就不再花时间说上一套API了。并且我现在还在考虑要不要把常用的FunSync函数改名为Fun函数。下一步我再考虑使用google的
ProtoBuf
,并且做一个代码生成工具来方便的构建一个rpc客户端。
现在,我们作为一个RPC框架的设计者,来对整体的功能、架构进行设计。
现在绝大部分的RPC框架提供生产者——消费者对应的功能,一个服务去监听一个端口,然后调用者需要知道对方的端口(gRPC是这样做的),每一个服务是以一个端口监听为单位的。我当初动了想要自己写RPC框架的心就是因为gRPC无法提供我想要的需求并且ProtoBuf的编写实在是麻烦。
我们的设计目标是:
接下来我设计了一套单服务中心架构:
我们使用了一个中心化的服务中心来进行整体服务的监控、调度等。这个服务中心是作为注册服务的统一的网关和调度者。
除了服务中心外,所有的注册的服务节点是水平的关系,并不存在明确的客户端
或服务端
的划分。我们称每一条连接到了服务中心的服务都是一个服务节点
,包括RPC center
提供的一个默认服务center service
,都是水平的服务节点。
既然这个服务中心是服务的统一网关,那么所有的服务调用请求都将要先发送至服务中心。服务中心收到函数调用请求后转发到对应服务的网络地址,并且等待一个响应。当服务器收到响应再继续转发回客户端进程。这是一个标准的请求(Request)/应答(Response)
模型。
我们发送一个远程函数调用请求的流程如下:
根据上图的模型,不难发现如果使用HTTP协议
将最为方便有效,但是我们并没有使用HTTP协议
,我们使用的是TCP
来进行通信,其中有以下几个考量:
TCP
的全双工连接更符合整个模型TCP
的效率更高但是使用TCP又带来了两个问题:
TCP
是典型的异步通讯模型,然而我们的服务模型是一个同步模型。TCP
的Socket编程
对于我本人近乎是一个未知的领域。第二个问题其实很容易解决,买本书顺便写几个月就不是问题了(逃
当我们使用了TCP
之后,那么我们远程函数调用的请求流程将要发生一些改变
上图中,紫色的部分是基础的TCP连接,所有跨过泳道的连线都代表数据的传输。
从客户端开始到服务端结束的蓝色的部分是远程函数的请求流程,相反顺序的绿色部分是远程函数的响应流程。
我们使用了TCP,并且决定让包的格式为二进制而非文本格式的包,所以将每一个数据包称为帧
,其中帧又分为请求帧
、响应帧
等。这个我们接下来再说。
虽然begonia-rpc上的所有服务节点都是平行的,都是服务中心的客户端,但是当发生一个函数调用的时候,我们将调用方称为客户端,同等的,被调用方称为服务端。
我们能够从图中发现客户端在发出请求帧之后就开始等待,直到获得它的响应或者超时。
到这里已经完成了一个rpc框架的基本设计,我们现在需要给它添加更多的功能。
实际上接下来的大部分功能目前我还没有完成,但是写blog我一定要写出来(x
目前我们设计的整个RPC都是裸着跑在网络上的,这样的问题很大,我们不应该让任何一个服务没有鉴权就直接允许调用和注册服务,相当于敞开着家里的大门。鉴权的思路有以下两个方案,我还没有选择好使用哪个方案。
我们回到最初的需求,我们希望解耦业务中的各个模块,并且让个人信息能够轻松的从各个模块拉取。但是,但是这里就出现了一个问题:我们上述设计的RPC不能提供这样的需求,无法注册一个同名的服务。这里我们就可以使用tag
功能来为一个服务的某个函数打上tag
,然后我们从Center Service
来拉取拥有某个tag
的所有函数,逐次调用即可。当然我们对于相同tag
的数据返回应该有一个约定俗成。
这个是大部分框架都会提供的功能,不再赘述。
同上,不再赘述。
我们现在已经有了一个基本的RPC框架设计。当然现在只是设计,设计还需要通过代码来实现,下面几篇我将会分别从不同的角度来讲解我们的设计如何实现(并且我也真的是这么实现的)。从开发者角度上我们将会有更多的事情来讨论:完成功能、高效优化、错误处理、容灾,等等。
领取专属 10元无门槛券
私享最新 技术干货