本教程从零开始,系统讲解 Apache Thrift 的 IDL(Interface Definition Language)语法与工程实践,内容覆盖语法细节、生成代码机制、跨语言特性、版本兼容策略、最佳实践与常见坑点。
Thrift 介绍
Thrift 是什么?
一个跨语言的 RPC 框架,让你用一种语言定义接口和数据结构,然后自动生成多种语言的代码,不同服务间可以像本地调用一样通信。
核心四件套:
-
IDL(接口定义语言):定义数据结构、服务接口、字段编号等,是 Thrift 的“合同”。
-
编译器:把 IDL 文件编译成你需要的目标语言代码(Go、Java、Python 等)。
-
序列化协议:数据怎么编码,有 Binary(简单)、Compact(体积小)、JSON(可读)等。
-
传输层:数据怎么传输,支持 TCP、HTTP 等。
IDL 不只是“定义接口”,它精准规定了:结构体有哪些字段、字段顺序、是否必填、默认值是什么,以及服务有哪些方法、抛什么异常。这一切都通过 IDL 来描述。
IDL 文件结构总览
一个 IDL 文件的典型结构(也是可运行的模板):
// 1. 多语言包名namespace go examplenamespace java com.example.demonamespace py user// 2. 引用其他 thrift 文件include "common.thrift"// 3. 常量const i32 MAX_RETRY = 3// 4. 枚举enum Status { SUCCESS = 0 FAIL = 1}// 5. 数据结构struct User { 1: i64 id 2: string name}// 6. 异常exception BizException { 1: i32 code 2: string message}// 7. 服务接口service UserService { User getUser(1: i64 id) throws (1: BizException e)}namespace
-
作用:为不同语言生成的代码指定包名或模块名。
-
用法:
namespace <语言> <包名> -
注意:可以给 Go 设
user,给 Java 设com.demo.user,互不影响。如果某个语言没指定,会使用这个语言的默认规则。
include
-
作用:把公共的类型、异常、服务定义抽取到单独文件,其他文件通过 include 引入。
-
用法:
include "common.thrift" -
引用方式:使用时要带上文件名前缀,如
common.User、common.Status。 -
注意:include 不会自动继承被引用文件的 namespace,必须显式加前缀。
完整的数据类型体系
Thrift 的类型系统可以统一归纳为三个层次。
基本数据类型
布尔、整数、浮点、字符串和二进制:
-
bool:布尔值(true / false) -
byte:有符号 8 位整数 -
i16:16 位有符号整数 -
i32:32 位有符号整数 -
i64:64 位有符号整数 -
double:64 位浮点数 -
string:UTF-8 编码的字符串 -
binary:原始字节序列
容器类型
完全支持泛型嵌套,表达能力很强:
-
list<T>:有序列表,如list<i32>、list<string>,等价于 Go 语言的[]int32、
[]string。 -
set<T>:无序不重复集合,如set<string>,等价于 Go 语言的map[string]struct{}。 -
map<K,V>:键值对映射,如map<string, i64>、map<string, list<i32>>,等价于 Go 语言的
map[string]int64、map[string][]int32。
- 容器可以任意嵌套,比如
map<string, list<map<i32, User>>>
用户自定义类型(重点)
enum 枚举
本质是 i32,但提供了语义化名称:
enum Role { ADMIN = 1 USER = 2}建议显式指定值,避免增减枚举项时发生错乱。
struct 结构体
这是数据传输的核心载体:
struct User { 1: required i64 id 2: optional string name 3: i32 age = 18}每个字段的格式:
<字段编号>: [required|optional] <类型> <字段名> [= 默认值]
exception 异常
本质就是 struct,只是语义上用来声明接口会抛出的错误:
exception BizException { 1: i32 code 2: string message}union 联合体
类似 C 的 union,同一时刻只能设置一个字段,节省空间:
union Result { 1: string success_msg 2: string error_msg}常量 const
可以定义基本类型及容器的编译时常量:
const i32 MAX_SIZE = 100const list<i32> IDS = [1, 2, 3]const map<string, i32> SCORES = {"math": 90, "eng": 85}服务定义、继承与单向调用
service 定义
服务是一个面向接口的契约:
service UserService { User getUser(1: i64 id) throws (1: BizException e) void ping()}-
方法可以有参数,也可以抛出异常。
-
参数列表也使用字段编号,与 struct 一致。
接口继承
可以实现接口的多层扩展:
service ChildService extends ParentService { void newMethod()}子服务会继承父服务的所有方法定义。
oneway 单向操作
修饰一个方法,表示“发后不管”:
oneway void notify(1: string msg)-
无返回值,必须是
void。 -
不能抛出异常。
-
客户端发送请求后不等待响应,适合日志、监控打点、通知等非关键路径。
字段规则、编号与默认值
这三个概念直接决定了数据如何编码、前后兼容性如何,它们是绑在一起的。
required 与 optional
-
required:字段必须存在,反序列化时如果缺失会报错。 -
optional:字段可以不存在,代码中会拿到类型的零值或你设定的默认值。
现代实践建议:尽量只用 optional。因为 required 一旦设置,后续想删除字段几乎不可能,否则老客户端收到缺少该字段的消息会直接崩溃,破坏兼容性。不写关键字时默认为 optional。
在现代 Thrift(尤其 0.10+ 之后)里:
❗ 如果不写 required / optional
👉 默认等价于 optional
字段编号
为什么每个字段前面有个数字?这是 Thrift 序列化协议的唯一标识,不是字段名。
-
规则:编号决定了字段在二进制流中的位置。
-
兼容性铁律:
-
绝对不能修改已上线字段的编号。
-
删除字段时,不要复用它的编号,否则可能新旧数据混淆。
-
新增字段只能使用全新的编号,且必须设为
optional,这样老端才能忽略它。
-
默认值
定义时可以为字段赋默认值:
3: i32 age = 18-
默认值主要作用于生成代码中的初始值,如果消息中该字段没有值,就会用默认值。
-
序列化时,如果字段的值等于默认值,某些协议(如 Compact)可能会省略传输,以节省空间。
代码生成
写好 .thrift 文件后,还需要用 Thrift 编译器把它变成你需要的编程语言代码。生成命令:
thrift -r --gen go demo.thrift参数解释:
-
thrift:调用 Thrift 编译器。 -
-r(recursive):递归生成。如果你的demo.thrift里include了其他文件,这个参数会连同被引用的文件一起生成代码,避免遗漏。 -
--gen go:指定生成 Go 语言代码。可以换成--gen java、--gen py等。 -
demo.thrift:你的 IDL 文件名。
生成结果:
编译器会在当前目录下创建一个 gen-go(或类似命名)的文件夹,里面就是可以直接引入项目使用的代码,包含:
-
结构体的定义(struct 对应的类/结构体)
-
服务端接口(service 对应的 interface / abstract class)
-
客户端调用桩(帮你封装好网络请求的代码)
-
序列化/反序列化逻辑(根据你在 IDL 中定义的字段编号自动生成)
完整实战示例
示例 IDL
namespace go hellostruct HelloRequest { 1: string name 2: i32 age = 0}struct HelloResponse { 1: string message}service HelloService { HelloResponse sayHello(1: HelloRequest req)}这个文件定义了一个问候服务(hello.thrift 文件):
-
请求
HelloRequest包含姓名和年龄。 -
响应
HelloResponse只返回一条消息。 -
服务
HelloService有一个sayHello方法。
生成命令与文件结构
# 查看版本,示例基于 Thrift 0.13.0thrift --versionthrift -r --gen go hello.thrift运行后,当前目录下会生成 gen-go/hello/ 文件夹,里面包含:
gen-go/└── hello/ # Go 包名,对应 namespace go hello ├── hello.go # 核心库:类型、接口、客户端、服务端处理器 ├── hello-consts.go # 常量定义(若 IDL 中无 const,文件可能接近空) ├── GoUnusedProtection__.go # 编译器内部文件,可忽略 └── hello_service-remote/ # 命令行测试客户端(独立可执行程序) └── hello_service-remote.go类型与服务接口的核心(hello.go)
3.1 结构体(IDL 中的 struct)
type HelloRequest struct { Name string `thrift:"name,1" db:"name" json:"name"` Age int32 `thrift:"age,2" db:"age" json:"age"`}type HelloResponse struct { Message string `thrift:"message,1" db:"message" json:"message"`}结构体自带 Read / Write 方法,用于序列化和反序列化,内部会硬编码字段编号。
3.2 方法参数与返回值包装类型
Thrift 内部会将每个方法的参数和返回值包装成独立的 struct(这些是你不需要手动使用的):
type HelloServiceSayHelloArgs struct { Req *HelloRequest `thrift:"req,1" db:"req" json:"req"`}type HelloServiceSayHelloResult struct { Success *HelloResponse `thrift:"success,0" db:"success" json:"success,omitempty"`}它们的作用是让框架能统一地读取请求、调用实现、返回结果。
3.3 服务接口
type HelloService interface { SayHello(ctx context.Context, req *HelloRequest) (r *HelloResponse, err error)}只需要创建一个自己的 struct 去实现这个接口,就完成了服务端业务逻辑。
3.4 客户端桩
type HelloServiceClient struct { c thrift.TClient}func NewHelloServiceClient(c thrift.TClient) *HelloServiceClient { return &HelloServiceClient{c: c}}func (p *HelloServiceClient) SayHello(ctx context.Context, req *HelloRequest) (r *HelloResponse, err error) { var _args HelloServiceSayHelloArgs _args.Req = req var _result HelloServiceSayHelloResult if err = p.c.Call(ctx, "sayHello", &_args, &_result); err != nil { return } return _result.Success, nil}客户端使用时只要:
trans, _ := thrift.NewTSocket("localhost:9090")trans.Open()protocol := thrift.NewTCompactProtocolFactory().GetProtocol(trans)client := hello.NewHelloServiceClient(thrift.NewTStandardClient(protocol, protocol))resp, _ := client.SayHello(ctx, &hello.HelloRequest{Name: "World"})3.5 服务端处理器
type HelloServiceProcessor struct { handler HelloService}func NewHelloServiceProcessor(handler HelloService) *HelloServiceProcessor { self2 := &HelloServiceProcessor{handler:handler, processorMap:make(map[string]thrift.TProcessorFunction)} self2.processorMap["sayHello"] = &helloServiceProcessorSayHello{handler:handler} return self2}func (p *HelloServiceProcessor) Process(ctx context.Context, iprot, oprot thrift.TProtocol) (success bool, err thrift.TException) { name, _, seqId, err := iprot.ReadMessageBegin() if err != nil { return false, err } if processor, ok := p.GetProcessorFunction(name); ok { return processor.Process(ctx, seqId, iprot, oprot) } iprot.Skip(thrift.STRUCT) iprot.ReadMessageEnd() x3 := thrift.NewTApplicationException(thrift.UNKNOWN_METHOD, "Unknown function " + name) oprot.WriteMessageBegin(name, thrift.EXCEPTION, seqId) x3.Write(oprot) oprot.WriteMessageEnd() oprot.Flush(ctx) return false, x3}启动服务端时,只需要:
handler := &MyHelloService{} // 自己的实现processor := hello.NewHelloServiceProcessor(handler)server := thrift.NewTSimpleServer2(processor, transport)server.Serve()完整 RPC 调用
sequenceDiagram
autonumber
participant AppClient as 你的业务代码
participant ClientStub as HelloServiceClient
participant TClient as thrift.TClient
participant ClientProtocol as 客户端Protocol
participant Transport as TSocket/TTransport
participant Network as 网络
participant ServerTransport as 服务端Transport
participant ServerProtocol as 服务端Protocol
participant Processor as HelloServiceProcessor
participant MethodProcessor as helloServiceProcessorSayHello
participant Handler as 你的实现(MyHelloService)
AppClient->>ClientStub: SayHello(ctx, req)
ClientStub->>ClientStub: 构造 HelloServiceSayHelloArgs{Req: req}
ClientStub->>TClient: Call(ctx, "sayHello", args, result)
TClient->>ClientProtocol: WriteMessageBegin("sayHello", CALL, seqId)
TClient->>ClientProtocol: args.Write(oprot)
ClientProtocol->>Transport: 序列化请求参数 HelloRequest
Transport->>Network: 发送二进制 RPC 请求
Network->>ServerTransport: 请求到达服务端
ServerTransport->>ServerProtocol: 读取二进制数据
ServerProtocol->>Processor: Process(ctx, iprot, oprot)
Processor->>ServerProtocol: ReadMessageBegin()
Processor->>Processor: 根据方法名 "sayHello" 查 processorMap
Processor->>MethodProcessor: Process(ctx, seqId, iprot, oprot)
MethodProcessor->>ServerProtocol: args.Read(iprot)
ServerProtocol-->>MethodProcessor: 反序列化 HelloRequest
MethodProcessor->>Handler: SayHello(ctx, req)
alt 业务处理成功
Handler-->>MethodProcessor: 返回 HelloResponse
MethodProcessor->>MethodProcessor: result.Success = response
MethodProcessor->>ServerProtocol: WriteMessageBegin("sayHello", REPLY, seqId)
MethodProcessor->>ServerProtocol: result.Write(oprot)
ServerProtocol->>ServerTransport: 序列化 HelloResponse
ServerTransport->>Network: 返回二进制 RPC 响应
Network->>Transport: 客户端收到响应
Transport->>ClientProtocol: 读取响应数据
ClientProtocol-->>TClient: 反序列化 HelloServiceSayHelloResult
TClient-->>ClientStub: Call 返回 nil
ClientStub-->>AppClient: 返回 HelloResponse
else 业务处理失败
Handler-->>MethodProcessor: 返回 error
MethodProcessor->>ServerProtocol: WriteMessageBegin("sayHello", EXCEPTION, seqId)
MethodProcessor->>ServerProtocol: 写入 TApplicationException(INTERNAL_ERROR)
ServerProtocol->>ServerTransport: 序列化异常
ServerTransport->>Network: 返回异常响应
Network->>Transport: 客户端收到异常响应
Transport->>ClientProtocol: 读取异常数据
ClientProtocol-->>TClient: 反序列化 error
TClient-->>ClientStub: Call 返回 error
ClientStub-->>AppClient: 返回 error
end
总结
怎样改 IDL 才不会炸
Thrift 的序列化协议依赖字段编号来识别数据,而不是字段名。这决定了你的修改行为是“安全”还是“灾难”。
安全操作(放心做):
-
新增字段:绝对安全。老代码收到新字段时会直接忽略;新代码读取老数据时,缺失的新字段会使用默认值。新字段必须设为
optional(或不写,默认就是 optional)。 -
删除字段:安全,但有一条铁律——被删除字段的编号永远不要再分配给其他字段。你可以注释掉该字段,并保留编号的注释作为记录,防止后人误用。
危险操作(绝对禁止):
-
修改字段类型:例如将
i32改为string,或者将list改为map。即使编号相同,类型一改,序列化/反序列化会直接失败,新旧两端都无法正常通信。 -
修改字段编号:这是最严重的错误。编号一旦分配给某个字段,就如同数据库主键,修改它意味着新旧数据完全错乱。
推荐策略:
-
永远只追加字段,不修改、不删除已有字段(真的需要删除时,只是废弃编号)。
-
禁止修改已有字段的语义。例如,原本
count表示数量,后来想改成金额,不如直接新增一个amount字段,旧字段原样保留。 -
方法参数的编号规则和 struct 字段完全一样,同样不能修改。
这些规则的本质:让新旧版本只做“增量”,不做“改变”。理解了字段编号的硬编码地位,你自然就会遵守这些约束。
协议与传输层选择
写完服务接口后,需要决定两件事:数据用什么格式编码(协议),以及通过什么方式传输(传输层)。
常用协议
对于内部 RPC 通信,一般直接选 TCompactProtocol,它在编码时使用变长整数等技巧,能显著节省带宽。
常用传输层
经典组合:TSocket + TCompactProtocol,适合绝大多数内部服务间通信。
性能优化建议
-
优先使用
TCompactProtocol
相比TBinaryProtocol,它能减少约 30%~50% 的传输数据量,编解码开销接近,性价比高。 -
避免深层嵌套
像map<string, list<map<string, list<...>>>>这样的结构,序列化和访问开销都很大。尽量将数据打平,用多个简单字段代替过深的容器嵌套。 -
控制单 struct 的字段数量
一个结构体塞入几十个字段,即便多数是 optional,也会增加序列化/反序列化的 CPU 开销和代码体积。如果结构体过大,应拆分成多个小 struct,再通过组合或 include 复用。 -
善用
oneway
对于日志上报、异步通知等不需要返回值的调用,使用oneway修饰,客户端无需等待响应,可以降低延迟。
Comments