Thrift框架学习和使用

  本来是想铁定以gRPC实现来了解RPC框架的,但是新公司业务重构选型要用Thrift,所以也就顺便转势过来了解Thrift了,反正自己也不是谷歌脑残粉,所以换个RPC框架学习也没有啥心理压力和什么梗。目前公司规模有限吧,自然不能同一线互联网大厂相比,能够有实力和资源自研一套符合自己业务特性的RPC框架,不过诸如gRPC、Thrift这些开源RPC框架都是久经考验的,而且他们设计之初架构比较灵活,必要情况下在其基础上做扩展或者二次开发也是可以实现的。
Thrift
  Thrift最初由Facebook设计和开发的,在Facebook内部业务中被广泛使用,他们当初的设想就是把这些繁重重复细节抽象出来一种可靠的通信方式,由一批专门的人员研究开发,而其他程序员作为RPC工具直接使用,可以更加专注于业务方面的开发。该项目目前由Apache软件基金会托管。了解Thrift最佳资料就是官方的那套白皮书了,包含了主要的设计思路和一些细节方面的东西,通览之后发现和gRPC都是十分相似的:他们都是通过IDL这种语言无关的形式事先定义通信的数据类型和服务接口,然后通过工具辅助产生特定语言的客户端和服务端代码(之所以使用静态语言生成形式,主要可以降低运行时候的类型检查等额外的操作),用户将这些生成代码添加到自己项目中就可以轻便使用RPC服务了,避免了各个业务重复造轮子的困境。
  采用RPC框架开发的优势之前就描述过了。现今这么多的开发语言,很大程度上都是因为他们在性能、开发易用和速度、现有成熟库这些方面都很难做到全面胜出,所以现代大型项目很少是有单个语言实现的,RPC乃至MQ用于解耦业务,屏蔽语言之间的差异,让能者发挥其所长变得越来越普遍了。

一、数据类型 Types

  数据必须要在生成的各种语言版本之间自动支持,尤其对于C/C++这种强类型的语言和Python等脚本语言交互数据的时候,Thrift设计就是要让各种语言能够使用其原生的基础数据类型,同时可以很自然的使用而不必考虑序列化、传输等各项细节信息。在考量之后,Thrift在IDL设计中支持的数据类型包括:bool、byte、i16、i32、i64、double、string这些类型,这里并没有支持对应的unsigned整形,因为在实际使用中显著需要无符号数据类型很少,而且在C/C++语言中,如果必要可以进行强制转换实现的,因为有无符号的强制转换只是内存值解释方式的差异,实际传输和存储的数据是没有差异的。
  Thrift支持struct类型,跟C语言比较类型,而其每个域具有可选的域标号和默认值,虽然域标号是可选的且需要的时候可以被自动生成,但是为了版本兼容特性,强烈手动提供域标号。对于常见的容器类型,Thrift支持list、set、map,其类型参数可以是支持迭代访问的任意类型,包括基础类型、容器类型、struct类型等。

1
2
3
4
struct Example {
1: i32 number=10,
2:string name="2333"
}

  Thrift还可以定义异常类型,形式根上面的struct相似只是使用exception关键字,主要是对于那些支持异常的面向对象语言提供无缝的支持。
  对于提供的服务,需要定义对应的service作为接口(也就是C++中的纯虚类),Thrift compiler可以产生对应的client、server stub桩代码。函数的返回可以是上面提到的各种类型外加void类型,且在函数返回类型为void的时候函数可以使用async进行修饰,此时生成的代码会让客户端的调用不必等待服务器的立即响应以实现异步的效果,不过使用该关键字修饰的函数,Thrift只保证该调用请求在transport层传输成功,而不能保证该请求是否会被执行(可能会被丢弃),应用层必须确认这种情况可被接收才能用该关键字。

二、数据传输层 Transport

  每种语言必须具有共有的接口可以进行双向数据的传输,而且支持多种类型的传输形式,比如TCP socket、内存原始数据、磁盘文件读写等。Thrift设计产生出这个层,目的在于将传输和代码生成层接触耦合,虽然通常的使用场景是TCP/IP的套接字传输,但是Thrift抽象出这层之后就可以提供其他类型的传输机制(socket、内存、文件),而Thrift自动生成的代码只需要关注open、close、isOpen、read、write、flush等操作接口(TTransport)就可以了。此外,Thrift还支持一个TServerTransport接口,可以支持网络服务端开发中常用的listen、accept接口。
  Thrift支持的传输类型概况有:
  TSocket:所有的语言都支持,就是对TCP/IP socket的通用、简单的接口;
  TFileTransport:为磁盘文件数据流的抽象接口,其可被用来将接收到的Thrift请求写到磁盘的文件上,该数据可以被重放,用以事后处理、重现、历史模拟等各种用途;
  TBufferedTransport:对底层的传输的读写请求进行缓存;
  TFramedTransport:对带有头部的frame数据进行传输;
  TMemoryBuffer:用于直接在进程空间的heap、stack内存读写数据;

三、协议 Protocol

  Thrift的另外一个抽象就是将数据结构和传输表示分离,使得无论数据是使用XML、ASCII、二进制等方式传输,都可以被安全的传输。传输的接口可以查看手册的内容,Thrift确保任何写入的encoder都能对应于一个读取的decoder。
  Thrift对于struct的编码确定是顺序流式的,即前面部分数据的解码不依赖于后面的数据,编码的时候也不依赖于具体消息的长度(而是数据流依次操作,每个数据域都有唯一的identifier,整个struct以特殊的STOP类型标示结束,其实现为self-delimiting机制),减少这样的依赖某些情况下可以增加性能,同时也可以实现并行数据的解码和使用而增加效率。
  Facebook已经实现space-efficient二进制协议实现高效率的网络传输,整形都被转换成了网络字节序、string都具有前导长度信息,传输过程中只用域标示符,因为字段名是冗余的信息。

