同学们好!单体架构系统中的各个业务模块之间只是普通的函数或类方法调用。微服务架构系统中的每个业务模块都是独立的程序,甚至可能运行在多台计算机上,彼此间的交互需要跨越进程和网络,这就要用到所谓的远程过程调用,即RPC。和所有的网络通信一样,参与远程过程调用的各方必要遵循同一套标准协议,即RPC协议。这就是我们在这节课所要学习的主要内容。


首先我们需要了解有关RPC的基本概念,以及在微服务架构中为什么需要使用RPC,然后通过一个简单的示例程序,体会一下在Go语言中,如何借助RPC,让客户机远程调用服务器上的方法。


那么究竟什么是RPC呢?


RPC是远程过程调用,即Remote Procedure Call的英文缩写。它是指通过网络调用运行在本机或远程计算机上某个进程中的一段过程。远程过程调用涉及调用方和被调用方跨越进程和网络的通信,需要一套标准。远程过程调用标准,即RPC协议,属于应用层协议。


A和B是两个独立的进程,可能运行在同一台计算机上,也可能运行在网络中的不同计算机上。进程A希望调用进程B中的一个函数。首先,进程A将期望调用的远程函数的函数名和参数通过RPC协议进行打包,并将该数据包通过网络发送给进程B。进程B通过对数据包的解析,从中提取出函数名和参数,根据函数名调用相应的本地函数,传入参数,获得返回值,并将返回值通过RPC协议打包回传给进程A。进程A从数据包中解析出远程函数的返回值,完成远程过程调用的完整流程。


事实上,远程过程调用并非微服务的专利。早在微服务概念被提出的多年以前,远程过程调用就已经在很多系统中获得了广泛的应用。那么究竟是什么促成了微服务与RPC的联姻呢?


微服务之所以需要RPC,就是因为RPC是微服务与微服务之间实现交互的基本途径。我们知道,在一个基于微服务架构的应用系统中,每个微服务都被封装成彼此独立的进程,它们甚至可以使用不同的语言实现,微服务之间的函数调用不是简单的本地调用,而是要跨越进程、网络和编程语言的远程调用。RPC可以很好地解决这种跨越进程、网络和编程语言的函数调用问题。


下面通过一个简单的Go语言例程,切身体会一下,一个客户机程序是如何通过RPC调用服务器程序中的远程方法的。


这个例程可以被理解为是RPC版的Hello World。


在开始编写代码之前,我们需要对作为被调用方的服务器和作为调用方的客户机,所要完成的操作步骤有一个大致的了解。服务器首先需要定义一个服务类,并在其中定义准备被客户机调用的方法,方法通常包含输入参数和输出参数。将服务类实例化为服务对象,与服务名一起通过rpc包的RegisterName方法,注册到系统中。和普通TCP服务器一样,这里需要通过net包的Listen方法创建监听器,调用监听器的Accept方法等待客户机的连接请求,并在连接建立后从Accept方法返回连接对象。借助Go关键字开启一个独立的goroutine,在其中将连接对象作为参数传递给rpc包的ServeConn方法。所有涉及数据收发和方法调用的细节都由该方法具体负责。客户机通过rpc包(注意不是net包)的Dial方法建立与服务器的TCP连接,并在该方法返回的连接对象上调用Call方法,执行远程函数中的代码。调用Call方法时需要提供三个参数,被圆点连接的服务名和方法名、输入参数和输出参数。其中输入参数和输出参数与服务类方法的输入参数和输出参数相对应。


如果将服务器和客户机看做一个整体,实现远程过程调用只需三个步骤。第一步,服务器端调用rpc包的RegisterName方法。该方法的第一个参数是一个字符串形式服务名,第二个参数是一个空接口,相当于C/C++语言的void*,可以接收任意类型的数据,这里我们传入服务类的实例化对象。服务类中包含服务方法,即被客户机远程调用的方法。这些方法必须被定义为导出方法,即方法名首字母大写,而且必须带有两个内建或导出类型的参数,分别用于输入和输出,输出参数为指针类型。服务方法只有一个error接口类型的返回值。


