ET客户端启动流程梳理

启动时, 客户端只有一个Fiber

我们就从Entry.cs脚本中StartAsync方法的最后一行FiberManager.Create方法开始看吧,这个方法内部有如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
fiber.ThreadSynchronizationContext.Post(async () =>
{
try
{
await EventSystem.Instance.Invoke<FiberInit, ETTask>((long)sceneType, new FiberInit() {Fiber = fiber});
tcs.SetResult(true);
}
catch (Exception e)
{
Log.Error($"init fiber fail: {sceneType} {e}");
}
});

这段代码会通过EventSystem触发参数为FiberInitSceneType为Main(因为调用FiberManager.Create方法的Entry传进来的SceneType就是Main)的InvokeHandler的Handler方法,也就是FiberInit_Main.cs脚本中的的Handle方法,并且将FiberInit参数传递到了这个方法内。

因为我们这里关心的是客户端部分,所以我们看await EventSystem.Instance.PublishAsync(root, new EntryEvent3());这一行,也就是说它会通过EventSystem触发参数是EntryEvent3SceneType是Main的Event的Run方法,也就是EntryEvent3_InitClient中的Run方法。

我们注意到,在该方法内先是给传进来的这个Scene类型的root字段添加了一些Component: GlobalComponentUIGlobalComponentUIComponentResourcesLoaderComponentPlayerComponentCurrentScenesComponent。然后根据加载的GlobalConfig中的AppType字段修改了传进来的root参数的SceneType字段,在Demo中该字段就是Demo。接着调用了await EventSystem.Instance.PublishAsync(root, new AppStartInitFinish());这一行,也就是通过EventSystem触发参数是AppStartInitFinishSceneType是Demo的Event的Run方法,也就是AppStartInitFinish_CreateLoginUI.cs中的Run方法。

到了AppStartInitFinish_CreateLoginUI.cs这里就不需要说太多了,顺着代码调用路径点下去就能找到UILoginEvent.cs这个脚本中的OnCreate方法,在这个方法的ui.AddComponent<UILoginComponent>();这一行触发了UILoginComponentSystem中的Awake方法,在这个Awake方法中,给登录按钮注册了OnLogin方法。由OnLogin方法我们执行到了LoginHelper.cs脚本中的Login方法,该方法要求你传一个类型为Scene的字段,这个字段就是从我们最一开始说的Entry.cs脚本中StartAsync方法的最后一行FiberManager.Create方法创建的那个Fiber里面的Root字段。LoginHelper.cs脚本中的Login方法中执行客户端向服务器发送登录请求,并等待服务器的回应继续执行之后的逻辑,也就是这一行long playerId = await clientSenderComponent.LoginAsync(account, password);,到此为止,客户端所有该做的事情就都做完了,现在客户端已经把请求发送给了服务端,等待着服务端的答复。

所有的Scene都是由Fiber创建出来的 在客户端有两个Scene或者叫Fiber在跑一个是Main 另一个是NetClient
在ClientSenderComponentSystem的LoginAsync方法中, 创建了一个新的Fiber, 这个Fiber创建后, FiberInit_NetClient被触发

若一个Entity上挂载了一个ProcessInnerSender组件, 那么它就具备了向其他Fiber发送消息的能力

ET服务端启动流程梳理

服务端的几个角色

  • Router
  • Realm
  • Gate 最终处理玩家数据的就是Gate

我们回到FiberInit_Main.cs这个脚本,这次我们要以EntryEvent2为线索来看一下服务端的启动流程,我们需要找到参数为EntryEvent2SecneType为MainAEvent,也就是EntryEvent2_InitServer。笔者直接把该类的Run方法贴在这里:

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
protected override async ETTask Run(Scene root, EntryEvent2 args)
{
switch (Options.Instance.AppType)
{
case AppType.Server:
{
// AppType 的默认值就是Server
int process = root.Fiber.Process;
StartProcessConfig startProcessConfig = StartProcessConfigCategory.Instance.Get(process);
if (startProcessConfig.Port != 0)
{
await FiberManager.Instance.Create(SchedulerType.ThreadPool, ConstFiberId.NetInner, 0, SceneType.NetInner, "NetInner");
}

// 根据配置创建纤程
// 应该是会创建12个Scene 这些Scene中有重复的
var processScenes = StartSceneConfigCategory.Instance.GetByProcess(process);
foreach (StartSceneConfig startConfig in processScenes)
{
await FiberManager.Instance.Create(SchedulerType.ThreadPool, startConfig.Id, startConfig.Zone, startConfig.Type, startConfig.Name);
}

break;
}
case AppType.Watcher:
{
root.AddComponent<WatcherComponent>();
break;
}
case AppType.GameTool:
{
break;
}
}

if (Options.Instance.Console == 1)
{
root.AddComponent<ConsoleComponent>();
}
}

