携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第41天,点击查看活动详情
网络消息底层机制
消息的打包和发送机制
Mirror并不是在connection上调用Send就直接通过网络发送一个消息包。Mirror会对消息进行打包,然后在一帧的结束统一发送。
Batch队列
在Send方法内部,调用GetBatchForChannelId(channelId).AddMessage(segment, NetworkTime.localTime);
将消息添加到batch中。在Batcher
类中,有一个队列:
Queue<NetworkWriterPooled> batches = new Queue<NetworkWriterPooled>();
复制代码
这就是Batch队列,这儿的batch是NetworkWriterPooled
对象。
AddMessage
在AddMessage中,会向当前操作的batch写入数据:
batch.WriteBytes(message.Array, message.Offset, message.Count);
如果当前batch容器不足以添加这个消息,则将它插入到batches队列中,并从池中获取一个新的batc进行操作。
if (batch != null &&
batch.Position + message.Count > threshold)
{
batches.Enqueue(batch);
batch = null;
}
// initialize a new batch if necessary
if (batch == null)
{
// borrow from pool. we return it in GetBatch.
batch = NetworkWriterPool.Get();
// write timestamp first.
// -> double precision for accuracy over long periods of time
// -> batches are per-frame, it doesn't matter which message's
// timestamp we use.
batch.WriteDouble(timeStamp);
}
复制代码
消息发送
在NetworkConnection
的Update
方法中,会遍历batches队列,调用SendToTransport
将消息发送出去。
using (NetworkWriterPooled writer = NetworkWriterPool.Get())
{
while (batcher.GetBatch(writer))
{
ArraySegment<byte> segment = writer.ToArraySegment();
if (ValidatePacketSize(segment, kvp.Key))
{
// send to transport
SendToTransport(segment, kvp.Key);
//UnityEngine.Debug.Log($"sending batch of {writer.Position} bytes for channel={kvp.Key} connId={connectionId}");
// reset writer for each new batch
writer.Position = 0;
}
}
}
复制代码
Mirror的消息打包和批量发送机制可以减少消息数量,由于网络消息是高层消息,为了发送它们会将它们包装到底层的网络包上,比如UDP包,如果每个UDP只携带很少的信息无疑是一种浪费。Mirror中每个消息设置为不超过1500字节,这是以太网的MTU,当然这儿的计算不是特别精确,因为UDP以及上层协议的包头都会占用一部分MTU,但总的来说,1500字节或者更大一些的UDP或TCP包是一个比较稳的容量。而游戏消息本身一般都比较小,所以一帧一般各种消息打包到一起也就能发送一个包。由于Mirror的这个机制,你可以自由的定义消息,消息体可以很小,因为你知道消息会被合并,不会浪费MTU。
自定义消息
再研究完Mirror的网络消息机制后,自己实现自定义消息是非常简单的。这儿我们实现一个从服务器发送给客户端的世界时间消息。虽然Mirror提供了NetworkTime,可以在客户端获取到RTT修正后的服务器时间。但是某些游戏类型,比如模拟来的游戏,会有一个逻辑上的世界时间,这个时间的流逝速度和真实时间成一个比例,并且有可能根据游戏玩法进行加速减速暂停等。因此我们不能直接使用服务器时间。为此我们在服务器上计算这个时间,并且发送网络消息。
消息定义
public struct WorldTimeMessage : NetworkMessage
{
public float WorldCurrentTime;
}
复制代码
非常简单,只包含一个float。float的精度对于我们已经足够了,因为并不是在客户端计算的,完全是从服务器每帧同步,所以不会有累积误差,唯一有的误差就是RTT,不过可以根据游戏需求是否要把RTT考虑在内,这儿我们就忽略RTT的影响了,只是简单同步服务器上的世界时间。
客户端注册消息Handler
[Client]
private void OnClientConnected()
{
NetworkClient.RegisterHandler<WorldTimeMessage>(OnWorldTimeMessage);
}
复制代码
当客户端连接时,即注册消息Handler,如果注册晚了,当世界时间开始更新,发送WorldTimeMessage过来,如果找不到Handler,就会有警告说找不到Handler。
消息发送
[ServerCallback]
void ServerUpdate()
{
if(worldTimer.isRunning())
{
worldTimer.UpdateTime();
if(NetworkServer.connections.Count > 0)
{
NetworkServer.SendToAll(new WorldTimeMessage() { WorldCurrentTime = worldTimer.CurrentTime }, Channels.Unreliable, true);
}
}
}
复制代码
当世界时钟在走时,我们在服务器更新时间时钟,并且如果有客户端连接存在,则使用SendToAll
发送WorldTimeMessage
。worldTimer
是服务器上的世界时钟对象,负责世界时间的更新和计算。SendToAll
使用了Channels.Unreliable信道,因为这个消息是否丢失不是很重要,毕竟每帧都在发送,所以使用该信道。
Handler实现
[ClientCallback]
public void OnWorldTimeMessage(WorldTimeMessage msg)
{
worldTimer.SetCurrentTime(msg.WorldCurrentTime);
TODSystem.Update();
}
复制代码
这儿的worldTimer
是客户端的世界时钟对象,该时钟不会自己更新,只能在这儿从消息中获取时间设置给自己。然后我们的客户端TOD系统也在这儿更新,TOD系统内部会使用worldTimer中的时间。
总结
Mirror的Network Message机制其实还是很好用的,虽然是底层机制,但是Mirror替我们实现了很多细节,包括消息的打包和批量发送,另外这儿没有说的是默认的序列化机制,Mirror支持的类型可以自动生成序列化代码,将消息体序列化然后进行打包。