前言

由于之前已经写过很多ET相关教程,本篇文章主要提及ET6.0的新概念和新特性,其实6.0很多知识点和之前版本一样适用,想看ET之前版本教程的,可以前往:ET篇:个人笔记汇总

本文写作时对应的commit版本:https://github.com/egametang/ET/commit/43abb90cd05445f2e9a0625ac12caaab59486672

基础概念

此部分内容参考:ET6.0的设计思路 推荐大家有问题先去ET论坛逛逛,里面有很多管理员整理好的日常讨论答案,技术教程,以及其他朋友提出的问题,可以学到很多知识。

ET 6.0创新概览

  1. 之前每个功能是一个进程,比如realm gate location map,现在改成每个功能是一个Scene,一个Scene可以放到一个进程中。这样一台物理机先启动固定的进程,然后各个scene放到进程中运行。非常类似docker。
  2. 所有的Scene放在一个进程就变成了AllServer模式
  3. 服务器内部全部使用actor发送消息,比如realm发给gate,其实是发个actor消息到gate scene
  4. dbserver将取消,每个进程直连mongodb,使用异步调用存取数据
  5. 协程锁简化了很多实现,例如location队列,actor队列,mailbox消息队列,全部使用协程锁实现,代码非常精简。
  6. Scene可以开服前配置好在哪个进程(比如realm gate)也可以动态创建(比如副本,分线场景)。动态创建Scene回收Scene非常简单。
  7. view层跟逻辑层完全分离,可以利用逻辑层代码写机器人。服务端分区支持,多个分区都能放到一个进程。
  8. 一个端就能完成任何测试,还能写机器人测试用例。这个解决大问题了,因为大家都知道游戏逻辑因为耦合非常严重,是很难写方法级别的单元测试,et6的这个设计可以非常方便的让大家写出协议级别的单元测试。并且一键开启上万个机器人来压测,把并发bug扼杀在摇篮里,开个客户端,同时点几下按钮就加了几个机器人,类似组队这种逻辑就可以一个人测试完成

Domain

domain就是指这个entity属于哪个scene,毕竟一个进程上可以容纳多个scene
domain还有个很重要的作用,就是设置domain的时候才会执行反序列化system,还有注册eventsystem
domain简单说是指属于哪个scene, 每个entity都有个domain字段,这样写逻辑方便能拿到自己scene上的数据

多进程多scene,具体scene放到那个进程完全取决于配制,全放到一个进程就是allserver了

如果是个很大的scene,需要容纳很多人,可能就需要单独占用一个进程,这样才能完全利用一个核

把每个scene都分配一个进程,就跟5.0差不多了

Ray:
et一开始就是多进程嘛…没毛病.不过现在是在进程内又开辟了相对独立的容器.熊猫能说说这么改的实际应用场景吗…什么实际需求促使了你做这个大刀阔斧的改动
熊猫:
@Ray 原因是很多动态副本跟分线的需求,现在可以16核机器起16个进程,然后动态分配副本跟分线到进程上.
比如很多单人副本,没必要一下子开很多进程来支持。需要的时候找一个负载低的进程动态创建一个就行了,用完就可以回收了

客户端

客户端domain也有用,客户端也会存在多个scenes。比如Game.Scene是永久存在的,再搞个Scene挂在Game.Scene下面作为当前进入的场景,切换场景的时候删除这个scene再创建一个scene。

Justin沙特王子:
我还是期待ET6.0能把客户端层给删掉。。。
熊猫:
删掉客户端?等6.0出来就知道et双端威力了。机器人框架就是服务端跟客户端合成一个程序,机器人直接使用客户端代码跑在linux上.
还有测试用例框架,直接调用客户端代码发送消息给服务端,不双端怎么能做到?

Entity&&Component

6.0把child集成到了Entity,跟Component并列,一个Entity必须设置Parent。这样删除一个Entity能把他的所有组件跟children全部删掉

热更新方案

没用了解过cs2lua,怕有坑,对于6.0咬定cs.lua本人持观望态度

因为对ILRuntime比较了解,并且与ET相性较好(基本上不需要跨域继承,仅这一点可以避免很多坑)个人选择仍然是ILRuntime,目前已经自己接入了,还包含了资源热更新:ET6.0 with ILRuntime && xAsset

流程

和大家一起过一下官方Demo的运行和通信流程

服务端初始化流程

老规矩还是我们的Server.App项目作为启动项

