
我最初拿到 ET 的服务端代码时最让我迷惑的就是网络消息这部分。几个类名奇奇怪怪一会儿IMessage一会儿MessageParser往下一追才发现全是 Google.Protobuf 的东西。当时我连.proto文件都没正经写过硬着头皮啃了一圈源码才把“写协议 → 生成代码 → 序列化 → 收发消息”这条流程彻底搞明白。这篇就用我在 ET 项目里的实际经历把 Google.Protobuf 的基本使用流程完整拆一遍。内容不指望覆盖所有高级特性但保证你看完后能自己动手写一个.proto文件、生成 C# 代码、完成序列化反序列化并且能看懂 ET 这类 C# 服务器框架里消息模块到底在干什么。适合刚开始做游戏服务器、或者刚接触 ET 框架但对网络协议还一知半解的朋友。1. 为什么通信协议最后选了 Protobuf1.1 JSON 做协议的三宗罪早年我做项目图省事客户端和服务端直接传 JSON 字符串。调试确实方便日志里一打印全都能看懂但上了量以后问题一个接一个往外冒。第一是包体大小。同样一个C2G_LoginJSON 要把字段名account、password反复写在每个消息里几百上千字节就没了。移动网络下这个开销经不起折腾。第二是解析性能。Unity 端用 JsonUtility 还得配特殊封装遇到嵌套结构就难受更别说服务端高并发下字符串反序列化的 CPU 开销。第三是字段管理失控。前后端字段谁改了没同步运行期才报错甚至不报错只是拿到一个 null排查起来极其痛苦。Protobuf 走的是二进制编码字段名不传输只传字段编号消息体天然小一圈编码解码是协议层直接操作字节流性能比文本解析高一个量级更重要的是它有.proto文件做约束字段长什么样一目了然改了哪个字段编译期就能暴露问题。对 ET 这种需要频繁前后端联调的项目来说这三点每一条都值回学习成本。1.2 Google.Protobuf、protobuf-net 和各路“PB”怎么区分很多 ET 学习者会卡在这里网上一搜 Protobuf教程五花八门有的让你写[ProtoContract]有的让你写.proto文件到底哪个对简单说protobuf-net是第三方基于 .NET 特性Attribute实现的序列化库写在 C# 类上用[ProtoContract]、[ProtoMember(1)]标注历史版本里 ET 框架用过它。而Google.Protobuf是官方出品标准流程是先写.proto文件再用工具生成 C# 代码生成的类实现了官方的IMessageT接口。这俩底层都是 protobuf 协议标准但运行时库、API、生成的代码风格完全不互通一个走了特性标注路线一个走了模板编译路线。下面所有内容都围绕Google.Protobuf展开因为官方实现是目前跨语言兼容性最好的选择同一个.proto文件客户端 C#、服务端 Go、监控系统 Python 都能各自生成代码协议永远是同一份。2. .proto 文件一切的起点2.1 一份最小可用的协议文件用 Google.Protobuf第一步不是写 C#而是写.proto文件。它定义“消息长什么样”是这个协议的唯一事实来源。拿 ET 里最常见的登录请求来举例最简单的协议文件长这样syntax proto3; package ET; option csharp_namespace ET.Proto; message C2G_Login { string account 1; string password 2; int32 server_id 3; } message G2C_Login { int32 error_code 1; string token 2; repeated RoleInfo role_list 3; } message RoleInfo { int64 role_id 1; string role_name 2; int32 level 3; int64 exp 4; }第一行syntax proto3;声明协议版本。现在新项目默认都该用 proto3proto2 的required和optional语义坑太多新代码没理由再用。package用来做命名空间隔离option csharp_namespace指定生成的 C# 类落在哪个命名空间下不写的话默认用包名转换但建议明确写避免生成出来的命名空间不符合工程规范。消息体里的每个字段都由“类型 字段名 字段编号”组成。repeated表示这个字段是个列表可以重复多个值非 repeated 的普通字段在 proto3 里就是单值。字段编号不是随便写的序号它是二进制编码时真正出现在字节流里的标识符。需要注意的是命名上我特意用了C2G_Login和G2C_Login这种成对形式这是 ET 里非常常见的命名习惯C 代表 ClientG 代表 Gate一眼能看出某个消息是客户端发往网关的还是网关返回客户端的。协议文件是前后端共同维护的契约名字起清楚比写十行注释都管用。2.2 字段编号的潜规则字段编号这个细节新手最容易踩雷。它不只是“从 1 开始排号”那么简单里面藏着一个性能和兼容性都相关的规则。编码时每个字段的标签Tag由“字段编号 线类型”编码而成。字段编号 1 到 15 只需要 1 个字节16 到 2047 需要 2 个字节再往上字节数还会增加。这意味着一份消息体里高频字段尽量把编号控制在 15 以内能省不少空间。反过来如果一个字段只是低频使用、甚至基本不用就故意给它分配一个大编号把小编号留给高频字段。比性能更重要的是兼容性规则一旦协议发布上线某个字段编号就永远不能再改。编号绑定了这个字段的“身份证”。你可以在中间新增字段只要用一个新的编号就行但如果你把account的编号从 1 改成 2那么所有之前按编号 1 存储或传输的数据都会被解析成新编号 2 的字段也就是password。旧数据全部错乱而且没有任何报错。这是 protobuf 体系里最要命的问题没有之一。提示字段编号删掉后也不能复用。如果一个字段下线正确做法是保留它的编号位注释掉即可新人看到编号空缺也会明白这里曾经有过字段。复用编号是给自己埋雷。2.3 常用类型与 repeated、enum、mapproto3 支持的基础类型和 C# 的对应关系必须要心里有数。字段类型声明错了生成代码后类型对不上编译期就能查到但每次都要去改.proto再重新生成代码浪费时间。proto 类型C# 类型说明int32 / int64int / long常用整数负数时编码体积较大uint32 / uint64uint / ulong无符号整数sint32 / sint64int / long带负数的有符号整数负值编码更优float / doublefloat / double浮点数注意精度问题boolbool布尔值stringstringUTF-8 编码长度不要超过内存能承受的尺度bytesByteString原始二进制数据适合放文件块、加密数据repeated TRepeatedField列表proto3 下通常不会为 nullmapK, VMapFieldK, V字典结构key 支持整数或字符串enum生成的枚举类型枚举首值必须是 0举一个带枚举、列表和 map 的完整例子enum RaceType { NONE 0; HUMAN 1; ELF 2; ORC 3; } message UnitInfo { int64 unit_id 1; RaceType race 2; repeated int32 attr_list 3; mapstring, string extra_data 4; }proto3 强制要求枚举的第一个值必须是 0因为 proto3 中默认为 0 意味着“如果线上数据里没传这个字段解析出来就是这个枚举的第一个值”。如果你的枚举第一个值业务上有特殊含义记得把NONE 0塞进去占位否则协议都编译不过这属于基础的规范要求。map字段比较新它底层本质上是一组map_entry消息但用法上 C# 生成的MapField可以直接像字典一样读写没有额外学习成本。3. 把 .proto 变成 C# 代码3.1 protoc 工具的获取与准备.proto文件写好后需要用protoc编译生成代码。protoc是 Protocol Buffers 的官方编译器可以从 GitHub 的 releases 页面下载对应平台的预编译包。Windows 就下protoc-xxx-win64.zipLinux 下protoc-xxx-linux-x86_64.zip解压后核心就一个protoc.exe或protoc可执行文件。如果你是 dotnet 系的工程还有更省事的方案在.csproj里引入Google.Protobuf.Tools包它会带一个 NuGet 版的 protoc配合dotnet new proto生成的模板可以直接在 VS 里塞一个.proto文件。但说实话这套工具链对纯新手反而增加了理解负担——我还是建议先把独立的 protoc 用明白再考虑自动化集成。C# 的运行时支持依赖Google.Protobuf这个 NuGet 包。注意它和Google.Protobuf.Tools是两个包一个负责运行时代码一个负责编译器工具。demo 项目里两个都要引实际生产环境里Tools只在生成代码时需要。3.2 命令行生成与工程接入准备好了 protoc接下来一条命令就能生成 C# 代码。假设我们把登录协议文件命名为Login.proto在它所在目录执行protoc -I. --csharp_out./Generated Login.proto-I指定 import 的搜索路径--csharp_out指定生成目录最后跟协议文件名。执行完Generated目录下会出现一个Login.cs文件。如果你的项目协议分散在多个目录可以一次传多个.proto文件。我实际习惯是写一个小脚本遍历整个Proto目录把所有.proto一次性编译到Generated目录避免每次新增协议都敲一条命令for f in Proto/*.proto; do protoc -IProto --csharp_outGenerated $f done生成的.cs文件需要手动加入工程或者你把Generated目录整体加到Compile Include里。引入工程后依赖Google.Protobuf运行时包如果看到缺类型的编译错误大概率是Google.Protobuf版本没对上或者运行时包没引成功。3.3 读一读自动生成的代码生成出来的Login.cs代码量不小但没必要逐行读完关键看几个结构点每个message会生成一个sealed partial class实现IMessageT接口每个类上会有一个静态只读的Parser属性类型是MessageParserT后面反序列化全靠它普通字段生成成属性的形式repeated字段生成成RepeatedFieldTmap生成成MapFieldK, V类里有Clone()、Equals()、GetHashCode()、ToString()等自动生成的方法其中ToString()输出的是文本格式调试时打印消息内容非常有用。有一点需要特别提醒不要手动改生成代码。凡是注释写着// Generated by the protocol buffer compiler. DO NOT EDIT!的文件下一次跑 protoc 就会被覆盖。如果有业务逻辑需要挂到生成的类上用 C# 的partial class特性另写一个文件补充。这样重新生成代码不会丢你的自定义逻辑这是 Google.Protobuf 相比于直接生成非 partial 类型的实现更友好的地方。4. 序列化与反序列化基础使用流程4.1 对象 → 字节写出去代码生成完了使用流程的核心就四个字序列化、反序列化。先看写出去这条路。构建一个消息对象赋值然后序列化成字节数组using Google.Protobuf; var request new C2G_Login { Account player001, Password 123456, ServerId 101 }; // 方式一直接拿字节数组 byte[] bytes request.ToByteArray(); Console.WriteLine($序列化后字节数: {bytes.Length}); // 方式二写入 Stream适合和网络流、文件流配合 using MemoryStream stream new MemoryStream(); request.WriteTo(stream); byte[] streamBytes stream.ToArray();ToByteArray()是扩展方法本质是调用WriteTo到一个MemoryStream再取数组。实际项目里我更推荐直接操作流如果底层收发模块已经拿到了NetworkStream或者自定义的ByteBufferWriteTo能少一次中间缓冲区的拷贝。4.2 字节 → 对象读回来接收方向是反序列化。官方生成的每个消息类都挂了Parser真正干活的就是它byte[] receivedBytes GetFromNetwork(); var parsed C2G_Login.Parser.ParseFrom(receivedBytes); Console.WriteLine($Account: {parsed.Account}); Console.WriteLine($ServerId: {parsed.ServerId});ParseFrom有多个重载支持byte[]、ReadOnlySpanbyte、Stream、CodedInputStream。如果数据在流里但流后面还可能跟着别的消息那就不能简单把整个流交给ParseFrom因为流式解析默认会一直读到流尾。这种情况下我习惯先把包头长度解析出来切出body的字节区间再交给 Parser核心就是这样一段逻辑// buffer 是从网络层拿到的完整包前 4 字节是消息长度 int bodyLength BitConverter.ToInt32(buffer, 0); byte[] body new byte[bodyLength]; Array.Copy(buffer, 4, body, 0, bodyLength); // 根据 opcode 找到对应消息类型再调它的 Parser var message C2G_Login.Parser.ParseFrom(body);这里有个小细节真正高性能的项目会尽量复用缓冲区避免new byte[]引起的 GC 压力。可以用ReadOnlySpanbyte直接切出 body 区段再ParseFrom省一次数组拷贝。不过新手阶段先把功能跑通再回来优化不迟。4.3 在 ET 网络层里跑一遍完整生命周期ET 框架里消息不是裸奔的它有一套自己的封装流程但核心思想是通用的消息头带 opcode消息体是 protobuf 字节流。不同 ET 版本的 opcode 长度、包头格式会有点差异但整体思路都一致。客户端发送一条登录消息大致是这样一个过程// 1. 构造协议体 var request new C2G_Login { Account account, Password password, ServerId serverId }; // 2. 序列化成字节 byte[] protoBody request.ToByteArray(); // 3. 算出这条消息对应的 opcode框架里一般有 OpcodeType 映射表 int opcode OpcodeMap.C2G_Login; // 4. 把 opcode 拼接在 body 前面组成一包完整数据 byte[] packet new byte[protoBody.Length opcodeSize lengthSize]; // ... 各种 Buffer.BlockCopy 拼包头 // 5. 交给 session 发送 session.Send(packet);服务端收到后反着拆// 1. 从连接对象里读到完整包 byte[] packet socketChannel.Receive(); // 2. 解析 opcode根据 opcode 找到对应的消息 CLR 类型 ushort opcode BitConverter.ToUInt16(packet, 0); Type msgType Game.EventSystem.GetType(opcode); // 3. 切出消息体用对应类型的 Parser 反序列化 MessageParser parser (MessageParser)msgType.GetProperty(Parser)?.GetValue(null); IMessage message (IMessage)parser.ParseFrom(packet.AsSpan(headerSize, packet.Length - headerSize)); // 4. 丢给消息分发器路由到对应的 Handler Game.Scene.GetComponentMessageDispatcherComponent().Handle(session, message);ET 里消息类型和 opcode 的绑定是通过 Attribute 注册的服务端启动时会扫描所有带[Message]特性的类型建立 opcode 到 Type 的映射。做分发的时候核心就是“从字节流里拿到 opcode再反查类型最后用类型的Parser反序列化”。理解了这条链整个 ET 消息入口的源码你就能顺畅读下去了。注意ET 不同版本对 opcode 位数定义不同有些用ushort有些用int拆包的时候一定要和发送端保持一致。这个问题排查起来像是数据乱码其实只是拆多了或者拆少了一个字节。我建议一上来就把这个常量抽成一个地方管理避免散落在各处。5. 现场排雷这些坑我替你们踩过了5.1 字段编号以后不能改改名可以这是最经典的一个坑。我的一个同事在联调期间觉得某个字段命名不规范“顺手”把server_id改成了server_id_new字段声明编号还是原来的 3。结果客户端和服务端虽然都重新生成了代码但测试服还有一批旧的登录缓存数据解析后server_id全是错的。问题的本质是protobuf 区分字段靠的是字段编号不是字段名。你改了字段名只要编号不变线上数据照样能解析到对应位置但如果你手欠改了编号哪怕字段名一字不差新老数据也会互相错乱。更隐蔽的是这种错乱往往没有异常只是数据表现反常。5.2 空 repeated 字段不是 null但序列化出来是空的proto3 里repeated字段在 C# 生成代码中是RepeatedFieldT初始化时就是一个空集合不会为 null。你可以在没 Add 任何元素时安全地遍历它。但注意一个没有被赋值的普通repeated字段序列化到字节流里是“不存在”的。反序列化回来后它仍然是一个空集合。这意味着什么如果你依赖“字段是否存在”做业务判断就会踩坑。比如前端发来的attr_list到底是“没传”还是“传了个空列表”解析端拿到都是空集合区分不了。如果业务需要区分就得加一个独立字段标记或者约定好默认值。不要试图从空集合上反推“对方没传”这在 protobuf 里无法成立。5.3 解析前一定要先切掉消息头Google.Protobuf 的ParseFrom(Stream)默认会把流从当前位置读到末尾。如果你把“包头 包体”的整段数据直接丢给 Parser它会把包头那几字节也当成 protobuf 数据去解析。protobuf 编码是一种自描述的二进制格式错误地把包头解析成一个字段的 tag 和 value大概率不会当场抛异常但字段值全乱而且后续包的边界也全乱了。这种 bug 的隐蔽性我领教过日志看起来像是“客户端所有消息都错位了一个字段”。我的经验是在靠近网络层的地方就把“拆包”和“反序列化”切成两个职责。拆包负责从字节流里按长度切出一帧完整数据反序列化只负责解析消息体。这两步不要混在同一个函数里。5.4 protobuf-net 和 Google.Protobuf 不要混用ET 老版本里如果看到[ProtoContract]那是 protobuf-net如果你看网上教程用.proto文件生成IMessage那是 Google.Protobuf。两套库都是 protobuf 二进制格式但生成的编码细节、消息字段映射机制有细微差别线上混用会导致解析出来全是默认值。所以当你接手一个 ET 项目时第一件事是确认它内部用的是哪套序列化方案。如果框架默认using ProtoBuf;你要新增协议就按照老代码的写法来如果框架已经全面切到 Google.Protobuf就沿用.proto文件生成代码的流程。混用在一个连接上做收发是能把人折腾到怀疑人生的。6. 规范化建议跑通基本流程之后协议管理才算真正开始。我没有独立的总结章只是在自己项目里吃过几次亏之后沉淀出几个固定习惯分享出来第一.proto文件统一用目录管理前后端共用一份通过版本控制同步。服务端不能自己偷偷改.proto然后只更新服务端代码客户端拉最新代码编译不通过或者协议语义变了问题一定会回流到服务器端。第二生成代码一律手动不修改一切自定义逻辑走partial class。哪怕是一次性测试用的日志打印也不要塞进生成文件里下次重新生成代码你会后悔为什么当初没听劝。第三每个消息都保持小而清晰。一个message字段太多就考虑拆成嵌套message或者用oneof表达互斥字段。ET 里的消息收发流程已经够复杂不要让单个协议对象承担太多业务含义这会让后续维护的人很可能就是三个月后的你头皮发麻。第四上线之前把字段编号规范、命名规范、repeated默认值语义这些约定写进团队文档。协议是接口的一部分接口的规范不写清楚光靠“大家自觉”迟早会有人掉进 5.1 节的坑里。我自己在实际操作中最深的感受是Google.Protobuf 的学习曲线其实不在 API而在思维方式的转变。写惯了 JSON 的人总会下意识觉得“字段名就是协议的标识”但 protobuf 告诉我真正稳定的是字段编号和编码规则。想通了这一点后面再去碰 service 定义、gRPC 传输、或者 ET 里更复杂的内网消息都非常顺。希望这篇能帮你少走我走过的弯路。