四、版本兼容性 Versioning

  在大型系统中,服务的升级是平滑不停的,所以在新老版本包括数据类型、服务接口的变化,必须要让老版本和新版本能够合作工作。
  之前说过,如果不给字段赋域标示符,那么系统将会自动为其生成,为了避免冲突的情况:手动指定的域标识符必须是正数,而系统自动赋予的标示符是从-1开始递减方向进行。正是因为传输的类型是self-delimiting,所以在接收端反序列化的发现某个域标识符未能识别,那么Thrift也可以根据数据中含有的类型信息自动跳过这个未识别字段而不影响其他字段的识别过程。
  域标识符不仅仅在定义结构等位置使用,在服务定义总的函数声明中也应当作出标识。

1
2
3
4
5
service StringCache {
void set(1:i32 key, 2:string value),
string get(1:i32 key) throws (1:KeyNotFound knf),
void delete(1:i32 key)
}

  对于接收到未期望的字段自然是好办,直接将其丢弃就可以了,但是如果需要操作某个字段但是该字段不存在,则必须通过某种机制来表示,Thrift通过Isset方式来标识某个字段是否存在,当接收者收到某个struct后,都应该首先检查其中的域是否存在,确认存在后再对其直接操作。
  因为字段可以增删,考虑客户端向服务端发送请求,那么上面的Version问题可以归结为如下几种情形:
  1. Added field, old client, new server:此时old client不会发送新增字段,此时new server对于未Isset的字段就可以区分出该请求是旧版本的请求,可以用旧版本的服务方式或者默认方式提供返回;
  2. Removed field, old client, new server:此时old client会发送被删除的字段,此时new server只需要简单忽略这个字段就可以了;
  3. Added field, new client, old server:此时new client会发送新增字段,而old server不能识别该字段,那么他可以简单忽略该字段,继续提供正常服务即可;
  4. Removed field, new client, old server:这种情况比较的复杂,new client不会发送被删除的字段,但是old server事先开发部署可能没有意识到这类问题。此时应该先上线new server,当不存在old server的时候再部署new client。

五、RPC实现细节

  Thrift目前支持C++、Java、Python、Ruby、PHP五种语言代码的自动生成,支持的服务端模型有:单线程的TSimpleServer、thread-per-connection的TThreadedServer和thread-pooling机制的TThreadPoolServer。
  Thrift中struct定义的所有域都是公共访问的,没有采用通常语言提供set()、get()访问接口以对数据实体进行隐藏,使用Isset机制可以保证安全的数据访问。对于自动生成的对象,Thrift也提供了read()、write()的public接口,在客户端和服务端代码中可以随意访问。
  Thrift中的函数接口定位是通过字符串的方式来识别的,文献说明虽然用别的方式来标示函数名可能会节省一些带宽,但是带来了实现的复杂度,而且在有问题的时候直白的字符串名更加有助于调试。为了避免大量的字符串比较,Thrift建立了map数据形成字符串到函数指针的映射关系,从而可以通过字符串快速定位到函数地址。
  对于多线程,Thrift改进了自己的Thread、Runnable、ThreadManager、Timer等机制。首先Thrift大量使用了boost::shared_ptr智能指针,主要是还有大量的环境不能支持C++11而只能是C++0x,这也说明了boost::shared_ptr已经很成熟稳定了。
  Thrift在多线程中区分开了Thread和Runnable,前者主要是和语言、平台相关的和线程创建、销毁等细节信息,后者主要是业务相关的运行逻辑,这样就可以将业务代码同平台相关的东西隔离开来。ThreadManager可以创建多个工作线程池,应用程序可以选择空闲的工作线程运行任务,ThreadManager没有设计动态线程池,因为动态线程池的负载度量是和业务具体相关的,ThreadManger提供了增加和删除线程的接口,具体的实现策略可以在用户代码中实现。
  Thrift的TimerManager允许调度一个Runnable对象在将来某个指定的事件运行,其实现是通过一个单独的线程来负责执行过期的Runnable,所以定时器到时需要执行的Runnable如果比较的重量级,或者需要blocking在IO上,那么最好将其具体的执行派发到其他的线程上去,以免阻塞掉其他的定时器任务。
  Thrift主要是用于blocking IO模型的,但是通过libevent和TFramedTransport也实现了TNonBlockingServer的高性能服务端实现,其实现主要是通过将所有的IO都绑定到固定的event loop事件循环上去,实现循环会读取framed的请求到TMemoryBuffer上,一旦完成这部就可以将请求发送TProcessor,后者可以直接从内存中读取数据,实现了非阻塞的效果。
Thrift
  Thrift的complier工作的原理框图如上面所示,感兴趣的话回头再看看这个。

  小结:先不论RPC内部的调度,但看其数据类型的定义是不是感觉和Protobuf相差无几呢?看到这句话就明白了:”Thrift is Facebook’s implementation of Proto Buff open sourced under Apache.”

PS:近几天腾讯宣布开源了其高性能RPC开发框架Tars,号称其性能要比Thrift、gRPC要好。不过感觉腾讯很多开源项目放到GitHub上就长草的料,开源之后基本就不再更新,再则很多的Issue也都没人搭理。一个企业的框架选型要考虑的东西太多了,这种情况下大家是会选择国内这些开源框架,还是经过Apache孵化后的顶级项目呢?

本文完!

参考