做ET框架项目的时候消息通信这块迟早要跟Google.Protobuf打交道。我一开始以为这玩意儿就是个普通的序列化库照着示例把.proto文件一写、代码一生成、往网络消息里一塞就完事结果真做起来才发现里面有不少门道特别是跟ET的框架一结合很多流程不是光看文档就能明白的。这篇东西就是把我在ET项目里折腾Protobuf的过程完整梳理一遍重点说清楚“理解基本的使用流程”这条主线从.proto定义到代码生成再到序列化、反序列化、接入ET消息派发最后是踩坑记录争取你看完就能照着在自己项目里跑通。1. 内容整体设计与思路拆解1.1 ET框架为什么需要ProtobufET框架这里指的是基于Entity组件模式的分布式游戏服务端框架在客户端和服务端之间通信最核心的需求就是“高效、跨语言、易扩展”。HTTP那套JSON格式虽然可读性好但解析开销大、字段冗余多在游戏这种高频率、低延迟的场景下并不合适。Protobuf是二进制协议序列化后体积小、解析速度快特别适合网络传输。我当时选择在ET里用Google.Protobuf主要是因为两点。第一是ET框架本身的消息层在底层已经整合了Protobuf相关支持大量网络消息的定义都是基于.proto文件生成的你如果自定义消息不按这个路子走就很难接入框架自带的网络组件和消息分发机制。第二是Google.Protobuf在C#生态里支持成熟NuGet包直接装就能用不需要自己撸序列化代码。1.2 使用流程的整体认知在ET里用Protobuf整个流程可以拆成五个环节编写.proto文件定义消息结构和字段编号。通过protoc编译器生成C#代码文件。将生成的代码引入Unity工程或服务端工程编译通过。在代码里完成消息对象的构造、序列化和反序列化。将消息挂到ET的网络消息处理流程中完成收发。这五步看起来简单但每一步都有不少隐藏细节。比如.proto文件里的字段编号很多人随手写完全没意识到编号一旦发布就不能随便改否则老客户端和新服务器之间解析就会错乱。再比如ET里消息类型通常要继承特定基类或者带有特定标记直接拿生成的裸消息类往ET里塞框架根本不认识。这些我都会在后面的实操环节展开。2. 核心细节解析与实操要点2.1 proto文件定义的关键点先看一个最基础的.proto文件。假设我们要定义一个角色信息消息syntax proto3; package ETModel; message CharacterInfo { int64 character_id 1; string name 2; int32 level 3; repeated int32 item_list 4; mapstring, int32 attr_map 5; }写之前必须确认几个问题。第一个是语法版本ET项目里现在基本都用proto3proto2里required、optional那一套已经被移除了默认就是optional而且在序列化时不区分字段是否设置只编码非默认值。第二个是包名package这个包名决定了生成代码的命名空间。我看到过不少人在ET里把package写成别的名字结果生成出来的C#类和ET自身的命名空间对不上引用起来非常麻烦。建议是直接跟随你项目里已有的消息文件风格比如ETModel、Hotfix等。字段编号是很多人忽略的重点。proto3里每个字段都要有一个唯一的编号这个编号用于二进制编码字段名在序列化过程中其实不参与传输。编号1到15占用1字节16到2047占用2字节所以高频字段尽量用小编号。更关键的是一旦某个消息上线后你就不能再修改字段编号否则线上老数据解析会直接错位。我自己的习惯是预留一段编号区间比如1到20放核心字段后续扩展从21开始递增避免未来需求变动时被逼着重构消息。2.2 类型映射与C#生成的对应关系Google.Protobuf在C#里生成的类型和.proto类型有明确的映射规则理解这个映射关系能少走很多弯路。proto类型C#生成类型默认值说明int32 / int64int / long0常用数字字段uint32 / uint64uint / ulong0无符号类型float / doublefloat / double0浮点类型boolboolfalse布尔值stringstring字符串默认空串bytesByteString空ByteString注意不是byte[]repeated TRepeatedField空集合类似ListmapK, VMapFieldK, V空集合类似Dictionaryenumenum第一个枚举值注意0必须有这里最容易踩的坑是bytes字段。如果你在proto里声明bytes data 1生成的C#属性类型是Google.Protobuf.ByteString不是byte[]。直接给这个字段赋值的时候需要把你自己的byte[]转成ByteString.CopyFrom()反过来读取时用.ToByteArray()。我在第一次写ET消息的时候就在这里卡了半小时编译器一直报类型不匹配后来翻文档才发现是ByteString这个包装类型的问题。另外一个坑是enum。proto3里枚举的第一个成员必须为0否则编译器报错。这个设计是为了保证默认值也是合法值。如果你需要类似“未知状态”这种语义就把它的值设成0。2.3 RepeatedField和MapField的实际用法repeated字段生成的是RepeatedField 它继承了IList 所以大多数场景下你可以像操作List 一样操作它。但是要注意直接用new Message()创建对象后这个RepeatedField是空对象但并非null你可以直接调用Add方法添加元素。MapFieldK, V的用法和Dictionary类似直接索引赋值就行。但在给整个MapField做批量赋值时不要直接替换整个属性对象因为它的setter是private的Google.Protobuf生成代码的属性通常只有getter正确的做法是Clear后循环Add。var msg new CharacterInfo(); msg.AttrMap.Clear(); foreach (var kv in sourceDict) { msg.AttrMap[kv.Key] kv.Value; }3. 实操过程与核心环节实现3.1 环境准备与工具选型在ET工程里使用Protobuf需要准备两样东西一个是protoc编译器另一个是Google.Protobuf运行时库。protoc我建议直接从GitHub的protobuf官方发布页下载对应你操作系统的预编译版本比如protoc-3.21.12-win64.zipWindows下解压后把protoc.exe所在目录加入PATH方便在命令行里直接调用。Google.Protobuf的C#运行时库我建议用NuGet直接安装到你的服务端工程dotnet add package Google.Protobuf --version 3.21.12如果你还有专门生成消息代码的工程可以单独放一个工具类工程引用Google.Protobuf和Google.Protobuf.Tools这样可以用MSBuild任务自动生成不过前期学习阶段没必要搞那么复杂手动跑一回protoc命令就能理解整个链路了。3.2 一步步生成C#代码先新建一个proto目录把上面的CharacterInfo.proto文件放进去。然后在命令行里执行protoc --csharp_out./output --proto_path./proto ./proto/CharacterInfo.proto参数说明--csharp_out指定生成的C#文件输出目录。--proto_path指定import搜索路径如果有多个proto文件互相import这里就要正确配置。最后一个参数是你要编译的proto文件路径。执行成功后output目录下会出现CharacterInfo.cs。打开这个文件你会发现它是个分部类内部包含字段的常量编号定义、Properties、Equals、GetHashCode、写入计算的逻辑等等。不要手动去改这个文件因为下次重新生成会覆盖掉。在ET里还有一个常见的做法就是通过一个批处理脚本或者小工具把整个proto目录下所有文件一次性生成。比如Windows下的bat脚本echo off set PROTOC_PATHD:\tools\protoc\bin\protoc.exe set PROTO_DIR.\Proto set OUTPUT_DIR.\Generated %PROTOC_PATH% --csharp_out%OUTPUT_DIR% --proto_path%PROTO_DIR% %PROTO_DIR%\*.proto pause这种方式适合消息文件多、手工一条条敲命令容易出错的情况。3.3 序列化与反序列化的基本用法生成代码之后序列化和反序列化其实就很简单了。核心类是IMessage接口所有生成的消息类都实现了它。最常用的序列化方法有两种// 方式一直接ToByteArray CharacterInfo info new CharacterInfo { CharacterId 10001, Name TestPlayer, Level 10 }; info.ItemList.Add(3); info.ItemList.Add(7); byte[] data info.ToByteArray(); // 反序列化 CharacterInfo parsed CharacterInfo.Parser.ParseFrom(data);// 方式二写入Stream适合需要合并多个消息或者大对象场景 using MemoryStream stream new MemoryStream(); info.WriteTo(stream); byte[] data2 stream.ToArray();需要注意的一点是ToByteArray和WriteTo都会把对象当前所有非默认值字段写进二进制流。解析的时候如果二进制流里没有某个字段对应属性就是默认值不会报错。这套机制在消息升级时有一定容错性服务器新增一个字段老客户端发来的包里没有这个字段解析出来就是默认值程序逻辑上要处理好“默认值即无效值”的情况。在ET中因为消息类型很多通常我们会写一个包装方法把消息对象统一转成byte[]再交给下层传输组件。反过来收到byte[]后根据opcode找到对应的Parser再反序列化。这个流程等会儿再说。3.4 在ET网络消息中的接入ET框架里网络消息一般不直接暴露给上层业务使用而是通过“消息分发”的方式。你要让Protobuf消息真正跑起来需要做三层事情。第一层定义消息类型与Opcode的映射。ET通过AOT或反射收集所有消息类型给每个消息分配一个唯一的Opcode编号发送方和接收方都根据这个编号判定消息格式。你用的消息类必须能被框架扫描到通常做法是把消息类放在约定好的程序集或名字空间下并加上特定特性标签。ET不同版本的做法略有不同有的版本是继承IMessage再通过名字约定映射有的版本是继承框架自带的BaseMessage或带Opcode特性。以较常见的做法为例[Message] public partial class CharacterInfo : IMessage { }这里有一个很重要的坑直接用protoc生成的类是不带ET框架这些特性的你需要通过一个partial类来补全。上面proto生成的文件里类声明是partial class CharacterInfo所以你可以在另一个文件里写using ET; namespace ETModel { [Message] public partial class CharacterInfo : IMessage { } }这样就能在不修改生成代码的前提下把消息类注册进ET框架。第二层完成业务层的收发。发消息的时候构造好消息对象序列化成byte[]然后调用网络会话的Send方法CharacterInfo msg new CharacterInfo(); msg.CharacterId 10001; msg.Name Player; Session session ...; session.Send(msg);ET的高层封装里Send方法内部会自动完成IMessage到byte[]的序列化所以业务层不需要手动ToByteArray。第三层接收消息的处理。ET一般通过消息分发组件根据Opcode找到对应的Handler。例如你定义了一个ResponseCharacterInfo的处理器并注册到消息分发器里。业务层拿到的就是一个已经反序列化好的CharacterInfo对象直接读取字段即可。public class CharacterInfoHandler : AMHandlerCharacterInfo { protected override async ETTask Run(Session session, CharacterInfo message) { Log.Debug($收到角色信息: {message.Name}, level{message.Level}); await ETTask.CompletedTask; } }整体上ET帮我们屏蔽了很多底层细节但前提是你必须理解它默认的行为什么类型的消息会被序列化、如何确定Opcode、Handler怎么注册。绕开这些规则消息就发不出去或收不到。4. 常见问题与排查技巧实录4.1 编译报错字段名冲突和命名空间遇到最多的一类错误是“类型已存在”或“成员名冲突”。proto字段命名如果用了C#里的关键字或者和生成类里的方法重名比如name、clone、equals这种生成代码就可能编译不过。解决方法是给字段加上csharp_name别名或者在proto命名时规避。还有一个典型的坑是package和C#命名空间不一致。protoc生成时C#命名空间默认取自package的驼峰化结果比如package etc.entity会生成Et.Entity。如果想自定义命名空间可以在.proto里写option csharp_namespace ETModel;否则你引用到的类名可能跟ET约定的命名空间对不上导致无法被框架扫描。4.2 运行时异常解析出奇怪的默认值如果客户端发来一个消息服务器解析后某些字段是默认值但实际数据里明明有值那多半是字段编号对不上。比如proto文件改了某个字段从1改成2但线上包还是按编号1发的解析时就错位了。这种问题很难从日志里发现因为不报错只是结果不对。排查思路就是导出原始byte[]用protoc命令的--decode_raw方式直接查看二进制流里各字段的编号和值protoc --decode_raw msg.bin这样能快速确认消息发送方实际写入的是哪个字段号再和当前.proto对比就知道哪里对不上了。4.3 性能问题GC和重复解析在游戏服务端的高频消息场景下Protobuf的GC开销不可忽视。每次反序列化都会new一个消息对象短时间大量消息会让托管堆压力增大。你要是发现GC频率偏高可以考虑对象池但不建议对Protobuf消息对象做全局池化因为这个类内部有RepeatedField等容器对象重置成本不低。另一个性能点在于bytes字段的拷贝。ByteString在解析时是有机会做到零拷贝的UnsafeByteOperations但在ET默认流程中通常还是会把字节流拷贝一次到托管数组。如果你追求极致性能可以考虑在高版本protobuf中使用指定ParseFrom(ReadOnlySequence )的重载不过大多数业务场景用不到默认方案已经足够稳定。4.4 兼容性不同protobuf版本混用ET框架自身可能历史版本使用的是旧版protobuf比如Google.Protobuf 3.9.x而你后面单独引用了新版比如3.21.12这个时候会遇到程序集加载冲突。最常见的就是“未能加载文件或程序集Google.Protobuf, Version3.9.0.0”一类错误。解决办法是保持全工程统一版本如果ET源码里锁定的是某个低版本不要强行升级除非你确认框架代码用的API在新版里没有破坏性变更。我个人的建议是如果你刚开始学习就用ET项目自带的protobuf版本号先跑通流程再考虑升级。很多看起来莫名其妙的问题其实就是版本打架。5. 实操心得与进阶建议这套流程我前前后后跑了不止三遍每次换一个新项目或者新版本ET都会在细节上踩到不一样的坑。第一次接触时我总觉得Protobuf的问题是“代码生成工具不会用”后来才发现真正难的地方在于理解消息生命周期从定义、生成、注册、序列化、传输到反序列化和业务处理每个环节都有对应的规范漏掉一个就容易整个链路断裂。有一点我要特别强调先跑通最小的闭环再扩展。不用一上来就设计几十个消息先定义一条最简单的消息从proto生成到在ET里收发成功把这一条完整链路跑通后面再增加消息类型就只是重复劳动了。我当时就是直接照搬一个复杂消息示例结果代码一堆压根不知道是哪个环节出了问题。还有一个小技巧分享给所有在ET里做通讯的人维护一份字段编号的登记表。当你的proto文件超过十几个以后不同消息的字段编号各自独立这个还好真正容易出事的是不同消息的Opcode分配。ET里Opcode如果冲突消息会发到错误的Handler且表现非常隐蔽。每次新增消息时先去查一下现有Opcode最大值再分配新的别偷懒。如果你认真把这篇流程走完再回头看ET框架里的网络组件很多东西就能串起来了。Protobuf本身只是个工具真正值钱的是你对整个消息生命周期的掌控力。以后遇到报错你能迅速判断到底是在哪一层出的问题这就是经验积累的价值。