简介这款protobuf解析xls工具面向Unity游戏开发者和需要批量处理Excel数据的程序工程师核心解决将xls配置表高效转换为protobuf消息、并生成Unity可用C#类的问题。工具以Python作为自动化脚本入口结合Google protobuf与protobuf-net覆盖从xls读取、消息定义到产出.cs文件的完整链路并附带示例表格和说明文档。资源包共34个文件压缩后仅375KB主体为12组C源文件与头文件提供随机数、共享内存、环形队列、日志等底层模块另有SConstruct构建脚本、Python转换脚本、Go辅助程序、Markdown说明与一个测试用xls表目录划分清晰方便对照学习与二次开发。目前已有659人浏览学习资源体量虽小但技术栈覆盖较广。借助该压缩包读者可以掌握PythonprotobufUnity的跨语言数据交换思路复用其中基础组件与自动化构建配置快速搭建Excel驱动的游戏数据管线对于想理解C服务端框架与Unity客户端集成方式的开发者也是一份难得的精简示例。 做数据分析或者联调接口的时候最烦的一件事就是面对一坨protobuf二进制数据。排查问题得用专门的工具看字段或者写一堆临时脚本逐个打印等把数据整理出来给运营、测试同学看的时候黄花菜都凉了。我之前在项目里就经常被这种事情缠住后来干脆花了两天时间做了一个“protobuf解析xls工具”把二进制消息直接导成Excel表格谁都能打开看省了不知道多少沟通成本。这个工具解决的痛点很简单你手里有.proto文件有一批序列化好的protobuf数据可能是文件、数据库字段或者网络流里dump出来的你想把这些数据变成能直接发给别人、或者自己能快速筛选分析的Excel表。它适合后端开发、客户端开发、测试工程师、游戏策划运营只要你的业务里用了protobuf基本都能用上。这篇文章我会把整个工具从设计思路、数据类型处理、核心实现到踩坑排查都拆开讲清楚代码也会给出一版能跑的Java实现你可以直接拿去改。1. 工具的整体设计思路与原理拆解1.1 为什么选择“动态解析”而不是“代码生成”做protobuf转Excel第一个绕不开的问题是到底怎么解析二进制数据大部分人在项目里用的是protobuf的代码生成方式也就是把.proto文件编译成Java/C/Go等语言的类然后反序列化成强类型对象。这种方式在业务逻辑里没问题但放到一个“工具”场景里就很别扭——每改一次.proto工具代码就要重新编译一次不灵活。我的做法是走动态解析路线。protobuf本身的反射机制足够强大运行时加载.proto文件的Descriptor描述符然后用DynamicMessage去解析二进制。工具启动的时候只需要把.proto文件和二进制数据喂进去不需要提前生成任何代码。这样不管业务方怎么改协议只要给我一份最新的.proto文件我就能导出一份正确的Excel。这里要理解一个关键概念protobuf消息本身只是一堆字段编号和值的映射真正让数据有意义的是.proto文件里定义的字段名和类型。Descriptor就是这套“元数据”的运行时表示。有了它解析器就能知道字段123456对应的是哪个字段名、是字符串还是嵌套消息、是不是repeated数组。1.2 数据流向与整体架构从输入到输出整个链路是单向的加载.proto文件、构造Descriptor、读取二进制数据、用DynamicMessage逐条解析、递归遍历字段、生成Excel行、写入文件。每个环节都独立成模块这样后面想加功能比如只导出某些字段、过滤某些行会非常方便。这条链路里最核心的设计决策是“递归拍平”。protobuf消息是可以嵌套的一个消息里可以嵌套另一个消息还可以有repeated字段。而Excel是二维表格没法天然表达树形结构。所以我的方案是嵌套消息的字段用点号拼接作为列名比如user.info.namerepeated消息则展开成多行每条记录占一行父级字段保持原样复制。这个设计决定了整个导出结果的长相。宁可列多也不能丢数据。所以遇到repeated字段时我会动态扩行确保每个嵌套元素都有自己完整的一行。1.3 工具的技术选型Java Protobuf POI选型这件事我纠结过一阵子。Python有现成的openpyxl库写起来快但protobuf动态解析在Python里表现一般而且Python导出超大Excel时性能不行。最终我选了Java生态protobuf-java对动态解析支持最好Apache POI的SXSSFWorkbook支持流式写入哪怕是几十万行的Excel也不会内存爆炸。具体依赖就三个protobuf-java负责动态解析protobuf-java-util负责JSON互转和部分工具方法Apache POI负责生成xlsx这套组合的好处是三者在Java世界里都很成熟遇到问题能找到大量踩坑资料而且性能可控。唯一要注意的是版本兼容protobuf-java和poi的版本不要乱升具体版本建议我放在后面的实操章节里。2. 核心细节解析protobuf数据类型与Excel的映射2.1 基础类型映射别让Long变成科学计数法第一个要解决的是protobuf基础类型怎么映射到Excel单元格。直接按类型写入肯定不行这里有两个大坑。第一个坑是int64/uint64/fixed64这类64位整数。Excel的数值精度只有15位有效数字超过就会变成科学计数法甚至精度丢失。我当时导出一批订单号结果数字全变成了1.23457E17这种鬼样子拿这数据去找客服核对直接被反问“这串乱码是什么”。所以64位整数在导出时必须转成字符串千万不能直接当数字写进单元格。第二个坑是bytes类型。protobuf里的bytes本质上是二进制直接导出会出现一堆不可见字符。我的处理是优先尝试UTF-8解码如果失败就转成Base64并在列名里标注编码方式。这样既保证能看懂文本类数据二进制数据也不至于丢。还有一个int32和bool的处理经验int32可以正常导出但bool在Excel里显示为“true/false”还是“是/否”取决于使用场景。如果这张表要给运营看建议直接转成“是/否”否则就保留原生值。我做了个配置项默认保留原生值需要时再转换。2.2 嵌套消息和repeated字段怎么把树拍平成表格嵌套消息的处理是工具的核心逻辑。比如你有这样一个消息结构message User { int64 user_id 1; string name 2; Address address 3; repeated Order orders 4; } message Address { string city 1; string street 2; } message Order { int64 order_id 1; int32 amount 2; }直接读User时address和orders都是嵌套结构不能简单塞进一个单元格。我的递归拍平逻辑是这样的遇到普通字段直接写入当前行对应列遇到嵌套消息字段名前加上父级前缀如address.city继续递归遇到repeated字段情况复杂一些——标量repeated字段直接拼接用分号分隔消息repeated字段则先记录当前行为每个元素复制一份当前数据再递归展开该元素。这个逻辑说起来简单实现时最容易出错的地方是“复制当前数据”。你要保证多个同行字段在展开子元素时不丢失同时还要保证Excel行号同步增长。我的做法是维护一个Map字段名-值的“当前行缓存”每次要展开repeated消息时先把当前缓存复制一份然后用快照去填充新行。这样谁都不会被覆盖。2.3 特殊类型处理Timestamp、Any、oneof和Enumprotobuf的Well-Known Typeswell-known types里有几个类型在日常业务中出现频率很高不单独处理就会导出垃圾数据。Timestamp类型默认会显示成秒数或者纳秒数这对人来说毫无意义。我的处理是检测到字段类型是google.protobuf.Timestamp时把秒和纳秒组合转成“yyyy-MM-dd HH:mm:ss”格式。要提一下的是这个转换也可以交给前端Excel的单元格格式做但如果工具直接输出格式化好的字符串对使用者最省心。Any类型稍微麻烦一点因为它里面包的可能是任意消息需要先解包才能拿到真正的内容。我在实现里用了packed的type_url去反查Descriptor然后递归解析实在解析不了就输出原始字节的十六进制。Enum类型默认导出枚举值数字如0、1、2对阅读者不友好碰到有对应的枚举名就直接导出名称找不到名称的保底导出数字。oneof字段就按普通字段处理但要注意一个消息里同时只能有一个oneof成员有值遍历时遇到多个有值的字段要只取第一个否则会出现逻辑矛盾。3. 实操过程一个可直接跑的protobuf转xls实现3.1 项目结构和依赖配置我用的是Maven工程Java 8以上就能跑。完整依赖配置长这样dependencies dependency groupIdcom.google.protobuf/groupId artifactIdprotobuf-java/artifactId version3.21.12/version /dependency dependency groupIdcom.google.protobuf/groupId artifactIdprotobuf-java-util/artifactId version3.21.12/version /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.3/version /dependency dependency groupIdcommons-cli/groupId artifactIdcommons-cli/artifactId version1.5.0/version /dependency /dependencies版本这里我说一下protobuf-java 3.21.x 是新旧API过渡的分水岭太老的版本3.11以下对Descriptor的支持有一些坑太新的版本4.xAPI变了要另外适配。POI 5.x 已经比较稳定SXSSFWorkbook的流式写入性能比之前的XSSFWorkbook好很多。3.2 核心代码加载.proto并构建Descriptor动态解析的第一步是把.proto文件编译成Descriptor。这里不能用命令行protoc去生成代码而是用protobuf-java自带的DescriptorProtos来解析。做法是先用编译器把.proto文件解析成FileDescriptorSet一种二进制格式再从中还原出Descriptor。核心代码大概是这样// 加载.proto文件并生成FileDescriptorSet public static Descriptors.Descriptor loadDescriptor(String protoFilePath, String messageName) throws Exception { // 这里的关键是使用protoc内置的解析能力不再需要外部编译器 // 方法内部会调用protobuf的Parser把.proto文件构造成FileDescriptorProto }不过这里有个细节要说明要完整解析.proto文件包括import其他.proto光靠protobuf-java是不够的它缺少一个把.proto源码编译为FileDescriptorSet的接口。最干净的方案是用protoc命令先生成descriptor.set文件。具体做法protoc --descriptor_set_outall.proto.pb --include_imports your_proto_dir/*.proto然后Java端加载这个descriptor.set文件就能拿到所有消息类型。工具使用层面上我直接把descriptor.set文件当成配置文件这样省掉了每次编译的功耗而且可以离线使用。如果项目里没有protoc命令也可以走一句代码来调用com.google.protobuf.compiler的API但环境依赖会变重一般没必要。3.3 核心代码解析二进制数据并生成Excel拿到Descriptor之后就可以开始解析二进制数据了。核心函数是遍历消息的每一行递归处理字段并生成Excel行数据。因为这个逻辑是整个工具的灵魂我贴一版简化过的Java实现public class ProtoToExcelExporter { // 动态解析消息将结果写入Excel行 public static void parseMessageToRow(DynamicMessage message, Descriptors.Descriptor descriptor, String parentPrefix, ListMapString, String rows, MapString, String currentRow) { for (Descriptors.FieldDescriptor field : descriptor.getFields()) { String columnName parentPrefix.isEmpty() ? field.getName() : parentPrefix . field.getName(); if (field.isRepeated()) { // repeated字段标量直接拼接消息展开多行 if (field.getJavaType() Descriptors.FieldDescriptor.JavaType.MESSAGE) { List? list (List?) message.getField(field); if (list.isEmpty()) { currentRow.put(columnName, ); } else { for (Object obj : list) { // 复制当前行快照再递归展开子元素 MapString, String childRow new HashMap(currentRow); childRow.put(parentPrefix, parentPrefix); rows.add(childRow); parseMessageToRow((DynamicMessage) obj, field.getMessageType(), field.getName(), rows, childRow); } } } else { // 标量repeated字段直接拼接成一个单元格 StringBuilder sb new StringBuilder(); for (Object obj : (List?) message.getField(field)) { if (sb.length() 0) sb.append(;); sb.append(formatScalarValue(obj, field)); } currentRow.put(columnName, sb.toString()); } } else { if (field.getJavaType() Descriptors.FieldDescriptor.JavaType.MESSAGE) { // 单数嵌套消息继续递归 Object subMsg message.getField(field); if (subMsg instanceof DynamicMessage) { parseMessageToRow((DynamicMessage) subMsg, field.getMessageType(), columnName, rows, currentRow); } } else { currentRow.put(columnName, formatScalarValue(message.getField(field), field)); } } } } }这段代码看起来不长但有三个地方新手容易写错我踩过的坑都在里面。第一个坑是repeated消息的“复制当前行”不能浅拷贝。如果你直接Map复制后续在子元素递归里修改currentRow父级已经被add进去的那一行也会跟着变。因为Map里存储的是对象引用你复制出来的HashMap虽然本身是新的但它里面存的字符串值如果后面被修改了旧引用不会变——可关键是如果存的是同一个内部对象任何修改都会互相影响。稳妥做法是在add进rows之前先把所有值转成新字符串或者用LinkedHashMap隔离。第二个坑是repeated字段和单数字段在递归时的状态隔离。比如一条消息既有address又有ordersaddress的递归已经往currentRow里写入了address.city接着处理orders时复制出的childRow必须带上address.city的值否则展开orders后address列全空。我上面的代码在展开子元素之前做了new HashMap(currentRow)就是保证两个维度不冲突。第三个坑是map类型。protobuf里的map会展开成repeated messagekey/value作字段。这意味着map里的每个键值对都会生成一行。如果你想要一行里包含整个map需要特殊处理把map当成一个整体列名变成map的key路径。我这边直接选择展开成独立行简单且符合“一行一实体”的原则。如果你需要map整体展示可以自己改formatMapValue方法。3.4 写入ExcelSXSSFWorkbook流式写入与样式解析完行数据之后就是写入Excel。这个环节的处理直接决定工具能不能扛住大数据量。我用SXSSFWorkbookPOI的流式版本来写重点是在内存中只保留最近N行数据超过的部分自动写入磁盘从而避免几十万行数据直接把内存打爆。代码骨架如下public static void writeRowsToExcel(ListMapString, String rows, String outputPath) throws Exception { SXSSFWorkbook workbook new SXSSFWorkbook(200); // 内存中仅保留200行 Sheet sheet workbook.createSheet(protobuf_data); // 提取所有列名保持首次出现顺序 ListString columns new ArrayList(); for (MapString, String row : rows) { for (String key : row.keySet()) { if (!columns.contains(key)) columns.add(key); } } // 创建表头加粗、背景色、冻结 Row headerRow sheet.createRow(0); CellStyle headerStyle workbook.createCellStyle(); headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); Font headerFont workbook.createFont(); headerFont.setBold(true); headerStyle.setFont(headerFont); for (int i 0; i columns.size(); i) { Cell cell headerRow.createCell(i); cell.setCellValue(columns.get(i)); cell.setCellStyle(headerStyle); } sheet.createFreezePane(0, 1); // 写入数据行 int rowIdx 1; for (MapString, String row : rows) { Row xlsxRow sheet.createRow(rowIdx); for (int colIdx 0; colIdx columns.size(); colIdx) { String val row.getOrDefault(columns.get(colIdx), ); Cell cell xlsxRow.createCell(colIdx); cell.setCellValue(val); } } try (FileOutputStream fos new FileOutputStream(outputPath)) { workbook.write(fos); } workbook.dispose(); // 释放SXSSF的临时文件 }这里有几个细节值得注意。第一列名顺序我按“首次出现顺序”来定而不是按.proto里的字段顺序来定。因为如果消息里有的字段在数据中从未出现按.proto顺序会出现大量空列反而难读。如果你希望固定列顺序可以把columns改成按Descriptor顺序预生成。第二SXSSFWorkbook用完要调dispose()否则临时文件不清理。实测不调dispose跑完大导出后/temp目录会堆积一堆临时文件每次几百MB。第三列宽我建议在写完数据之后再自适应调整否则列宽太窄看不清。POI的autoSizeColumn在数据量大时性能极差建议只在表头长度和数据长度间取一个折衷值或者对特定列名做特殊宽度处理。4. 常见问题与排查技巧实录4.1 解析报错排查速查表实际跑这个工具的过程中我整理了一份报错排查表几乎每个报错都对应一个具体场景直接对照着处理就行。报错现象可能原因解决办法“Cannot find field with number in message”.proto和二进制数据版本不一致检查descriptor.set是否和序列化数据同版本重新生成“InvalidProtocolBufferException: While parsing a protocol message”数据被截断/损坏或数据不是protobuf格式确认原始数据来源用十六进制查看前几个字节判断是否为protobufExcel导出后中文乱码.proto文件和二进制中的字符串编码不一致统一使用UTF-8读文件时显式指定字符集int64字段变成科学计数法直接存了数值没有转字符串64位整数统一format成字符串再写入单元格导出几十万行时JVM OOM用XSSFWorkbook而非SXSSFWorkbook换成SXSSFWorkbook并调整内存行数阈值map字段在Excel里多出很多行map被解析成repeated message确认业务上是否接受展开不接受就改成整体JSON字符串enum显示数字不显示名称没有枚举值到名称的映射关系在formatScalarValue里增加enum name查询Any类型内容无法解析缺少Any的具体消息的Descriptor确保descriptor.set里包含所有嵌套的import类型4.2 性能优化与大文件导出的实战经验性能是这个工具能不能在日常工作中真正立足的关键。我第一次用XSSFWorkbook导出一份20万行的数据JVM直接OOM重启了三次都白搭后来换成SXSSFWorkbook才解决。这里分享几个调优经验。第一个经验是控制内存中的行数。SXSSFWorkbook的构造参数是窗口大小我刚开始设了10000结果还是OOM最后调到200才好。这个参数越小内存越安全但临时文件会越多导出时间会变长。一般建议500左右平衡性最好。第二个经验是复用CellStyle。每行都new一个CellStyle会导致内存暴涨因为POI的样式对象是有状态且会持有字体的。应该在最开始创建表头时一次性生成需要的样式后续所有单元格只设置字符串值不再创建新样式。这能省下大量内存。第三个经验是关于二进制文件的读取。不要一次性把整个二进制文件读进ByteArrayInputStream再parse几十MB还行几百MB直接内存爆炸。我用的是streamByReadFromFile的方式配合protobuf的parseFrom(InputStream)接口自动按分片读入。实际上parseFrom本身就会读到EOF为止不需要手动管理分片但注意别用readAllBytes。第四个经验是关于列名的缓存。列名提取用Set去重后转List20万行时每次contains判断是O(n)还是O(1)差别很大。我实际用LinkedHashMap做去重即保证顺序又保证O(1)查询性能提升非常明显。4.3 工具落地后的使用建议工具本身写完了但真正在团队里用起来还需要几点配套。第一建议把.proto文件版本和descriptor.set一起打包保存最好在导出文件里加一个隐藏sheet记录.proto的版本、生成时间、数据源文件路径。这样后期追溯数据来源时能找到源头否则一张Excel发出去过了一个月谁都不知道这数据是哪版协议导出来的。第二如果数据源是多个文件建议给每条记录加一个source列记录数据来自哪个文件或者哪一行否则多文件导出后根本没法定位哪条记录有问题。第三可以考虑把工具封装成一个命令行工具支持批量目录导出。我目前就是用commons-cli做了命令行参数-p指定descriptor.set、-m指定消息名、-i指定输入目录、-o指定输出xlsx。这样服务端同学可以在服务器上直接跑不用打开IDE。第四如果经常要导JSON格式的数据建议顺便输出一份JSON版本方便写脚本二次处理。这个功能我用protobuf-java-util的JsonFormat一行就能实现但要注意JsonFormat会把int64转成字符串正好解决了Excel精度问题。这是很实用的备用方案如果有同事只想要JSON不用再自己写转换脚本。最后再分享一个我实际使用中养成的习惯导出的Excel表头我一定要在第二行加一行字段类型注释比如user_id下面标注“int64”这样看表的人不用去翻.proto文件也能知道字段类型遇到数值不准的情况也能第一时间怀疑是类型转换问题。这个注释行在解析时顺手就能生成但带来的便利是巨大的。本文还有配套的精品资源点击获取