ET服务端与客户端的通信流程

我们已经知道了客户端和服务端各自的启动流程了,客户端和服务端是从哪里建立起的连接呢?
就看一下 客户端怎么知道往哪个IP地址发请求

现在让我们回到LoginHelper.cs脚本中的Login方法的long playerId = await clientSenderComponent.LoginAsync(account, password);这一行。接下来我们要看一下,客户端是怎么把消息发出去的,服务端是怎么接收到来自客户端的消息、处理客户端的消息然后返回给客户端,客户端收到服务器返回的消息是怎么处理的以及客户端处理完服务器返回的消息之后又做了什么。本小节涉及到部分ET框架层面的实现。

流程
客户端发起连接请求->Router服务器返回Realm地址->客户端根据Realm地址向服务器发送申请->

EnterMap

所有

ET中的配置表

  • StartProcessConfig
  • StartMachineConfig
  • StartSceneConfig
  • StartZoneConfig

基本的继承结构

  • Fiber
  • TypeSystems
  • OneTypeSystems 里面维护着所有继承自SystemObject基类的类型

BaseAttribute (搞清楚这些Attribute的含义)

  • AIHandlerAttribute
  • CodeAttribute
  • ConfigAttribute
  • ConsoleHandlerAttribute
  • EnableClassAttribute
  • EntitySystemAttribute
  • EntitySystemOfAttribute
  • EventAttribute
  • HttpHandlerAttribute
  • InvokeAttribute
  • LSEntitySystemAttribute
  • LSEntitySystemOfAttribute
  • MessageAttribute
  • MessageHandlerAttribute
    • MessageLocationHandlerAttribute
  • MessageSessionHandlerAttribute
  • NumericWatcherAttribute
  • ResponseTypeAttribute
  • UIEventAttribute

EntitySystemSingleton

interface

环境为ET8.1的Demo,梳理点击地板控制角色移动的全流程

客户端发送给服务端的消息体是C2M_PathfindingResult

服务端返回给客户端的消息体是C2M_PathfindingResult

负责发送消息体的类是ProcessInnerSender,但是将消息体传递给ProcessInnerSender之前,需要先用A2NetClient_Message类包装一下,通过ProcessInnerSender类,将要发送的消息体装载到MessageQueue中

ProcessInnerSenderSystem脚本中的Reply方法

MessageQueue负责各个纤程之间的通讯,在这个Demo中看起来并没有区分客户端和服务端,或者说客户端和服务端在两个不同的纤程中,模拟了服务端和客户端分离的效果。

MessageObject是纤程(客户端、服务端)之间通信的消息体,

SystemObject
AwakeSystem
UpdateSystem

ICriticalNotifyCompletion接口的作用

在Init的Update中执行着

FiberManager.Instance.Update()

- this.mainThreadScheduler.Update()
    -  fiber.Update();
        - this.EntitySystem.Update();
            - iUpdateSystem.Run(component); -> 继承IUpdateSystem接口的UpdateSystem中实现了该Run方法
                - this.Update((T)o); -> 再由继承了UpdateSystem的对象实现Update方法

PathfindComponnetSystem

寻路算法的实现是在MoveHelper中的FindPathMoveToAsync中,该方法由C2M_PathfindingResultHandler中的Run方法调用,这些继承自MessageLocationHandler的Run方法统一由MessageLocationHandler的Handle方法调用,Handle方法又由MessageDispatcher中的Handle调用

驱动客户端Unit移动的逻辑看起来在MoveComponentSystem类中的MoveForward方法中,该方法由MoveTimer类中的Run方法调用

真正在前端做表现的是通过ChangePosition_SyncGameObjectPos

FiberManager

这是ET中一个比较重要的模块,

CodeTypes脚本的Awake方法中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public void Awake(Assembly[] assemblies)
{
Dictionary<string, Type> addTypes = AssemblyHelper.GetAssemblyTypes(assemblies);
foreach ((string fullName, Type type) in addTypes)
{
this.allTypes[fullName] = type;

if (type.IsAbstract)
{
continue;
}

// 记录所有的有BaseAttribute标记的的类型
object[] objects = type.GetCustomAttributes(typeof(BaseAttribute), true);

foreach (object o in objects)
{
this.types.Add(o.GetType(), type);
}
}
}