1
2
3
4
5
6
List<Type> types = new List<Type>();
types.AddRange( typeof(Game).Assembly.GetTypes());
types.AddRange( DllHelper.GetHotfixAssembly().GetTypes());
Game.EventSystem.AddRangeType(types);
Game.EventSystem.TypeMonoInit();
Game.EventSystem.EventSystemInit();

先注册全部的类型到事件系统,作为ET框架的驱动基石

1
2
ProtobufHelper.Init();
MongoHelper.Init();

这两句代码主要是对两个序列化库做的初始化,大多是一些注册类型的操作

1
2
3
4
5
6
7
8
9
// 命令行参数
Options options = null;
Parser.Default.ParseArguments<Options>(args)
.WithNotParsed(error => throw new Exception($"命令行格式错误!"))
.WithParsed(o => { options = o; });
GloabDefine.Options = options;
GloabDefine.ILog = new NLogger(GloabDefine.Options.AppType.ToString());
LogManager.Configuration.Variables["appIdFormat"] = $"{GloabDefine.Options.Process:000000}";
Log.Info($"server start........................ {Game.Scene.Id}");

这些全都是配置操作,由于我们传入程序的启动参数args为空所以会使用Options定义的默认值。

注意第7行会为NLog配置Log格式。注意我们在Option里面配置了Log等级,如果目标Log的等级没有达到我们配置的等级,就将不会打印

我们默认使用了NLog作为Log方案:NLog简单入门 ,项目Log输出目录位于 ET/Log/ 文件夹中

1
Game.EventSystem.Publish(new EventType.AppStart());

第一行抛出事件开启内部初始化流程,注意ET6.0的事件系统参数已经改为支持参数的struct类型,下面是抛出的事件会执行的逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class AppStart_Init: AEvent<EventType.AppStart>
{
protected override async ETTask Run(EventType.AppStart args)
{
Game.Scene.AddComponent<ConfigComponent>();

await ConfigComponent.Instance.LoadAsync();
//读取StartProcessConfig中id为Game.Options.Process(这里值为1)的整行配置
//ET6.0由于使用了protobuffer作为导表工具,所以请去Excel文件夹查看原数据
StartProcessConfig processConfig = StartProcessConfigCategory.Instance.Get(GloabDefine.Options.Process);

Game.Scene.AddComponent<TimerComponent>();
Game.Scene.AddComponent<OpcodeTypeComponent>();
Game.Scene.AddComponent<MessageDispatcherComponent>();
Game.Scene.AddComponent<CoroutineLockComponent>();
// 发送普通actor消息
Game.Scene.AddComponent<ActorMessageSenderComponent>();
// 发送location actor消息
Game.Scene.AddComponent<ActorLocationSenderComponent>();
// 访问location server的组件
Game.Scene.AddComponent<LocationProxyComponent>();
Game.Scene.AddComponent<ActorMessageDispatcherComponent>();
// 数值订阅组件
Game.Scene.AddComponent<NumericWatcherComponent>();

Game.Scene.AddComponent<NetThreadComponent>();
switch (GloabDefine.Options.AppType)
{
case AppType.Server:
{
Game.Scene.AddComponent<NetInnerComponent, IPEndPoint>(processConfig.InnerIPPort);
List<StartSceneConfig> processScenes = StartSceneConfigCategory.Instance.GetByProcess(GloabDefine.Options.Process);
foreach (StartSceneConfig startConfig in processScenes)
{
await SceneFactory.Create(Game.Scene, startConfig.Id, startConfig.InstanceId, startConfig.Zone, startConfig.Name,
startConfig.Type, startConfig);
}
break;
}
case AppType.Watcher:
{
StartMachineConfig startMachineConfig = WatcherHelper.GetThisMachineConfig();
WatcherComponent watcherComponent = Game.Scene.AddComponent<WatcherComponent>();
watcherComponent.Start(GloabDefine.Options.CreateScenes);
Game.Scene.AddComponent<NetInnerComponent, IPEndPoint>(NetworkHelper.ToIPEndPoint($"{startMachineConfig.InnerIP}:{startMachineConfig.WatcherPort}"));
break;
}
case AppType.GameTool:
break;
}
if (GloabDefine.Options.Console == 1)
{
Game.Scene.AddComponent<ConsoleComponent>();
}
}
}

