- 后端
- 微服务
- API设计
【免费下载链接】thrift
Apache Thrift
Apache Thrift 的 Delphi 语言库(lib/delphi)提供了一个名为SkipTest的专项测试,用于验证同一服务接口在不同版本定义下、以任意启动顺序互操作时是否保持完全兼容。本文以 skip/README.md 为主线,结合该测试的两份 IDL、两个控制台程序及底层TProtocolUtil.Skip实现,完整剖析这一"版本演进兼容性验证"方案的设计思想与运行机制。读完本文,你将掌握 Thrift 跨版本兼容的核心原理——未知字段跳过(field skipping),并能直接复用 SkipTest 的模式为自有服务的接口演进设计回归验证。
测试定位:两个程序、一套协议、任意启动顺序
按照 skip/README.md 的原始说明,SkipTest 的测试形态非常简洁而明确:
- 两个项目彼此配套、缺一不可("These two projects belong together");
- 两个程序分别模拟同一协议的不同版本的服务端与客户端;
- 测试意图是确保 Delphi Thrift 实现的兼容特性完全可用;
- 预期结果是:无论两个程序以何种顺序先后启动,双方都不应出现任何错误。
这一设计直接对应 Thrift 生态中两个经典的兼容性诉求:
- 前向兼容(forward compatibility):使用旧版本 IDL 生成代码的客户端,能否正确处理新版本服务端发来的、包含它"不认识字段"的响应;
- 后向兼容(backward compatibility):使用新版本 IDL 的客户端,能否正确理解旧版本服务端的响应。
SkipTest 用一套自包含的"文件交换"机制把这两个方向都覆盖到了,而不是依赖真实的网络连接。
测试工程结构
SkipTest 位于仓库的 lib/delphi/test/skip 目录下,共包含 6 个核心文件:
| 文件 | 作用 |
|---|---|
| README.md | 测试设计说明(本文主体文档) |
| idl/skiptest_version_1.thrift | 版本 1 的接口定义(命名空间Skiptest.One) |
| idl/skiptest_version_2.thrift | 版本 2 的接口定义(命名空间Skiptest.Two) |
| skiptest_version1.dpr | 版本 1 控制台程序(可扮演 client 或 server 角色) |
| skiptest_version2.dpr | 版本 2 控制台程序 |
两个.dproj工程文件 | Delphi 工程配置,内含代码生成预构建命令 |
两个 Delphi 程序完全共享同一套运行逻辑(文件交换 + 三种协议),唯一区别是:版本 1 程序编译时引用gen-delphi\Skiptest.One.pas,版本 2 程序引用gen-delphi\Skiptest.Two.pas(见 skiptest_version1.dpr 与 skiptest_version2.dpr)。
代码生成与构建
gen-delphi目录下的生成代码不是手工编写的,而是由两个.dproj的 PreBuildEvent 在编译前自动生成:
- 版本 1 工程:
thrift.exe -r -gen delphi idl\skiptest_version_1.thrift - 版本 2 工程:
thrift.exe -r -gen delphi:rtti idl\skiptest_version_2.thrift
可见版本 2 额外启用了rtti生成选项(与仓库其他测试如 typeregistry 使用的register_types选项一脉相承,服务于 Delphi 的类型注册机制)。编译时两个程序均链接lib/delphi/src下的运行时库(Thrift.pas、Thrift.Transport.pas、Thrift.Protocol.pas、Thrift.Protocol.JSON.pas、Thrift.Protocol.Compact.pas、Thrift.Server.pas、Thrift.Stream.pas等约 15 个单元)。需要提醒的是,Delphi 库要求Delphi 2010 及以上版本(见 lib/delphi/README.md),因为实现重度依赖泛型特性。
两份 IDL:一场精心设计的接口演进
SkipTest 的兼容性验证全部建立在这两份 IDL 的差异之上,值得逐字段对比。
版本 1:最小化的接口雏形
skiptest_version_1.thrift 定义了一个非常精简的服务:
// version 1 of the interface namespace * Skiptest.One const i32 SKIPTESTSERVICE_VERSION = 1 enum PingPongEnum { PingOne = 0, PongOne = 1, } struct Ping { 1 : optional i32 version1 100 : PingPongEnum EnumTest } exception PongFailed { 222 : optional i32 pongErrorCode } service SkipTestService { Ping PingPong( 1: Ping ping) throws (444: PongFailed pof); }注意几个刻意设计的细节:
- 常量
SKIPTESTSERVICE_VERSION = 1,运行时用于打印当前程序版本; - 结构体字段 ID 从1起步,但枚举字段特意使用了100这样的大 ID,模拟"预留高位字段号"的演进习惯;
- 异常
PongFailed只有字段222; - 服务方法
PingPong只有一个入参,且只声明了一种异常PongFailed(ID 444)。
版本 2:全面扩张后的完整接口
skiptest_version_2.thrift 则把接口"长胖"了很多:
// version 2 of the interface namespace * Skiptest.Two const i32 SKIPTESTSERVICE_VERSION = 2 enum PingPongEnum { PingOne = 0, PongOne = 1, PingTwo = 2, PongTwo = 3, } struct Pong { 1 : optional i32 version1 2 : optional i16 version2 100 : PingPongEnum EnumTest } struct Ping { 1 : optional i32 version1 10 : optional bool boolVal 11 : optional byte byteVal 12 : optional double dbVal 13 : optional i16 i16Val 14 : optional i32 i32Val 15 : optional i64 i64Val 16 : optional string strVal 17 : optional Pong structVal 18 : optional map< list< Pong>, set< string>> mapVal 100 : PingPongEnum EnumTest } exception PingFailed { 1 : optional i32 pingErrorCode } exception PongFailed { 222 : optional i32 pongErrorCode 10 : optional bool boolVal 11 : optional byte byteVal 12 : optional double dbVal 13 : optional i16 i16Val 14 : optional i32 i32Val 15 : optional i64 i64Val 16 : optional string strVal 17 : optional Pong structVal 18 : optional map< list< Pong>, set< string>> mapVal } service SkipTestService { Ping PingPong( 1: Ping ping, 3: Pong pong) throws (1: PingFailed pif, 444: PongFailed pof); }版本 2 相对版本 1 的演进点可以归纳为五类:
- 枚举新增成员:
PingPongEnum从 2 个成员扩到 4 个(PingTwo = 2、PongTwo = 3); - 新增结构体
Pong(字段 1/2/100); - 结构体新增字段:
Ping从 2 个字段扩到 11 个,覆盖了 Thrift 的全部基础标量类型(bool/byte/double/i16/i32/i64/string)、嵌套结构体(Pong)以及复杂容器(map<list<Pong>, set<string>>,即 map 的键是 list、值是 set); - 新增异常
PingFailed,并给PongFailed追加大量字段; - 服务方法签名变化:入参从
1: Ping ping变为1: Ping ping, 3: Pong pong,异常列表从单个PongFailed扩为PingFailed + PongFailed。
这些差异覆盖了接口演进中最具代表性的场景:字段号保持稳定(1/100 不变)、新增字段号不冲突(10~18、2、3)、异常类型增减、枚举扩展、容器嵌套。这正是验证"跳过未知字段"机制是否健壮的最佳试验场。
运行机制:用文件交换模拟"客户端-服务端"对谈
SkipTest 最巧妙之处在于,它不使用 Socket,而是用磁盘文件在"上一轮程序"和"下一轮程序"之间传递请求与响应。版本 1、2 两个程序(skiptest_version1.dpr、skiptest_version2.dpr)的代码几乎一致,共用三个核心例程:
① 写请求:CreateRequest(扮演客户端)
procedure CreateRequest( protfact : IProtocolFactory; fname : string); var stm : TFileStream; ping : IPing; proto : IProtocol; client : TSkipTestService.TClient; // we need access to send/recv_pingpong() cliRef : IUnknown; // holds the refcount begin stm := TFileStream.Create( fname+REQUEST_EXT+'.tmp', fmCreate); try ping := CreatePing; proto := CreateProtocol( protfact, stm, FALSE); client := TSkipTestService.TClient.Create( nil, proto); cliRef := client as IUnknown; client.send_PingPong( ping); // 版本 2 为 send_PingPong( ping, ping.StructVal) finally client := nil; cliRef := nil; stm.Free; end; DeleteFile( fname+REQUEST_EXT); RenameFile( fname+REQUEST_EXT+'.tmp', fname+REQUEST_EXT); // 原子化落盘 end;细节要点:
- 请求先写入
*.request.tmp,随后改名为*.request,保证另一个程序看到的一定是完整文件; - 直接调用生成的
TClient.send_PingPong把请求序列化进文件——注意这里绕过了TTransport的网络语义,直接用TStreamTransportImpl把IProtocol挂到TFileStream上; - 版本 2 的
CreatePing会填充全部 11 个字段,包括嵌套Pong和map<list<Pong>, set<string>>(见 skiptest_version2.dpr),把最复杂的容器形态也纳入验证。
② 处理请求:ProcessFile(扮演服务端)
procedure ProcessFile( protfact : IProtocolFactory; fname : string); var stmIn, stmOut : TFileStream; protIn, protOut : IProtocol; server : IProcessor; begin stmIn := TFileStream.Create( fname+REQUEST_EXT, fmOpenRead); try stmOut := TFileStream.Create( fname+RESPONSE_EXT+'.tmp', fmCreate); protIn := CreateProtocol( protfact, stmIn, TRUE); protOut := CreateProtocol( protfact, stmOut, FALSE); server := TSkipTestService.TProcessorImpl.Create( TDummyServer.Create); server.Process( protIn, protOut); finally stmIn.Free; stmOut.Free; end; DeleteFile( fname+RESPONSE_EXT); RenameFile( fname+RESPONSE_EXT+'.tmp', fname+RESPONSE_EXT); end;TDummyServer实现了对应版本的TSkipTestService.Iface,收到请求后打印客户端版本号与请求内容,并回发一个用TConstants.SKIPTESTSERVICE_VERSION标记的Ping;- 通过
TProcessorImpl.Process驱动完整的"读请求 → 分发 → 写响应"服务端链路。
③ 读响应:ReadResponse(再次扮演客户端)
stm := TFileStream.Create( fname+RESPONSE_EXT, fmOpenRead); proto := CreateProtocol( protfact, stm, TRUE); client := TSkipTestService.TClient.Create( proto, nil); ping := client.recv_PingPong;主流程Test:一轮完整的闭环
procedure Test( protfact : IProtocolFactory; fname : string); begin // 1) 若磁盘上已有上一轮留下的请求文件,先处理它并读取响应 if FileExists( fname + REQUEST_EXT) then begin ProcessFile( protfact, fname); ReadResponse( protfact, fname); end; // 2) 无论如何:自己写请求、自己处理、自己读响应 CreateRequest( protfact, fname); ProcessFile( protfact, fname); ReadResponse( protfact, fname); end;于是,当两个版本的程序交替运行时,就自然形成了四种组合的交叉验证:
| 写请求方(client 视角) | 处理请求方(server 视角) | 验证的兼容方向 |
|---|---|---|
| 版本 1 程序 | 版本 1 程序 | 基准(同版本自洽) |
| 版本 2 程序 | 版本 2 程序 | 基准 |
| 版本 1 写、版本 2 处理 | 版本 2 读到 v1 字段缺失/未知字段 | 前向兼容(旧请求进新服务端) |
| 版本 2 写、版本 1 处理 | 版本 1 读到大量未知字段 | 后向兼容(新请求进旧服务端) |
主程序还会打印'Delphi SkipTest '+IntToStr(TConstants.SKIPTESTSERVICE_VERSION)+' using '+Thrift.Version,并依次对三种协议各跑一遍完整流程:
Test( TBinaryProtocolImpl.TFactory.Create, FILE_BINARY); // pingpong.bin Test( TJSONProtocolImpl.TFactory.Create, FILE_JSON); // pingpong.json Test( TCompactProtocolImpl.TFactory.Create, FILE_COMPACT); // pingpong.compact也就是说,每个程序单次运行会生成/消费 6 类数据文件:3 种协议 ×(.request+.response)。整个测试贯穿"Binary / JSON / Compact"三种最常用的线格式,确保跳过机制不依赖具体协议实现。
底层原理:TProtocolUtil.Skip的未知字段跳过机制
SkipTest 能成立的前提,是 Delphi 运行时在反序列化时遇到不认识的字段不会报错,而是把该字段"原样跳过"。这个能力由 Thrift.Protocol.pas 中的TProtocolUtil.Skip(第 864-922 行)提供:
class procedure TProtocolUtil.Skip( prot: IProtocol; type_: TType); begin tracker := prot.NextRecursionLevel; // 递归深度防护 case type_ of // —— 基础标量:直接读掉即可,无需解码语义 —— TType.Bool_ : prot.ReadBool(); TType.Byte_ : prot.ReadByte(); TType.I16 : prot.ReadI16(); TType.I32 : prot.ReadI32(); TType.I64 : prot.ReadI64(); TType.Double_ : prot.ReadDouble(); TType.String_ : prot.ReadBinary(); // 注释:只跳过,不解码字符串 TType.Uuid : prot.ReadUuid(); // —— 结构化类型:递归跳过容器内的每个元素 —— TType.Struct : begin prot.ReadStructBegin(); while TRUE do begin field := prot.ReadFieldBegin(); if (field.Type_ = TType.Stop) then Break; Skip(prot, field.Type_); prot.ReadFieldEnd(); end; prot.ReadStructEnd(); end; TType.Map : begin map := prot.ReadMapBegin(); for i := 0 to map.Count-1 do begin Skip(prot, map.KeyType); Skip(prot, map.ValueType); end; prot.ReadMapEnd(); end; TType.Set_ : ... // 按 ElementType 逐个 Skip TType.List : ... // 按 ElementType 逐个 Skip else raise TProtocolExceptionInvalidData.Create('Unexpected type '+IntToStr(Ord(type_))); end; end;这段实现有四个关键设计,恰好对应版本 2 IDL 刻意安排的字段类型:
- 标量直接消费:bool/byte/i16/i32/i64/double/string/uuid 各调一次对应的
ReadXxx,把字节从流中读走即可,无需知道字段含义——这正是"跳过"的本质; - 字符串不解码:
TType.String_分支特意用ReadBinary并注释"Don't try to decode the string, just skip it",避免不必要的 UTF-8 解码开销; - 容器递归:
map会先读map.Count再对每个键值对分别Skip(KeyType)/Skip(ValueType),list/set同理。因此版本 2 中嵌套两层的map<list<Pong>, set<string>>会被一层层拆解跳完; - 递归深度防护:每次进入都会取
prot.NextRecursionLevel追踪器,防止恶意或异常嵌套数据导致栈溢出。
Skip的调用点遍布整个运行时。一个典型例子是 Thrift.pas 中TApplicationException.IBase_Read的读取逻辑:当读取异常对象时,如果字段 1(消息)或字段 2(异常类型)的实际类型与预期不符,或者读到未知字段号,都会落入TProtocolUtil.Skip( iprot, field.Type_ )分支。也就是说,服务端抛出的未知异常字段、未知的异常类型,在客户端同样被安全跳过——这正是版本 2 给PongFailed疯狂追加字段后,版本 1 客户端仍能正常识别pof异常的原因。
当遇到完全无法识别的类型时,Skip会抛出TProtocolExceptionInvalidData('Unexpected type ...'),两个测试程序的主try/except会捕获并打印E.ClassName + ': ' + E.Message。因此,任何兼容性缺陷都会以显式异常的形式暴露在控制台,而不是静默产生脏数据。
运行与预期结果
整个测试的玩法可以归纳为一句话:交替启动两个程序,观察是否出现异常输出。
- 首次运行任一程序:磁盘上没有
*.request文件,程序直接走"写请求 → 处理 → 读响应"流程,生成第一份.request/.response数据文件; - 再运行另一个版本的程序:它发现上一轮留下的
.request文件,会先以"服务端"身份处理(此时读到的是另一版本写出的数据),再以"客户端"身份读响应,随后又覆盖写出一份新请求; - 如此往复,任意顺序、任意次数:只要两个程序都只打印
Test completed without errors.并正常退出,即说明:- 旧版本代码读到新版本字段时,
Skip正确跳过(后向兼容); - 新版本代码读到旧版本缺失的字段时,按
optional语义安全缺省(前向兼容); - 三种协议(Binary / JSON / Compact)下行为一致。
- 旧版本代码读到新版本字段时,
这正是 skip/README.md 所要求的"regardless in which order they might be started"(无论以何种顺序启动)都不出错。任何一方崩溃或打印异常堆栈,都意味着 Delphi 实现的兼容性在某处出现了裂缝,需要回到生成器(compiler/cpp的 Delphi 代码生成模块)或运行时(lib/delphi/src)排查。
总结:SkipTest 的价值与可复用性
从 skip/README.md 这寥寥数行的设计说明出发,SkipTest 实际上示范了一套极具工程价值的接口演进回归测试范式:
- 用文件交换代替网络连接:无需真实 Socket 与端口管理,两个程序天然解耦,任意顺序、任意次数重复执行,天然覆盖所有 client/server 版本组合;
- IDL 差分设计:两份 IDL 的差异精准覆盖枚举扩展、字段新增、嵌套容器、异常演进等全部兼容性风险点;
- 三种协议并联验证:Binary / JSON / Compact 各自独立生成
pingpong.bin/pingpong.json/pingpong.compact数据文件,保证跳过逻辑与具体线格式无关; - 底层机制清晰:兼容性的基石是 TProtocolUtil.Skip 的"按类型消费字节"策略——识别字段类型、递归遍历容器、忽略字段语义,配合
optional缺省语义与TApplicationException读取中的跳过逻辑,共同支撑起 Thrift"接口可以演进、双方不必同步升级"的核心承诺。
如果你正在维护一个长期演进的 Thrift 服务,完全可以照搬 SkipTest 的骨架:保留一份 v1 IDL 与一份最新 IDL,用两份生成代码 + 文件交换式测试程序做周期性回归,任何破坏兼容性的改动都会第一时间暴露。
- 后端
- 微服务
- API设计
【免费下载链接】thrift
Apache Thrift
相关推荐
Apache Thrift Delphi 版本兼容性测试:SkipTest 双版本客户端/服务端实战指南
Apache Thrift Delphi 版本兼容性测试:SkipTest 双版本客户端/服务端实战指南 导读 本文围绕 Apache Thrift 仓库中 l
后端RPC框架序列化代码生成用 PowerShell 一键验证 Apache Thrift Delphi 代码生成:codegen 测试脚本深度解析
用 PowerShell 一键验证 Apache Thrift Delphi 代码生成:codegen 测试脚本深度解析 Apache Thrift 的 Del
后端RPC框架序列化代码生成Kitex TTheader协议与Apache Thrift的兼容性分析
Kitex TTheader协议与Apache Thrift的兼容性分析 背景介绍 在分布式系统开发中,跨语言服务调用是一个常见需求。Kitex作为一款高性能的
后端RPC框架微服务
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考