前言

前阵子猫大狠狠的更新了一波ET6.0,然后说已经稳定可用了,只是机器人什么的都还没有加入,准备今天研究下新的ET6.0,感受下经过项目千锤百炼的优秀框架的思路。

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

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

基础概念

此部分内容参考: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个进程,然后动态分配副本跟分线到进程上.
比如很多单人副本,没必要一下子开很多进程来支持。需要的时候找一个负载低的进程动态创建一个就行了,用完就可以回收了

ET6.0客户端架构

客户端

客户端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.lus本人持观望态度,因为对ILRuntime比较了解,并且与ET相性较好(基本上不需要跨域继承,仅这一点可以避免很多坑)个人选择仍然是ILRuntime

流程

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

服务端初始化流程

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

1
2
Game.EventSystem.Add(typeof(Game).Assembly);
Game.EventSystem.Add(DllHelper.GetHotfixAssembly());

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

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

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

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

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

注意第7行有两个作用,第一是为NLog配置Log格式,第二是Game.Scene会执行创建基础Scene的操作。

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
public class AppStart_Init: AEvent<EventType.AppStart>
{
protected override async ETTask Run(EventType.AppStart args)
{
Game.Scene.AddComponent<ConfigComponent>();
//注入加载所有配置表方法,用于ConfigComponent.Instance.LoadAsync中调用
ConfigComponent.GetAllConfigBytes = LoadConfigHelper.LoadAllConfigBytes;
await ConfigComponent.Instance.LoadAsync();
//读取StartProcessConfig中id为Game.Options.Process(这里值为1)的整行配置
//ET6.0由于使用了protobuffer作为导表工具,所以请去Excel文件夹查看原数据
StartProcessConfig processConfig = StartProcessConfigCategory.Instance.Get(Game.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>();
Game.Scene.AddComponent<NetInnerComponent, IPEndPoint>(processConfig.InnerIPPort);
//同上,用于创建此Process Scene包含的所有Scene
//查看StartSceneConfig得知添加了所有Scene,所以官方的Demo仍然是AllServer的
var processScenes = StartSceneConfigCategory.Instance.GetByProcess(Game.Options.Process);
foreach (StartSceneConfig startConfig in processScenes)
{
await SceneFactory.Create(Game.Scene, startConfig.SceneId, startConfig.Zone, startConfig.Name, startConfig.Type, startConfig);
}
}
}

对于创建Scene的代码如下所示,会根据Scene类型不同而添加不同的组件,需要注意的是,这里的StartZoneConfig表中内容是MongoDB数据库地址以及数据库名,意为此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
public static async ETTask<Scene> Create(Entity parent, long id, int zone, string name, SceneType sceneType, StartSceneConfig startSceneConfig = null)
{
await ETTask.CompletedTask;
Scene scene = EntitySceneFactory.CreateScene(id, zone, sceneType, name);
scene.Parent = parent;
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>();
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
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<ResourcesComponent>();
ResourcesComponent.Instance.LoadBundle("config.unity3d");
Game.Scene.AddComponent<ConfigComponent>();
ConfigComponent.GetAllConfigBytes = LoadConfigHelper.LoadAllConfigBytes;
await ConfigComponent.Instance.LoadAsync();
ResourcesComponent.Instance.UnloadBundle("config.unity3d");

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

Game.Scene.AddComponent<NetThreadComponent>();
ResourcesComponent.Instance.LoadBundle("unit.unity3d");
Scene zoneScene = await SceneFactory.CreateZoneScene(1, 1, "Game");
//显示我们的登陆界面
await Game.EventSystem.Publish(new EventType.AppStartInitFinish() { ZoneScene = zoneScene });
}
}

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

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

zoneScene.AddComponent<NetKcpComponent>();
zoneScene.AddComponent<UnitComponent>();

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

return zoneScene;
}

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

双端通信流程

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

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

1
2
3
4
5
//这里的address是我们Realm服务器的外网地址和端口
using (Session session = zoneScene.GetComponent<NetKcpComponent>().Create(NetworkHelper.ToIPEndPoint(address)))
{
r2CLogin = (R2C_Login) await session.Call(new C2R_Login() { Account = account, Password = "111111" });
}

Session的Send函数,注意当Service是内网类型时,会比外网类型多在包头写入一个long类型的的actor id

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;
}
}
}

然后消息来到服务器这边,注意G2R这个消息是一个内网的Actor消息

1
2
3
4
5
6
//从所有Gate服务器随机分配一个
StartSceneConfig config = RealmGateAddressHelper.GetGate(session.DomainZone());
//Log.Debug($"gate address: {MongoHelper.ToJson(config)}");
// 向gate请求一个key,客户端可以拿着这个key连接gate
G2R_GetLoginKey g2RGetLoginKey = (G2R_GetLoginKey) await ActorMessageSenderComponent.Instance.Call(
config.SceneId, new R2G_GetLoginKey() {Account = request.Account});

然后会来到这里,初次处理会因为我们没有为其创建Session而新建一个Session,IP和端口号就是我们配置的进程IP和内网端口号

1
2
3
4
5
6
7
8
9
10
11
// 内网actor session,channelId是进程号
public static Session 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模式)

获取到loginKey之后就会给客户端回复消息,也就是会把分配的Gate的IP和端口号以及GateId返回给客户端

1
2
3
4
5
6
7
8
9
10
11
response.Address = config.OuterIPPort.ToString();
response.Key = g2RGetLoginKey.Key;
//注意这里的GateId是我们用StartSceneConfig数据自动计算出来的,代码如下
//public override void EndInit()
//{
// this.Type = EnumHelper.FromString<SceneType>(this.SceneType);
// InstanceIdStruct instanceIdStruct = new InstanceIdStruct(this.Process, (uint) this.Id);
// this.SceneId = instanceIdStruct.ToLong();
//}
response.GateId = g2RGetLoginKey.GateId;
reply();

然后客户端拿着这个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});

然后服务器接收,处理后告知客户端结果,此次交互结束