公司动态
基于ET框架构建Unity游戏服务器:ECS架构与Actor模型实战指南
1. 项目概述为什么选择ET框架构建Unity游戏服务器如果你正在用Unity开发一款需要联网的游戏无论是MMORPG、MOBA还是棋牌对战服务器端的技术选型都是一个绕不开的坎。自己从零开始用C#写Socket、处理协议、设计架构这个坑太深时间成本和维护成本都高得吓人。直接用现成的商业后端方案灵活性可能受限定制化需求难以满足。这几年一个名为ET的开源框架在Unity游戏服务器开发者圈子里越来越火它几乎成了“高性能”、“全栈C#”、“ECS架构”的代名词。ET框架的核心价值在于它用一套C#代码同时解决了Unity客户端和游戏服务器的开发问题。这意味着你的前后端逻辑可以高度复用通信协议天然一致调试起来也方便得多。更重要的是它基于Entity Component System实体组件系统思想构建这种数据与逻辑分离的架构对于需要处理大量并发实体比如成千上万的玩家和怪物的游戏服务器来说性能优势非常明显。我经历过从传统MonoBehaviour脚本到ET框架的迁移过程最大的感受就是代码结构清晰了服务器性能瓶颈更容易定位热更新也变得可行。对于中小团队或者独立开发者来说ET框架提供了一条快速搭建稳定、高性能游戏服务器的捷径。2. ET框架核心架构与设计思想拆解要玩转ET框架不能只停留在“怎么用”的层面必须理解其背后的设计思想。这就像开车知道油门刹车是基础但了解发动机原理才能开得更好、更安全。2.1 实体组件系统数据驱动与逻辑分离的精髓ET框架的基石是ECS架构但这和我们常说的Unity的ECS包Entities不是一回事。ET的ECS更偏向于一种设计模式。在这里Entity实体只是一个ID它本身没有任何逻辑和数据。Component组件是纯粹的数据容器挂载在实体上比如MoveComponent存放坐标、速度UnitComponent存放单位类型、等级。System系统则是纯逻辑它遍历拥有特定组件的实体并对这些组件的数据进行操作。这种设计带来的好处是颠覆性的高性能数据以数组形式紧密排列ArchetypeCPU缓存命中率高。System逻辑集中处理同类数据避免了虚函数调用等开销。这在服务器需要同时更新数万实体状态时性能提升是数量级的。高清晰度数据就是数据逻辑就是逻辑。你不会在某个Component里找到一段Update方法去修改另一个Component的数据。这迫使你写出更模块化、更可测试的代码。排查一个移动问题你只需要看MoveSystem而不需要在一堆混杂的MonoBehaviour脚本里大海捞针。易于预测与复现由于逻辑是纯函数式的仅依赖输入数据产生输出在配合帧同步等需要确定性模拟的游戏模式时优势巨大。注意初学者最容易犯的错误就是试图在Component里写逻辑或者让System去直接修改其他不相关的Component。时刻记住Component是数据表System是处理数据的函数。2.2 网络模型基于Actor的消息驱动服务器本质是一个高并发的网络程序。ET采用了Actor模型来处理并发。每个Entity都可以看作一个轻量级的Actor它有自己的消息队列。组件和系统通过事件Event进行通信。当一个网络包到达或一个定时器触发都会转化为一个事件投递到对应实体的消息队列中由相应的System顺序处理。这种模型完美契合了游戏逻辑。想象一下一个玩家实体Player收到一个“释放技能”的事件Event。SkillSystem会处理这个事件读取玩家身上的SkillComponent数据计算伤害然后创建一个“受伤害”事件发送给目标怪物实体。整个过程是异步、消息驱动的避免了复杂的线程锁问题逻辑链条清晰。网络层基于高性能的Socket库如KCP、TCP并内置了消息的序列化/反序列化通常使用Protobuf。你需要定义的只是.proto协议文件框架会自动生成C#代码。这确保了前后端通信协议的一致性是减少联调BUG的关键。2.3 服务与纤程管理服务器进程内模块一个游戏服务器进程App内可能运行着多种服务比如登录服Realm、网关服Gate、地图服Map。在ET中这些服务也是以Entity的形式存在的。框架通过纤程Fiber来模拟多线程环境下的协作式并发。每个服务运行在独立的纤程中它们之间通过消息Actor消息通信而不是直接共享内存。这样做的好处是你可以像操作多个独立进程一样编写代码享受清晰的模块边界但实际上它们又在同一个进程内通信效率极高。你可以很方便地把两个服务合并到一个进程或者拆分成两个物理进程代码几乎不需要改动。这种灵活性对于服务器资源的弹性伸缩至关重要。3. 从零开始搭建ET框架开发环境与第一个Demo理论讲得再多不如动手跑起来。下面我带你把ET框架的环境搭起来并创建第一个“Hello World”级的服务器和客户端。3.1 环境准备与框架获取首先你需要一个干净的开发环境Unity版本建议使用LTS版本如2021.3.x或2022.3.x。ET框架对Unity版本有一定要求太老或太新的版本都可能遇到兼容性问题。.NET SDKET服务器端运行在.NET Core/.NET 6环境。你需要安装最新的.NET SDK如.NET 8。去微软官网下载安装即可。IDE客户端开发用Unity自带的Visual Studio或Rider都可以。服务器端开发强烈推荐使用JetBrains Rider或Visual Studio 2022它们对C#和.NET项目的支持更完善。获取ET框架框架的代码托管在Gitee码云上。直接搜索“ET”即可找到官方仓库。我建议克隆Clone最新的发布分支如7.0而不是主分支以保证稳定性。3.2 项目结构初始化ET框架的代码分为好几个仓库通常我们只需要关注核心的Unity客户端项目和Server服务器项目。导入Unity项目将克隆下来的Unity文件夹作为一个新项目在Unity Hub中打开。首次打开会导入很久因为要下载和编译很多依赖。生成协议与代码这是最关键的一步。ET框架使用.proto文件定义网络消息。在Unity编辑器的菜单栏你会找到ET-BuildTool选项。运行它它会做两件事根据.proto文件生成C#的网络消息类客户端和服务器共用。生成服务器的实体组件代码框架。 这个过程可能会报错最常见的原因是Protobuf编译器protoc路径没设置对。你需要根据错误提示下载对应平台的protoc工具并在BuildTool的设置中指定其路径。配置服务器项目用Rider或VS打开Server文件夹下的.sln解决方案文件。这是一个标准的.NET控制台应用。首次编译前可能需要通过NuGet还原包。3.3 创建第一个登录流程我们来模拟一个最简单的流程客户端连接发送登录消息服务器验证后回复。定义协议在Proto文件夹下创建一个OuterMessage.proto文件如果不存在。定义两个消息// 客户端-服务器登录请求 message C2G_Login // 命名约定C2G表示Client to Gate { string Account 1; string Password 2; } // 服务器-客户端登录回复 message G2C_Login { int32 Error 1; // 0表示成功其他为错误码 string Message 2; long PlayerId 3; // 登录成功后分配的玩家ID }再次运行BuildTool生成C#代码。编写服务器处理器在服务器项目的Handler目录下创建C2G_LoginHandler.cs。这是一个标准的消息处理器类。// 消息处理类需要继承AMRpcHandler并指定请求和响应类型 [MessageHandler] public class C2G_LoginHandler : AMRpcHandlerC2G_Login, G2C_Login { protected override async ETTask Run(Session session, C2G_Login request, G2C_Login response, Action reply) { // 1. 这里可以连接数据库验证账号密码 Log.Debug($收到登录请求账号:{request.Account}); if (request.Account ! test || request.Password ! 123456) { response.Error ErrorCode.ERR_AccountOrPasswordError; response.Message 账号或密码错误; reply(); // 必须调用reply来发送响应 return; } // 2. 验证通过创建玩家实体 long playerId IdGenerater.Instance.GenerateId(); // 生成唯一ID // ... 这里通常还会从数据库加载玩家数据初始化组件 response.Error ErrorCode.ERR_Success; response.PlayerId playerId; reply(); // 发送成功响应 } }注意async ETTask这是ET框架内建的异步任务类型用于编写异步逻辑而不阻塞主线程。编写客户端发送逻辑在Unity客户端项目中找到UI登录按钮的点击事件。public async void OnLoginButtonClick() { string account accountInput.text; string password passwordInput.text; // 获取网络会话 Session session Game.Scene.GetComponentNetOuterComponent().GetSession(); // 创建并发送请求 C2G_Login request new C2G_Login { Account account, Password password }; G2C_Login response (G2C_Login)await session.Call(request); // 异步等待服务器回复 if (response.Error 0) { Log.Debug($登录成功玩家ID: {response.PlayerId}); // 跳转到游戏主场景... } else { Log.Error($登录失败: {response.Message}); } }启动与测试首先启动服务器程序。在Server项目的App类里配置好服务器类型如Gate和启动参数然后运行。在Unity编辑器中运行客户端场景。确保客户端的网络配置IP、端口指向你启动的服务器。点击登录按钮你将在服务器的控制台和客户端的日志里看到相应的输出。这个过程看似简单但已经涵盖了ET框架最核心的通信流程。第一次成功跑通时你会对整个框架的工作流有一个直观的认识。4. 核心模块深度解析与实战技巧掌握了基础流程我们深入看看几个核心模块在实战中如何运用以及那些官方文档里不会写的“坑”。4.1 资源管理与热更新游戏离不开资源。ET框架在客户端提供了一套基于Addressable或YooAsset等方案的资源管理抽象层但更精髓的是它对代码热更新的支持。热更新原理ET利用.NET的Assembly.Load和Mono.Cecil等技术将游戏逻辑代码编译成动态链接库DLL在运行时加载。当需要更新时只需从服务器下载新的DLL重新加载即可无需重新安装游戏包。实战步骤与避坑代码分离你必须严格区分“框架代码”和“热更代码”。所有游戏逻辑组件、系统、UI都应放在Hotfix和HotfixView项目中。Model和ModelView中的代码则不能热更。框架启动时会先加载基础程序集再从服务器或本地加载热更程序集。生成与打包使用BuildTool中的“构建热更DLL”功能。这会编译Hotfix项目并将生成的DLL复制到StreamingAssets目录下。你需要编写工具将这些DLL上传到你的资源服务器。客户端热更流程// 游戏启动后检查版本 VersionComponent versionComponent Game.Scene.GetComponentVersionComponent(); string localVersion versionComponent.GetLocalVersion(); string remoteVersion await GetRemoteVersionFromServer(); if (remoteVersion ! localVersion) { // 1. 下载新的热更DLL列表和文件 Liststring dllList await DownloadFileList(remoteVersion); // 2. 使用ET的AssemblyLoader加载新的DLL Game.EventSystem.Add(DLLType.Hotfix, Assembly.Load(File.ReadAllBytes(Hotfix.dll))); Game.EventSystem.Add(DLLType.HotfixView, Assembly.Load(File.ReadAllBytes(HotfixView.dll))); // 3. 重新触发游戏入口 Game.EventSystem.Run(EventIdType.HotfixStart); }巨大陷阱——引用与委托热更新最大的坑是“类型不匹配”。如果你在不可热更的Model层持有了一个可热更Hotfix层类型的委托或引用热更后旧的引用指向的是老的类型而新加载的程序集里是新的类型两者不相等会导致调用失败甚至崩溃。解决方案是永远通过事件Event或字符串标识来间接通信避免直接的类型引用跨越热更边界。4.2 定时器、协程与异步编程服务器逻辑充斥着“等待”等待技能冷却、等待怪物刷新、等待玩家操作。ET框架提供了强大的定时器和基于ETTask的异步编程模型。ETTask vs TaskETTask是ET框架对C#Task的封装和优化它更轻量并且与ET的单线程纤程模型完美结合。在ET的System里你应该始终使用ETTask和await。定时器的使用public class TimerComponentSystem : SystemClassTimerComponent { // 在System中为组件添加定时器 public static ETTask WaitAsync(this Entity entity, long waitTime) { ETTaskCompletionSource tcs new ETTaskCompletionSource(); long timerId Game.TimerComponent.NewOnceTimer(TimeInfo.Instance.ServerNow() waitTime, () tcs.SetResult()); // 记住当实体销毁时必须取消定时器否则会引发内存泄漏和逻辑错误 entity.AddChild(timerId); // 将定时器ID作为实体的子节点实体销毁时会自动清理 return tcs.Task; } } // 在某个System中使用 protected override async ETTask Run(...) { Log.Debug(开始等待3秒); await this.Entity.WaitAsync(3000); // 等待3000毫秒 Log.Debug(3秒已过); // 注意如果在这3秒内调用此方法的实体被销毁了await会抛出OperationCanceledException需要处理。 }核心技巧一定要管理定时器的生命周期最常见的BUG就是实体如一个怪物死亡被销毁了但它的定时器还在时间一到就会回调访问一个不存在的实体导致空引用异常。通过AddChild将定时器与实体绑定是最佳实践。4.3 数据库访问与缓存层设计除了内存中的实体玩家数据最终要持久化到数据库。ET框架通常与MongoDB搭配使用因为MongoDB的文档模型和JSON格式与ET的实体组件结构很契合。基础操作// 在某个Handler中保存玩家数据 public class PlayerComponent : Entity, IAwake, IDestroy, ISerializeToEntity { public string Name; public int Level; // ... 其他字段 } // 保存 PlayerComponent player ...; await Game.Scene.GetComponentDBComponent().Save(player); // 异步保存到MongoDB // 查询 PlayerComponent player await Game.Scene.GetComponentDBComponent().QueryPlayerComponent(playerId);缓存层设计——UnitCache 直接频繁读写数据库是服务器性能杀手。ET框架抽象了UnitCache组件作为缓存层。其工作流程是查询时先问UnitCache要。UnitCache没有再去数据库查并存入缓存。保存时先更新内存实体然后标记为脏数据。由一个定时任务批量将脏数据写回数据库。你需要根据游戏类型设计缓存策略。对于读写非常频繁的数据如玩家当前位置可能只适合放在内存通过定期快照存库。对于重要性高但更新不频繁的数据如任务进度则适合用UnitCache。事务与一致性在分布式服务器多个Map服环境下保证数据一致性是难题。ET框架通过Location Proxy位置代理服务来管理实体在哪个服务器上但跨服的事务需要你自己设计例如使用消息队列确保操作顺序或采用最终一致性模型。5. 性能调优与生产环境部署当你的游戏Demo跑通准备面向真实玩家时性能和稳定性就成了首要问题。5.1 性能分析与瓶颈定位使用ProfilerUnity Profiler客户端和.NET的dotnet-trace、dotnet-counters服务器是你的第一工具。关注CPU哪些System耗时最长是否有不必要的复杂计算或频繁的GC分配内存实体数量是否无限增长组件是否存在泄漏特别是事件监听、定时器未清理网络消息频率和大小是否合理是否发送了大量冗余的同步数据ET框架特有优化点System遍历优化避免在Update里遍历所有实体。使用ET的EntitySystem特性只为需要每帧更新的实体添加Update标签。消息压缩对于频繁同步且数据量大的消息如移动同步考虑使用差分压缩或只同步变化量。池化技术频繁创建销毁的实体如技能特效、伤害数字一定要使用对象池。ET框架内置了简单的对象池对于复杂实体需要自己实现。5.2 服务器分布式部署实战单进程服务器有性能上限。ET框架天生支持分布式部署。进程划分典型的MMO架构会分为Realm登录服负责账号验证、选区。Gate网关服网络接入、消息路由、协议加解密。Map地图服承载游戏核心逻辑如战斗、NPC、副本。可以按地图或场景横向扩展多个Map进程。Location定位服记录实体玩家在哪个Map服上。DB数据库代理服封装数据库操作提供缓存。配置与启动每个服务器进程都是一个独立的控制台程序通过命令行参数或配置文件指定自己的AppType进程类型和StartConfig启动配置如监听的IP端口。使用进程管理工具如supervisord来统一启动和管理这些进程。通信进程间通过ET内置的RPCIActorMessage通信。框架底层会自动处理消息的路由。例如Gate收到客户端消息发现目标玩家在Map1服上就会把消息转发给Map1。5.3 监控、日志与容灾日志系统ET使用NLog你需要配置好按级别Debug, Info, Error和进程分文件输出。将日志收集到ELKElasticsearch, Logstash, Kibana或类似平台便于线上问题排查。监控指标暴露关键指标如在线人数、各服CPU/内存、消息队列长度、数据库响应时间。可以使用Prometheus收集用Grafana展示。容灾与热更灰度发布先更新一组服务器观察无误后再全量更新。状态同步对于Map服要考虑玩家跨服时的状态迁移。ET的Location服可以帮助定位但迁移过程中的数据一致性如正在进行的交易、组队状态需要精心设计。回滚预案任何时候都要有快速回滚到上一个稳定版本的能力。6. 常见问题排查与开发心法最后分享一些我踩过坑后总结的经验希望能帮你少走弯路。6.1 编译与生成问题速查表问题现象可能原因解决方案运行BuildTool报错提示找不到protocProtobuf编译器路径未正确配置下载对应系统的protoc在BuildTool设置窗口指定完整路径。服务器编译失败大量“未找到类型或命名空间”错误未正确生成协议代码或项目引用错误1. 确保BuildTool生成成功。2. 在Rider/VS中检查Server项目是否引用了Share、ThirdParty等输出目录下的DLL。3. 尝试“重新生成解决方案”。客户端能连接服务器但收不到消息或消息解析错误1. 协议文件.proto两端不一致。2. 消息处理器MessageHandler未注册。1. 核对客户端和服务器使用的Proto文件夹是否完全一致。2. 检查服务器端的处理器类是否加了[MessageHandler]特性并且所在程序集被正确加载。热更新后新逻辑不生效1. 热更DLL未成功下载或加载。2. 旧类型引用未清除。1. 检查热更文件下载路径和加载日志。2. 重启整个游戏客户端热更新通常只更新逻辑不重启可能无法完全清理旧缓存。6.2 运行时典型问题空引用异常NullReferenceException这是ET开发中最常见的异常90%的原因在于实体生命周期管理不当。场景一定时器回调时实体已销毁。解决用AddChild绑定定时器或在校验回调时用if (this.IsDisposed) return;。场景二事件监听未及时移除。在组件的Destroy方法中务必取消订阅Game.EventSystem.RemoveListener和移除定时器。场景三从组件缓存字典中获取的实体在使用前已被其他逻辑销毁。解决使用前用if (entity null || entity.IsDisposed)判断。内存泄漏实体或组件没有被正确回收。检查点定期在Profiler中查看Entity和自定义组件的实例数量是否只增不减。确保所有实体最终都通过Dispose方法被销毁并且其上的组件也都被移除。逻辑卡死或性能骤降检查点一是否存在死循环或耗时极长的同步操作如复杂计算、同步IO阻塞了主纤程所有可能耗时的操作都必须await异步化。检查点二某个System的Update遍历了过多的实体。使用分帧处理或条件过滤优化。6.3 心法拥抱变化保持简洁数据驱动配置将尽可能多的逻辑参数技能伤害公式、怪物刷新表、任务条件放到配置表如Excel中用工具导出成Protobuf或二进制格式。这样修改平衡性不需要改代码、重新编译、热更。善用事件Event组件间通信优先用事件而不是直接调用。这能极大降低耦合度。ET的Game.EventSystem非常好用。保持System的纯粹性一个System只做一件事。移动系统只处理坐标和速度更新伤害系统只计算伤害和血量变化。不要写出一个“战斗系统”把移动、技能、BUFF全包了。测试驱动为关键的System如伤害计算、寻路编写单元测试。ET框架的结构非常适合单元测试因为System是纯逻辑函数。阅读源码遇到无法理解的行为或报错不要犹豫直接去读ET框架的源码。它的代码结构相对清晰是学习优秀架构设计的最佳材料。理解Game.EventSystem、EntityScene、TimerComponent这些核心类的实现能让你从“使用者”变为“掌控者”。ET框架的学习曲线初期确实有些陡峭尤其是ECS思维模式的转变。但一旦你跨过那个门槛你会发现它带来的代码组织性、性能潜力和开发效率的提升是完全值得的。它不是银弹不能解决所有游戏类型的问题但对于中重度的网络游戏来说它无疑是一把利器。从今天开始试着用ET的思维去构建你的下一个游戏服务器模块你可能会遇到问题但社区活跃资料也越来越多坚持下去你会收获一套属于自己的高性能服务器开发方法论。