三、 Thrift的工作原理
1.普通的本地函数调用过程
例如,有如下关于本地函数的调用的java代码,在函数caller中调用函数getStr获取两个字符串的拼接结果:
代码3.1
本地函数调用调用方和被调用方都在一个程序内部,只是cpu在执行调用的时候切换去执行被调用的函数,执行完被调用函数之后,再切换回来执行调用之后的代码,其调用过程如下图3.1所示:
图3.1
站在调用方的角度,在本地函数调用过程中,执行被调用函数期间,调用方会被卡在那里一直等到被调用函数执行完,然后再继续执行剩下的代码。
2.Thrift的RPC调用过程
远程过程调用(RPC)的调用方和被调用方不在同一个进程内,甚至都不在同一台机子上,因此远程过程调用中,必然涉及网络传输;假设有如下代码3.2所示的客户端代码:
代码3.2
上述代码含义在“二”中已经有详细解释,其调用过程如下图3.2所示:
图3.2 thrift的RPC调用过程
Thrift框架的远程过程调用的工作过程如下:
(1)
通过IDL定义一个接口的thrift文件,然后通过thrift的多语言编译功能,将接口定义的thrift文件翻译成对应的语言版本的接口文件;
(2) Thrift生成的特定语言的接口文件中包括客户端部分和服务器部分;
(3)
客户端通过接口文件中的客户端部分生成一个Client对象,这个客户端对象中包含所有接口函数的存根实现,然后用户代码就可以通过这个Client对象来调用thrift文件中的那些接口函数了,但是,客户端调用接口函数时实际上调用的是接口函数的本地存根实现,如图3.2中的箭头1所示;
(4)
接口函数的存根实现将调用请求发送给thrift服务器端,然后thrift服务器根据调用的函数名和函数参数,调用实际的实现函数来完成具体的操作,如图3.2中的箭头2所示;
(5)
Thrift服务器在完成处理之后,将函数的返回值发送给调用的Client对象;如图3.2中的箭头3所示;
(6) Thrift的Client对象将函数的返回值再交付给用户的调用函数,如图3.2中的箭头4所示;
说明:
[1]本地函数的调用方和被调方在同一进程的地址空间内部,因此在调用时cpu还是由当前的进行所持有,只是在调用期间,cpu去执行被调用函数,从而导致调用方被卡在那里,直到cpu执行完被调用函数之后,才能切换回来继续执行调用之后的代码;
[2]RPC在调用方和被调用方一般不在一台机子上,它们之间通过网络传输进行通信,一般的RPC都是采用tcp连接,如果同一条tcp连接同一时间段只能被一个调用所独占,这种情况与[1]中的本地过程更为相似,这种情况是同步调用,很显然,这种方式通信的效率比较低,因为服务函数执行期间,tcp连接上没有数据传输还依然被本次调用所霸占;另外,这种方式也有优点:实现简单。
[3]在一些的RPC服务框架中,为了提升网络通信的效率,同一个tcp连接可以被多个调用在同一时间段内同时使用,只要调用方和被调用方能够把调用和结果对应起来即可,这种情况是异步调用,它的通信效率比同步调用高,但是实现起来比较复杂,因为调用方和被调用方都需要把同一时间段内的多个调用和结果对应起来。
二、 Thrift源码分析
源码分析主要分析thrift生成的java接口文件,并以TestThriftService.java为例,以该文件为线索,逐渐分析文件中遇到的其他类和文件;在thrift生成的服务接口文件中,共包含以下几部分:
(1)异步客户端类AsyncClient和异步接口AsyncIface,本节暂不涉及这些异步操作相关内容;
(2)同步客户端类Client和同步接口Iface,Client类继承自TServiceClient,并实现了同步接口Iface;Iface就是根据thrift文件中所定义的接口函数所生成;Client类是在开发Thrift的客户端程序时使用,Client类是Iface的客户端存根实现,
Iface在开发Thrift服务器的时候要使用,Thrift的服务器端程序要实现接口Iface。
(3)Processor类,该类主要是开发Thrift服务器程序的时候使用,该类内部定义了一个map,它保存了所有函数名到函数对象的映射,一旦Thrift接到一个函数调用请求,就从该map中根据函数名字找到该函数的函数对象,然后执行它;
(4)参数类,为每个接口函数定义一个参数类,例如:为接口getInt产生一个参数类:getInt_args,一般情况下,接口函数参数类的命名方式为:接口函数名_args;
(5)返回值类,每个接口函数定义了一个返回值类,例如:为接口getInt产生一个返回值类:getInt_result,一般情况下,接口函数返回值类的命名方式为:接口函数名_result;
参数类和返回值类中有对数据的读写操作,在参数类中,将按照协议类将调用的函数名和参数进行封装,在返回值类中,将按照协议规定读取数据。
Thrift调用过程中,Thrift客户端和服务器之间主要用到传输层类、协议层类和处理类三个主要的核心类,这三个类的相互协作共同完成rpc的整个调用过程。在调用过程中将按照以下顺序进行协同工作:
(1) 将客户端程序调用的函数名和参数传递给协议层(TProtocol),协议层将函数名和参数按照协议格式进行封装,然后封装的结果交给下层的传输层。此处需要注意:要与Thrift服务器程序所使用的协议类型一样,否则Thrift服务器程序便无法在其协议层进行数据解析;
(2) 传输层(TTransport)将协议层传递过来的数据进行处理,例如传输层的实现类TFramedTransport就是将数据封装成帧的形式,即“数据长度+数据内容”,然后将处理之后的数据通过网络发送给Thrift服务器;此处也需要注意:要与Thrift服务器程序所采用的传输层的实现类一致,否则Thrift的传输层也无法将数据进行逆向的处理;
(3) Thrift服务器通过传输层(TTransport)接收网络上传输过来的调用请求数据,然后将接收到的数据进行逆向的处理,例如传输层的实现类TFramedTransport就是将“数据长度+数据内容”形式的网络数据,转成只有数据内容的形式,然后再交付给Thrift服务器的协议类(TProtocol);
(4) Thrift服务端的协议类(TProtocol)将传输层处理之后的数据按照协议进行解封装,并将解封装之后的数据交个Processor类进行处理;
(5) Thrift服务端的Processor类根据协议层(TProtocol)解析的结果,按照函数名找到函数名所对应的函数对象;
(6) Thrift服务端使用传过来的参数调用这个找到的函数对象;
(7) Thrift服务端将函数对象执行的结果交给协议层;
(8) Thrift服务器端的协议层将函数的执行结果进行协议封装;
(9) Thrift服务器端的传输层将协议层封装的结果进行处理,例如封装成帧,然后发送给Thrift客户端程序;
(10) Thrift客户端程序的传输层将收到的网络结果进行逆向处理,得到实际的协议数据;
(11) Thrift客户端的协议层将数据按照协议格式进行解封装,然后得到具体的函数执行结果,并将其交付给调用函数;
上述过程如图4.1所示:
图4.1、调用过程中Thrift的各类协同工作过程
上图4.1的客户端协议类和服务端协议类都是指具体实现了TProtocol接口的协议类,在实际开发过程中二者必须一样,否则便不能进行通信;同样,客户端传输类和服务端传输类是指TTransport的子类,二者也需保持一致;
在上述开发thrift客户端和服务器端程序时需要用到三个类:传输类(TTransport)、协议接口(TProtocol)和处理类(Processor),其中TTransport是抽象类,在实际开发过程中可根据具体清空选择不同的实现类;TProtocol是个协议接口,每种不同的协议都必须实现此接口才能被thrift所调用。例如TProtocol类的实现类就有TBinaryProtocol等;在Thrift生成代码的内部,还需要将待传输的内容封装成消息类TMessage。处理类(Processor)主要在开发Thrift服务器端程序的时候使用。
1. TMessage
Thrift在客户端和服务器端传递数据的时候(包括发送调用请求和返回执行结果),都是将数据按照TMessage进行组装,然后发送;TMessage包括三部分:消息的名称、消息的序列号和消息的类型,消息名称为字符串类型,消息的序列号为32位的整形,消息的类型为byte类型,消息的类型共有如下17种:
public
final classTType {
public staticfinal byte STOP =0;
public staticfinal byte VOID =1;
public staticfinal byte BOOL =2;
public staticfinal byte BYTE =3;
public staticfinal byte DOUBLE = 4;
public staticfinal byte I16 =6;
public staticfinal byte I32 =8;
public staticfinal byte I64 =10;
public staticfinal byte STRING = 11;
public staticfinal byte STRUCT = 12;
public staticfinal byte MAP =13;
public staticfinal byte SET =14;
public staticfinal byte LIST =15;
public staticfinal byte ENUM =16;
}
Byte共可表示0~255个数字,这里只是用了前17个
2. 传输类(TTransport)
传输类或其各种实现类,都是对I/O层的一个封装,可更直观的理解为它封装了一个socket,不同的实现类有不同的封装方式,例如TFramedTransport类,它里面还封装了一个读写buf,在写入的时候,数据都先写到这个buf里面,等到写完调用该类的flush函数的时候,它会将写buf的内容,封装成帧再发送出去;
TFramedTransport是对TTransport的继承,由于tcp是基于字节流的方式进行传输,因此这种基于帧的方式传输就要求在无头无尾的字节流中每次写入和读出一个帧,TFramedTransport是按照下面的方式来组织帧的:每个帧都是按照4字节的帧长加上帧的内容来组织,帧内容就是我们要收发的数据,如下:
+---------------+---------------+
| 4字节的帧长 | 帧的内容 |
+---------------+---------------+
3. 协议接口(TProtocol)
提供了一组操作协议接口,主要用于规定采用哪种协议进行数据的读写,它内部包含一个传输类(TProtocol)成员对象,通过TProtocol对象从输入输出流中读写数据;它规定了很多读写方式,例如:
readByte()
readDouble()
readString()
…
每种实现类都根据自己所实现的协议来完成TProtocol接口函数的功能,例如实现了TProtocol接口的TBinaryProtocol类,对于readDouble()函数就是按照二进制的方式读取出一个Double类型的数据。
类TBinaryProtocol是TProtocol的一个实现类,TBinaryProtocol协议规定采用这种协议格式的进行消息传输时,需要为消息内容封装一个首部,TBinaryProtocol协议的首部有两种操作方式:一种是严格读写模式,一种值普通的读写模式;这两种模式下消息首部的组织方式不一样,在创建时也可以自己指定使用那种模式,但是要注意,如果要指定模式,Thrift的服务器端和客户端都需要指定。
严格读写模型下的消息首部的前16字节固定为版本号:0x8001,如图4.2所示;
图4.2二进制协议中严格读写模式下的消息组织方式
在严格读写模式下,首部中前32字节包括固定的16字节协议版本号0x8001,8字节的0x00,8字节的消息类型;然后是若干字节字符串格式的消息名称,消息名称的组织方式也是“长度+内容”的方式;再下来是32位的消息序列号;在序列号之后的才是消息内容。
普通读写模式下,没有版本信息,首部的前32字节就是消息的名称,然后是消息的名字,接下来是32为的消息序列号,最后是消息的内容。
图4.3 二进制协议中普通读写模式下的消息组织方式
通信过程中消息的首部在TBinaryProtocol类中进行通过readMessageBegin读取,通过writeMessageBegin写入;但是消息的内容读取在返回值封装类(例如:getStr_result)中进行;
(1) TBinaryProtocol的读取数据过程:
在Client中调用TBinaryProtocol读取数据的过程如下:
readMessageBegin()
…
读取数据
…
readMessageEnd()
readMessageBegin详细过程如下:
[1]首先从传输过来的网络数据中读取32位数据,然后根据首位是否为1来判断当前读到的消息是严格读写模式还是普通读写模式;如果是严格读写模式则这32位数据包括版本号和消息类型,否则这32位保存的是后面的消息名称
[2]读取消息的名称,如果是严格读写模式,则消息名称为字符串格式,保存方式为“长度+内容”的方式,如果是普通读写模式,则消息名称的长度直接根据[1]中读取的长度来读取消息名称;
[3]读取消息类型,如果是严格读写模式,则消息类型已经由[1]中读取出来了,在其32位数据中的低8位中保存着;如果是普通读写模式,则要继续读取一字节的消息类型;
[4]读取32为的消息序列号;
读取数据的过程是在函数返回值的封装类中来完成,根据读取的数值的类型来具体读取数据的内容;在TBinaryProtocol协议中readMessageEnd函数为空,什么都没有干。
(2) TBinaryProtocol的写入数据过程:
在sendBase函数调用TBinaryProtocol将调用函数和参数发送到Thrift服务器的过程如下:
writeMessageBegin(TMessage m)
…
写入数据到TTransport实现类的buf中
…
writeMessageEnd();
getTransport().flush();
writeMessageBegin函数需要一个参数TMessage作为消息的首部,写入过程与读取过程类似,首先判断需要执行严格读写模式还是普通读写模式,然后分别按照读写模式的具体消息将消息首部写入TBinaryProtocol的TTransport成员的buf中;
4. Thrift客户端存根代码追踪调试
下面通过跟踪附件中thrift客户端代码的test()函数,在该函数中调用了Thrift存根函数getStr,通过追踪该函数的执行过程来查看整个Thrift的调用流程:
(1)客户端代码先打开socket,然后调用存根对象的
m_transport.open();
String res = testClient.getStr("test1","test2");
(2)在getStr的存根实现中,首先发送调用请求,然后等待Thrift服务器端返回的结果:
send_getStr(srcStr1, srcStr2);
return recv_getStr();
(3)发送调用请求函数send_getStr中主要将参数存储到参数对象中,然后把参数和函数名发送出去:
getStr_args args = new getStr_args();//创建一个该函数的参数对象
args.setSrcStr1(srcStr1);//将参数值设置带参数对象中
args.setSrcStr2(srcStr2);
sendBase("getStr", args);//将函数名和参数对象发送出去
(4)sendBase函数,存根类Client继承自基类TServiceClient,sendBase函数即是在TServiceClient类中实现的,它的主要功能是调用协议类将调用的函数名和参数发送给Thrift服务器:
oprot_.writeMessageBegin(new TMessage(methodName,TMessageType.CALL, ++seqid_));//将函数名,消息类型,序号等信息存放到oprot_的TFramedTransport成员的buf中
args.write(oprot_);//将参数存放到oprot_的TFramedTransport成员的buf中
oprot_.writeMessageEnd();
oprot_.getTransport().flush();//将oprot_的TFramedTransport成员的buf中的存放的消息发送出去;
这里的oprot_就是在TProtocol的子类,本例中使用的是TBinaryProtocol,在调用TBinaryProtocol的函数时需要传入一个TMessage对象(在本节第2小节中有对TMessage的描述),这个TMessage对象的名字就是调用函数名,消息的类型为TMessageType.CALL,调用序号使用在客户端存根类中(实际上是在基类TServiceClient)中保存的一个序列号,每次调用时均使用此序列号,使用完再将序号加1。
在TBinaryProtocol中包含有一个TFramedTransport对象,而TFramedTransport对象中维护了一个缓存,上述代码中,写入函数名、参数的时候都是写入到TFramedTransport中所维护的那个缓存中,在调用TFramedTransport的flush函数的时候,flush函数首先计算缓存中数据的长度,将长度和数据内容组装成帧,然后发送出去,帧的格式按照“长度+字段”的方式组织,如:
+---------------+---------------+
| 4字节的帧长 | 帧的内容 |
+---------------+---------------+
(5)recv_getStr,在调用send_getStr将调用请求发送出去之后,存根函数getStr中将调用recv_getStr等待Thrift服务器端返回调用结果,recv_getStr的代码为:
getStr_resultresult = new getStr_result();//为接收返回结果创建一个返回值对象
receiveBase(result, "getStr");//等待Thrift服务器将结果返回
(6)receiveBase,在该函数中,首先通过协议层读取消息的首部,然后由针对getStr生成的返回值类getStr_result读取返回结果的内容;最后由协议层对象结束本次消息读取操作;如下所示:
iprot_.readMessageBegin();//通过协议层对象读取消息的首部
……
result.read(iprot_);//通过返回值类对象读取具体的返回值;
……
iprot_.readMessageEnd();//调用协议层对象结束本次消息读取
在本节第4小节中有对readMessageBegin函数的描述;
5. 处理类(Processor)
该类主要由Thrift服务器端程序使用,它是由thrift编译器根据IDL编写的thrift文件生成的具体语言的接口文件中所包含的类,例如2.5节中提到的TestThriftService.java文件,处理类(Processor)主要由thrift服务器端使用,它继承自基类TBaseProcessor。
例如,2.5节中提到服务器端程序的如下代码:
TProcessor tProcessor =
New
TestThriftService.Processor<TestThriftService.Iface>(m_myService);
这里的TestThriftService.Processor就是这里提到的Processor类,包括尖括号里面的接口TestThriftService.Iface也是由thrift编译器自动生成。Processor类主要完成函数名到对应的函数对象的映射,它内部维护一个map,map的key就是接口函数的名字,而value就是接口函数所对应的函数对象,这样服务器端从网络中读取到客户端发来的函数名字的时候,就通过这个map找到该函数名对应的函数对象,然后再用客户端发来的参数调用该函数对象;在Thrift框架中,每个接口函数都有一个函数对象与之对应,这里的函数对象继承自虚基类ProcessFunction。
ProcessFunction类,它采用类似策略模式的实现方法,该类有一个字符串的成员变量,用于存放该函数对象对应的函数名字,在ProcessFunction类中主要实现了process方法,此方法的功能是通过协议层从传输层中读取并解析出调用的参数,然后再由具体的函数对象提供的getResult函数计算出结果;每个继承自虚基类ProcessFunction的函数对象都必须实现这个getResult函数,此函数内部根据函数调用参数,调用服务器端的函数,并获得执行结果;process在通过getResult函数获取到执行结果之后,通过协议类对象将结果发送给Thrift客户端程序。
Thrift服务器端程序在使用Thrrift服务框架时,需要提供以下几个条件:
(1)定义一个接口函数实现类的对象,在开发Thrift服务程序时,最主要的功能就是开发接口的实现函数,这个接口函数的实现类implements接口Iface,并实现了接口中所有函数;
(2)创建一个监听socket,Thrift服务框架将从此端口监听新的调用请求到来;
(3)创建一个实现了TProtocol接口的协议类对象,在与Thrift客户端程序通信时将使用此协议进行网络数据的封装和解封装;
(4)创建一个传输类的子类,用于和Thrift服务器之间进行数据传输;