这里我们举了一个例子。Service就是我们定义的服务类,其中包含了一个名为Method的服务方法。该方法的方法名首字母为大写,因此它是一个导出方法。Method方法带有两个参数,第一个是Params类型的输入参数,第二个是Result*类型的输出参数。Params和Result都是我们自己定义的导出类型。Method方法只有一个返回值,error接口类型错误对象。通过new关键字将Service服务类实例化为服务对象,与字符串形式的服务名”service“一起作为参数传递给rpc包的RegisterName方法,将服务对象注册到系统中。


第二步,服务器在与客户机建立TCP连接后,将连接对象作为参数,传递给rpc包的ServeConn方法。该方法将通过连接对象接收来自客户机的远程调用请求,其中包含服务名、方法名和输入参数等信息,根据服务名找到与之对应的服务对象,调用其中与方法名匹配的服务方法,传入输入参数,并将服务方法的输出参数和返回值,以响应包的形式经由连接对象返回给客户机。需要注意的是,服务方法的执行过程可能会很耗时,建议在独立的goroutine中调用rpc包的ServeConn方法。


在这段示例代码中,首先通过net包的Listen方法启动TCP监听,并获得一个监听器对象,调用该对象的Accept方法等待客户机连接,并在连接建立后返回一个连接对象。在通过go关键字开启的goroutine中,以连接对象为参数,调用rpc包的ServeConn方法,完成对服务方法的调用。


第三步:作为客户机需要在通过rpc包的Dial方法建立与服务器的TCP连接之后,获得一个连接对象,调用该对象的Call方法,执行远程函数中的代码。该方法接收三个参数,一个字符串形式的服务名和方法名,中间用“.”分隔,一个任意类型的输入参数和一个指针类型的输出参数,同时返回一个error接口类型的错误对象。事实上,该方法的后两个参数及返回值,与服务类服务方法的参数及返回值是对应一致的。


在这段示例代码中,首先通过rpc包的Dial方法建立与服务器的TCP连接,并获得一个连接对象。在准备好表示输入参数和输出参数的对象后,调用连接对象的Call方法,传入服务名和方法名,以及代表输入输出的两个对象参数,其中输出参数需要提供对象的地址,接收该方法返回的错误对象。这时服务器端与服务名和方法名相对应的服务对象的服务方法会被执行。Call方法的输入参数和输出参数会被传递给服务方法的对应参数,同时返回服务方法的返回值。


在下面的MathServer工程中,我们演示了一个简单的RPC服务器:

在这段代码中,我们首先定义了一个名为Operands的结构体类型,表示参与算术运算的运算数。其中的两个成员A和B,分别表示算术运算的左运算数和右运算数。下面我们又定义了一个Arithmetic类,这就是我们所说的服务类,其中包括两个服务方法Add和Sub,分别用于完成两个整数的加法和减法运算。注意这两个服务方法都带有两个参数,一个Operands类型的参数用于输入运算数,一个整型指针类型的参数用于输出运算结果,同时它们还会返回一个error接口类型错误对象。在main函数中,首先通过rpc包的RegisterName方法,将Arithmetic类型的服务对象注册到系统中,并被冠以“arithmetic”服务名。服务名和服务类名不一定非要一样,在应该存在一定的相关性,至少在逻辑上是合理的,以避免造成误会和歧义。其次通过net包的Listen方法,启动监听并获得监听器对象。在下面的无限循环中,通过监听器对象的Accept方法等待客户机的连接请求,并在连接建立后返回可用于与客户机通信的连接对象。最后在一个独立的goroutine中,以连接对象为参数,调用rpc包的ServeConn方法,接收客户机的请求,根据请求中的服务名、方法名和输入数据调用服务类的服务方法,并将其输出和返回值以响应报文的形式返回给客户机。


