ET框架组件式设计:Entity + Component 架构与热插拔组件实战解析
【免费下载链接】ETUnity3D Client And C# Server Framework项目地址: https://gitcode.com/GitHub_Trending/et/ET
本文基于 ET 框架(Unity3D Client And C# Server Framework)的组件式设计思想,系统剖析其为何放弃传统面向对象继承体系、转而采用 Entity + Component 组合模型,并结合仓库源码展示组件挂载、组件事件(Awake/Update 等 System)与服务器进程拼装的完整实战方案。读完本文,你将掌握 ET 组件模型的挂载/移除/级联销毁机制、System 事件分发原理,以及如何用组件拼装出 LoginServer、战斗服等分布式服务器进程。
为什么面向对象在复杂游戏逻辑面前力不从心
提到代码复用与数据组织,很多人的第一反应是面向对象(OOP)。继承、封装、多态三大特性确实解决了一大批代码复用与数据复用问题,但面向对象并不是万能的,在游戏这种"逻辑变化极其频繁"的领域,它有三大致命缺陷。
1. 数据结构耦合非常强
一旦父类中新增或删除一个字段,就可能影响所有子类以及所有与子类相关的逻辑。在一套复杂的继承体系中,修改父类字段会越来越麻烦。
假设A、B、C都是D的子类。某天发现需要给A、B增加一份数据,而C不需要,那么这份数据显然不能放进父类D,只能再抽象出一个父类E:E继承自D,A、B的公共字段加到E上。而一旦继承结构改变,接口往往也要跟着变——如果某接口的入参类型是E,当A、B不再需要这份公共字段、继承关系改回直接继承D时,接口入参类型又要改回D,相关逻辑代码大概率也要跟着调整。
更糟的是,游戏逻辑变化非常复杂且频繁,今天加一个字段,明天删一个字段。如果每次都要调整继承结构,简直就是噩梦。继承结构在频繁的数据结构调整面前非常无力。
从源码结构看,ET 之所以把Entity设计成"组合容器"而非"继承基座",正是为了规避这一问题:Entity.cs 中Entity只承载Id、InstanceId、Parent、Children、Components等基础设施字段,业务数据全部通过挂载不同组件来组织。
2. 难以热插拔
继承结构无法在运行时增删字段。例如 Player 平时走路,使用坐骑后要骑行——坐骑的信息需要一直挂在 Player 对象上,这不灵活:不骑马的时候,为什么要让马的数据常驻内存?
接口也有同样的问题:一个类实现了某个接口,接口就永远粘在这个类上,想甩掉都不行。还是以骑马为例,Player 可以骑乘,于是继承一个IRide接口;问题是当 Player 从坐骑上下来时,Player 依然带着这个接口,没有任何办法在运行时动态删除这个接口。
而组件模式天然支持运行时增删:需要就AddComponent,不需要就RemoveComponent,见下文。
3. 方法与数据耦合
传统面向对象都是"类里带方法",尤其推崇虚函数多态。方法和数据放在一起带来大量耦合问题。为了解耦,业界发明了一堆设计模式,比如依赖接口、依赖倒置。坦率地说,这是"脱裤子放屁"——为了解耦,把类抽成接口再去继承接口,这不叫依赖吗?这种做法让代码里充满接口,可读性极差。
更现实的问题是:代码书写没有标准,高手和菜鸟写出来的代码完全不同。大多数程序员是"逻辑男孩",哪有时间天天琢磨类怎么设计?随着逻辑越来越复杂,类里的方法越来越大,可怕的是这类方法极难重构。很多项目里都能看到上万行的虚函数类。
面向过程的倒退
面对复杂游戏逻辑,面向对象非常无力,于是不少游戏开发者干脆倒退回去用面向过程开发。面向过程简单粗暴,不考虑复杂继承、不考虑抽象、不考虑多态,属于"撸起袖子就是干"的freestyle,但与此同时,代码逻辑的复用性、数据的复用性也大大降低。面向过程同样不是好的游戏开发模式。
组件模式:游戏领域验证过的解决方案
组件模式很好地解决了面向对象和面向过程的缺陷,在游戏客户端被广泛使用,Unity3D、Unreal 4 等都采用组件模式。其特性可以总结为三条:
- 高度模块化:一个组件就是"一段数据 + 一段逻辑";
- 可热插拔:需要就加,不需要就删;
- 类型间依赖极少:任意类型添加或移除组件,都不会影响其他类型。
不过目前只有极少数服务端设计采用组件。守望先锋(Watchtower)服务端使用了组件设计,其开发者称之为 ECS 架构,本质上是组件模型的变体:E 是 Entity,C 是 Component,S 是 System——把组件的逻辑部分剥离出来叫 System。我们回到 ET 框架本身。
ET 的一切皆实体:Entity + Component 组合模型
ET 框架采用组件式设计。任何继承自Entity的类都可以被挂载组件,比如玩家类:
public sealed class Player : Entity { public string Account { get; private set; } public long UnitId { get; set; } public void Awake(string account) { this.Account = account; } public override void Dispose() { if (this.Id == 0) { return; } base.Dispose(); } }给玩家对象挂上MoveComponent,玩家就能移动;挂上ItemsComponent(背包组件),玩家就能管理物品;挂上SpellComponent(技能组件),玩家就能施法;挂上BuffComponent,玩家就能管理 Buff:
player.AddComponent<MoveComponent>(); player.AddComponent<ItemsComponent>(); player.AddComponent<SpellComponent>(); player.AddComponent<BuffComponent>();从源码看挂载与生命周期
在仓库中,AddComponent系列 API 定义在 Entity.cs,支持无参、1~3 个初始化参数以及AddComponentWithId指定组件 Id 的多种重载,例如:
public K AddComponent<K, P1>(P1 p1, bool isFromPool = false) where K : Entity, IAwake<P1>, new() { Entity component = this.CreateComponent(typeof (K), this.Id, isFromPool); EntitySystemSingleton.Instance.Awake(component, p1); return component as K; }关键点在于CreateComponent(Entity.cs#L671-L684):
- 通过
ObjectPool.Fetch从对象池取对象(isFromPool控制); - 若当前实体已存在同类型组件,直接抛出
entity already has component异常,保证单类型组件唯一; - 通过
ComponentParent设置父子关系,并把组件登记进父实体的Components字典。
与挂载对应的还有一套完整的生命周期 API:
GetComponent<K>()(Entity.cs#L613-L635)按类型取组件;RemoveComponent<K>()(Entity.cs#L547-L574)移除组件,可选是否立即 Dispose;Dispose()(Entity.cs#L436-L497)会级联清理:先遍历children逐个 Dispose,再遍历components逐个 Dispose,最后触发IDestroy事件并从父实体解绑——这正是"热插拔"的底层保障:拆掉一个组件,不会影响同一实体上的其他组件。
从仓库中的真实组件可以印证这套模型,例如 MoveComponent.cs:
[ComponentOf(typeof(Unit))] public class MoveComponent: Entity, IAwake, IDestroy { // 目标点、开始时间、实时位置等纯数据字段…… }[ComponentOf(typeof(Unit))]声明了该组件只能挂在Unit上,且MoveComponent本身只含数据、不含逻辑;移动逻辑实现在 Hotfix 层的 MoveComponentSystem.cs 中。
另外值得注意:ET 还区分"子实体"(Child)与"组件"(Component)。从 Entity.cs#L222 与 #L286 可见,设置Parent时IsComponent = false(进入Children集合),而AddComponent内部设置ComponentParent时IsComponent = true(进入Components集合)。子实体有独立 Id(如Unit通过AddChild创建),组件共享父实体 Id,二者在Dispose时都会被级联清理。
组件的高复用性:NPC 也能走、也能放技能
组件高度可复用。例如一个 NPC,它也能移动,那就给它挂MoveComponent;某些 NPC 也能施法,就给它挂SpellComponent;NPC 不需要背包,那就不挂ItemsComponent——需要的能力挂上去,不需要的能力不挂,互不影响。
Unit类本身也是这个思路的体现:Unit.cs 定义为[ChildOf(typeof(UnitComponent))] public partial class Unit: Entity, IAwake<int>,只保存ConfigId、UnitType、Position、Rotation等最基础的通用数据,其余行为全部由外部组件按需装配。
服务器进程也是组件的拼装
ET 框架的模块全部做成组件形式,一个进程就是由不同组件拼接而成的。比如 LoginServer 既需要对外连接客户端,又需要对内连接服务器,于是给场景挂上内外网两个组件:
// intranet network component NetInnerComponent, which handles the connection to the intranet Game.Scene.AddComponent<NetInnerComponent, string, int>(innerConfig.Host, innerConfig.Port); // NetOuterComponent, an extranet component, handles the connection to the client Game.Scene.AddComponent<NetOuterComponent, string, int>(outerConfig.Host, outerConfig.Port);而战斗服务器不需要连接外网(外网消息由 GateServer 转发),所以只挂内网组件即可。组件即插即用,进程能力随组件增减,服务器拓扑的差异只是"挂的组件不同"。
当前仓库中的真实服务器拼装示例
文档中使用的Game.Scene.AddComponent是早期写法;当前仓库中,每个服务器纤程(Fiber)的初始化正是通过向根场景root挂组件完成的。以 NetInner 纤程为例,见 FiberInit_NetInner.cs:
[Invoke(SceneType.NetInner)] public class FiberInit_NetInner: AInvokeHandler<FiberInit, ETTask> { public override async ETTask Handle(FiberInit fiberInit) { Fiber fiber = fiberInit.Fiber; Scene root = fiber.Root; root.AddComponent<MailBoxComponent, int>(MailBoxType.UnOrderedMessage); root.AddComponent<TimerComponent>(); root.AddComponent<CoroutineLockComponent>(); root.AddComponent<ProcessOuterSender, IPEndPoint>(root.GetSingleton<AddressSingleton>().InnerAddress); root.AddComponent<ProcessInnerSender>(); root.AddComponent<ProcessFiberAddressComponent>(); await ETTask.CompletedTask; } }再看一个组件化的真实案例——消息发送组件 MessageSender.cs:
[ComponentOf(typeof(Scene))] public class MessageSender: Entity, IAwake { public const long TIMEOUT_TIME = 40 * 1000; public readonly Dictionary<int, MessageSenderStruct> requestCallback = new(); private EntityRef<ProcessInnerSender> processInnerSender; // ... }以及负责对外网通信的 ProcessOuterSender.cs:
[ComponentOf(typeof(Scene))] public class ProcessOuterSender: Entity, IAwake<IPEndPoint>, IUpdate, IDestroy { public const long TIMEOUT_TIME = 40 * 1000; public int RpcId; public readonly Dictionary<int, MessageSenderStruct> requestCallback = new(); public AService AService; public NetworkProtocol InnerProtocol = NetworkProtocol.KCP; // ... }这两个组件一个声明IAwake(创建后初始化),一个声明IAwake<IPEndPoint>, IUpdate, IDestroy(带参数初始化、每帧更新、销毁时释放),对应的逻辑都在 Hotfix 层的 ProcessOuterSenderSystem.cs 中——Awake 里按 TCP/KCP 协议创建AService并注册回调,Update 里驱动AService.Update(),Destroy 里释放网络服务。组件之间的职责边界一目了然。
组件事件:Awake、Update 等 System 的挂接方式
类似 Unity3D 的组件,ET 框架也提供组件事件,如 Awake、Start、Update 等。要给某个 Component 或 Entity 添加这些事件,需要编写一个辅助类。以 NetInnerComponent 组件需要 Awake 和 Update 方法为例,文档给出了早期基于ObjectEvent的写法:
[ObjectEvent] public class NetInnerComponentEvent : ObjectEvent<NetInnerComponent>, IAwake, IUpdate { public void Awake() { this.Get().Awake(); } public void Update() { this.Get().Update(); } }这样,NetInnerComponent在AddComponent之后会调用其 Awake 方法,并且每帧调用 Update 方法。
当前仓库的 System 分发实现
当前仓库已演进为更简洁的声明式写法:先让组件类声明IAwake/IUpdate/IDestroy等标记接口,再在 Hotfix 层为组件写一个[EntitySystemOf(typeof(Xxx))]标注的静态分部类,用[EntitySystem]标注的静态扩展方法实现对应 System。上文ProcessOuterSenderSystem就是标准范例。
其底层机制可以从三个文件得到印证:
- 标记接口:
IAwake系列接口定义在 IAwakeSystem.cs,本质是空的标记接口,用于约束组件的"能力声明";IUpdate定义在 IUpdateSystem.cs。 - System 载体:
AwakeSystem<T>、AwakeSystem<T, A>等抽象类(IAwakeSystem.cs#L50-L111)实现IAwakeSystem接口,用[EntitySystem]特性标注,子类实现抽象的Awake方法即可。 - 运行时分发:EntitySystemSingleton.cs 在启动时扫描所有带
[EntitySystem]特性的类型,把 System 按"实体类型 → System 类型 → 实例"建表缓存;AddComponent内部调用EntitySystemSingleton.Instance.Awake(component, ...)(EntitySystemSingleton.cs#L122-L146)完成分发,Update/Destroy 同理。
为什么不用反射实现事件分发
文档明确指出:ET 不像 Unity 那样用反射实现这类功能,因为反射性能差。从源码看,ET 仅在进程启动时用一次反射扫描CodeTypes完成 System 注册建表,运行期的 Awake/Update 分发全部走字典查找,避免了每帧反射的开销。
这种实现还有一个关键优势:System 类可以放在热更新 DLL 中。这样组件的 Awake、Start、Update 等方法都能放到热更新层,Entity与Component本身只做成"没有方法的纯数据类",逻辑全部放到热更新层,方便通过热更新修复逻辑 Bug。这正是仓库中 Model 层与 Hotfix 层分离的组织原因:Model 只存数据,Hotfix 存放[EntitySystemOf]的 System 实现。
方法数据分离:告别"该继承谁"的纠结
组件式开发最大的优势是:无论是菜鸟还是专家,开发一个功能时都能很快知道如何组织数据、如何组织逻辑,面向对象可以完全抛弃。面向对象开发最头疼的问题是"我该继承哪个类?"。以前最可怕的是 Unreal 3,继承结构很多层,完全不知道从哪里开始继承,最终可能为了一个很小的功能去继承一个非常大的类——这在 Unreal 3 开发中很常见。所以 Unreal 4 也转向了组件模式。
组件模式的模块隔离非常好:一个菜鸟把某个组件写得很烂,也不会影响其他模块,大不了重写这个组件。
ET 在组件设计上的创新点在于方法与数据彻底分离、完全解耦:不用绞尽脑汁去想怎么解耦,随手写静态方法都没有耦合,哪怕是菜鸟写的代码也容易重构。从仓库看,Model 层的Entity子类只声明字段(如 Unit.cs 只含ConfigId、Position等数据),所有行为方法都以静态扩展方法形式存在于 Hotfix 层(如 MessageSenderSystem.cs 的Awake、Send、Call等)。
单进程多服:组件化带来的分布式调试能力
正是因为 ET 采用了可拆分的组件模型,ET 可以把所有服务器组件加载到同一个进程里,那么这个进程就可以当作一整套分布式服务器来使用。由此带来一个杀手级能力:可以用 Visual Studio 直接调试分布式服务器。
日常开发时只用一个进程,发布时再拆分成多个进程上线即可。这套设计解决了分布式游戏服务器开发中的一个大痛点,极大地提升了开发效率。本质上,进程边界被"组件边界"替代:开发期把多个进程的组件装进一个进程联调,发布期按进程划分组件集合,代码零改动。
总结
ET 的组件式设计可以概括为一条主线:用"组合"替代"继承",用"数据与逻辑分离"替代"类内聚"。
- 实体(Entity)是纯数据容器,任何继承自
Entity的类都能挂组件,AddComponent/RemoveComponent/GetComponent构成了组件的热插拔能力; - 组件(Component)是"一段数据 + 一段逻辑",通过
IAwake、IUpdate、IDestroy等标记接口与[EntitySystemOf]System 类把生命周期事件挂接起来,启动时一次性注册、运行期字典分发,天然适配热更新; - 无论是客户端玩家、NPC,还是服务端的 LoginServer、战斗服,都是同一套"拼组件"的思维,进程即组件的集合,这也是 ET 单进程调试多服的关键前提。
如果你正在设计一套需要频繁调整数据结构的游戏服务器,或者厌倦了深不可测的继承体系,可以直接参考 Entity.cs 与 FiberInit_NetInner.cs 来动手实践这套组件模型。
【免费下载链接】ETUnity3D Client And C# Server Framework项目地址: https://gitcode.com/GitHub_Trending/et/ET
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考