Thrift 初探

引言

知乎使用的 RPC 框架是基于 Thrift 构建的。自然就很有必要了解下 Thrift 是什么?如何使用?以及有什么最佳实践?

Thrift 官方是这样介绍自己的:

Thrift is a software framework for scalable cross-language services development. It combines a software stack with a code generation engine to build services that work efficiently and seamlessly between C++, Java, Python, PHP, Ruby, Erlang, Perl, Haskell, C#, Cocoa, JavaScript, Node.js, Smalltalk, and OCaml.

以下是 Thrift 协议栈示意图:

  • Input Code:是依照 Thrift 语法编写的服务(包含变量、服务方法、异常等定义)
  • Generated Code:使用 thrift gen 编译生成的指定某种语言的客户端代码,以便于在业务中调用服务的 RPC 接口
    • Service Client
    • write()/read()
  • TProtocol:提供了可选的协议层
  • TTransport:提供了满足不同需求的传输层服务

运行时库(Runtime Library)

整个协议层和传输层都是运行时库的一部分。这就意味着,我们可以在上层定义好服务后,在需要的时候随时替换协议层或传输层,而无需重新编译生成客户端代码。

协议层(Protocol Layer)

协议层的主要作用是提供序列化和反序列化的能力,目前支持的类型如下:

  • TBinaryProtocol:直接二进制格式,数字会被编码成二进制,而非文本
  • TCompactProtocol:异常高效、紧凑的数据编码格式
  • TDenseProtocol:类似于 TCompactProtocol,但是在传输时会移除 meta 信息,而在接收时补回 meta 信息
  • TJSONProtocol:使用 JSON 格式进行编码
  • TSimpleJSONProtocol:使用 JSON 编码,且只用于写的协议。适用于脚本语言解析
  • TDebugProtocol:使用人类友好的文本格式,便于调试

服务端支持

传输层负责读取和写入。目前支持的几种方式如下:

  • TSocket:使用阻塞式网络 IO 传输
  • TFramedTransport:逐帧发送数据,每帧数据都在前面加上长度信息。对于使用非阻塞式的服务来说,需要使用这种传输方法
  • TFileTransport:使用文件作为传送媒介
  • TMemoryTransport:借助内容传输。Java 的实现中使用了一个简单的 ByteArrayOutputStream 来实现
  • TZlibTransport:使用 zlib 压缩,可以和其它传输方式组合在一块

Processor

Processor 将输入和输出协议作为参数。它负责从输入读取数据,通过用户自定义的 Handler 处理数据,然后再将数据写到输出。

支持的服务模式(Supported Servers)

服务器自然是用于在指定端口监听请求,并将接收到的数据发送到 Processor 处理。有这么几种服务可以用使用:

  • TSimpleServer:单线程阻塞 IO,方便测试
  • ThreadPoolServer:多线程阻塞 IO
  • TNonblockingServer:多线程非阻塞 IO,需要使用 TFramedTransport 传输方式

Thrift 语言指南

类型系统

Thrift 类型系统主要包括如下几个部分:

  • 预定义的基本类型
  • 用户定义的结构体
  • 容器类型
  • 异常
  • 服务

基本类型

  • bool:布尔类型(可选值 truefalse),占用一个字节
  • byte:有符号单字节
  • i16:16 位有符号整型
  • i32:32 位有符号整型
  • i64:64 位有符号整型
  • double:64 位浮点数类型
  • binary:字节串
  • string:编码未知文本或二进制字符串

需要注意的是,Thrfit 并不支持无符号整数,因为在很多编程语言中并没有直接的映射。

容器

  • list<T>:列表,类似 Python 中的 list
  • set<T>:无序集合
  • map<K, V>:字典,类似 Python 中的 dict

容器使用的类型可以是任意合法的 Thrfit 类型(如结构体、异常等),但「服务」除外。

结构体与异常

Thrift 结构体定义类似 C 语言中的 struct,对于某些 OO 语言,结构体会被翻译成 Class。

异常的定义类似结构体,可以包含不同的字段组成,但是用 exception 关键字。我们在定义服务 RPC 时,可以让某些方法抛出异常(如资源未找到)。

服务

