如果你做过移动端数据采集、客户端协议调研或者接口日志分析大概率会遇到过这样一种情况抓包工具里明明看到一条完整请求但真正有用的业务数据却被塞进了一个叫pb的参数里复制出来扔进 base64 解码工具出来的是一堆乱码。我第一次碰到这玩意儿时对着十六进制字节看了半个晚上后来才搞明白这背后是 Protocol Buffers——也就是大家常说的 pb 格式。这篇文章就把我解析 pb 数据的完整思路、工具链和踩坑经验写出来内容包括编码原理、无 proto 文件时的字节推断、从 URL 协议里提取 pb 参数后怎么还原出结构化数据以及解析完之后的数据清洗和多字段批量替换函数怎么写适合正在跟 pb 数据死磕的开发者、数据清洗工程师和协议分析相关方向的朋友。1. 为什么pb解析值得单独写一篇1.1 一个让很多人懵圈的真实场景手机 App 的抓包文件里经常能看到类似这样的链接com.baidu.tieba://unidispatch/pb?obj_locatep_w_singlecolumnrelativerecoobj_source...这类com.xxx://unidispatch/pb形式的协议链接在百度系 App 等很多客户端内部跳转时非常常见。你要注意的重点是 URL 上那个pb参数很多核心数据都塞在这里面。把这串参数复制出来用普通的在线 Base64 工具解码大概率得到一堆不可读的二进制乱码。原因在于pb 不是文本协议它本身就是二进制序列化格式而 URL 里的 pb 参数往往只是对二进制再做了一层 urlsafe Base64 编码。很多第一次接触的人在这一步就卡住了——既不知道需要换解码方式也不知道解码之后该怎么继续。我认识不少做数据清洗的同事遇到这种数据第一反应是跳过要么就把整串乱码当作无意义内容丢掉。但实际上这份数据里往往存着用户行为路径、推荐位点击、页面曝光等关键业务字段。不解析 pb就等于把最有价值的那部分数据倒进了垃圾桶。1.2 pb解决的核心问题以及你会遇到的真实需求Protocol Buffers 是 Google 设计的一套与语言无关、与平台无关的序列化协议。它以.proto文件定义数据结构通过编译工具生成各语言的读写代码。相比 JSONpb 的核心优势有三点体积小二进制编码数值型字段按 varint 紧凑存储很多字段几个字节就能搞定。解析快不需要像 JSON 那样做字符串词法分析二进制按 tag 直取。跨语言一份.proto可以生成 Java、Python、C、Go、JavaScript 等一堆语言的类。所以移动端接口、IM 消息、日志上报、监控采集里用 pb 的地方特别多。就我接触到的实际需求pb 解析通常集中在这么几类场景数据清洗从抓包数据或上报日志里把 pb 字段还原成结构化表格入库分析。协议复现接口调试、 mock 服务搭建需要把 pb 参数解析出来看业务含义。字段提取比如从com.baidu.tieba://unidispatch/pb?obj_locate...这类协议链接中提取推荐位信息。迁移转换老系统用 JSON新系统切 pb或者反过来需要做批量转换。搞清楚 pb 的内部结构是第一步所以下一节我带你把它的编码原理过一遍这部分不弄清楚后面解析大概率要走弯路。2. 看懂二进制之前protobuf编码原理速通2.1 关键概念tag、field number与wire typepb 二进制流的第一个知识点是 tag。每一个字段在序列化时都会先输出一个 key这个 key 占 1 到 5 个字节不等它合并了两个信息字段编号field number和线类型wire type。计算公式很简单key (field_number 3) | wire_type打开任意一段 pb 二进制你看到第一个字节 0x08换算成二进制是 0000 1000低 3 位是 000即 wire_type 0表示这是一个 varint 字段剩下 5 位是 0001表示 field_number 1。所以 0x08 的意思是第 1 号字段varint后面跟着一个 varint 值。wire type 一共有 6 种实际常用的就 4 种wire_type含义典型字段类型0varintint32、int64、bool、enum164-bitfixed64、double2length-delimitedstring、bytes、嵌套消息、repeated packed532-bitfixed32、float其中 wire_type 2 的意思是key 之后会有一个 varint 长度值然后再跟那么多个字节的数据。字符串、bytes 字段和嵌套消息全都走这个类型。这意味着你看到一个高 3 位等于 010 的 key 字节就能知道后面得先读长度再读内容。2.2 varint编码和zigzag为什么负数要特殊处理varint 的编码规则不复杂但初见容易懵每个字节只用低 7 位存数据最高位做延续标志。最高位是 1 表示“后面还有字节”是 0 表示这个字段到这里结束。例如 300 的二进制是 100101100按每 7 位一组拆成 0000010 和 0101100低字节在前所以编码结果是 0xAC 0x02。这套编码方式让小正整数非常节省空间1 到 127 的字段值都只需要 1 个字节。但负数如果用普通补码去编会直接被 varint 扩展到 10 个字节非常浪费。所以 pb 里定义了 sint32、sint64 这种带符号类型用 zigzag 编码把负数映射成正数0 映射 0-1 映射 11 映射 2-2 映射 3这样无论正负都紧凑。实际解析时如果你读到的字段是 int32 类型但值看起来巨大无比八成是没意识到它原本应该按 sint32 的 zigzag 规则去解。2.3 没有proto文件时怎么裸读一段十六进制字节有.proto定义时工具帮你处理一切没有定义时就得靠裸读。你拿一串十六进制字节比如0A 03 61 62 63 10 96 01逐字节拆0A 0000 1010低 3 位是 010wire type 2字段号是 1所以这是第 1 号字段长度分隔类型。下一字节03表示长度 3。再读 3 个字节61 62 63按 ASCII 是abc。10 0001 0000低 3 位是 000varint字段号 2。96 01 10010110 00000001去掉延续位后拼接是 0010110 0000001颠倒字节序varint 是低字节在前得到 0000001 0010110即 150。按这个思路哪怕没有 proto 文件也能一步步把字段编号、类型和值摸出来。实际操作里数据可能嵌套很深肉眼读太累我会在下一节介绍两条真正省力的解析路线。3. 两条解析路线有proto定义和没有proto定义3.1 有.proto文件直接用protoc生成代码最理想的情况是你能拿到对应的.proto文件比如合作方提供、开源仓库里找到甚至是从客户端安装包里提取。这时候事情就变得很机械了。以 Python 为例先在环境里装好 grpcio-tools 或者 protocpip install grpcio-tools然后执行python -m grpc_tools.protoc -I./ --python_out./ --pyi_out./ your.proto这会生成一个your_pb2.py你可以直接 import 并解析import base64 import your_pb2 msg your_pb2.YourMessage() msg.ParseFromString(raw_bytes) from google.protobuf.json_format import MessageToJson print(MessageToJson(msg))如果你的 pb 数据是 URL 里的 urlsafe Base64 字符串先解码成 bytesraw_bytes base64.urlsafe_b64decode(pb_str)ParseFromString会自动解析所有字段MessageToJson会把整个消息对象转成可读 JSON。如果你的消息很大转 JSON 这一步可能比较慢但大部分场景都没问题。3.2 没有.proto文件借助动态解析工具和字段推断拿不到.proto文件才是常态尤其是你在做协议调研、逆向分析或者历史数据修复的时候。这种情况下我会分两步走。第一步先看整体结构。我用得最多的是一个叫protobuf-inspector的命令行工具pip install protobuf-inspector protobuf_inspector data.bin它会把原始 pb 数据自动缩进展开输出类似message { field_1: varint: 123 field_2: bytes_len: 5 abcde field_3: message { field_1: varint: 1 } }这种输出不依赖任何.proto定义能让我在 10 秒内判断出大体结构哪几个字段是嵌套消息、哪几个是 repeated 字段、有没有 packed 数值。注意工具的识别只是根据 wire type 推测具体字段名当然看不到你得结合业务含义去猜。第二步确认结构后写一个最小.proto文件然后像上一节一样走 protoc 流程。比如刚才那个例子可以写成syntax proto3; message Outer { string field_1 1; int32 field_2 2; Inner field_3 3; } message Inner { int32 field_1 1; }再重新生成、解析。这个过程不见得一次到位字段序号对不上就会解析失败或者值异常需要逐步修正 proto 定义。3.3 几种解析方案怎么选我先给一个基于实际经验的选择参考方案适用场景优势劣势protoc 生成代码有 proto 文件类型安全、解析快需要文件环境依赖重protobuf-inspector无 proto快速探测零配置、秒级看结构没有字段名含义Python DynamicMessage无 proto但要批量处理动态拼 descriptor灵活代码复杂不适合新手protobufjs前端/Node 环境与 JS 生态集成好大消息性能一般在线解析工具一次性看数据浏览器即开即用网络时代不建议传敏感数据这里我又要单独说一下方式三Python 的 DynamicMessage。当你既没有.proto定义又不想手工维护临时 proto 时可以用google.protobuf.descriptor_pool动态构建消息描述直接解析出可读结果。代码看起来不短但核心思路是先用descriptor_pool注册一个 FileDescriptor再通过message_factory.GetMessageClass获得消息类。优点是不落地 proto 文件适合写进自动化清洗脚本里批量跑。4. 实战从URL协议中提取并解析pb数据4.1 从协议链接中抠出pb参数并完成解码前面提到的com.baidu.tieba://unidispatch/pb?obj_locatep_w_singlecolumnrelativerecoobj_source...这类链接让我用一个具象的例子把完整流程走一遍。假设我抓到了这么一条原始协议 URLcom.baidu.tieba://unidispatch/pb?obj_locatep_w_singlecolumnrelativerecoobj_sourceunilaunchappcallpbChQKCFJlY29t...第一步是拆分 query 参数提取pb键对应的值。这里有一个非常容易踩的坑URL 里的 pb 参数用的是 urlsafe Base64 字符集普通 Base64 的解析逻辑遇到_和-会直接报错或者解出错误字节。正确写法from urllib.parse import urlparse, parse_qs import base64 url com.baidu.tieba://unidispatch/pb?obj_locatep_w_singlecolumnrelativerecopbChQKCFJlY29t... params parse_qs(urlparse(url).query) pb_str params[pb][0] raw_bytes base64.urlsafe_b64decode(pb_str ) # 补齐 padding注意urlparse本身是给http://用的对com.xxx://这类自定义 scheme 也能正确处理只是urlparse识别com.baidu.tieba为 scheme这样 query 部分依然能解析到params。urlsafe_b64decode对缺少 padding 的字符串会自动补全但如果你基于字符串长度自己算 padding会更可靠padding * (-len(pb_str) % 4) raw_bytes base64.urlsafe_b64decode(pb_str padding)4.2 通过临时proto定义还原结构化数据拿到raw_bytes如果事先能确认这个 pb 对象对应的业务模块是p_w_singlecolumnrelativereco那多半和“单列相对推荐位”有关推测里面有推荐位 ID、标题、位置、排序值这些字段。我会先写一个粗粒度的 protosyntax proto3; message SingleColumnRelativeReco { string target_id 1; string title 2; int32 position 3; repeated Item items 4; } message Item { string id 1; string name 2; int32 score 3; string schema_url 4; }然后正常走 protoc 生成、解析、转 JSON。这个阶段的输出里字段名是我自己起的不代表真实业务含义但字段编号和层级关系是依据二进制实际结构确定的。先把结构跑通再结合业务去重命名。很多人在没有 proto 定义时容易犯一个错拿工具看到字段编号就急着下结论。比如 field 4 是一个 length-delimited 字段里面包着一连串子消息但只有当你连续看到多个相同编号的 tag 时才算确认这是 repeated 字段。单次出现可能是字符串也可能是空的嵌套对象需要结合更多样本观察。4.3 一种特殊但常见的场景uint 类型与字段默认值pb 解析完成后你会注意到一个现象很多字段不见了尤其是值为 0、空字符串、false 的字段在 proto3 里默认不参与序列化。这不是数据缺失而是优化行为。举个例子一个字段编号为 2 的 int32 字段如果值是 0序列化后根本找不到10 00这个 tag解析出来的时候字段缺失msg.position访问得到 0。清洗数据时如果下游要求全字段输出你需要对所有缺省字段做补默认值处理。Python 里可以判断msg.HasField(position)proto3 对标量类型没有HasField所以更简单的做法是转 JSON 后用json.loads结果再补默认值。补充一点bytes类型字段是数据清洗时最容易出错的。pb 的bytes序列化后也走 length-delimited但内容不一定可打印。如果字段值看起来是又一个二进制 blob里面很可能嵌套了一层 pb这时候要回到.proto定义去看原始结构别用字符串解码硬拼。5. 解析之外数据清洗与多字段替换函数5.1 pb解析完不等于可以直接入库数据从 pb 解析成 JSON 或 DataFrame 之后离入库还有一段距离。我实践中遇到最多的四类脏数据一是默认值缺失前面刚说过proto3 不序列化零值字段直接取数会缺列。二是枚举值问题pb 里枚举字段如果遇到未定义的枚举编号直接按整数输出还行按文本输出会直接抛异常。三是未知字段即老版本程序写入、新版本 proto 未定义的那些 tag反序列化时会被丢进 UnknownFieldSet普通 JSON 转换根本看不到。四是二进制字段如bytes字段存图片、加密串等情况需要单独判断是否该转 Base64。针对这些情况我通常会写一层清洗配置def clean_pb_json(data: dict) - dict: # 给缺省字段补默认值 for field in [position, score, status]: if field not in data: data[field] 0 # 枚举字段统一转文本 if status in data: data[status_text] ENUM_MAP.get(data[status], UNKNOWN) # bytes 字段统一做 base64 for field in [raw_image, extra_blob]: if field in data and isinstance(data[field], bytes): data[field] base64.b64encode(data[field]).decode(ascii) return data这不是什么高深技巧但能避免入库后在数据质量报表上被人反复催。5.2 替换多个字符串的正确写法热搜词里有个“替换多个怎么写函数”这在数据清洗里非常常见。比如要把清洗结果里的\u0000去掉、把None替换成空字符串、把一批旧标签改成新标签。我见过最省事但也最容易出 bug 的写法是连续调用多次str.replaces s.replace(旧A, 新A).replace(旧B, 新B)这样写有两个坑第一多次遍历字符串大数据量下性能很差第二后一次替换可能污染前一次的结果。比如你先把 “A” 替换成 “B”再把 “B” 替换成 “C”原本的 A 就会变成 C而不是预期的 B。更稳的写法是使用正则表达式的 callback 把多次替换合并成一次遍历import re def multi_replace(text: str, replacements: dict) - str: if not replacements: return text pattern re.compile(|.join(re.escape(k) for k in replacements.keys())) return pattern.sub(lambda m: replacements[m.group(0)], text)用法是rep { 旧A: 新A, 旧B: 新B, None: , \u0000: , } cleaned multi_replace(raw_text, rep)这个函数有个细节要注意如果两个 key 存在包含关系比如旧和旧A正则|会按从左到右的顺序匹配匹配到了旧A才用旧A的替换值否则匹配旧。把更长的 key 放前面可以避开不少意外pattern re.compile(|.join(re.escape(k) for k in sorted(replacements, keylen, reverseTrue)))这样限制更少效率也更高。如果替换目标本身又包含替换源无论如何都会有语义上的歧义我建议这种情况先想清楚规则别指望一个函数解决所有业务逻辑。5.3 嵌套结构里的键名批量改名另一个高频需求是解析出来的 JSON 里字段名不统一比如有的叫user_name有的叫username有的叫name_要全部改成同一个标准字段名。直接对整体 JSON 字符串做multi_replace风险很大因为字段名和字符串值可能撞车把值也一并改了。我的做法是先json.loads成 Python 对象然后递归遍历字典改 keydef rename_keys(obj, mapping): if isinstance(obj, dict): new_dict {} for k, v in obj.items(): new_key mapping.get(k, k) new_dict[new_key] rename_keys(v, mapping) return new_dict elif isinstance(obj, list): return [rename_keys(item, mapping) for item in obj] else: return obj同样道理删除废弃字段、扁平化嵌套字段都可以写进这个递归逻辑里。对 pb 解析后的 JSON 做清洗时这种方式比正则安全得多因为对象本身已经结构化了。6. 版本、枚举、性能与调试的踩坑复盘6.1 proto2与proto3的坑pb 数据的proto文件版本如果不一致解析结果是两回事。proto2 里required字段缺失时解析直接抛DecodeErrorproto3 默认所有字段都是 optional 语义没有required。因此同一段数据在不同版本的 proto 下解析结果差异巨大。我遇到过最典型的场景老系统用 proto2 定义消息把一些业务字段标成required后来客户端版本升级这些字段不传了服务端用同样的.proto解析抛错率突然飙升。排查了半天才定位到是版本兼容问题。建议处理历史数据时先确认这条 pb 数据对应哪个阶段的协议版本不要盲目套最新 proto。6.2 枚举字段的未知值处理pb 里枚举字段如果遇到一个定义之外的枚举值用MessageToJson会抛异常。比如ValueError: 123 is not a valid value for enum FieldStatus这不是数据坏了而是协议版本更新后新增了枚举值你的 proto 定义没跟上。稳妥做法是解析时先按MessageToString输出或者直接以整数形式访问把枚举值统一在清洗阶段处理。还有一个冷门问题proto2 的枚举字段是开放枚举proto3 的枚举字段第 0 号必须是默认值且必须存在。如果你拿到的 proto3 数据第一号枚举就是业务值而没有 0说明协议实现了不标准解析时同样可能产生隐蔽的偏移错误。6.3 大数据量解析性能与资源控制pb 解析虽然比 JSON 快但数据量大到一定程度也会有明显瓶颈尤其是当你用 Python 的MessageToJson转大消息时字符串转换和对象拷贝非常耗内存。我的两个经验一是解析大量小消息时尽量复用 Message 对象不要每次新建。例如msg MyMessage() for raw in raw_batch: msg.ParseFromString(raw) # 立即拷贝结果或者转 dict避免循环复用被覆盖二是如果要落 CSV 或 Parquet不要经过巨大的 JSON 中间字符串直接读字段值写入行。像MessageToDict比MessageToJson少一层字符串序列化的开销批量清洗时首选前者。6.4 调试工具的整体配合最后分享一套我目前用得最顺手的工具组合首次分析未知 pbprotobuf-inspector看结构。有 proto 文件、正式解析protoc生成 Python 代码。少量数据快速验证临时写一个最小 proto跑protoc或在线解析。字段值抽查xxd看原始 hex对照 tag 公式手动推算。批量清洗入库Python 的google.protobufpandas。实际动手时的建议是先花十分钟理解 tag 和 wire type再动手写解析远比你拿一堆乱码瞎试要高效。真遇到某个字段值怎么都解释不通回到十六进制字节流从 tag 开始重新推一遍大多数谜底都在字节层面。我在实际项目里处理过数量非常大的 pb 上报数据最大的体会是pb 格式本身并不难难的是数据来源五花八门协议版本不齐字段含义往往要靠调试和业务经验反复验证。所以如果你正在被 pb 解析折磨千万别急着找“万能解析工具”先把二进制背后的编码规则看懂再配合一套稳定的清洗流程后续处理都会顺很多。