为了从远程调用RPC服务器上的服务方法,我们创建了MathClient工程,作为RPC客户机:

在这段代码中,我们定义了与服务器侧完全一致的结构体类型Operands,表示参与算术运算的运算数。其中的两个成员A和B,分别表示算术运算的左运算数和右运算数。在main函数中,首先通过rpc包的Dial方法,建立与服务器的TCP连接,并获得一个连接对象。在用特定的数据类型定义好表示输入输出参数的变量后,调用连接对象的Call方法,传入服务名和方法名,“arithmetic.Add”或“arithmetic.Sub”,以及输入输出参数,注意输出参数传递的是地址,同时接收Call方法返回的错误对象,该对象来自所调用远程方法的返回值。


在RPC服务器中,服务类的服务方法,如果在执行过程中遇到错误,可直接返回实现了error接口的错误对象,并在其中包含有关错误的详细信息。该对象将由RPC客户机中连接对象的Call方法返回给调用者。另外请注意,只要从服务方法中返回了错误,无论给不给输出参数赋值,RPC客户机从连接对象的Call方法中得到的都是空。


从前面有关RPC协议的叙述中我们不难理解,在RPC客户机和RPC服务器之间需要对所传递的数据进行打包和解包。调用时,客户机负责将服务名、方法名和输入参数里的值打包,通过网络发送给服务器,而服务器则负责解包,根据服务名找到服务对象,根据方法名找到服务方法,用输入参数里的值调用服务对象的服务方法。返回时,服务器负责将服务方法输出参数里的值和返回值打包,通过网络回传给客户机,而客户机则负责解包,将输出参数里的值填入输出参数,同时将返回值返回给调用者。打包的过程通常被称为序列化,而解包的过程则被称为反序列化。序列化的输出也即反序列化的输入表现为数据包的格式。有些包格式只能用于Go语言编写的客户机和服务器,而另一些包格式则可以在不同语言编写的客户机和服务器间通用。


默认情况下,比如前面的例子,Go语言借助自己的序列化机制,亦称GOB,在RPC客户机和服务器之间解决打包解包问题。但GOB只能用于Go语言编写的客户机和服务器。如果客户机和服务器中的一方,使用除Go语言以外的其它语言开发,GOB则无能为力,即所谓专用序列化。为了实现跨语言的RPC,可以采用更通用的序列化和反序列化方法,比如JSON、ProtoBuf等。如果将我们前面所编写代码中的rpc包替换为jsonrpc包,那么穿梭于客户机和服务间的数据包就完全采用JSON字符串的形式,而包括Go语言在内的任何编程语言,都具备处理JSON字符串的能力。这就叫通用序列化。


下面的JMathServer工程就是一个基于JSON格式实现序列化和反序列化的RPC服务器:

JMathServer与MathServer的代码几乎一模一样,唯一的不同就是在调用ServeConn方法时,使用的是jsonrpc包,而非rpc包。


下面的JMathClient工程就是一个基于JSON格式实现序列化和反序列化的RPC客户机:

JMathClient与MathClient的代码也是如出一辙,唯一的差别仅在于,这里调用的是jsonrpc包的Dial方法,而非rpc包里的同名方法。


为了验证这里的序列化和反序列化,已经完全采用了JSON格式。我们可以进行这样的实验。首先以服务器方式运行NetCat监听本地9000端口。运行JMathClient,验证客户机发送的请求报文是否为JSON字符串。其次运行JMathServer,以客户机方式运行NetCat向本地9000端口发送一个JSON字符串,其内容与方才JMathClient所发请求报文完全一致,观察服务器回传的响应报文是否为JSON字符串。


