ET框架Google.Protobuf入门:协议定义、代码生成与消息序列化
很多人在学习ET框架时第一个被劝退的点往往不在框架本身的Actor模型或协程调度而是挂在Google.Protobuf这道门槛上。ET框架的网络消息、存档、配置表到处都有Protobuf的身影你不理解它看框架源码就像在看天书改个消息定义都要提心吊胆。但说实话Google.Protobuf的基本使用流程并不复杂核心就三件事写.proto文件、生成C#代码、调用序列化API。这篇内容我按自己实际摸索的过程来写把ET框架里用到Protobuf的完整路径捋清楚适合刚接触ET、想在项目里真正用起来的人也适合那些已经把框架跑起来但始终没搞懂消息是怎么序列化和反序列化的朋友。1. 为什么ET框架把消息序列化押宝在Google.Protobuf上1.1 游戏网络消息的痛点从JSON聊起在讲Protobuf之前先想一个问题游戏客户端和服务端通信消息到底要解决什么最直接的方案是JSON大家都会写{playerId: 10001, name: 张三}一清二楚。但放到游戏场景里就难受了一个战斗同步消息可能每秒要发几十条每条都带一堆字段名冗余带宽被白白吃掉JSON的解析性能在C#里虽然不错但全量字符串解析在频繁GC和CPU占用上并不理想。更关键的是JSON的结构约束是软的客户端和服务端各自解析字段类型对不上不会在编译期暴露经常上线了才发现消息对不上。游戏网络消息需要的是一种强类型、体积小、解析快的序列化方案。这个需求在业界有非常成熟的答案就是Protocol Buffers简称Protobuf。它先把消息结构写在.proto文件里然后通过工具一次性生成C#类编译期就能保证客户端和服务端用的是同一份协议定义。1.2 Google.Protobuf 补齐了哪块短板刚接触ET的人会产生一个疑问为什么不用MessagePack、JSON或者自己写字节流我的理解是这样的ET框架的定位是开箱即用的双端框架它需要消息协议既能描述复杂嵌套结构又能在不同语言之间互通。Google.Protobuf天然满足这些——它数字段序号、支持嵌套消息和枚举、有成熟的C#实现性能虽然未必是所有方案里最强的但胜在稳定和生态完整。ET选择它本质上是为了让使用者专注于业务而不是自己造序列化轮子。Google.Protobuf在C#中的实现有几个关键特性值得注意生成的类是partial class你可以额外写文件扩展它不用改生成代码。序列化不需要反射文本格式和二进制格式完全分离。兼容性好旧字段不删、新字段加在后面老客户端和新服务器可以平滑过渡。你不需要立刻把这些都学完但至少要建立这个认知Protobuf不是某个框架的私有功能它是一个独立的、跨语言的协议描述与序列化工具ET只是它的一个使用场景。2. 工具链打通从.proto定义到C#代码落地2.1 需要准备的三个东西要在ET框架里操作Google.Protobuf第一步不是写代码而是把工具链装配好。实际需要三样东西工具/包作用获取方式protoc编译器读取.proto文件生成C#代码从Googol的Protocol Buffers官方仓库下载对应平台版本Google.ProtobufNuGet包运行时库提供序列化/反序列化APIET框架的Unity工程和Server工程里通常已经引用.proto文件协议定义源文件你自己维护或从ET示例工程中拷贝这里要注意ET框架各个版本对Google.Protobuf的版本要求不同。如果你用ET 6.0以上生成的C#代码和运行时库版本必须严格对应否则会报诡异的InvalidProtocolBufferException。建议先查看ET框架仓库中src/Server/Server.csproj和src/Unity/Unity.csproj里引用的版本号再去找相同版本的protoc工具。比如ET6早期用的是Google.Protobuf 3.x那么生成代码时也要用3.x版本的protoc4.x版本的protoc生成出来的代码依赖的运行时API可能存在差异。提示托管包引用不要轻易升级特别是Unity环境里。Google.Protobuf的Major版本升级经常伴随着API破坏性变更ET框架本身没有适配过的版本你升上去就是在给自己挖坑。2.2 写一个最小可用的消息定义工具链齐了先写一个最简单的.proto文件感受一下。打开ET项目里的某个示例协议文件通常是这样的syntax proto3; package ET; // 客户端请求登录 message C2G_LoginRequest // 命名直接用了ET的风格 { string account 1; string password 2; } // 服务端返回登录结果 message G2C_LoginResponse { int32 error 1; string message 2; string token 3; }proto3是最常用的语法版本ET框架也以proto3为主。每个字段后面的数字1、2、3是字段编号不是默认值而是消息二进制格式中的标识。同一句话——字段编号一旦确定并发布就不要再改我后面会专门讲这个坑。2.3 protoc生成代码完整命令与产物解读在终端里执行protoc --csharp_out./Generated ./Proto/Login.proto--csharp_out指定生成目录最后的参数是输入文件。如果你有多个proto文件可以一次传多个也可以用-I参数指定搜索路径protoc -I./Proto --csharp_out./Generated ./Proto/Login.proto ./Proto/Battle.proto生成成功后在Generated目录下会看到Login.cs。用IDE打开这个文件你会发现代码里包含一个sealed partial class C2G_LoginRequest : pb::IMessageC2G_LoginRequest。Account和Password两个属性。序列化用的WriteTo、CalculateSize、MergeFrom、ParseFrom等方法的实现。这就是所谓基本使用流程的地基定义一次生成代码逻辑里直接操作强类型对象至于底层怎么写字节完全不用关心。如果你在Unity里ET框架一般会把生成代码放到Hotfix或Model层让客户端和服务端共用同一份协议保证两边不会出现我以为你是A结构其实你是B结构的情况。2.4 生成的代码长什么样生成出来的类看起来复杂但实际使用起来非常简单。看一个最小例子var req new C2G_LoginRequest { Account player01, Password 123456 }; byte[] data req.ToByteArray(); // 序列化 var parsed C2G_LoginRequest.Parser.ParseFrom(data); // 反序列化 Console.WriteLine(parsed.Account); // player01ToByteArray()和Parser.ParseFrom()是Google.Protobuf C#版本中最常用的两个API工作中90%的场景就这两行。理解了它们基本使用流程就跑通一半了。3. 消息字段设计游戏业务里最常见的几个用法3.1 字段编号是协议的一部分不是注释这个问题我见到太多人踩了。有人在联调阶段把string name 1;改成string name 5;觉得无伤大雅。但如果线上有旧数据或者旧客户端编号变了解析就会错位——服务器读到编号1的数据时按新协议认为那是别的字段轻则拿到错误数据重则反序列化直接抛异常。正确做法是新增字段用新的编号不要复用删除过的编号。字段只能标记为reserved后再复用绝不要直接删了继续用旧编号。开发阶段如果协议没有上线可以改编号一旦上线就要按不可变对待。ET框架中消息是客户端和服务端的契约契约一旦双方开始遵守单方面修改必然出问题。想加字段就加在末尾message G2C_LoginResponse { int32 error 1; string message 2; string token 3; int64 server_time 4; // 新增字段编号接着往下排 }老客户端解析这段数据时会忽略编号4的内容新客户端能拿到。这就是ProtoBuf向后兼容的核心规则。3.2 类型选择的实战建议ET框架里常见的Protobuf字段类型有这么几类选型上我直接给结论整数用int32、int64日常坐标、ID、数值都够用。小数用float、double注意网络消息尽量避免用float做同步的精确判断不同平台浮点误差会导致表现不一致。字符串用string默认UTF-8别传二进制数据。字节数组用bytes传图片、加密数据、序列化后的嵌套结构时用。布尔用bool。大列表用repeated相当于C#的ListT。这里有一个比较关键的实践不要用单条消息承载超大数组。有些新手会把一整张地图的怪物列表塞进一个消息里编号1到10000结果一个包几MB。ET框架本身的网络层是有最大消息大小限制的我记忆中默认配置下超大消息会被直接丢弃或报错。正确业务设计应该是分页、分块或者拆分到多个消息里。3.3 嵌套消息和枚举的天然优势ET框架里的消息不全是扁平结构很多时候需要套娃。比如玩家信息消息里包含装备列表装备本身也有属性结构message ItemInfo { int64 item_id 1; int32 count 2; mapstring, int32 attrs 3; } message PlayerInfo { int64 player_id 1; string name 2; repeated ItemInfo items 3; }嵌套消息的好处是结构一目了然C#里直接访问player.Items[0].ItemId不需要自己拼字符串或者做二次解析。映射类型mapk,v底层是MapFieldTKey, TValue使用上等同于Dictionary。不过注意map的性能开销比repeated大一些高频率同步消息里尽量别用。枚举类型也推荐用proto的enum而不是C#的int常量它能提供编译期约束。定义时留一个0值作为UNKNOWN这是proto3的规范要求enum HeroType { HERO_TYPE_NONE 0; HERO_TYPE_WARRIOR 1; HERO_TYPE_MAGE 2; }4. 序列化与反序列化真正动手操作核心API4.1 序列化把消息对象变成字节数组ET的消息发送流程中你经常需要把一个消息对象变成字节数组。Google.Protobuf C#版本提供了多种序列化出口常见的有方法返回类型适用场景ToByteArray()byte[]最常用方便调试字节数组可直接走网络层WriteTo(Stream)void适合大消息避免额外的字节复制WriteTo(CodedOutputStream)void高级用法适合拼接多个消息ET框架的网络层在发送时一般会拿到byte[]所以ToByteArray()是最主流的入口。但要留意ToByteArray()会产生一个新的数组如果每秒发送大量消息频繁分配缓冲区会带来GC压力。ET框架的高性能做法通常是消息写入一个可重用的Buffer里这个属于框架内部封装作为使用者可以先不必深究但你需要知道这个性能敏感性别在业务Update里随便ToByteArray()。4.2 反序列化把字节数组还原成消息对象反序列化是接收侧的核心操作。最常用的是C2G_LoginRequest req C2G_LoginRequest.Parser.ParseFrom(data);如果数据来自一个Stream也可以用Parser.ParseFrom(stream)。这里有个重要的原则解析前最好校验一下数据的合法性边界。ET网络层收到数据时会根据消息Opcode找到对应的消息类型然后调用解析方法。如果数据本身被截断或者串包ParseFrom可能抛异常所以在业务层解包时必须捕获异常并做好日志记录避免出现服务端突然断开连接但查不到原因的情况。还有一种常见的写法是用GetAwaiter和协程异步处理解析出的消息解析本身是CPU同步操作放到协程里并不会加速但消息的业务逻辑处理必须放在异步流程里这个后面讲ET接入时再展开。4.3 Partial方法把业务逻辑挂进生成的代码Google.Protobuf生成的类是partial的这是它做得非常聪明的设计。你可以在项目里新建一个同命名空间的类文件扩展生成类的行为// Generated/Login.cs 是自动生成的不要手动改 // 手动扩展文件Login.Ext.cs namespace ET { public partial class C2G_LoginRequest { public string LogSummary() { return $account{Account}, hasPassword{!string.IsNullOrEmpty(Password)}; } } }这个做法的核心价值是生成的代码可以随时重新生成覆盖而你的扩展逻辑不会丢。在ET框架里经常看到有人封装消息的ToLogString()、CheckValid()这样的方法都是用partial类来扩展。5. 接进ET框架一条消息从发出到处理的完整链路5.1 认识ET消息的三件套ET框架里的消息不是简单的一个类就完事它有一套固定的套路理解这套套路才能真正明白基本使用流程怎么串起来。ET消息涉及三个概念消息体就是Protobuf生成的类比如C2G_LoginRequest。Opcode消息的唯一编号一般是ushort或int类型ET框架里通过OpcodeType这样的静态类维护。Handler或System处理消息的委托函数接收消息体并执行业务逻辑。你可以把消息体想象成快递包裹里的东西Opcode是快递单号Handler是仓库里负责拆包的人。网络数据进来先看单号再找对应的人拆包。5.2 服务端Handler是怎么被调起来的在ET框架服务端一条外部消息进来后的路径大致是网络层收到字节流判断消息Opcode。根据Opcode找到对应的消息类型如C2G_LoginRequest。调用ProtobufHelper.ToObject反序列化得到消息对象。根据消息类型找到注册好的Handler / System。进入Handler执行逻辑。Handler本身只是一个普通的类核心方法一般长这样public class C2G_LoginHandler : AMRpcHandlerC2G_LoginRequest, G2C_LoginResponse { protected override async ETTask Run(Session session, C2G_LoginRequest request, G2C_LoginResponse response, Action reply) { // 业务逻辑校验账号密码生成Token response.Error 0; response.Token some_token; reply(); await ETTask.CompletedTask; } }注意reply()的调用时机它代表响应已准备好网络层会把response序列化后发回客户端。如果不调reply()客户端会一直等到超时或报错。5.3 客户端请求-响应的一次完整旅行客户端发消息到服务端再回来的完整链路我习惯把它拆成五个步骤构建消息对象var req new C2G_LoginRequest { Account ..., Password ... };序列化把对象转成字节数组。通过Session发送在ET里通常是session.Call(req)或session.Send(req)。Call是请求-响应模式Send是单向通知。服务端处理完成后回包响应消息通过网络层穿回来。客户端拿到响应对象通过协程等待Call返回得到G2C_LoginResponse。Call和Send的区别非常关键方法语义是否等待响应常用场景Send发后即忘否位置同步、操作摇杆、客户端上报Call请求-响应是登录、买道具、通关结算每次Call会在客户端和服务端产生一个RPC调用ID用于配对请求与响应。这部分逻辑是ET内建的不需要你手动处理但你要明白它是依托消息对象的类型和Opcode来正确路由的一旦消息类型对应的Opcode在两端不一致就会出现响应不回来或错配的问题。6. 我踩过的坑字段变更、热重载与定位问题6.1 修改字段编号的惨痛教训前文提到字段编号不能乱改这里补一个真实案例。有一次我为了清理不用的字段把某个消息里一个不再需要的字段从编号5改成编号6以为只是序号变动。结果已经打出去的测试包在解析旧的登录回包时全部抛异常测试同学反馈客户端进不去游戏而且因为错误被网络层吞掉定位花了大半天。最后查出来的原因就是字段编号错位。旧包里编号5存的是token字符串新代码里编号5变成了role_id整数解析出来的结果完全乱掉。从那以后我在所有协议Review中强制加了一条规则任何人修改编号都必须经过协议评审并且用diff工具检查.proto文件变动。6.2 热重载下的序列化兼容问题ET框架在开发期经常用热重载来加速迭代Unity侧的代码可以运行时替换但这里折叠了一个不容易察觉的问题如果热重载时同时修改了消息定义旧内存中已经反序列化出来的消息对象和新代码中生成的类可能不兼容。特别是当你改了字段类型比如把int32改成int64后反序列化结果可能会被强制转换或丢失精度。我自己习惯的做法是如果改了.proto文件并且生成代码有变动那么客户端的旧的已连接会话最好强制断开重登或者服务端进程重启让两端协议完全一致。热重载适合迭代逻辑不适合迭代协议结构。注意ET框架不同版本对热重载的支持程度不一样但协议变更请重启这条建议长期有效。6.3 用JsonFormatter做日志定位最后分享一个非常实用的调试技巧。Google.Protobuf C#版本自带JsonFormatter可以把消息对象转成格式化JSON字符串比直接看二进制舒服得多string json JsonFormatter.Default.Format(message); Log.Debug($收到玩家消息: {json});这条代码在低峰期或者Debug版本的日志里非常有用可以清楚地看到每个字段解析的对不对。但在高流量战斗中记得关掉否则大量日志输出会把性能拖垮。我的实践是在配置里加一个协议日志开关线上出问题时动态打开抓一小段时间再关掉。还有一个定位问题是消息丢了。ET框架中一条消息发出去后如果客户端和服务端对不上Opcode会出现服务端没反应或客户端收到无法识别的包。排查思路是先确认两端加载的Opcode表一致再确认消息类型注册完成最后看网络层的日志。很多时候不是Protobuf的问题而是消息在框架内没有被正确映射这时候不要盯着序列化代码死磕先检查注册表。最后把这个流程变成你自己的肌肉记忆我在刚开始用ET的那一个月里几乎每天都在和Google.Protobuf较劲。后来发现真正需要掌握的基本使用流程其实就一条线定义协议、生成代码、序列化、反序列化、挂Handler、发消息、收消息。把这七步跑通80%的ET消息开发工作就是填充业务逻辑了。最后再分享一个检查习惯每次修改.proto文件后我会在本地跑一条命令把所有协议文件生成一遍再把生成的.cs文件提交到Git方便Review时直接看到协议的最终形态。收到网络相关Bug时第一件事不是看业务逻辑而是先用JsonFormatter打印出消息内容确认数据对不对再去查逻辑对不对。这个顺序能帮你节省大量排查时间。