服务的定义类似 OO 编程中定义的 interface,或者 Rust 中的 trait。Thrift 编译器会生成完整的实现了接口的客户端和服务端桩(Stub)。

类型定义

1
2
typedef i32 int
typedef Tweet ReTweet

枚举

类 C 的枚举定义:

1
2
3
4
5
6
enum TweetType {
TWEET,
RETWEET = 2,
DM = 0xa,
REPLY
}

需要注意的是,不像 Protocol Buffers,Thrift 还不支持嵌套 Enum 定义(或者嵌套结构体),枚举值必须是 32 位正整数。

注释

1
2
3
4
5
6
7
# 这是合法的注释

/*
* 多行注释,类似 C 语言支持的那样
*/

// 单行注释

命名空间

Thrift 中的命名空间类似 C++ 中的那样,或者 Java 中的包的概念。由于每种语言都有自己的包管理机制(如 Python 有模块的概念),Thrift 允许针对不同的语言定制命名空间行为:

1
2
3
4
5
// 翻译成 C++ `namespace com { namespace example { namespace project {`
namespace cpp com.example.project

// 翻译成 Java 包就是 `package com.example.project`
namespace java com.example.project

Includes

我们通常可以将 Thrift 定义拆分到多个文件中以便维护、复用,使用模块化的方式进行组织。Thrift 运行在一个文件中 include 其它文件。被 include 的文件会在当前目录或者使用 -I 编译标志指定的目录中查找。

1
2
3
4
5
6
include "tweet.thrift" // 注意没有 `;` 结尾
...

struct TweetSearchResult {
1: list<tweet.Tweet> tweets;
}

常量

1
2
3
4
5
const i32 MAX_PROCS = 120;  // 分号是可选的

// 也可以定义复杂类型的常量,支持结构体、字典等
// 使用 JSON 的格式初始化
const map<string, string> MAP_CONST = {"hello": "world"};

结构体定义

在 Thrfit IDL (Interface Defenition Language) 中,结构体是基本的构建基石。结构体组合了多个字段,每个字段有独立的标识符,类型,名称以及可选的默认值。

1
2
3
4
5
6
7
8
9
10
11
12
struct Location {
1: required double latitude;
2: required double longitude;
}

struct Tweet {
1: required i32 user_id;
2: required string user_name;
3: required: string description;
4: optional: Location loc;
5: optional: string contry = "China";
}

对于使用 required 标记的字段,实例化时必须要赋值,否则会报错。对于 optional 字段,如果没有设置值,就不会在序列化时传输。如果 optional 字段设置了默认值,当解析结构体的时候会给相应字段赋予默认值。

不同于服务,结构体是不允许继承的,也就是说不允许扩展自其它结构体。

Required Is Forever

在将一个字段设置为 required 之前一定要三思啊!如果你想在什么时候停止发送某个 required 字段时(如设置为 optional),就很容易出问题:旧版本的读取器会认为没有原先 required 字段的消息是不完整的,可能会拒绝甚至无意中丢弃掉。所以这时可能就需要编写特定的自定义校验逻辑来解决问题。有些人认为使用 required 弊大于利;他们更喜欢只用 optional。然而,这个也是因人而异,看场景需要的了。

定义服务

虽然已经有很多流行的序列化/反序列化框架(如 Protocol Buffers)了,但很少有框架提供开箱即用的跨语言 RPC 服务的支持,这也是 Thrift 最主要的亮点之一。

可以把服务定义当做 Java 中的接口定义,你需要提供相应的方法名称、签名等;此外,服务也可以扩展自其它服务(使用 extends 关键字)。

Thrift 编译器会根据选择的目标编程语言生成相应的接口代码(Server 端)以及 Stubs(客户端)。Thrift 为大多数语言提供了用于运行客户端和服务端的 RPC 库。

1
2
3
4
5
6
7
8
9
10
11
12
service Twitter {
// 方法定义格式类似 C 语言。需要指定返回类型、参数列表,可能会抛的异常列表
// 需要注意的是参数列表和异常列表使用的定义方式类似结构体字段定义
void ping();
// 看到没,也可以使用逗号结尾
// 不过还是建议使用分号吧
bool post_tweet(1: Tweet tweet) throws (1: TwitterUnavailable unavailable),
TweetSearchResult search_tweets(1: string query);

// `async` 表示客户端尽管请求,而无需等待其响应。这种方法必须是 `void`
async void zip();
}