如图所示,RPC客户机通过连接对象的Call方法,将所要调用远程过程的服务名、方法名、输入参数等信息,传递给客户机侧的RPC系统。后者将其序列化为特定格式的请求报文,比如JSON字符串,跨越网络和进程,传递给服务器侧的RPC系统。服务器侧的RPC系统对收到的请求报文做反序列化,根据其中的服务名和方法名找到相应的服务对象和服务方法,用输入参数调用该方法,并将得到的输出参数和返回值序列化为响应报文,回传给客户机侧的RPC系统。最终响应报文中的输出参数和返回值在经过反序列化后,由连接对象的Call方法传到RPC客户机的用户代码手中。RPC协议的设计目标就是要让跨域网络、进程和编程语言的远程方法调用,能够象相同语言的本地方法调用一样简单。


借助RPC协议,我们的确已经能够象调用本地方法一样调用远程方法了。但对于客户机来说,通过将方法名(其实还有服务名)作为另一个方法(比如Call)的参数来调用远程方法,毕竟和直接调用本地方法,至少在形式上还是不太一样的。而对于服务器来说,对服务类和服务方法存在诸多要求,稍有不慎就可能因违背RPC协议的规范而导致远程调用失败。针对上述问题,我们很自然地想到通过必要的面向对象化的封装,将存在差异的东西隐藏起来,将必须遵守的东西规定下来,既能提高客户机侧代码的编写效率,又能降低服务器侧代码的出错机率。这就是我们下面要讲的RPC协议的封装。


在RPC服务器一侧,能够被注册到RPC系统,并在适当的时机被其调用的服务方法,必须满足特定的要求。首先这些方法必须是方法名首字母大写的导出方法。其次它们必须带有两个内建或导出类型的参数,且第二个参数必须是指针,用于输出数据。最后它们还必须有一个error接口类型的返回值,表示执行过程中可能发生的错误。如果所注册的服务方法没能满足上述要求,在编译时不会看到任何提示,但在运行时却会导致错误。杜绝这些错误当然要靠代码编写者的细心和谨慎,但如果我们能够建立一种机制,使这种错误得以在编译时就被发现,而不是拖到运行时才暴露出来,那不是更好吗?其实方法也很简单,首先我们将满足要求的服务方法以服务接口的形式规定下来,然后要求服务类必须实现服务接口中的这些方法。服务接口是对服务能力的抽象,而服务类则是对这些能力的具体实现,实现的过程就体现在服务方法的定义中。


在下面的IMathServer工程中,我们将尝试基于服务接口构建RPC服务器的方法:

IMathServer工程的代码与我们之前编写的JMathServer工程基本上是一致的。不同的是,这里我们定义了一个名为“IArithmetic”的服务接口,该接口包含两个方法Add和Sub,分别表示对两个整数的加法和减法运算。这两个方法从方法名,到参数表,甚至返回值的类型,都完全符合RPC协议对服务方法的要求。而作为该接口的实现类Arithmetic,则负责实现接口中方法,具体完成加法和减法的运算过程。此外,我们还定义了一个以IArithmetic接口为参数的多态函数RegisterService,任何实现了IArithmetic接口的服务类都可以通过该函数被注册到RPC系统中。


在RPC客户机一侧,通过连接对象的Call方法调用远程过程的形式与调用本地方法并不一致。为此我们可以在客户机侧定义与服务器侧同样的服务接口,并将对连接对象Call方法的调用封装到该接口的实现类中。


在下面的IMathClient工程中,我们将尝试基于服务接口构建RPC客户机的方法:

与服务器侧类似,这里我们同样定义了名为“IArithmetic”的服务接口。与服务器侧不同的是,该接口的实现类Arithmetic所关注的并非加法和减法的运算过程,而是通过连接对象的Call方法调用远程服务器上的对应方法。连接对象作为一个属性被封装在Arithmetic类的内部,并通过Init和Deinit方法完成连接的建立和关闭。从main函数中对Arithmetic对象Add和Sub方法的调用不难看出,远程过程调用和本地过程调用在形式上获得了完美的统一。


谢谢大家,我们下节课再见!