Thrift白皮书
翻译自:Thrift: Scalable Cross-Language Services Implementation
绪论
Thrift是一个软件库和一套在Facebook开发的代码生成工具,可以加速开发和实现高效,可扩展的后端服务。其主要目标是通过将需要最多定制的每种语言的部分抽象为以每种语言实现的公共库来实现跨编程语言的有效和可靠的通信。具体而言,Thrift允许开发人员在单一语言中立文件中定义数据类型和服务接口,并生成构建RPC客户端和服务器所需的所有代码。
本文详细介绍了我们在Thrift中做出的动机和设计选择,以及一些更有趣的实现细节。它不打算被视为研究,而是它对我们所做的事情和原因的阐述。
1. 介绍
随着Facebook的流量和网络结构的扩展,网站上许多运营的资源需求(即搜索,广告选择和交付,事件记录)已经大大超出了LAMP框架范围内的技术要求。在我们实施这些服务时,已经选择了各种编程语言来优化性能,易用性和开发速度,现有库的可用性等的正确组合。总的来说,Facebook的工程文化倾向于选择最好的工具和实现可用于任何一种编程语言的标准化,并且不情愿地接受其固有的局限性。
鉴于这种设计选择,我们面临着在许多编程语言中构建透明,高性能桥接的挑战。我们发现大多数可用的解决方案要么太有限,不能提供足够的数据类型自由,要么性能低下。
我们实现的解决方案结合了跨多种编程语言实现的语言中立软件栈以及将简单接口和数据定义语言转换为客户端和服务器远程过程调用库的相关代码生成引擎。通过在动态系统上选择静态代码生成,我们可以创建可以在不需要任何高级内省运行时类型检查的情况下运行的经过验证的代码。它也被设计为对开发人员来说尽可能简单,开发人员通常可以在一个简短的文件中为复杂的服务定义所有必要的数据结构和接口。
令人惊讶的是,尚未存在针对这些相对常见问题的强大开放式解决方案,我们很早就致力于将Thrift实施开源。
在评估网络环境中跨语言交互的挑战时,确定了一些关键组件:
类型:跨编程语言必须存在通用类型系统,而不要求应用程序开发人员使用自定义Thrift数据类型或编写自己的序列化代码。也就是说,C ++程序员应该能够透明地为动态Python字典交换强类型STL映射。程序员不应该被迫在应用程序层下面编写任何代码来实现这一点。第2节详细介绍了Thrift型系统。
运输:每种语言都必须具有双向原始数据传输的通用接口。实现给定传输的具体方式对服务开发人员来说无关紧要。相同的应用程序代码应该能够针对TCP流套接字,内存中的原始数据或磁盘上的文件运行。第3节详细介绍了Thrift Transport层。
协议:数据类型必须有某种方式使用传输层对自身进行编码和解码。同样,应用程序开发人员不必关心此层。服务是使用XML还是二进制协议对应用程序代码来说无关紧要。重要的是数据可以在一致的确定性事项中读写。第4节详细介绍了Thrift协议层。
版本:对于健壮的服务,涉及的数据类型必须提供自身版本控制的机制。具体而言,应该可以在对象中添加或删除字段或更改函数的参数列表而不会中断服务(或者更糟糕的是,令人讨厌的分段错误)。第5节详细介绍了Thrift的版本控制系统。
处理器:最后,我们生成能够处理数据流以完成远程过程调用的代码。第6节详细介绍了生成的代码和TProcessor范例。
第7节讨论了实现细节,第8节描述了我们的结论。
2. 类型
Thrift类型系统的目标是使程序员能够完全使用本地定义的类型进行开发,无论他们使用何种编程语言。根据设计,Thrift类型系统不会引入任何特殊的动态类型或包装器对象。它也不要求开发人员为对象序列化或传输编写任何代码。 Thrift IDL(接口定义语言)文件在逻辑上是开发人员使用必要的最少额外信息来注释其数据结构的一种方式,以告知代码生成器如何跨语言安全地传输对象。
2.1 基本类型
类型系统依赖于几种基本类型。在考虑支持哪些类型时,我们的目标是为了清晰和简洁,重点关注所有编程语言中可用的关键类型,省略仅在特定语言中可用的任何类型。
Thrift支持的基类型是:
- bool布尔值,true或false
- byte 有符号字节
- i16 16位有符号整数
- i32 32位有符号整数
- i64 64位有符号整数
- double一个64位浮点数
- string与编码无关的文本或二进制字符串
特别值得注意的是缺少无符号整数类型。因为这些类型在许多语言中没有直接转换为原生基元类型,所以它们所带来的优势就会丧失。此外,没有办法阻止像Python这样的语言的应用程序开发人员将负值赋给整数变量,从而导致不可预测的行为。从设计的角度来看,我们观察到无符号整数很少(如果有的话)用于算术目的,但在实践中更常用作键或标识符。在这种情况下,标志是无关紧要的。有符号整数用于同样的目的,并且在绝对必要时可以安全地转换为无符号对应物(最常见的是C ++)。
2.2 结构体(Structs)
Thrift结构体定义了跨语言使用的通用对象。结构体本质上等同于面向对象编程语言中的类。结构体具有一组强类型字段,每个字段都具有唯一的名称标识符。定义Thrift结构的基本语法看起来非常类似于C结构定义。字段可以使用整数字段标识符(在结构体内唯一)和可选的默认值进行注释。如果省略字段标识符将自动分配,但出于后面讨论的版本控制原因强烈建议将其进行注释。
2.3 容器(containers)
Thrift容器是强类型容器,映射到常用编程语言中最常用的容器。它们使用C ++模板(或Java Generics)样式进行注释。有三种类型:
- list
: 有序的元素列表。直接转换为脚本语言中的STL向量,Java ArrayList或native数组。可能包含重复项。 - set
:一组无序的唯一元素。转换为STL集,Java HashSet,用Python设置或PHP / Ruby中的本机字典。 - map
: 键值对集合,其中键为唯一。转换为STL映射,Java HashMap,PHP关联数组或Python / Ruby字典。
虽然提供了默认值,但未明确指定类型映射。此外,增加了自定义代码生成器指令以替换目标语言中的自定义类型(即哈希映射或Google的稀疏哈希映射可以在C ++中使用)。唯一的要求是自定义类型支持所有必需的原始迭代。容器元素可以是任何有效的Thrift类型,包括其他容器或结构。
struct Example {、
1:i32 number=10,
2:i64 bigNumber,
3:double decimals,
4:string name="thrifty"
}
在目标语言中,每个定义都会生成一个类型和两个方法,分别为read
和write
方法,它们使用Thrift TProtocol对象执行对象的序列化和传输。
2.4 异常(Exceptions)
除了使用exception关键字而不是struct关键字声明它们之外,异常在语法和功能上等同于结构体。
生成的对象在每种目标编程语言中适当地从异常基类继承,以便与任何给定语言的本机异常处理无缝集成。同样,设计重点在于使应用程序开发人员熟悉代码。
2.5 服务(Services)
服务使用Thrift类型定义。服务的定义在语义上等同于在面向对象的编程中定义接口(或纯虚拟抽象类)。 Thrift编译器生成实现该接口的全功能客户端和服务器存根。服务定义如下:
service {
()
[throws ()]
...
}
一个例子:
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类型之外,void是函数返回的有效类型。另外,可以将异步修改关键字添加到void函数,该函数将生成不等待来自服务器的响应的代码。请注意,纯void函数将返回对客户端的响应,以确保操作已在服务器端完成。使用异步方法调用,只能保证客户端在传输层成功。 (在许多传输方案中,由于Byzantine Generals’ problem,这本质上是不可靠的。因此,应用程序开发人员应该注意在可接受丢弃的方法调用或已知传输可靠的情况下使用异步优化。)
另外值得注意的是,函数的参数列表和异常列表是作为Thrift结构体实现的。所有三种结构在符号和行为上都是相同的。
3. 传输
生成的代码使用传输层来促进数据传输。
3.1 接口(interface)
Thrift实现中的关键设计选择是将传输层与代码生成层分离。虽然Thrift通常用在TCP / IP堆栈之上,并且流套接字作为通信的基础层,但没有令人信服的理由将该约束构建到系统中。与实际I / O操作(通常调用系统调用)的成本相比,抽象I / O层(每个操作大约一个虚拟方法查找/函数调用)引起的性能折衷是无关紧要的。
从根本上说,生成的Thrift代码只需要知道如何读写数据。数据的来源和目的地无关紧要;它可以是套接字,共享内存段或本地磁盘上的文件。 Thrift传输接口支持以下方法:
- open Opens the tranpsort
- close Closes the tranport
- isOpen Indicates whether the transport is openread Reads from the transport
- write Writes to the transport
- flush Forces any pending writes
此处未记录的一些其他方法用于帮助批处理读取并且可选地用信号通知从生成的代码完成读取或写入操作。
除了上面的TTransport接口之外,还有一个TServerTransport接口用于接受或创建原始传输对象。其接口如下:
- open Opens the transport
- listen Begins listening for connections
- accept Returns a new client transport
- close Closes the transport
3.2 实现(Implementation)
传输接口设计用于任何编程语言的简单实现。新的传输机制可以根据开发者的需要轻松的定制。
3.2.1 TSocket
TSocket类是跨所有目标语言实现的。它为TCP / IP流套接字提供了一个通用,简单的接口。
3.2.2 TFileTransport
TFileTransport是对数据流的磁盘文件的抽象。它可用于将一组传入的Thrift请求写入磁盘上的文件。然后可以从日志中重放盘上数据,用于后处理或用于过去事件的再现和/或模拟。
3.2.3 Utilities
传输接口旨在使用常见的OOP技术(例如组合)支持轻松扩展。一些简单的实用程序包括TBufferedTransport,它缓冲底层传输的写入和读取,TFramedTransport,它传输带有帧大小头的数据,用于分块优化或非阻塞操作,以及TMemoryBuffer,它允许直接从堆或堆栈读取和写入该进程拥有的内存。
4. 协议(protocol)
Thrift的第二个主要抽象是数据结构与传输表示的分离。Thrift在传输数据时强制执行某种消息传递结构,
但它与使用中的协议编码无关。也就是说,数据是否被编码为XML,人类可读的ASCII或密集的二进制格式并不重要,只要数据支持一组固定的操作,允许它由生成的代码进行确定性读取和写入。
4.1 接口
Thrift Protocol接口非常简单。它从根本上支持两件事:
- 双向序列化消息传递
- 基本类型,容器和结构体的编码。
writeMessageBegin(name, type, seq)
writeMessageEnd() writeStructBegin(name)
writeStructEnd() writeFieldBegin(name,
type, id) writeFieldEnd()
writeFieldStop() writeMapBegin(ktype,
vtype, size) writeMapEnd()
writeListBegin(etype, size)
writeListEnd() writeSetBegin(etype, size)
writeSetEnd() writeBool(bool)
writeByte(byte) writeI16(i16)
writeI32(i32) writeI64(i64)
writeDouble(double) writeString(string)
name, type, seq = readMessageBegin()
readMessageEnd() name = readStructBegin()
readStructEnd() name, type, id =
readFieldBegin() readFieldEnd() k, v,
size = readMapBegin() readMapEnd() etype,
size = readListBegin() readListEnd()
etype, size = readSetBegin() readSetEnd()
bool = readBool() byte = readByte() i16 =
readI16() i32 = readI32() i64 = readI64()
double = readDouble() string = readString()
请注意,除writeFieldStop()
外,每个写入函数都只有一个读取函数。这是一种表示结构体结束(the end of a struct)的特殊方法。读取结构体的过程是readFieldBegin()
直到遇到停止字段,然后是readStructEnd()
。生成的代码依赖于该调用序列以确保协议编码器写入的所有内容都可以由匹配的协议解码器读取。进一步注意,这组功能在设计上比必要的更强大。例如,writeStructEnd()
不是严格必需的,因为stop字段可能隐含结构体的结束。由于其可以分离这些调用,这种方法可以大大简化协议的复杂性(例如,XML中的结束标记)。
4.2 结构(structure)
Thrift 结构旨在支持编码成流协议。在编码之前,结构实现永远不需要构造或计算结构的整个数据长度。这对于许多情况下的性能至关重要。考虑一长串相对较大的字符串。如果协议接口需要读取或写入列表作为原子操作,则实现将需要在编码任何数据之前对整个列表执行线性传递。但是,如果列表可以在执行迭代时写入,则相应的读取可以并行开始,理论上提供端到端的加速(kN-C),其中N是列表的大小,k是成本因素与序列化单个元素相关联,C是固定的偏移量,用于写入数据和读取数据之间的延迟。
类似地,结构体不会先验地编码它们的数据长度。相反,它们被编码为一系列字段,每个字段都具有类型指定符(type specifier)和唯一字段标识符(unique field identifier)。请注意,包含类型指定符允许在没有任何生成代码或访问原始IDL文件的情况下安全地解析和解码协议。结构体根据特殊STOP类型的字段标头终止。因为所有基本类型可以被确定性地读取,所以可以确定性地读取所有结构(甚至包含其他结构的结构)。 Thrift协议是自定界的,不需要任何帧和无论编码格式如何。
在不需要流式传输或框架有利的情况下,可以使用TFramedTransport抽象将其简单地添加到传输层中。
4.3 实现(Implementation)
Facebook已经实施并部署了一种空间效率高的二进制协议,大多数后端服务都使用该协议。从本质上讲,它以执行二进制格式写入所有数据。整数类型转换为网络字节顺序,字符串在前面添附其字节长度,所有消息和字段标头都使用原始整数序列化结构编写。字段的字符串名称被省略 - 当使用生成的代码时,字段标识符是足够的。
为了代码的简单和清晰起见,我们决定不进行一些极端的存储优化(即将小整数打包成ASCII或使用7位连续格式)。当我们遇到需要它们的性能关键用例时,可以很容易地做出这些改动。
5. 版本(Version)
Thrift在面向版本控制和数据定义更改方面是强大的。这对于分阶段部署对已部署服务的更改至关重要。 系统必须能够支持从日志文件中读取旧数据,以及从过时客户端到新服务器的请求,反之亦然。
5.1 字段识别器(Field Identifiers)
Thrift中的版本控制是通过字段识别器实现的。Thrift中每个结构成员的字段标头都使用唯一的字段标识符进行编码。该字段标识符及其类型指定符的组合用于唯一标识字段。 Thrift定义语言支持字段标识符的自动分配,但始终明确指定字段标识符是良好的编程习惯。标识符具体如下:
struct Example {
1:i32 number=10,
2:i64 bigNumber,
3:double decimals,
4:string name="thrifty"
}
为避免手动和自动分配的标识符之间发生冲突,将省略标识符的字段分配标识符从-1开始递减,并且该语言仅支持手动分配正标识符。
当数据被反序列化时,生成的代码可以使用这些标识符来正确识别字段并确定它是否与其定义文件中的字段对齐。如果无法识别字段标识符,则生成的代码可以使用类型说明符跳过未知字段而不会出现任何错误。同样,这是可能的,因为所有数据类型都是自定界限的。
字段标识符可以(也应该)在函数参数列表中指定。实际上,参数列表不仅表示为后端的结构,而且实际上在编译器前端中共享相同的代码。这允许对方法参数进行版本安全的修改
service StringCache {
void set(1:i32 key, 2:string value),
string get(1:i32 key) throws (1:KeyNotFound knf),
void delete(1:i32 key)
}
字段标识符的语法用来重复它们的结构。结构体可以被认为是一个字典,其中标识符是键,而值是强类型的命名字段
字段标识符内部使用i16 Thrift类型。但请注意,TProtocol抽象可以编码任何格式的标识符。
5.2 Isset
遇到意外的字段时,可以安全地忽略并丢弃它。当找不到预期的字段时,必须有某种方式向开发人员发出信号,表明它不存在。这是通过定义对象内部的内部isset结构实现的。 (Isset功能隐含在PHP中为空值,在Python中为None,在Ruby中为nil。)实质上,每个Thrift结构的内部isset对象为每个字段设定一个布尔值,表示该字段是否存在于结构中。当接受者收到一个结构时,它应该在直接操作之前检查是否设置了一个字段。
class Example {
public:
Example() :
number(10),
bigNumber(0),
decimals(0),
name("thrifty") {}
int32_t number;
int64_t bigNumber;
double decimals;
std::string name;
struct __isset {
__isset() :
number(false),
bigNumber(false),
decimals(false),
name(false) {}
bool number;
bool bigNumber;
bool decimals;
bool name;
} __isset;
...
}
5.3 例子分析
有四种情况可能会发生版本不匹配。
- 添加了字段,旧客户端,新服务器(Added field, old client, new server)。在这种情况下,旧客户端不发送新字段。新服务器识别出该字段未设置,并为过时的请求(来自旧客户端的请求)实施默认行为。
- 删除了字段,旧客户端,新服务器(Removed field, old client, new server)。在这种情况下,旧客户端发送已删除的字段。新服务器只是忽略它。
- 添加了字段,新客户端,旧服务器(Added field, new client, old server)。新客户端发送旧服务器无法识别的字段。旧服务器只是忽略它并正常处理。
- 删除了字段,新客户端,旧服务器(Removed field, new client, old server)。这是最危险的情况,因为旧服务器不太可能为缺失的字段实现合适的默认行为。建议在这种情况下,在新客户端之前推出新服务器。
5.4 协议/传输版本(Protocol/Transport Versioning)
TProtocol抽象还旨在使协议实现能够以他们看到的任何方式自由地进行版本化。具体而言,任何协议实现都可以在writeMessageBegin()调用中随意发送它喜欢的任何内容。完全取决于实现者如何在协议级别处理版本控制。关键是协议编码更改与接口定义版本更改安全隔离。
请注意,TTransport接口完全相同。例如,如果我们希望在TFileTransport中添加一些新的校验和或错误检测,我们可以简单地将一个版本标题添加到它写入文件的数据中,这样它仍然可以接受没有给定标题的旧日志文件。