对于创建Scene的代码如下所示,会根据Scene类型不同而添加不同的组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public static async ETTask<Scene> Create(Entity parent, long id, long instanceId, int zone, string name, SceneType sceneType, StartSceneConfig startSceneConfig = null)
{
await ETTask.CompletedTask;
Scene scene = EntitySceneFactory.CreateScene(id, instanceId, zone, sceneType, name, parent);
// 添加注册邮箱,因为内网通信都是通过Actor消息
scene.AddComponent<MailBoxComponent, MailboxType>(MailboxType.UnOrderMessageDispatcher);
switch (scene.SceneType)
{
case SceneType.Realm:
scene.AddComponent<NetKcpComponent, IPEndPoint>(startSceneConfig.OuterIPPort);
break;
case SceneType.Gate:
scene.AddComponent<NetKcpComponent, IPEndPoint>(startSceneConfig.OuterIPPort);
scene.AddComponent<PlayerComponent>();
scene.AddComponent<GateSessionKeyComponent>();
break;
case SceneType.Map:
scene.AddComponent<UnitComponent>();
scene.AddComponent<RecastPathComponent>();
break;
case SceneType.Location:
scene.AddComponent<LocationComponent>();
break;
}
return scene;
}

之后会进入轮询,不断地更新框架状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
while (true)
{
try
{
Thread.Sleep(1);
ThreadSynchronizationContext.Instance.Update();
Game.Update();
Game.LateUpdate();
Game.FrameFinish();
}
catch (Exception e)
{
Log.Error(e);
}
}

总结

  • 通过启动参数确定Option的数据(如果启动参数为空,则使用默认的Option数据),其中记录了进程Id(Option.Process)等关键数据

  • 拿着这个Option.Process去StartProcessConfig得到此进程对应的物理机器Id以及此进程对应的内网端口

  • 拿着这个物理机器Id去StartMachineConfig获取物理机的内网IP和外网IP,以供下面Scene中网络组件的初始化

  • 进行进程基础组件的添加,例如Actor组件,协程锁组件等

  • 拿着这个进程Option.Process去StartSceneConfig获取所有归属于此进程的Scenes以及Scene的外网端口,并且创建这些Scene

至此,完成服务端所有初始化工作

但是对于StartZoneConfig的使用,Demo并未给出,个人猜测Zone号是每个Scene所对应的区号,不同的区号会连接到不同的数据库

客户端初始化流程

现在我们来到客户端,走一下初始化流程

前面说过了Server的AppStart事件通知流程,所以这里我们直接来到客户端AppStart事件通知后的地方

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class AppStart_Init: AEvent<EventType.AppStart>
{
protected override async ETTask Run(EventType.AppStart args)
{
Game.Scene.AddComponent<TimerComponent>();
Game.Scene.AddComponent<CoroutineLockComponent>();
Game.Scene.AddComponent<ConfigComponent>();
await ConfigComponent.Instance.LoadAsync();

Game.Scene.AddComponent<OpcodeTypeComponent>();
Game.Scene.AddComponent<MessageDispatcherComponent>();

Game.Scene.AddComponent<NetThreadComponent>();

Game.Scene.AddComponent<ZoneSceneManagerComponent>();

Game.Scene.AddComponent<GlobalComponent>();

Game.Scene.AddComponent<AIDispatcherComponent>();
Scene zoneScene = await SceneFactory.CreateZoneScene(1, "Game", Game.Scene);

//显示登陆界面
await Game.EventSystem.Publish(new EventType.AppStartInitFinish() { ZoneScene = zoneScene });
}
}

需要注意的是20行的创建Scene代码,他会涉及网络组件和UI组件的添加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static async ETTask<Scene> CreateZoneScene(int zone, string name, Entity parent)
{
Scene zoneScene = EntitySceneFactory.CreateScene(Game.IdGenerater.GenerateInstanceId(), zone, SceneType.Zone, name, parent);

zoneScene.AddComponent<ZoneSceneFlagComponent>();
zoneScene.AddComponent<NetKcpComponent>();
zoneScene.AddComponent<UnitComponent>();
zoneScene.AddComponent<AIComponent, int>(1);

// UI层的初始化
await Game.EventSystem.Publish(new EventType.AfterCreateZoneScene() {ZoneScene = zoneScene});

return zoneScene;
}

至此,客户端的初始化也结束了

双端通信流程

然后我们来走一下双端的通信流程

客户端发起RPC调用,服务端接受外网消息

以客户端发起登录请求,服务器处理为例

