Thrift 语法详解

标签:微服务首次发布:2026-08-05最近修改:2026-08-07
摘要

本教程从零开始,系统讲解 Apache Thrift 的 IDL(Interface Definition Language)语法与工程实践,内容覆盖语法细节、生成代码机制、跨语言特性、版本兼容策略、最佳实践与常见坑点。

Thrift 介绍

Thrift 是什么?

一个跨语言的 RPC 框架,让你用一种语言定义接口和数据结构,然后自动生成多种语言的代码,不同服务间可以像本地调用一样通信。

核心四件套:

  • IDL(接口定义语言):定义数据结构、服务接口、字段编号等,是 Thrift 的“合同”。

  • 编译器:把 IDL 文件编译成你需要的目标语言代码(Go、Java、Python 等)。

  • 序列化协议:数据怎么编码,有 Binary(简单)、Compact(体积小)、JSON(可读)等。

  • 传输层:数据怎么传输,支持 TCP、HTTP 等。

IDL 不只是“定义接口”,它精准规定了:结构体有哪些字段、字段顺序、是否必填、默认值是什么,以及服务有哪些方法、抛什么异常。这一切都通过 IDL 来描述。

IDL 文件结构总览

一个 IDL 文件的典型结构(也是可运行的模板):

thrift
// 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.Usercommon.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]int64map[string][]int32

  • 容器可以任意嵌套,比如 map<string, list<map<i32, User>>>

用户自定义类型(重点)

enum 枚举
本质是 i32,但提供了语义化名称:

proto
enum Role {  ADMIN = 1  USER = 2}

建议显式指定值,避免增减枚举项时发生错乱。


struct 结构体

这是数据传输的核心载体:

thrift
struct User {  1: required i64 id  2: optional string name  3: i32 age = 18}

每个字段的格式:
<字段编号>: [required|optional] <类型> <字段名> [= 默认值]


exception 异常

本质就是 struct,只是语义上用来声明接口会抛出的错误:

thrift
exception BizException {  1: i32 code  2: string message}

union 联合体

类似 C 的 union,同一时刻只能设置一个字段,节省空间:

thrift
union Result {  1: string success_msg  2: string error_msg}

常量 const

可以定义基本类型及容器的编译时常量:

thrift
const i32 MAX_SIZE = 100const list<i32> IDS = [1, 2, 3]const map<string, i32> SCORES = {"math": 90, "eng": 85}

服务定义、继承与单向调用

service 定义

服务是一个面向接口的契约:

thrift
service UserService {  User getUser(1: i64 id) throws (1: BizException e)  void ping()}
  • 方法可以有参数,也可以抛出异常。

  • 参数列表也使用字段编号,与 struct 一致。

接口继承

可以实现接口的多层扩展:

thrift
service ChildService extends ParentService {  void newMethod()}

子服务会继承父服务的所有方法定义。

oneway 单向操作

修饰一个方法,表示“发后不管”:

thrift
oneway void notify(1: string msg)
  • 无返回值,必须是 void

  • 不能抛出异常。

  • 客户端发送请求后不等待响应,适合日志、监控打点、通知等非关键路径。

字段规则、编号与默认值

这三个概念直接决定了数据如何编码、前后兼容性如何,它们是绑在一起的。

required 与 optional

  • required:字段必须存在,反序列化时如果缺失会报错。

  • optional:字段可以不存在,代码中会拿到类型的零值或你设定的默认值。

现代实践建议:尽量只用 optional。因为 required 一旦设置,后续想删除字段几乎不可能,否则老客户端收到缺少该字段的消息会直接崩溃,破坏兼容性。不写关键字时默认为 optional

在现代 Thrift(尤其 0.10+ 之后)里:

❗ 如果不写 required / optional
👉 默认等价于 optional

字段编号

为什么每个字段前面有个数字?这是 Thrift 序列化协议的唯一标识,不是字段名。

  • 规则:编号决定了字段在二进制流中的位置。

  • 兼容性铁律:

    • 绝对不能修改已上线字段的编号。

    • 删除字段时,不要复用它的编号,否则可能新旧数据混淆。

    • 新增字段只能使用全新的编号,且必须设为 optional,这样老端才能忽略它。

默认值

定义时可以为字段赋默认值:

thrift
3: i32 age = 18
  • 默认值主要作用于生成代码中的初始值,如果消息中该字段没有值,就会用默认值。

  • 序列化时,如果字段的值等于默认值,某些协议(如 Compact)可能会省略传输,以节省空间。

代码生成

写好 .thrift 文件后,还需要用 Thrift 编译器把它变成你需要的编程语言代码。生成命令:

thrift
thrift -r --gen go demo.thrift

参数解释:

  • thrift:调用 Thrift 编译器。

  • -r(recursive):递归生成。如果你的 demo.thriftinclude 了其他文件,这个参数会连同被引用的文件一起生成代码,避免遗漏。

  • --gen go:指定生成 Go 语言代码。可以换成 --gen java--gen py 等。

  • demo.thrift:你的 IDL 文件名。

生成结果:
编译器会在当前目录下创建一个 gen-go(或类似命名)的文件夹,里面就是可以直接引入项目使用的代码,包含:

  • 结构体的定义(struct 对应的类/结构体)

  • 服务端接口(service 对应的 interface / abstract class)

  • 客户端调用桩(帮你封装好网络请求的代码)

  • 序列化/反序列化逻辑(根据你在 IDL 中定义的字段编号自动生成)

完整实战示例

示例 IDL

thrift
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 方法。

生成命令与文件结构

bash
# 查看版本,示例基于 Thrift 0.13.0thrift --versionthrift -r --gen go hello.thrift

运行后,当前目录下会生成 gen-go/hello/ 文件夹,里面包含:

thrift
gen-go/└── hello/                          # Go 包名,对应 namespace go hello    ├── hello.go                    # 核心库:类型、接口、客户端、服务端处理器    ├── hello-consts.go             # 常量定义(若 IDL 中无 const,文件可能接近空)    ├── GoUnusedProtection__.go     # 编译器内部文件,可忽略    └── hello_service-remote/       # 命令行测试客户端(独立可执行程序)        └── hello_service-remote.go

3.1 结构体(IDL 中的 struct)

go
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(这些是你不需要手动使用的):

python
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 服务接口

go
type HelloService interface {  SayHello(ctx context.Context, req *HelloRequest) (r *HelloResponse, err error)}

只需要创建一个自己的 struct 去实现这个接口,就完成了服务端业务逻辑。

3.4 客户端桩

go
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}

客户端使用时只要:

go
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 服务端处理器

go
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}

启动服务端时,只需要:

go
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,适合绝大多数内部服务间通信。

性能优化建议

  1. 优先使用 TCompactProtocol
    相比 TBinaryProtocol,它能减少约 30%~50% 的传输数据量,编解码开销接近,性价比高。

  2. 避免深层嵌套
    map<string, list<map<string, list<...>>>> 这样的结构,序列化和访问开销都很大。尽量将数据打平,用多个简单字段代替过深的容器嵌套。

  3. 控制单 struct 的字段数量
    一个结构体塞入几十个字段,即便多数是 optional,也会增加序列化/反序列化的 CPU 开销和代码体积。如果结构体过大,应拆分成多个小 struct,再通过组合或 include 复用。

  4. 善用 oneway
    对于日志上报、异步通知等不需要返回值的调用,使用 oneway 修饰,客户端无需等待响应,可以降低延迟。

Comments

评论区将在滚动到这里时加载。