TDR Blob 内嵌 Protobuf 部分字段读写
当 TDR 表的 Blob 中保存了 Protobuf(下文简称 PB)数据时,可以通过字段路径,只读取或更新其中需要的内容。例如,只修改路由规则中的实例地址,无需在客户端读取、解析并回写整个 Blob。表本身仍然是 TDR 表。
PB 的二进制编码是半自解释的:每个字段带有字段编号和 wire type,解析方可以据此确定字段数据的边界;其中字符串、bytes、message 等使用带长度的编码。Tcaplus 利用这些信息,按路径提取或替换所选内容,保留未选中的内容。编码格式可参阅 Protobuf 官方编码说明。
PB 数据与定义的兼容性需要业务自行保证。 服务端知道 TDR 表结构,但不持有 Blob 内业务数据的 .proto 定义。wire type 并不等于完整的业务类型,也不能单独说明 message 层级、map/repeated 或 oneof 关系。存量数据、本次写入的数据、构造路径和解析结果所用的 PB 定义必须兼容;路径转换成功不代表历史数据一定符合当前定义。已上线的字段编号不能随意改变或复用,其他类型变更也应遵守 Protobuf 的消息演进规则。
下面先用一组 C++ 示例混合选择常见类型的路径,并给出简短的 Go 对照,再展开说明路径规则。完整接入流程见 C++ SDK 示例和 Go SDK 示例。
1. 综合读写示例
1.1 示例数据与准备
本功能适用于 TDR Generic 表,支持单条部分读取、单条部分更新和批量部分读取,不适用于 List/SortList 表。服务端和 SDK 均须支持该功能;C++ 使用提供 tcaplus_tdr_pb_fields.h 的 Service API,Go SDK 的 v0.6.36 已包含本文使用的接口。
沿用 service_info.xml 中的 routeinfo Blob 和原生 TDR 字段 filterdata:
<entry name="routeinfo_len" type="uint" defaultvalue="0" desc="路由规则信息长度" />
<entry name="routeinfo" type="char" count="1024" refer="routeinfo_len" desc="路由规则信息" />
routeinfo_len 是 Blob 的有效字节数,1024 是数组容量。Blob 中存放以下普通 PB message,无需创建 PB 表或添加 Tcaplus PB 表选项:
syntax = "proto3";
package routeinfo;
message RouteInfo {
uint32 version = 1;
string strategy = 2;
Weight weight = 3;
repeated string tags = 4;
repeated Instance instances = 5;
map<int64, Instance> instance_map = 6;
map<string, string> settings = 7;
}
message Weight { uint32 cpu = 1; uint32 memory = 2; }
message Instance { string addr = 1; uint32 port = 2; uint32 weight = 3; }
假定客户端已初始化并注册该表,主键为 ("dev", "oa", "com") 的记录已存在,Blob 中是合法的 RouteInfo 编码,且至少有一个 instances 元素、instance_map[1001] 和 settings['region']。这些前提与 Go SDK 初始数据示例一致。
C++ 代码使用生成的 SERVICE_INFO、routeinfo::RouteInfo,并包含 tcaplus_tdr_pb_fields.h。下文省略客户端初始化、主键填写、请求创建和响应接收循环,完整流程见 C++ SDK 示例。
1.2 混合选择路径并读取
先创建 TCAPLUS_API_PB_FIELD_GET_REQ 请求、添加记录,并通过 SetData 设置带主键的 TDR 数据。然后将需要的 PB 字段名和原生 TDR 字段放在同一组请求中:
const char* pb_fields[] = {
"version", // 普通字段
"weight.cpu", // 嵌套 message 子字段
"tags", // 整个 repeated 字段
"instances[0].addr", // repeated 元素的子字段
"instance_map[1001].port", // 整数 key 的 map 子字段
"settings['region']" // 字符串 key 的 map value
};
const TcaplusService::TdrPbFieldGroup field_groups[] = {
TcaplusService::TdrPbFieldGroup(
"routeinfo", routeinfo::RouteInfo::descriptor(), pb_fields),
TcaplusService::TdrPbFieldGroup("filterdata") // 原生 TDR 一级 value 字段
};
int32_t ret = TcaplusService::SetAllTdrPbFieldNames(request, field_groups);
if (ret != TcapErrCode::GEN_ERR_SUC) return ret;
return server.SendRequest(request);
TdrPbFieldGroup 的三个参数依次是 TDR Blob 路径、PB 类型的 descriptor、相对 PB 根 message 的字段名列表。descriptor 只提供类型信息,不携带读写数据;多个 Blob 可以分别设置分组。这种字段名写法称为 name path,名称取 .proto 中的原名,不是 JSON name 或 C++/Go 生成的成员名。
收到响应后,依次检查 GetResult、FetchRecord 和 GetData 的结果。下面假定返回的 TDR 记录已取到 row 中,按有效长度解析部分 PB:
if (row.dwRouteinfo_len > sizeof(row.szRouteinfo)) {
return TcapErrCode::API_ERR_OVER_MAX_FIELD_VALUE_LEN;
}
routeinfo::RouteInfo partial;
if (!partial.ParsePartialFromArray(row.szRouteinfo, static_cast<int>(row.dwRouteinfo_len))) {
return TcapErrCode::API_ERR_UNPACK_MESSAGE;
}
// 从 partial 读取所选 PB 字段,从 row.szFilterdata 读取原生 TDR 字段。
结果保留 PB 的 message 层级,只包含选中的内容。未返回的 strategy、weight.memory 等字段不代表原记录中为空;返回的 refer 也是部分 PB 的长度。不能将部分结果直接用于完整 Blob 覆盖。
1.3 用同一组路径更新
创建新的 TCAPLUS_API_PB_FIELD_SET_REQ 请求并添加记录,填写相同主键,复用上述 field_groups。下面为每条选中路径准备新值,并设置原生 TDR 字段:
routeinfo::RouteInfo delta;
delta.set_version(2);
delta.mutable_weight()->set_cpu(80);
delta.add_tags("blue");
delta.add_tags("green");
delta.add_instances()->set_addr("10.0.0.99");
(*delta.mutable_instance_map())[1001].set_port(9090);
(*delta.mutable_settings())["region"] = "gz";
snprintf(row.szFilterdata, sizeof(row.szFilterdata), "%s", "enabled");
const size_t encoded_size = delta.ByteSizeLong();
if (encoded_size > sizeof(row.szRouteinfo)) {
return TcapErrCode::API_ERR_OVER_MAX_FIELD_VALUE_LEN;
}
if (!delta.SerializePartialToArray(row.szRouteinfo, static_cast<int>(encoded_size))) {
return TcapErrCode::API_ERR_PACK_MESSAGE;
}
row.dwRouteinfo_len = static_cast<uint32_t>(encoded_size);
int32_t ret = record->SetData(&row, sizeof(row));
if (ret != TcapErrCode::GEN_ERR_SUC) return ret;
ret = TcaplusService::SetAllTdrPbFieldNames(request, field_groups);
if (ret != TcapErrCode::GEN_ERR_SUC) return ret;
return server.SendRequest(request);
请求由“主键 + 字段路径 + 增量 PB”组成:路径决定修改范围,增量提供新值,这里的“增量”不是数值自增。此次更新将 tags 整体替换为两项,只修改第一个实例的地址及 map 中指定的内容;strategy、weight.memory、实例的其他字段和其他 map key 保留。选中的字段若未提供新值,可能表示清空,详见第 3 节。
该接口只更新已有记录,记录不存在或已过期时不会自动插入。发送成功后仍须检查服务端响应。
1.4 Go 对照
Go 使用 BuildPaths 构造同一组路径,再交给同步接口。以下 client 已初始化,data 已填好主键,TableName 指向示例表:
paths, err := tdrpb.BuildPaths(
tdrpb.Blob("routeinfo", (*routeinfo.RouteInfo)(nil),
"version", "weight.cpu", "tags", "instances[0].addr",
"instance_map[1001].port", "settings['region']"),
tdrpb.Raw("filterdata"),
)
if err != nil {
return
}
if err = client.DoPBFieldGet(TableName, data, &option.TDROpt{FieldNames: paths}); err != nil {
return
}
partial := &routeinfo.RouteInfo{}
if err = tdrpb.UnmarshalPartial(data.Routeinfo, data.Routeinfo_Len, partial); err != nil {
return
}
(*routeinfo.RouteInfo)(nil) 只提供类型信息。更新时构造与 C++ 示例相同的增量,用 tdrpb.MarshalPartial 或 MarshalPartialTo 写入 data.Routeinfo、填写 Routeinfo_Len 和 Filterdata,再调用 client.DoPBFieldUpdate(TableName, data, &option.TDROpt{FieldNames: paths})。完整代码见 Go SDK 示例。
2. 字段名接口与 SetFieldNames
SetFieldNames 原来用于选择 TDR 一级字段,如 filterdata、routeinfo。本功能将选择范围扩展到 Blob 内嵌的 PB 字段,因此 PB 路径可以与原生 TDR 一级 value 字段混用。嵌套 PB 路径须配合本功能的部分读写接口使用,普通 TDR Get/Update 的字段选择规则不变。
C++ 的 SetAllTdrPbFieldNames 封装了字段名转换和 SetFieldNames 调用。Go 的 BuildPaths 返回转换后的列表,将其赋给 TDROpt.FieldNames 后,由 DoPB* 接口构造请求并调用 SetFieldNames。字段名转换本身不发送请求。
SetFieldNames 中的路径保留 TDR 字段名,进入 PB 后使用 .proto 等号右侧的字段编号,称为 tagid path。综合示例的对应关系如下:
| 选择的字段 | 完整 tagid path |
|---|---|
version |
routeinfo.1 |
weight.cpu |
routeinfo.3.1 |
tags |
routeinfo.4 |
instances[0].addr |
routeinfo.5#[0].1 |
instance_map[1001].port |
routeinfo.6[1001].2 |
settings['region'] |
routeinfo.7['region'] |
原生 TDR 字段 filterdata |
filterdata |
需要直接指定字段编号时,C++ 可调用 int32_t SetFieldNames(const char* field_name[], unsigned field_count);Go 可把对应列表赋给 TDROpt.FieldNames,或直接调用 Request 的 SetFieldNames([]string) error。tdrpb.Raw 也可原样传入完整 tagid path,不做字段名转换。SetFieldNames 不接收 routeinfo.version 这样的 PB 字段名路径。
直接构造请求时,应先初始化请求、添加记录并 SetData,再设置字段路径,检查各步返回值。SDK 自动补齐承载 PB 的一级 TDR 字段及所需 refer,无需手动加入 routeinfo、routeinfo_len;手动同时选择 routeinfo 和 routeinfo.1 会产生父子路径冲突。
3. 路径规则与读写语义
3.1 路径终点决定读写范围
路径结束在子字段、message 或集合上,操作范围也随之变化。以下普通路径均为 PB name path:
| 选择的路径 | 增量内容 | 更新结果 |
|---|---|---|
version |
空 PB | 清除 version 编码,本例 proto3 解析为 0 |
weight.cpu |
weight: {cpu: 80} |
只修改 cpu,memory 保留 |
weight |
weight: {cpu: 80} |
替换整个 weight,memory 不保留 |
tags |
tags: ["blue", "green"] |
替换整个数组,不是追加 |
instance_map[1001] |
map 中 key 1001 的新 value | 替换或新增该元素,其他 key 保留 |
instance_map |
map 中仅含 key 1001 | 替换整个 map,其他 key 不保留 |
普通标量、message 或整个集合被列入路径,但增量中没有该字段,表示清除。proto3 零值、空字符串等可能不写入编码,因此路径列表也表达了清空意图。未选中的字段保留,包括未选中的未知字段;整体替换 message、集合或 Blob 时,须提供其中所有希望保留的内容。map 指定 key 的更新和删除另有规则,见下文。
读取普通字段时,若记录中没有该字段的编码,可以成功返回不含该字段的部分 PB;解析后的默认值由客户端定义决定。指定的 map key 不存在或 repeated 下标越界则返回错误,不等同于读到默认值。
3.2 repeated 下标与返回位置
name path 用 instances[0] 选择完整元素,用 instances[0].addr 继续选择其子字段。tagid path 对应 routeinfo.5#[0]、routeinfo.5#[0].1。下标从 0 开始,-1 表示最后一个元素,例如 instances[-1].addr。
tagid path 用 #[] 表示 repeated 下标,用 [] 表示 map key。 PB 的 map 在编码上相当于 repeated entry message;服务端没有业务 .proto,无法仅凭编码区分两者,因此需要 #。字段名接口根据 PB 类型自动转换;手写 routeinfo.5[0] 会被解释为按 map key 访问。
按下标访问仅适用于非 packed repeated,如字符串和 message 数组。更新时目标下标必须存在,增量数组只放一个待写元素,无需补齐前面的占位项。即使将综合示例的路径改为 instances[3].addr,增量仍只需一次 add_instances();更新的是原数组第 4 个元素。
读取结果中的数组紧凑排列:读取 instances[3].addr,结果位于返回 PB 的 instances[0].addr。同一 repeated 不支持在一次请求中更新多个不同下标,也不支持按下标追加或删除;需要整体更新时选择 instances 并提供完整新数组。
3.3 map 更新、key 与删除
更新 instance_map[1001].port 只修改该 value 的 port;选择 instance_map[1001] 则替换完整 value,key 不存在时新增。增量 map 必须包含同一个 key,缺少它会返回 COMMON_ERR_ELEMENT_NOT_EXIST,不会隐式删除元素。
按 key 访问支持 string、int32、int64、uint32、uint64。整数使用不带引号的十进制数,字符串使用单引号或双引号;整数 1001 与字符串 '1001' 不等价。字符串还可用十六进制字节表示,name path 和 tagid path 均支持:
| key 写法 | 含义 |
|---|---|
['region']、[0x726567696f6e] |
同一个字符串 region |
[0x610062] |
三个字节 61 00 62,即 a、零字节、b |
[0x31] |
字符串 1,不同于整数 [1] 和字符串 ['0x31'] |
[''] |
空字符串 |
十六进制支持 0x/0X 和大小写数字,奇数位在开头补零,如 0xabc 等价于 0x0abc;裸 0x 或非十六进制字符不合法。0x61 与 0x6100 是不同的 key。该写法不改变 PB string 的编码约束。
删除整个 map 元素使用 POP。为新的 TCAPLUS_API_PB_FIELD_SET_REQ 请求设置主键和空增量 Blob(refer 为 0)后,设置删除路径:
const char* field_names[] = {"POP routeinfo.6[1001]"};
int32_t ret = request->SetFieldNames(field_names, 1);
if (ret != TcapErrCode::GEN_ERR_SUC) return ret;
发送请求即可删除 key 1001,其他 key 保留;删除不存在的 key 也成功。Go 使用 tdrpb.Pop("routeinfo", (*routeinfo.RouteInfo)(nil), "instance_map[1001]") 分组,交给 BuildPaths 后得到相同路径。
POP 是路径列表中的指令,不通过 SetOperation 或 opt.Operation 传递。普通更新直接填写路径,也接受显式的 SET routeinfo.1。Go 的 Blob 中只填写字段名路径,删除使用 Pop,不在字段名中拼接操作符。
4. 限制与进阶用法
4.1 字段选择、容器层数与嵌套 Blob
字段列表不能为空,不能包含 TDR key 字段。同一请求不能重复选择同一路径,也不能同时选择父路径与子路径:
| tagid path 组合 | 冲突原因 |
|---|---|
routeinfo、routeinfo.1 |
完整 Blob 与其子字段重叠 |
routeinfo.6、routeinfo.6[1001] |
完整 map 与其元素重叠 |
routeinfo.7['region']、routeinfo.7[0x726567696f6e] |
同一个 key |
routeinfo.6[1001]、POP routeinfo.6[1001] |
同一元素同时更新和删除 |
一条 PB 路径最多访问一层容器元素,如 instance_map[1001].port。如果该 value 内还有 map/repeated,不能在同一路径上再次选择其中的元素;可以读取 value,修改后整体替换,并配合版本检查。
Blob 也可以位于 TDR 嵌套结构体中。假设 extra.bin 同样保存 RouteInfo,将分组的 Blob 前缀改为 extra.bin,选择 version 就会生成 extra.bin.1。原生 TDR 结构体子字段(如 extra.tag)不支持独立选择,需要整体读写一级字段 extra。
4.2 PB 类型与兼容性
| 项目 | 规则 |
|---|---|
| packed repeated | 只能按完整字段读取或覆盖;没有 proto 时无法可靠识别编码段中的元素类型和业务下标 |
| map key 类型 | bool、sint*、fixed*、sfixed* 等不支持按 key 访问,必要时整体读写 map |
| oneof | 不自动清除同组其他成员。切换时将旧成员一并列入路径清空,或整体替换包含 oneof 的 message |
| proto2 required | 部分数据可能缺 required 字段,须使用 partial 编解码;服务端不验证最终数据是否满足 required 约束 |
| proto2 默认值 | 清除字段编码后 has_xxx() 为 false,读取值取客户端定义的默认值,不保证为 0 |
| 自增及更新表达式 | 不支持 PB 字段自增请求,也不支持通过独立 SetOperation/opt.Operation 添加更新表达式;map 删除使用路径 POP |
C++ 使用 SerializePartialToArray、ParsePartialFromArray 等 partial 接口并检查结果。Go 可使用 tdrpb.MarshalPartial、MarshalPartialTo、UnmarshalPartial;Blob 生成为 []int8 时使用对应的 Int8 函数。TDR 记录使用配套的 SetData/GetData,参见 TDR 表数据读写与版本兼容性。
4.3 数量与容量
| 项目 | 限制 |
|---|---|
| 单条路径 | 最多 1023 字节,含操作前缀和空白,另保留结尾 \0 |
| 单请求字段配额 | 最多 256 项,包括 SDK 自动补齐、去重后的一级字段及独立 refer |
| 批量读取 | 单次最多 1024 条主键 |
| 条件文本 | 最多 1023 字节,并受所用 SDK 的条件支持范围约束 |
| Blob 大小 | 增量和合并后的完整 PB 均须满足 TDR 数组容量及 refer 类型范围 |
综合示例的 7 条路径,加上自动补齐的 routeinfo、routeinfo_len,共占 9 项;单独选择 extra.bin.1 时补齐一级结构体 extra,共占 2 项。不要仅按叶子路径数量估算配额。
增量小不代表最终 Blob 一定能写入:更长的新值可能使合并数据超过容量。若 refer 为 uint8,即使数组更大,也最多表示 255 字节。
4.4 repeated 范围读取
非 packed repeated 可用 tagid path routeinfo.5#[0-1] 读取前两个元素的完整内容。左右边界均包含在内,且须位于已有元素范围内;不支持范围更新。
C++ 直接将该路径交给 SetFieldNames。当前 Go name path 不支持范围转换,可通过 tdrpb.Raw("routeinfo.5#[0-1]") 或直接填写 TDROpt.FieldNames 传入。
4.5 批量读取、条件、版本与响应
| 操作 | C++ Service API 请求类型 | Go 同步接口 |
|---|---|---|
| 单条部分读取 | TCAPLUS_API_PB_FIELD_GET_REQ |
DoPBFieldGet |
| 单条部分更新 | TCAPLUS_API_PB_FIELD_SET_REQ |
DoPBFieldUpdate |
| 批量部分读取 | TCAPLUS_API_PB_BATCH_FIELD_GET_REQ |
DoPBBatchFieldGet |
批量读取对所有主键使用同一组路径。C++ 检查响应 GetResult 和每次 FetchRecord 的结果;Go 通过 BatchResult、BatchVersion 返回逐条结果和版本,即使接口返回错误,也应继续检查已返回的逐条结果。
条件作用于记录当前数据,条件字段不必出现在选择列表中。例如 routeinfo.1 < 100 AND STRING(routeinfo.2) = 'round_robin'。条件中的 PB 路径使用 tagid;SetAllTdrPbFieldNames 和 BuildPaths 不转换条件表达式。单条读取和更新均支持条件;C++ 批量读取支持条件,当前 Go 批量读取不支持。条件不成立返回 COMMON_ERR_CONDITION_NOT_MATCHED,不执行更新,语法及类型约束见 条件过滤和更新语法说明。
根据旧值计算新值、整体覆盖 message/集合时,应使用读到的正版本号并启用版本匹配检查。冲突返回 SVR_ERR_FAIL_INVALID_VERSION,需要重新读取后判断是否重试。部分更新不能防止同一字段被并发覆盖;Go 的读取响应会回写 opt.Version,复用选项更新时须明确是否需要版本检查。自动递增策略下,删除不存在的 map key 也会作为成功更新递增版本。
更新响应可选择不返回字段、回显请求、返回更新后完整记录或更新前完整记录。回显的增量不等于合并后的完整 Blob,详见 ResultFlag 响应控制与 SDK 示例。网络超时不能证明更新未执行,应按业务幂等性要求重试或读取确认。
5. SDK 示例与相关文档
- C++ SDK 示例:
TdrPbFieldGroup、SetAllTdrPbFieldNames的接口定义,单条与批量请求、响应处理,以及完整示例的文件位置和编译运行入口。 - Go SDK 示例:初始数据、增量构造、单条和批量调用、选项及错误码。动态 PB 类型可使用
BlobDesc/PopDesc。 - 部分字段查询和更新:普通 TDR 接口、PB 表及本功能的入口。
- TDR 表数据读写与版本兼容性:TDR 编解码和元数据兼容要求。
- 错误码文档:参数错误、记录或元素不存在、条件及版本不匹配等错误的处理。