2023年4月

来到通辽草原深处的露天煤矿进行技术交流,尽管草场还没有泛绿,但是仍然很美丽。来的时候,沙尘暴刚刚逃离,蓝天白云触手可及。走的时候,春风拂过,感受一丝暖意,行驶途中绵绵细雨滑落在车窗上,远处圆润山峰和路两侧的积雪还未融化,到了机场就感受到了雪花的漂落。四季分明又同日轮回。

交流“智能化”矿山建设,显得那样虚无缥缈而又力不从心。在矿上主要领导那获悉,他们也在反思“智能化”矿山建设,到底如何规划?如何建设?建成什么样?自动驾驶的效率只有人工驾驶效率的80%;无人值守也不能完全替代人,相应的又增加了运维的成本。诸如此类,等等。

他们集团的信息化公司及研究院在一个矿区正在做智能矿山的统一平台的 试点,项目投资大概1亿,包括了矿山的各方面的内容,显示得我们这次交流有点多余。他们2年前开始做智能矿山的规划,有很多煤矿行业专家的加持,他们的描述是非常完美,但是去年底应该上线的平台,现在还没有看到影子。他们确实有很多场景需求,但是被所谓的“智能化”项目搞的有点谨慎和被动。

我们看到了一丝合作的机会,“智能化”建设谈何容易,“智能化”之后又该做些什么呢?难道“智能化”不是一个持续的过程嘛,持续精准化管理的过程嘛。从业15年左右,单从技术和业务层面看,大部分甲方连信息化都搞不好、连选择优秀乙方都非常困惑。

做信息化也好、智能化也好,做个绚丽的大屏,供领导做决策,领导真的有很多要决策嘛,相信领导的决策早早就烂熟于心了。相反,我们更应该强调“以人为本”的信息化、智能化建设,人员一进矿区,任务、关键点、指标、健康等就是透明化的,ChatGPT实时在线咨询、决策、迭代总结。

不可否认“智能化”建设存在一些问题,但是仍然是持续前进的方面,也不能因为项目成败、资金大小、思维不同等否定现在做的工作。


物联网&大数据技术 QQ群:54256083

物联网&大数据项目 QQ群:727664080
QQ:504547114
微信:wxzz0151
博客:https://www.cnblogs.com/lsjwq
微信公众号:iNeuOS

Release版VC6 MFC程序 程序正常退出时得到一个如下异常调用栈:

0:000>kb
# ChildEBP RetAddr Args to Child
WARNING: Frame IP not
inany known module. Following frames may be wrong.00 0019eb94 76124f2f 00c3afc8 0019ebdc 0019ebb8 0x8c73d01 01 0019ebdc 0079451a 00c3afc8 73d82ec0 00000001 USER32!IsZoomed+0xaf 02 0019ebe4 73d82ec0 00000001 73d35d1c 00c3afc8 JXC_MED!CMainFrame::`scalar deleting destructor'+0x8 03 0019ebec 73d35d1c 00c3afc8 00000000 73d35c0f MFC42!CControlFrameWnd::PostNcDestroy+0xb 04 0019ec2c 73d31e1d 00c3afc8 00c3afc8 00cae670 MFC42!CWnd::OnNcDestroy+0x10d 05 0019eca4 73d31b07 00000082 00000000 73dca448 MFC42!CWnd::OnWndMsg+0x2f4 06 0019ecc4 73d31a78 00000082 00000000 00000000 MFC42!CWnd::WindowProc+0x22 07 0019ed24 73d319d0 00c3afc8 00000000 00000082 MFC42!AfxCallWndProc+0x91 08 0019ed44 73dbe00c 00031002 00000082 00000000 MFC42!AfxWndProc+0x34 09 0019ed70 76135cab 00031002 00000082 00000000 MFC42!AfxWndProcBase+0x390a 0019ed9c 761267bc 73dbdfd300031002 00000082 USER32!_InternalCallWinProc+0x2b0b 0019ee80 7612635a 73dbdfd300000000 00000082 USER32!UserCallWinProcCheckWow+0x3ac0c 0019eee4 76133f87 01225f9000000000 00000082 USER32!DispatchClientMessage+0xea0d 0019ef28 77e62add 0019ef4400000020 0019efac USER32!__fnNCDESTROY+0x370e 0019ef60 73d364c500031002 009a034c 009a1f14 ntdll!KiUserCallbackDispatcher+0x4d0f 0019ef7400794760 009a8968 00000000 00c3afc8 MFC42!CWnd::DestroyWindow+0x31 10 0019efb8 73d38fb4 00c3afc8 00c3afc8 00794965 JXC_MED!CMainFrame::DestroyWindow+0x13c [MainFrm.cpp @ 852]11 0019efd0 02b056f3 00000000 00794a45 00c3afc8 MFC42!CFrameWnd::OnClose+0xf5 12 0019efec 007949a3 00000000 73dca64c 00000001 ToolLib!CTFrameWnd::OnClose+0x13 13 0019f044 73d31e1d 00c3afc8 00c3afc8 00cae670 JXC_MED!CMainFrame::OnClose+0x3e [MainFrm.cpp @ 1133]

顶层两个函数调用帧都是错的,地址怪异 ,没有函数名子通过反汇编校验,根据帧返回地址是
0079451a
判断出具体函数源码位置,
不及格的程序员-八神

JXC_MED!CMainFrame::`scalar deleting destructor':
00794512 56push    esi00794513 8bf1           mov     esi, this(ecx)00794515 e814000000     call    JXC_MED!CMainFrame::~CMainFrame (79452e) //顶层栈实际上是在调用此析构函数中的代码
0079451a f644240801 test
byte ptr [esp+8], 10079451f7407 je JXC_MED!CMainFrame::`scalar deleting destructor'+0x16 (794528) 00794521 56push esi00794522 e8338c0000 call JXC_MED!operator delete(79d15a)00794527 59 pop this(ecx)007945288bc6 mov eax, esi
0079452a 5e pop esi
0079452b c20400 ret
4JXC_MED!CMainFrame::~CMainFrame:
0079452e b806828200 mov eax, 828206h
00794533 e8489b0000 call JXC_MED!__EH_prolog (79e080)00794538 51 push this(ecx)00794539 51 push this(ecx)
0079453a
56push esi
0079453b 8bf1 mov esi,
this(ecx)
0079453d
57push edi
0079453e 8975f0 mov dword ptr [ebp
-10h], esi00794541c70668a98800 mov dword ptr [esi], 88A968h00794547 8b8e58040000 mov this (ecx), dword ptr [esi+458h]
0079454d c745fc06000000 mov dword ptr [ebp
-4], 6 00794554 85c9 test this (ecx), this(ecx)00794556 7407 je JXC_MED!CMainFrame::~CMainFrame+0x31(79455f)00794558 8b01 mov eax, dword ptr [this(??) (ecx)] //通过指令飞越技术查出是这里出错,代码对应 delete loadWareWork;
0079455a 6a01 push
10079455c ff5004 call dword ptr [eax+4
]
0079455f a150889a00 mov eax, dword ptr ds:[009A8850h]
00794564 83780400 cmp dword ptr [eax+4], 0 00794568 741f je JXC_MED!CMainFrame::~CMainFrame+0x5b (794589)
00794623 c3 ret
CMainFrame::~CMainFrame()
{
if(loadWareWork) deleteloadWareWork; //这个被重复删了 导至出错if (gpDb->IsOpen())
{
WriteOpLog(gpDb, gstrOprCode, GSP_OPLOG_MODULEID, "2", "退出系统");
}
}

在内存窗口中查看此变量周围已经被16进制 feee feee填充,看我博文
Microsoft平台开发,内存特征码识别
有讲述通过16进制分辨内存数据,说明此内存区已经被heap free.

图1:指令飞越技术重现@eip内存地址
0x8c73d01

解决办法,将这段代码删掉,此处多余的代码,理由是此类型是CWnd子类。
不及格的程序员-八神
创建时使用WS_CHILD类型创建,它会随着父窗体自动DESTORY, 并且子类重载函数 PostNcDestroy 并 delete this了,不需要这里再次delete。

1 连接过程 - 握手

传统的 C/S 架构下,Client 和 Server 通常会建立一条抽象的 Connection,用来进行两端的通信。
UE 的官方文档中提供了 Client 连接到 Server 的
示例
,简单来说分为如下几步:

  • 打包构建好 Client 和 Server 进程
  • 启动 Server 进程,启动参数为
    ./Binaries/Win64/<PROJECT_NAME>Server.exe -log
  • 启动 Client 进程,启动参数为
    ./Binaries/Win64/<PROJECT_NAME>Client.exe 127.0.0.1:7777 -WINDOWED -ResX=800 -ResY=450

默认情况下,专用服务器在 localhost Ip 地址(
127.0.0.1
)的端口
7777
处监听。可以添加命令行参数
-port=<PORT_NUMBER>
,更改专用服务器的端口。如果要更改服务器正在使用的端口,则还需要更改将客户端连接到服务器时的端口。

1.1 启动 Server

Client 连接到 Server 的前提是 Server 启动完毕,监听完毕端口,准备好接收连接了。UE 中监听的核心接口如下:

bool UWorld::Listen( FURL& InURL );

其接口核心参数为一个
FURL
,UE 中会根据启动参数和配置等构建一个 FURL,其结构如下 (只展示部分变量):

//URL structure.  
USTRUCT()  
struct  FURL  
{  
   // Optional hostname, i.e. "204.157.115.40" or "unreal.epicgames.com", blank if local.  
   UPROPERTY()  
   FString Host;  
   // Optional host port.  
   UPROPERTY()  
   int32 Port;  
   // Map name, i.e. "SkyCity", default is "Entry".  
   UPROPERTY()  
   FString Map;  
   // Options.  
   UPROPERTY()  
   TArray<FString> Op;  
}

可以看到里面有关键的 Host 和 Port 等信息。
Listen 接口具体做了什么呢?

  • 通过
    UEngine:: CreateNamedNetDriver
    创建 NetDriver,主要驱动网络同步
  • UNetDriver::InitListen
    解析 FURL,监听端口
    网络相关的流程在这里开始就交付给了
    UNetDriver
    ,显然它是一个比较重要的网络管理类,这里简单看下其结构

image.png

可以看到主要负责:

  • Server 端初始化监听端口
  • 初始化连接
  • 管理 UNetConnection,UNetConnection 显然就是抽象出来的连接
    • 这里有 ServerConnection 和 ClientConnections,当拥有 ServerConnection 时表示当前是 Client 端,拥有 ClientConnection 时表示当前时 Server 端

同时其派生了不同的类,如:

  • UDemoNetDriver:用来支持游戏录像和回放(类似守望先锋的击杀回放)
  • UWebSocketNetDriver:用于实现 WebSocket 协议的网络通信。WebSocket 是一种基于 TCP 的网络协议,允许在客户端和服务器之间进行双向通信,可以实现实时通信和数据传输。通过使用
    UWebSocketNetDriver
    ,可以在 UE4中使用 WebSocket 协议进行网络通信
  • UIpNetDriver:用于实现基于 IP(Internet Protocol)的网络通信
    Server 端完整的绑定端口监听的流程大致如下:

image.png

可以看到其实和普通的 C++ 创建 TCP C/S 连接类似,最终都是创建一个 Socket 并且 Bind 到指定端口。

1.2 Client 初始化

客户端启动之后,也是类似的流程,创建 NetDriver 驱动网络相关的流程,对比 Server,其多了一个
UPendingNetGame
的对象。
UPendingNetGame
类是一个用于处理网络游戏连接过程的类。它在客户端尝试连接到服务器时创建,并在连接成功或失败后销毁。

关于 UPendingNetGame

  1. 用处:
    UPendingNetGame 主要负责处理客户端与服务器之间的连接流程。主要功能包括:
    a. 处理连接请求:客户端向服务器发起连接请求时,UPendingNetGame 负责处理这个请求,包括创建套接字连接、发送握手请求等。
    b. 加载关卡:在连接过程中,若服务器需要客户端加载一个关卡,UPendingNetGame 负责处理这个请求,包括加载关卡资源、同步关卡状态等。
    c. 状态同步:在连接过程中,UPendingNetGame 负责与服务器进行状态同步,包括玩家数据、游戏规则等。
    d. 错误处理:若连接过程中出现错误,如超时、被拒绝等,UPendingNetGame 负责处理这些错误,通知用户并做出相应处理

  2. 创建与销毁:
    a. 创建:当客户端尝试连接到服务器时,会创建一个 UPendingNetGame 实例。
    b. 销毁:当客户端成功连接到服务器并完成状态同步后,UPendingNetGame 完成其任务并被销毁。如果连接过程中出现错误,如超时、被拒绝等, UPendingNetGame 也会在处理完错误后被销毁

Client 的初始化流程大致如下:

  • UEngine::Browse 解析 FURL
  • UPendingNetGame::InitNetDriver 初始化网络驱动
  • UIpNetDriver::InitConnect 初始化连接
    • 创建 UIpNetConnection
    • UIpNetConnection::InitLocalConnection 初始化连接信息
  • 调用 Connection 的 Handler 的 BeginHandshaking 发握手包
    其大致执行堆栈如下:

1.3 Server 收包

Server 端上 PacketHandler 处理的数据包的结构如下:

/**  
 * Represents a view of a received packet, which may be modified to update Data it points to and Data size, as a packet is processed.
 * Should only be stored as a local variable within functions that handle received packets. 
 **/
 struct FReceivedPacketView  
{  
   /** View of packet data, with Num() representing BytesRead - can reassign to point elsewhere, but don't use to modify packet data */  
   TArrayView<const uint8>       Data;  
   /** Receive address for the packet */  
   TSharedPtr<FInternetAddr>  Address;  
   /** Error if receiving a packet failed */  
   ESocketErrors           Error;  
};

1.3.1 收包流程

Server 监听完端口之后就要处理客户端发过来的连接请求,由于是 UDPSocket,所以只需要简单的 Bind + RecvFrom 就能接收数据了。其主流程主要由 NetDriver 的 TickDispatch 驱动,如下:

  • UIpNetDriver::TickDispatch
  • FPacketIterator (UIpNetDriver*) ++,UE 实现了一个 Iterator 遍历消费 Socket 的 Packet
  • UIpNetDriver::AdvanceCurrentPacket
  • FPacketIterator::ReceiveSinglePacket
    迭代器收包
    • UIpNetDriver 中检查 SocketReceiveThreadRunnable
      如果存在这个线程(默认情况下应该是没开的,这个时候就相当于这个线程的逻辑在 GameThread 跑了)
      ,从 SocketReceiveThreadRunnable->ReceiveQueue 这个 Packet 队列弹出,这里主要是区分用 GameThread 还是用 SocketReceiveThread 来取包。
      • FReceiveThreadRunnable::Run
        本身是生产者,可以将 ReceiveQueue 理解为一个数据中间件,IpNetDriver 的 TickDispatch 则是消费者,一直消费 ReceiveQueue 的数据
      • ReceiveQueue 在
        SocketReceiveThreadRunnable
        线程中一直使用
        FSocket::RecvFrom
        (抽象接口,大部分情况下都是为
        FSocketBSD::RecvFrom
        )接收数据,其底层实现就是使用
        recvfrom
        这个操作系统接口

image.png

SocketReceiveThreadRunnable 默认是没有打开的,官方说明如下

// If the cvar is set and the socket subsystem supports it, create the receive thread.
CVarNetIpNetDriverUseReceiveThread.GetValueOnAnyThread() != 0 && SocketSubsystem->IsSocketWaitSupported()

1.3.2 处理客户端连接

首先 Server 需要检查这个 Packet 是否已经有连接了,这里引出一个问题,Server 端是如何管理和查询 Connection 的?主要是通过解析 Packet 的 Address,在
UNetDriver
中查询缓存地址映射关系。

// 声明
class UNetDriver {
	TMap<TSharedRef<const FInternetAddr>, UNetConnection*, FDefaultSetAllocator, FInternetAddrConstKeyMapFuncs<UNetConnection*>> MappedClientConnections;
}
// 使用
const TSharedRef<const FInternetAddr> FromAddr = ReceivedPacket.Address.ToSharedRef();
UNetConnection** Result = MappedClientConnections.Find(FromAddr);

接下来是处理 Packet

  • TickDispatch 正常消费到 Packet 之后,要确定 Packet 该丢给哪一层
  • 由于未建立连接,下一层交由
    UIpNetDriver::ProcessConnectionlessPacket
    • PacketHandler::IncomingConnectionless
      校验 Packet 正确性
      • PacketHandler::Incoming_Internal
        • 遍历
          HandlerComponent
          对包进行处理
        • StatelessConnectHandlerComponent::IncomingConnectionless
          处理无连接的 Packet
          • StatelessConnectHandlerComponent::ParseHandshakePacket
            检查是否为握手包,根据 Packet 时间戳确定是否是 bInitialConnect
          • 握手包回一个 Challenge 包
            StatelessConnectHandlerComponent::SendConnectChallenge
    • StatelessConnectHandlerComponent::HasPassedChallenge
      校验
    • 检查是否是重连,处理重连逻辑
    • 创建
      UIpConnection
    • UIpConnection::InitRemoteConnection
      这里
      初始化连接,给客户端发送 NMT_Hello 包,开始正式的握手流程
      ,这里开始有一个状态机来驱动连接过程
      • UNetConnection 的 ClientLoginState 初始化为
        EClientLoginState::Type::LoggingIn
    • FNetworkNotify::NotifyAcceptedConnection
      通知接收连接
    • UNetDriver::AddClientConnection
      添加
      UIpConnection

关于 Challenge
Challenge 消息是 Unreal Engine 4(UE4)中的一种网络消息,用于在客户端和服务器之间进行身份验证。在 UE4 中,客户端和服务器之间的通信是通过一种称为 Unreal Network Protocol(简称 UNet)的协议实现的。UNet 通过在客户端和服务器之间发送各种类型的网络消息来管理通信。

在 UE4 中,当客户端第一次连接到服务器时,服务器会向客户端发送一个 Challenge 消息,其中包含一个随机生成的 Challenge 令牌。客户端必须将这个 Challenge 令牌使用预共享密钥(PSK)进行签名,并将签名后的结果发送回服务器。服务器会验证签名是否正确,如果正确,则表示客户端是一个合法的用户,并将向客户端发送一个 ChallengeAck 消息,其中包含服务器的签名和一些其他的验证信息。客户端必须验证 ChallengeAck 消息是否正确,并将消息发送回服务器,以便进行最终的身份验证。

关于 NMT_Hello
可以看到收到客户端连接包之后,除了回复正常的 Ack 包之外,会主动给客户端发送一个 NMT_Hello 包,这里的 NMT_Hello 是一个枚举。UE4 中 NMT 开头的枚举是指 NetworkMessageTypes,是 Unreal Engine 4(UE4)中用于管理网络消息类型的一组枚举。在 UE4 中,网络消息是通过一种称为 Unreal Network Protocol(简称 UNet)的协议进行传输和管理的。UNet 通过在客户端和服务器之间发送各种类型的网络消息来管理通信。

通过接收不同的 NMT 消息,从而在客户端服务器连接过程中,不同阶段执行不同的操作,比如当前收到这个消息应该加载地图或者创建 PlayerController。

1.4 握手小结

至此大致梳理完了 Client 和 Server 的握手流程:

  • 创建网络驱动 UNetDriver
  • Server 端 Listen
  • Client 端先创建 UIpConnection 发起连接
  • Server 端接收连接,回复 ConnectChallenge 包
  • Client 收包,回复 ChallengeResponse 包
  • Server 回复 ChallengeAck
  • 握手完毕
    其中重点内容主要有:
  • UNetDriver 是网络同步核心,用于驱动网络同步
  • Client 会有一个
    UPendingNetGame
    在正式连接前驱动握手过程
  • Client 会先创建 Connection,Server 收到后才创建对应的 Connection,Connection 用于收发握手过程中的数据包
  • Server 和 Client 收包底层使用 Connection 的 PacketHandler
  • 握手过程主要利用
    PacketHandler
    的 HandlerComponent 中的
    StatelessConnectHandlerComponent
    ,其负责整个握手过程,此外 PacketHandler 的 HandlerComponent 可以挂载各种组件来支持对数据包的处理,比如 RSA,加密解密等

    双方完整握手的流程如下:

new.png

1.5 QA

1.5.1 丢包处理

握手过程中显然有丢包的可能,在 CS 握手过程中,大致发送的 Packet 如下:
image.png

Client 主要发送两个包,Handshake 和 ChallengeResponse,当 Client 没有收到回应时,对应阶段在
StatelessConnectHandlerComponent::Tick
都会有一个重发机制。参考代码如下:

void StatelessConnectHandlerComponent::Tick(float DeltaTime)  
{  
   if (Handler->Mode == Handler::Mode::Client)  
   {  
	   // ... 省略一些代码
	 if (LastSendTimeDiff > 1.0)  
	 {  
		if (State == Handler::Component::State::UnInitialized)  
		{  
		   NotifyHandshakeBegin();  
		}  
		else if (State == Handler::Component::State::InitializedOnLocal && LastTimestamp != 0.0)  
		{  
		   SendChallengeResponse(LastSecretId, LastTimestamp, LastCookie);  
		}  
	 }  
   }

1.5.2 连接过程用到了哪些关键 Class

大致如下:

handshakeuml.png

2 连接过程 - Enter Game

握手完毕后就要准备一些 Gameplay 层的相关操作,比如加载地图等,Packet 对于应用层还是太底层了,UE 为此引入了 Bunch 和 Channel 的概念

2.1 Bunch

2.1.1 Bunch 和 Packet 的区别

首先 Bunch 和 Packet 的关系如下:

  1. Bunch:
    Bunch是UE4中的一个基本网络数据单位
    。它可以被看作是一组数据的集合,这些数据代表了某个特定时刻的游戏状态变化。Bunch充当了一种中介,将游戏的状态信息打包成可以在网络上发送和接收的格式。它包含了一些关于对象、事件和属性的信息,以及一些控制网络通信的元数据。
  2. Packet:
    Packet是一个更大的网络数据单位
    ,用于在网络上实际传输数据。
    一个Packet通常包含多个Bunch
    ,以及其他一些网络层所需的信息,如包序号、时间戳等。Packet在网络上发送时,会被分割成更小的数据包,以适应各种网络环境和传输协议。
    Bunch和Packet之间的关系是层次性的。Bunch负责打包游戏状态的变化,而Packet负责在网络上传输这些Bunch。在数据传输过程中,
    Bunch被组合成Packet
    ,Packet在发送端被编码为可以在网络上传输的二进制数据,然后在接收端被解码还原为Bunch,以便在游戏中应用状态变化。

image.png|325

2.1.2 Bunch 的结构

Bunch 分为 FInBunch 和 FOutBunch,根据这个名字可以看出分别对应收到的 Bunch 结构和 发送的 Bunch 结构,其继承链如下:

image.png

FInBunch 的结构如下:

class ENGINE_API FInBunch : public FNetBitReader  
{  
public:  
// 省略一些字段
   int32           PacketId;  // Note this must stay as first member variable in FInBunch for FInBunch(FInBunch, bool) to work  
   FInBunch *       Next;  
   UNetConnection *   Connection;   // 属于哪个 Connection
   int32           ChIndex;  // channel 的下标
   int32           ChType;   // channel 的类型
   FName           ChName;  // channel 的名称
   int32           ChSequence;  // Channel 的 Seqid
   uint8           bOpen:1;   // 是否是 Channel 的首包
   uint8           bClose:1;  // 是否是 Channel 的结束包
   uint8           bDormant:1;                // 是否处于休眠
   uint8           bIsReplicationPaused:1;       // 复制同步是否被暂停了
   uint8           bReliable:1;         // 是否为可靠的 Bunch
   uint8           bPartial:1;                // 该 Bunch 是否被拆分
   uint8           bPartialInitial:1;       // 是不是分片传输中的第一个 Bunch
   uint8           bPartialFinal:1;         // 是不是分片传输中的最后一个 Bunch
}

FOutBunch 的结构如下:

class ENGINE_API FOutBunch : public FNetBitWriter  
{  
public:  
// 省略一些字段
   FOutBunch *             Next;  
   UChannel *          Channel;  
   double             Time;  
   int32              ChIndex;  
   int32              ChType;  
   FName              ChName;  
   int32              ChSequence;  
   int32              PacketId;  
   uint8              ReceivedAck:1;  // 标记这个数据包是否已经被确认,以避免重复发送
   uint8              bOpen:1;  
   uint8              bClose:1;  
   uint8              bDormant:1;  
   uint8              bReliable:1;  
   uint8              bPartial:1;             // Not a complete bunch  
   uint8              bPartialInitial:1;    // The first bunch of a partial bunch  
   uint8              bPartialFinal:1;         // The final bunch of a partial bunch  
}

Bunch 的信息中,除了一些分包相关的信息,最主要的便是 Channel 相关的信息了,比如这个 Bunch 属于哪个 Channel?Channel 的类型是什么?那么什么是 Channel ?其用处是什么?

2.2 Channel 定义

UE 中,Channel 主要分为三种类型:

  • ActorChannel:
    用于在服务器和客户端之间同步Actor状态的通道
    。它负责在网络上移动、旋转、缩放等操作,并确保所有客户端都具有相同的Actor状态。它还负责同步Actor的变量和属性。
  • ControlChannel:一个特殊类型的网络通道,
    主要负责处理底层的网络连接和控制消息
    。与其他类型的通道(如UActorChannel)主要用于游戏数据传输不同,UControlChannel处理的消息与游戏逻辑关系较少,
    主要用于维护网络连接状态、通知连接事件以及传输核心控制信息
    。ControlChannel 的一些职责示例如下:
  1. 连接建立和断开
    :UControlChannel会处理网络连接建立和断开的消息。例如,当客户端与服务器建立连接时,UControlChannel会发送和接收连接请求和响应,以便双方建立通信。同样,当连接断开时,UControlChannel会负责发送断开通知,通知另一方连接已关闭。
  2. 心跳检测
    :为了确保连接保持活跃,UControlChannel会定期发送和接收心跳消息。这些消息用于检测双方是否仍在线,以便在一方掉线时及时处理连接断开事件。
  3. 通道管理
    :UControlChannel负责处理通道的打开和关闭。例如,当需要创建一个新的UActorChannel以传输游戏对象数据时,UControlChannel会发送相应的打开通道请求。同样,当某个通道不再需要时,UControlChannel会负责发送关闭通道请求。
  4. 控制消息
    :UControlChannel还可以处理其他一些控制消息,如暂停、恢复游戏等。这些消息通常对游戏逻辑产生一定影响,但主要用于维护游戏状态和连接。
  • VoiceChannel:主要处理语音数据,比如常见的游戏中的队伍聊天

2.3 Channel 的创建

  • Client :Client 上 Channel 的创建接口为
    UNetDriver::CreateInitialCilentChannels
    ,其实就是在 InitNetDriver 的时候就创建好了 Channel
    image.png

  • Server :Server 上 Channel 的创建时机如下:
    image.png

基本上都是在握手过程中就创建好了 Channel。其关系如下:

image.png

2.3 Client 发送 NMT_Hello

Server 端在 InitRemoteConnection 之后,会执行
UNetConnection::SetExpectedClientLoginMsgType(NMT_Hello)
,表示等待 Client 端发送 NMT_Hello 的消息,而 Client 端发送该消息的时机就在握手完毕之后。
Client 端在调用 BeginHandshake 的时候,会传入一个 Delegates,Handshake 完毕之后会调用 Delegates. Broadcast,通知握手完毕,绑定了该 Delegate 的接口都会被执行,大致如下:

// 握手完毕的回调
void UPendingNetGame::InitNetDriver() {
	// 省略一些代码
	// 发起握手,传入握手完毕的回调
	ServerConn->Handler->BeginHandshaking( FPacketHandlerHandshakeComplete::CreateUObject(this, &UPendingNetGame::SendInitialJoin));
}

// SendInit
void UPendingNetGame::SendInitialJoin() {
	// 省略一些代码
	// 发送 NMT_Hello
	FNetControlMessage<NMT_Hello>::Send(ServerConn, IsLittleEndian, LocalNetworkVersion, EncryptionToken);
}

因此握手完毕后,Client 端就会调用 UPendingNetGame::SendInitialJoin ,发送 NMT_Hello 给 Server 端。
这里还有个问题,如何确定这个 Message 会发送给 ControlChannel ?实际上这里由
FNetControlMessage<>::Send
接口处理,其内部实现会直接发送一个
FControlChannelOutBunch
,该 Bunch 会直接使用 Channel[0] 初始化,Channel[0] 默认情况下就是 ControlChannel。

2.5 ControlChannel 处理 ControlMessage

2.5.1 Server

Server 端处理 Bunch 的 CallStack 如下:

其大致流程如下:

  • NetDriver 收到 Packet
  • NetConnection 拆分 Packet 成多个 Bunch
  • 根据 Bunch.ChIndex 找到对应的 Channel(Channel 缓存在 NetConnection)
  • Channel 调用
    ReceivedBunch
    (不同的 Channel 会各自重写该接口)
  • ControlChannel 收到 Message 后调用 NotifyControlMessage 进行广播,执行回调,其中 Server 登录流程相关的最主要的就是
    UWorld::NotifyControlMessage
    接口

2.5.2 Client

Client 端登录过程中主要处理 ControlMessage 的接口为
UPendingNetGame::NotifyControlMessage

2.6 登录,加载地图,创建 PlayerController

  • Server 端收到 NMT_Hello 后,会回复 NMT_Challenge
  • Client 收到 NMT_Challenge 后,整合玩家数据 NickName,PlayerId 等,发送 NMT_Login
  • Server 收到 NMT_Login:
    • 设置 Connection 的 PlayerId
    • 调用 GameMode::PreLogin,这里我们也可以定义自己的 PreLogin,来加一些 Token 校验之类的确定是否让玩家进入游戏。
    • 返回 NMT_Welcome,同时会设置 LevelName,这样客户端就可以知道连接什么地图。
  • Client 收到 NMT_Welcome:
    • 设置地图路径,在 UPendingNetGame 的 URL 中,UEngine::TickWorldTravel 会一直轮询 UPendingNetGame 的地图 URL
    • Travel 到目标地图
    • 返回 NMT_NetSpeed 表示成功连接
  • Server 收到 NMT_NetSpeed,没有什么特殊操作,只是简单设置下 NetSpeed
  • Client 加载地图完毕,发送 NMT_Join。
    UPendingNetGame::LoadMapCompleted
    ->
    UPendingNetGame::SendJoin
  • Server 收到 NMT_Join:
    • 如果对应的 Connection 没有 PlayerController 则创建一个
    • 触发
      AGameModeBase::Login
    • 如果当前 World 的 Map 是 Transition 的或者在一个错误的 World,则也通知 Client 再次进行 Travel
      总体流程图如下:
      image.png

3. 总结

个人将 UE 中,Client 和 Server 建立连接到进入游戏中的过程分为了 2 步:

  1. 建立一个 UDP 连接(其实 UDP 没有连接的概念),并且在 Server 和 Client 都维护一个 UNetConnection
  2. 利用 Control Message 和 Control Channel 进行通信,进入游戏,执行 GameMode 的登录,加载地图,创建 PlayerController 等跟 Gameplay 密切相关的操作

将字典数据,配置在 yml 文件中,通过加载yml将数据加载到 Map中
Spring Boot 中 yml 配置、引用其它 yml 中的配置。# 在配置文件目录(如:resources)下新建
application-xxx

必须以application开头的yml文件, 多个文件用 "," 号分隔,不能换行

项目结构文件
image

application.yml

server:
  port: 8088
  application:
    name: VipSoft Env Demo


spring:
  profiles:
    include:
      dic      # 在配置文件目录(如:resources)下新建application-xxx 开头的yml文件, 多个文件用 "," 号分隔,不能换行

#性别字典
user-gender:
  0: 未知
  1: 男
  2: 女

application-dic.yml
将字典独立到单独的yml文件中

#支付方式
pay-type:
  1: 微信支付
  2: 货到付款


resources
目录下,创建
META-INF
目录,创建
spring.factories
文件,
Spring Factories是一种类似于Java SPI的机制,它在META-INF/spring.factories文件中配置接口的实现类名称,然后在程序中读取这些配置文件并实例化。
内容如下:

# Environment Post Processor
org.springframework.boot.env.EnvironmentPostProcessor=com.vipsoft.web.utils.ConfigUtil

ConfigUtil

package com.vipsoft.web.utils;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.context.properties.bind.BindResult;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.env.EnvironmentPostProcessor;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.PropertySource;
import org.springframework.util.Assert;

public class ConfigUtil implements EnvironmentPostProcessor {

    private static Binder binder;

    private static ConfigurableEnvironment environment;

    public static String getString(String key) {
        Assert.notNull(environment, "environment 还未初始化!");
        return environment.getProperty(key, String.class, "");
    }

    public static <T> T bindProperties(String prefix, Class<T> clazz) {
        Assert.notNull(prefix, "prefix 不能为空");
        Assert.notNull(clazz, "class 不能为空");
        BindResult<T> result = ConfigUtil.binder.bind(prefix, clazz);
        return result.isBound() ? result.get() : null;
    }

    /**
    * 通过 META-INF/spring.factories,触发该方法的执行,进行环境变量的加载
    */
    @Override
    public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
        for (PropertySource<?> propertySource : environment.getPropertySources()) {
            if (propertySource.getName().equals("refreshArgs")) {
                return;
            }
        }
        ConfigUtil.environment = environment;
        ConfigUtil.binder = Binder.get(environment);
    }
}

DictVo

package com.vipsoft.web.vo;


public class DictVO implements java.io.Serializable {
    private static final long serialVersionUID = 379963436836338904L;
    /**
     * 字典类型
     */
    private String type;
    /**
     * 字典编码
     */
    private String code;
    /**
     * 字典值
     */
    private String value;

    public DictVO(String code, String value) {
        this.code = code;
        this.value = value;
    }

    public String getType() {
        return type;
    }

    public void setType(String type) {
        this.type = type;
    }

    public String getCode() {
        return code;
    }

    public void setCode(String code) {
        this.code = code;
    }

    public String getValue() {
        return value;
    }

    public void setValue(String value) {
        this.value = value;
    }
}

DefaultController

package com.vipsoft.web.controller;

import com.vipsoft.web.utils.ConfigUtil;
import com.vipsoft.web.vo.DictVO;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;


@RestController
public class DefaultController {
    @GetMapping(value = "/")
    public String login() {
        return "VipSoft Demo !!!";
    }

    @GetMapping("/list/{type}")
    public List<DictVO> listDic(@PathVariable("type") String type) {
        LinkedHashMap dict = ConfigUtil.bindProperties(type.replaceAll("_", "-"), LinkedHashMap.class);
        List<DictVO> list = new ArrayList<>();
        if (dict == null || dict.isEmpty()) {
            return list;
        }
        dict.forEach((key, value) -> list.add(new DictVO(key.toString(), value.toString())));
        return list;
    }
}

运行效果
image

单元测试

package com.vipsoft.web;

import com.vipsoft.web.controller.DefaultController;
import com.vipsoft.web.utils.ConfigUtil;
import com.vipsoft.web.vo.DictVO;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

@SpringBootTest
public class DicTest {
    @Autowired
    DefaultController defaultController;

    @Test
    public void DicListTest() throws Exception {
        List<DictVO> pay_type = defaultController.listDic("pay-type");
        pay_type.forEach(p -> System.out.println(p.getCode() + " => " + p.getValue()));


        List<DictVO> user_gender = defaultController.listDic("user-gender");
        user_gender.forEach(p -> System.out.println(p.getCode() + " => " + p.getValue()));
    }


    @Test
    public void getString() throws Exception {
        String includeYml = ConfigUtil.getString("spring.profiles.include");
        System.out.println("application 引用了配置文件 =》 " + includeYml);
    }
}

image

自定义Mybatis-plus插件(限制最大查询数量)

需求背景

​ 一次查询如果结果返回太多(1万或更多),往往会导致系统性能下降,有时更会内存不足,影响系统稳定性,故需要做限制。

解决思路

1.经分析最后决定,应限制一次查询返回的最大结果数量不应该超出1万,对于一次返回结果大于限制的时候应该抛出异常,而不应该截取(limit 10000)最大结果(结果需求不匹配)。

2.利用mybatis拦截器技术,统一拦截sql,并真对大结果的查询先做一次count查询。

步骤一

1.1 定义拦截器PreCheckBigQueryInnerInterceptor

public class PreCheckBigQueryInnerInterceptor implements InnerInterceptor {}
1.2 重写willDoQuery方法
 public boolean willDoQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
        // 解析sql
        Statement stmt = CCJSqlParserUtil.parse(boundSql.getSql());
        if (stmt instanceof Select) {
            PlainSelect selectStmt = (PlainSelect) ((Select) stmt).getSelectBody();
            if (Objects.nonNull(selectStmt.getLimit())) {
                //包含limit查询
                return true;
            }
            for (SelectItem selectItem : selectStmt.getSelectItems()) {
                //计数查询 count();
                SelectExpressionItem selectExpressionItem = (SelectExpressionItem) selectItem;
                if (selectExpressionItem.getExpression() instanceof Function) {
                    //包含function查询
                    return true;
                }
            }
            Long aLong = doQueryCount(executor, ms, parameter, rowBounds, resultHandler, boundSql);
            if (aLong == 0L) {
                return false;
            }
            if (aLong > 20) {
                throw new RuntimeException("单个查询结果大于20条!!!");
            }
        }
        return true;
    }
1.3 代码解析
1.3.1 利用CCJSqlParserUtil解析sql,并判断sql类型,只对Select的SQL拦击.
1.3.2 对于已有limit的sql查询,直接放行.
1.3.3 对于包含function查询(例如count(1)计算,max()...),直接放行.
1.3.4 否则判断为大结果查询,执行(doQueryCount)与查询数量.
1.3.5 对于大于指定数量的结果,抛出异常.
1.4 定义doQueryCount方法
private Long doQueryCount(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
        MappedStatement countMs = buildAutoCountMappedStatement(ms);
        String countSqlStr = autoCountSql(true, boundSql.getSql());
        PluginUtils.MPBoundSql mpBoundSql = PluginUtils.mpBoundSql(boundSql);
        BoundSql countSql = new BoundSql(countMs.getConfiguration(), countSqlStr, mpBoundSql.parameterMappings(), parameter);
        PluginUtils.setAdditionalParameter(countSql, mpBoundSql.additionalParameters());
        CacheKey cacheKey = executor.createCacheKey(countMs, parameter, rowBounds, countSql);
        Object result = executor.query(countMs, parameter, rowBounds, resultHandler, cacheKey, countSql).get(0);
        System.out.println(result);
        return (result == null ? 0L : Long.parseLong(result.toString()));
    }
代码解读:参考PaginationInnerInterceptor(mybatis-plus)分页插件
1.4.1:构造MappedStatement对象buildAutoCountMappedStatement(ms),MappedStatement相当于一个存储 SQL 语句、输入参数和输出结果映射等信息的封装体,它对应一条 SQL 语句,并包含了该 SQL 语句执行所需的所有信息。如下代码
<mapper namespace="com.example.UserMapper">
   <select id="selectAllUsers" resultType="com.example.User">
       SELECT * FROM user
   </select>
</mapper>

注意:必须重新构造,不能直接使用入参中的ms

1.4.2:autoCountSql(true, boundSql.getSql()) 定义并优化计数查询语句
String.format("SELECT COUNT(1) FROM (%s) TOTAL", originalSql);
1.4.3: 执行查询executor.query

步骤二

1.1 注册拦截器PreCheckBigQueryInnerInterceptor

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));//分页插件(Mybatis-plus)
    interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());//防止全表更新(Mybatis-plus)
    interceptor.addInnerInterceptor(new PreCheckBigQueryInnerInterceptor());//防止全表查询(自定义插件)
    return interceptor;
}

知识小结:

  1. MybatisPlusInterceptor
public class MybatisPlusInterceptor implements Interceptor {
    @Setter
    private List<InnerInterceptor> interceptors = new ArrayList<>();
}

​ 他是基于mybatis的Interceptor接口做的拦截器,上文中我们 注册拦截器PreCheckBigQueryInnerInterceptor的拦截器其实添加到MybatisPlusInterceptor.interceptors集合中。

  1. 为啥重写willDoQuery见代码而不是beforeQuery
 public Object intercept(Invocation invocation) throws Throwable {
       ......
                for (InnerInterceptor query : interceptors) {
                    if (!query.willDoQuery(executor, ms, parameter, rowBounds, resultHandler, boundSql)) {
                        return Collections.emptyList();
                    }
                    query.beforeQuery(executor, ms, parameter, rowBounds, resultHandler, boundSql);
                }
     ......
        return invocation.proceed();
 }

2.1 willDoQuery先于beforeQuery方法,且一定会执行