CodeLoader中的Start方法如下:

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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
public void Start()
{
if (!Define.IsEditor)
{
byte[] modelAssBytes = this.dlls["Unity.Model.dll"].bytes;
byte[] modelPdbBytes = this.dlls["Unity.Model.pdb"].bytes;
byte[] modelViewAssBytes = this.dlls["Unity.ModelView.dll"].bytes;
byte[] modelViewPdbBytes = this.dlls["Unity.ModelView.pdb"].bytes;
// 如果需要测试,可替换成下面注释的代码直接加载Assets/Bundles/Code/Unity.Model.dll.bytes,但真正打包时必须使用上面的代码
//modelAssBytes = File.ReadAllBytes(Path.Combine(Define.CodeDir, "Unity.Model.dll.bytes"));
//modelPdbBytes = File.ReadAllBytes(Path.Combine(Define.CodeDir, "Unity.Model.pdb.bytes"));
//modelViewAssBytes = File.ReadAllBytes(Path.Combine(Define.CodeDir, "Unity.ModelView.dll.bytes"));
//modelViewPdbBytes = File.ReadAllBytes(Path.Combine(Define.CodeDir, "Unity.ModelView.pdb.bytes"));

if (Define.EnableIL2CPP)
{
foreach (var kv in this.aotDlls)
{
TextAsset textAsset = kv.Value;
RuntimeApi.LoadMetadataForAOTAssembly(textAsset.bytes, HomologousImageMode.SuperSet);
}
}
this.modelAssembly = Assembly.Load(modelAssBytes, modelPdbBytes);
this.modelViewAssembly = Assembly.Load(modelViewAssBytes, modelViewPdbBytes);
}
else
{
if (this.enableDll)
{
byte[] modelAssBytes = File.ReadAllBytes(Path.Combine(Define.CodeDir, "Unity.Model.dll.bytes"));
byte[] modelPdbBytes = File.ReadAllBytes(Path.Combine(Define.CodeDir, "Unity.Model.pdb.bytes"));
byte[] modelViewAssBytes = File.ReadAllBytes(Path.Combine(Define.CodeDir, "Unity.ModelView.dll.bytes"));
byte[] modelViewPdbBytes = File.ReadAllBytes(Path.Combine(Define.CodeDir, "Unity.ModelView.pdb.bytes"));
this.modelAssembly = Assembly.Load(modelAssBytes, modelPdbBytes);
this.modelViewAssembly = Assembly.Load(modelViewAssBytes, modelViewPdbBytes);
}
else
{
Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies();
foreach (Assembly ass in assemblies)
{
string name = ass.GetName().Name;
if (name == "Unity.Model")
{
this.modelAssembly = ass;
}
else if (name == "Unity.ModelView")
{
this.modelViewAssembly = ass;
}

if (this.modelAssembly != null && this.modelViewAssembly != null)
{
break;
}
}
}
}

(Assembly hotfixAssembly, Assembly hotfixViewAssembly) = this.LoadHotfix();

World.Instance.AddSingleton<CodeTypes, Assembly[]>(new[]
{
typeof (World).Assembly, typeof (Init).Assembly, this.modelAssembly, this.modelViewAssembly, hotfixAssembly,
hotfixViewAssembly
});

IStaticMethod start = new StaticMethod(this.modelAssembly, "ET.Entry", "Start");
start.Run();
}

如果你是Editor模式下的话,你应该会走到this.enableDll为true的分支
通过执行CodeLoader中的Start方法,被遍历到的程序集有World类所在的程序集Unity.CoreInit类所在的程序集Unity.Loader、modelAssemBlyUnity.Model,modelViewAssemblyUnity.ModelView,hotfixAssemblyUnity.Hotfix,hotfixViewAssemblyUnity.HotfixView

代入一下就是,通过AddSingleton方法,创建了CodeTypes实例,并将上面提到的程序集作为参数传入CodeType的Awake方法中.

然后在modelAssemblyUnity.Model中,找到ET.Entry类中的Start方法,并执行该方法,然后在该方法中,执行了CodeTypes.Instance.CreateCode方法,该方法会在上面收集到的程序集中,找到所有被标记了CodeAttribute属性的类,并将这些类实例化出来,这些类分别是

  • EntitySystemSingleton
  • MessageDispatcher MessagePatcher中的Awake方法中实例化了所有被标记为MessageHandlerAttribute属性的类
  • EventSystem
  • HttpDispatcher
  • LSEntitySystemSingleton
  • AIDispatcherComponent
  • ConsoleDispatcher
  • MessageSessionDispatcher
  • NumericWatcherComponent
  • UIEventComponent
    也就是说,上面这十个类,通过执行ET的Entry方法之后就已经被创建出来了.

项目组织

基本上重要的脚本都是在Unity工程下面,在ET.sln视角下,看到的Unity外面几个目录下有很多代码,这些代码都是类似超链接的东西连接到Unity工程中的.

ET中的HotFix、HotFixView、Model、ModelView四个程序集都是以dll的方式加载到内存中运行的,因此如果你修改了这四个程序集里面的代码,你可能需要重新遍历一下才能把更新的内容放进程序集中。

参考资料

ET8框架的讲解视频
一篇将服务器架构历史的博客
一篇介绍C#和ET异步方法的帖子