代码生成

在前面的 协议栈 小节对整个 Thrift 协议栈有了概括性的了解,接下来将从代码生成的角度讲讲 Thrift 协议栈。

基本概念

以下是 Thrift 网络栈的概念图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
+-------------------------------------------+
| cGRE |
| Server |
| (single-threaded, event-driven etc) |
+-------------------------------------------+
| cBLU |
| Processor |
| (compiler generated) |
+-------------------------------------------+
| cGRE |
| Protocol |
| (JSON, compact etc) |
+-------------------------------------------+
| cGRE |
| Transport |
| (raw TCP, HTTP etc) |
+-------------------------------------------+

传输层(Transport Layer)

传输层提供了网络读写的简单抽象,这样可以将底层的传输和系统其它部分解耦开来(如序列化、反序列化等)。
以下是 Transport 接口提供的方法:

  • open
  • close
  • read
  • write
  • flush

除了 Transport 接口,Thrift 也使用了 ServerTransport 接口接收或创建原始的传输对象。顾名思义,ServerTransport 主要用于服务端为进入的连接创建 Transport 对象。其提供的方法如下:

  • open
  • listen
  • accept
  • close

以下是大部分 Thrift 支持的语言都会提供的传输方式:

  • file: 从磁盘这两个读取或写入
  • http

协议层(Protocol Layer)

协议抽象提供了将内存数据结构映射成传输格式的机制,也就是说,协议定义了数据类型如何利用底层的 Transport 层来编解码自己。因此,协议层实现负责 Schema 编码以及序列化/反序列化。常用的协议包括:JSON、XML、纯文本和压缩二进制等。

以下是 Protocol 接口定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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()

Thrift 协议层采用面向流(Stream Oriented)的设计。例如,没必要在知道字符串或列表中的元素数量的长度的情况下,才可以序列化它们。

以下是 Thrift 支持的语言中通常会提供的协议:

  • binary
  • compact
  • json

处理器(Processor)

Processor 对于从输入流读取数据和写出到输出流操作做了封装,输入和输出流使用 Protocol 对象表示。接口定义非常简单:

1
2
3
interface TProcessor {
bool process(TProtocol in, TProtocol out) throws TException
}

服务器(Server)

服务器包含如下功能:

  1. 创建 Transport
  2. 为 Transport 创建输入、输出 Protocols
  3. 基于输入输出 Protocols 创建一个 Processor
  4. 等待连接,并将它们丢给 Processor 处理

最佳实践

版本化、兼容性考量

协议随着时间会不断进化的。如果现有的消息类型不再满足当前需求(例如希望给某个结构体新增一个字段)时,但是希望旧版本格式生成的代码继续兼容(向前兼容),不要担心!你可以非常轻松地更新协议而不用担心破坏之前的任何代码。不过,前提是需要遵循一些规则:

  1. 对于任何已有的字段,不要修改前面的 Tag Number
  2. 任何新增的字段都应标记为可选的。也就是说,使用旧版本消息格式生成的代码序列化的消息是可以被新生成的代码正确解析的,因为它们不会丢失任何必须的字段。对于这些字段应当设置合理的默认值,这样新的代码可以正确地与旧代码生成的消息交互。同样地,使用新代码生成的消息也可以被旧代码正确解析,只是忽略掉新增的字段。然而,未知字段并不会被遗弃,如果消息之后被序列化,未知字段还是会参与序列化的。因此,如果消息被发送到新版本代码,新增字段依然可用。

  3. 非必须的字段可以被移除掉,前提是 Tag Number 不要再次使用(推荐重命名废弃字段,可以添加 OBSOLETE_ 前缀)

可能造成版本不匹配的情况分析

参考

  1. Thrift Tutorial
  2. Thrift The Missing Guide
  3. Thrift docs
  4. Paper: Scalable Cross-Language Services Implementation
  5. Paper: Apache Thrift

竞争对手

  1. Data Serialization Comparison
  2. Schema evolution in Avro, Protocol Buffers and Thrift
0%