1
2
3
4
5
6
7
8
// 创建一个ETModel层的Session
R2C_Login r2CLogin;
//这里的address是我们Realm服务器的外网地址和端口
Session session = zoneScene.GetComponent<NetKcpComponent>().Create(NetworkHelper.ToIPEndPoint(address));
{
r2CLogin = (R2C_Login) await session.Call(new C2R_Login() { Account = account, Password = password });
}
session.Dispose();

Session.Send函数,注意当Service是内网类型时,会比外网类型多在包头写入一个long类型的的actor id,当然我们这里是客户端,是Outer类型,就不需要写入了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void Send(IMessage message)
{
switch (this.AService.ServiceType)
{
case ServiceType.Inner:
{
(ushort opcode, MemoryStream stream) = MessageSerializeHelper.MessageToStream(0, message);
OpcodeHelper.LogMsg(this.DomainZone(), opcode, message);
this.Send(0, stream);
break;
}
case ServiceType.Outer:
{
(ushort opcode, MemoryStream stream) = MessageSerializeHelper.MessageToStream(message);
OpcodeHelper.LogMsg(this.DomainZone(), opcode, message);
this.Send(0, stream);
break;
}
}
}

然后消息来到服务器这边,服务端的AService负责将接收到的服务端二进制数据流传递给NetKcpComponentSystem.OnRead函数,因为我们在创建NetKcpComponent时就将OnRead注册到ReadCallback回调里了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[ObjectSystem]
public class NetKcpComponentAwakeSystem: AwakeSystem<NetKcpComponent>
{
public override void Awake(NetKcpComponent self)
{
self.MessageDispatcher = new OuterMessageDispatcher();

self.Service = new TService(NetThreadComponent.Instance.ThreadSynchronizationContext, ServiceType.Outer);
self.Service.ErrorCallback += (channelId, error) => self.OnError(channelId, error);
self.Service.ReadCallback += (channelId, Memory) => self.OnRead(channelId, Memory);

NetThreadComponent.Instance.Add(self.Service);
}
}

然后通过OuterMessageDispatcher.Dispatch进行消息的反序列化和分发,也就会来到C2R_LoginHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[MessageHandler]
public class C2R_LoginHandler : AMRpcHandler<C2R_Login, R2C_Login>
{
protected override async ETTask Run(Session session, C2R_Login request, R2C_Login response, Action reply)
{
// 从当前大区的Gate服务器随机分配一个
StartSceneConfig config = RealmGateAddressHelper.GetGate(session.DomainZone());
//Log.Debug($"gate address: {MongoHelper.ToJson(config)}");

// 向gate请求一个key,客户端可以拿着这个key连接gate
// 注意G2R这个消息是一个内网的Actor消息
// config.InstanceId(Gate的ActorId,因为我们要把这个消息从Realm(自己)发送到这个Gate嘛)为StartSceneConfig.EndInit通过算法计算出的InstanceId
// public override void EndInit()
// {
// this.Type = EnumHelper.FromString<SceneType>(this.SceneType);
// InstanceIdStruct instanceIdStruct = new InstanceIdStruct(this.Process, (uint) this.Id);
// this.InstanceId = instanceIdStruct.ToLong();
// }
G2R_GetLoginKey g2RGetLoginKey = (G2R_GetLoginKey) await ActorMessageSenderComponent.Instance.Call(
config.InstanceId, new R2G_GetLoginKey() {Account = request.Account});
response.Address = config.OuterIPPort.ToString();
response.Key = g2RGetLoginKey.Key;
response.GateId = g2RGetLoginKey.GateId;
reply();
}
}

服务端内网消息流转

ActorMessageSenderComponent.Instance.Call会来到这里,其中NetInnerComponent.Instance.Get初次处理会因为我们没有为其创建Session而新建一个Session,IP和端口号就是我们配置表里的Process的IP和内网端口号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static void Send(this ActorMessageSenderComponent self, long actorId, MemoryStream memoryStream)
{
if (actorId == 0)
{
throw new Exception($"actor id is 0: {memoryStream.ToActorMessage()}");
}

// 为了自己到目标Process的通信创建一个Session
ProcessActorId processActorId = new ProcessActorId(actorId);
Session session = NetInnerComponent.Instance.Get(processActorId.Process);
session.Send(processActorId.ActorId, memoryStream);
}

// 内网actor session,channelId是进程号
public static Session NetInnerComponent.Instance.Get(this NetInnerComponent self, long channelId)
{
Session session = self.GetChild<Session>(channelId);
if (session == null)
{
IPEndPoint ipEndPoint = StartProcessConfigCategory.Instance.Get((int) channelId).InnerIPPort;
session = self.CreateInner(channelId, ipEndPoint);
}
return session;
}

在Demo中就是自己给自己发送消息(AllServer模式),会来到R2G_GetLoginKeyHandler类,获取到loginKey之后就会给客户端回复消息,也就是会把分配的Gate的IP和端口号以及GateId返回给客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
[ActorMessageHandler]
public class R2G_GetLoginKeyHandler : AMActorRpcHandler<Scene, R2G_GetLoginKey, G2R_GetLoginKey>
{
protected override async ETTask Run(Scene scene, R2G_GetLoginKey request, G2R_GetLoginKey response, Action reply)
{
long key = RandomHelper.RandInt64();
scene.GetComponent<GateSessionKeyComponent>().Add(key, request.Account);
response.Key = key;
response.GateId = scene.Id;
reply();
await ETTask.CompletedTask;
}
}

然后客户端拿着这个gateSession往由服务器分配给自己的GateSession发送登陆信息

1
2
3
4
5
6
// 创建一个gate Session,并且保存到SessionComponent中
Session gateSession = zoneScene.GetComponent<NetKcpComponent().Create(NetworkHelper.ToIPEndPoint(r2CLogin.Address));
gateSession.AddComponent<PingComponent>();
zoneScene.AddComponent<SessionComponent>().Session = gateSession;
G2C_LoginGate g2CLoginGate = (G2C_LoginGate)await gateSession.Call(
new C2G_LoginGate() { Key = r2CLogin.Key, GateId = r2CLogin.GateId});

然后服务器接收,处理后告知客户端结果,此次交互结束,消息流程图如下

顺带提一嘴Actor模型在ET6.0的消息流转,核心就是由IdGenerater引申出的几个Id类,这个几个Id类通过内部的算法可以实现数据的压缩和反解密操作,从而方便的实现Id的转换和获取

机器人模块

机器人模块本质上是一个单独的工程,引用了客户端与服务端的代码,使用方式是通过命令行的方式进行控制,具体来说

编译并启动机器人工程相当于提供了一个完全虚拟的客户端+服务端环境,我们可以通过在终端中输入指定的命令来进行调试和测试

实现方式也非常的简洁,通过监听终端的输入,然后解析输入的内容,对应到Handler进行处理,核心代码位于

1
2
3
4
ET/Robot/Hotfix/Robot/
ET/Robot/Hotfix/Module/RobotBase/
ET/Robot/Model/Robot/
ET/Robot/Model/Module/RobotBase/

总结

结构总结

ET6相对于ET5的架构发生了翻天覆地的变化

首先是ECS结构的变化,从Entity-Component-System变为了Entity-System模式,利用Entity必须设定Parent的限制来模拟ECS下的层级结构,非常方便,

其次是客户端分了5个程序集,这是为了方便机器人进程引用代码进行测试,其中除了Unity.Mono其余4个程序集都是热更的。更加详细的:

  • Unity.Mono:所有冷更层代码
  • Unity.Model:热更层的Model,纯数据
  • Unity.ModelView:热更层的ModelView,涉及到Unity交互的都可以放在这里,例如相机类,UI类等,依旧是纯数据
  • Unity.Hotfix:对应Unity.Model的纯逻辑
  • Unity.HotfixView:对应Unity.ModeView的纯逻辑

然后是服务端,也发生了一些变化,完整的结构从外往里依次为

  • Zone:一个大区,可以包含多个物理服务器
  • Process:一个进程,一个物理服务器可以包含多个进程,可以直接对位计算机操作系统的进程概念
  • Scene:一个功能组件,一个进程可以包含多个Scene

最后献上一张ET6.0网络层架构图

功能总结

可以看出ET6相对于ET5增添了很多革命性的功能和组件,个人感觉最厉害的是ETTask的优化和完善,支持了WaitAll这种操作,进一步提升了程序性能

然后就是机器人模块,可以非常方便的利用机器人进行测试,能够发现非常多隐蔽的BUG

至于AI模块,仅仅从功能来说的话,个人感觉中规中矩,处理简单的AI逻辑可能还行,复杂起来终究不如可视化的行为树来的更直观,但是如果配合上机器人模块就有的玩了,可以非常方便的写出机器人AI,组队,打怪,商城购物等等。