前言 游戏开发过程中经常会设计到时间的运算判定,比如开服时间,触发时间,倒计时时间等,所有的这些时间都要基于和服务器或网络对时后的网络时间为基准来计算。单纯采用本地客户端时间的话会导致通过修改本地客户端时间就能达到加速和调时间的目的。本章节通过深入学习网络对时,实现一套没有服务器也能正确网络对时的对时框架(如果有后端的话以后端时间对时为准即可,本人是出于不懂服务器开发所以才采用网络对时的方案 ),为未来自己的独立游戏的网络对时打基础。
需求
通过网络实现对时,确保本地计算用的时间是准确无误不会受本地调时间影响的
时间 UTC时间 在了解同步时间之前,让我们先来了解下什么是时间。
平时我们说的时间年月日时分秒,这个时间是从哪里来的了?为什么不同的国家有不同的时间了?为什么要区分时区了?
带着这些疑问来看以下这篇文章介绍:
网络时间同步是怎么实现的?怎样消除延迟带来的影响?
这里我直接跳到重要的理论知识和结论上来。
世界标准时间:
世界时 :基于天文现象 + 钟表计时,永远与地球自转时间相匹配
国际原子时 :基于原子钟计时,每一秒的周期完全等长且固定
原子时非常稳定,但世界时随着地球自转变慢,会越来越慢. 科学家通过「闰秒」的方式来矫正这个误差,从而实现世界时+国际原子时混合的方式得到准确的计时。
上面说的基于原子时 + 世界时「协调」 得出的时间就是我们通常说的UTC时间 。有了UTC标准时间,各个国家的时间就是基于UTC+-的方式的出来的,也就是我们通常说的时区时间(e.g. 北京时间,巴黎时间……)。*当我们开发多个国家的游戏的时候,为了确保时间的统一,这个时候我们就可以采用UTC时间作为时间标准,每个国家的时间戳计算标准都是统一的(不需要考虑时区问题)。
在了解了UTC(统一标准时间)的来路后,那么接下来就是本文的重点如何同步网络时间。
Unix时间戳 时间戳适用于表示一个时间从固定时间点到当前时间的总秒数(或微秒数不同编程语言有不同)。
而编程里常用的则是Unix时间戳。
Unix时间戳是从UTC1970年1月1日0时0分0秒起至现在的总秒数,不考虑闰秒。
Note:
时间戳没有时区之分
Unix时间戳是从1970年1月1日0点0分0秒作为基准算起
网络对时 网络对时顾名思义需要通过网络进行对时访问,而网络的访问我们都知道会有网络延时,那么我们如何在网络延时的基础上做到正确的网络对时了?
前人已经为此提供了解决方案,那就是NTP(Network Time Protocol)(网络对时服务)
这里简单了解下NTP是如何做到网络时间同步的。
通过在网络报文上打「时间戳」的方式,然后配合计算网络延迟,从而修正本机的时间。
根据图示可以计算出网络「传输延迟」,以及客户端与服务端的「时间差」:
网络延时 = (t4 - t1) - (t3 - t2)
时间差 = t2 - t1 - 网络延时 / 2
这个计算过程假设网络来回路径是对称的,并且时延相同。
可以看到通过包含发送,接收,返回和返回接收时间戳的方式,我们可以计算出网络延迟从而做到正确的同步的网络时间
为了确保逻辑正确,时间严格意义上是不允许倒退的。那通过NTP同步的时间会出现时光倒流的情况吗?
NTP为此提供了两种方式:
ntpdate :一切以服务端时间为准,「强制修改」本机时间
ntpd :采用「润物细无声」的方式修改本机时间,把时间差均摊到每次小的调整上,避免发生「时光倒流」
在了解了NTP的对时原理后,接下来让我们通过实战Socket网络请求利用NTP实现网络对时功能。
实战 在实现通过Socket访问网络请求时,先让我们了解一下NTP的报文格式:
了解了NTP的报文格式我们就能编写Socket代码请求访问获取对时相关信息:
对时NTP网址,我采用的是阿里云提供的地址:
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 * Description: NTPHostConfig.cs * Author: TonyTang * Create Date: 2022/07/21 */ using System.Collections;using System.Collections.Generic;using UnityEngine;public static class NTPHostConfig { public static List<string > NTPHostList = new List<string > { "ntp2.aliyun.com" , "ntp3.aliyun.com" , "ntp4.aliyun.com" , "ntp5.aliyun.com" , "ntp6.aliyun.com" , "ntp7.aliyun.com" , }; }
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 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 /* * Description: NTPClient.cs * Author: TonyTang * Create Date: 2022/07/15 */ using System; using System.Collections; using System.Collections.Generic; using System.Net; using System.Net.Sockets; using UnityEngine; /// <summary> /// NTPClient.cs /// NTP客户端 /// </summary> public class NTPClient : SingletonTemplate<NTPClient> { /// <summary> /// 对时网络地址(直接传IP地址时无值) /// </summary> public string Host { get; private set; } /// <summary> /// 对时IP连接地址 /// </summary> public IPEndPoint IPEnd { get; private set; } /// <summary> /// NTP连接端口号 /// </summary> private const int PortNumber = 123; /// <summary> /// NTP Socket /// </summary> private Socket mNTPSocket; /// <summary> /// NTP请求数据 /// </summary> private byte[] mNtpSendData; /// <summary> /// NTP接受数据 /// </summary> private byte[] mNtpReceiveData; /// <summary> /// <summary> /// 服务器接收客户端时间请求时间起始位置 /// </summary> private const int ServerReceivedTimePos = 32; /// <summary> /// 服务器回复时间起始位置 /// </summary> private const int ServerReplyTimePos = 40; /// <summary> /// UTC时间戳基准时间 /// </summary> private readonly DateTime UTCBaseTime = new DateTime(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc); /// <summary> /// NTP网址列表 /// </summary> private List<string> mNTPHostList; /// <summary> /// 时间同步是否成功 /// </summary> private bool IsTimeSyncSuccess; public NTPClient() { mNtpSendData = new byte[48]; // Setting the Leap Indicator, Version Number and Mode values // LI = 0 (no warning), VN = 3 (IPv4 only), Mode = 3 (Client Mode) mNtpSendData[0] = 0x1B; mNtpReceiveData = new byte[48]; IsTimeSyncSuccess = false; } /// <summary> /// 设置NTP网址列表 /// </summary> /// <param name="ntpHostList"></param> public bool SetHostList(List<string> ntpHostList) { mNTPHostList = ntpHostList; if (!HasHost()) { Debug.LogError($"请勿设置空NTP网址列表!"); return false; } return true; } /// <summary> /// 同步网络时间 /// </summary> /// <returns></returns> public bool SyncTime() { // 未来有服务器的话,改为和服务器对时 if(!HasHost()) { Debug.Log($"没有有效NTP网址,对时失败!"); return false; } DateTime? syncDateTime = null; for (int i = 0, length = mNTPHostList.Count; i < length; i++) { InitByHost(mNTPHostList[i]); if (SyncTime(out syncDateTime)) { IsTimeSyncSuccess = true; TimeHelper.SetNowUTCTime((DateTime)syncDateTime); return true; } } IsTimeSyncSuccess = false; Debug.LogError($"所有NTP地址都同步时间失败!"); return false; } /// <summary> /// 网络对时是否成功 /// </summary> /// <returns></returns> public bool IsSyncTimeSuccess() { return IsTimeSyncSuccess; } /// <summary> /// 初始化对时地址 /// </summary> /// <param name="host"></param> private bool InitByHost(string host) { Host = host; IPEnd = null; var ipAdresses = Dns.GetHostAddresses(Host); if(ipAdresses != null && ipAdresses.Length > 0) { IPEnd = new IPEndPoint(ipAdresses[0], PortNumber); return true; } else { Debug.LogError($"网络地址:{host}的IP解析错误!"); return false; } } /// <summary> /// 初始化对时IP地址 /// </summary> /// <param name="ip"></param> private bool InitByIP(string ip) { Host = null; IPEnd = null; var ipAdress = IPAddress.Parse(ip); if (ipAdress != null) { IPEnd = new IPEndPoint(ipAdress, PortNumber); return true; } else { Debug.LogError($"IP地址:{ip}解析错误!"); return false; } } /// <summary> /// 网络对时 /// </summary> /// <param name="syncDateTime"></param> /// <returns></returns> private bool SyncTime(out DateTime? syncDateTime) { syncDateTime = null; if (IPEnd != null) { try { mNTPSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); Debug.Log($"网址:{Host} IP地址:{IPEnd.ToString()}"); mNTPSocket.Connect(IPEnd); mNTPSocket.ReceiveTimeout = 3000; // 客户端发送时间 ulong clientSendTime = (ulong)DateTime.UtcNow.Millisecond; Debug.Log($"客户端发送时间:{clientSendTime}"); mNTPSocket.Send(mNtpSendData); Array.Clear(mNtpReceiveData, 0, mNtpReceiveData.Length); var recceiveByteNumbers = mNTPSocket.Receive(mNtpReceiveData); Debug.Log($"接受返回字节数:{recceiveByteNumbers}"); // 客户端接收时间 ulong clientReceiveTime = (ulong)DateTime.UtcNow.Millisecond; Debug.Log($"客户端接收时间:{clientReceiveTime}"); mNTPSocket.Shutdown(SocketShutdown.Both); // 服务器接受消息时间 var serverReceivedTime = GetMilliSeconds(mNtpReceiveData, ServerReceivedTimePos); Debug.Log($"服务器接受消息时间:{serverReceivedTime}"); // 服务器返回消息时间 var serverReplyTime = GetMilliSeconds(mNtpReceiveData, ServerReplyTimePos); Debug.Log($"服务器返回消息时间:{serverReplyTime}"); // 网路延时 = (客户端接收时间 - 客户端发送时间) - (服务器返回消息时间 - 服务器接受消息时间) // 时间差 = 服务器接受消息时间 - 客户端发送时间 - 网络延时 / 2 = ((服务器接受消息时间 - 客户端发送时间) + (服务器返回消息时间 - 客户端接收时间)) / 2 // 当前同步服务器时间 = 客户端接收时间 + 时间差 var offsetTime = ((serverReceivedTime - clientSendTime) + (serverReplyTime - clientReceiveTime)) / 2; var syncTime = clientReceiveTime + offsetTime; syncDateTime = UTCBaseTime.AddMilliseconds(syncTime); Debug.Log($"IP地址:{IPEnd.ToString()},当前同步UTC时间:{syncDateTime.ToString()}"); return true; } catch(SocketException e) { Debug.LogError($"IP地址:{IPEnd.ToString()}连接异常:{e.Message},ErrorCode:{e.ErrorCode}!"); } finally { //关闭Socket并释放资源 mNTPSocket.Close(); } return false; } else { Debug.LogError($"未初始化IP地址,网络对时失败!"); return false; } } /// <summary> /// 是否有NTP网址 /// </summary> /// <returns></returns> private bool HasHost() { return mNTPHostList != null && mNTPHostList.Count > 0; } /// <summary> /// 获取指定偏移的时间戳 /// </summary> /// <param name="byteDatas"></param> /// <param name="byteOffset"></param> /// <returns></returns> private ulong GetMilliSeconds(byte[] byteDatas, int byteOffset) { // Note: // 64bit时间戳,高32bit表示整数部分,低32bit表示小数部分 // 默认网络获取的时间戳是大端 ulong intPart = BitConverter.ToUInt32(mNtpReceiveData, ServerReplyTimePos); ulong fractPart = BitConverter.ToUInt32(mNtpReceiveData, ServerReplyTimePos + 4); // 转换成小端 if(ByteUtilities.IsLittleEndian) { // 注意只转换64里的低32位 intPart = ByteUtilities.SwapEndianU32(intPart); fractPart = ByteUtilities.SwapEndianU32(fractPart); } return (intPart * 1000) + ((fractPart * 1000) / 0x100000000UL); } }
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 using System.Collections;using System.Collections.Generic;using UnityEngine;public static class ByteUtilities { public static bool IsLittleEndian = CheckLittleEndian(); public static unsafe bool CheckLittleEndian ( ) { int i = 1 ; byte * b = (byte *)&i; return b[0 ] == 1 ; } public static ushort SwapEndianU16 (ushort value ) { return (ushort )((value & 0xFF U) << 8 | (value & 0xFF00 U) >> 8 ); } public static uint SwapEndianU32 (uint value ) { return (value & 0x000000FF U) << 24 | (value & 0x0000FF00 U) << 8 | (value & 0x00FF0000 U) >> 8 | (value & 0xFF000000 U) >> 24 ; } public static ulong SwapEndianU32 (ulong value ) { return (value & 0x000000FF U) << 24 | (value & 0x0000FF00 U) << 8 | (value & 0x00FF0000 U) >> 8 | (value & 0xFF000000 U) >> 24 ; } public static ulong SwapEndianU64 (ulong value ) { return (value & 0x00000000000000FF UL) << 56 | (value & 0x000000000000FF00 UL) << 40 | (value & 0x0000000000FF0000 UL) << 24 | (value & 0x00000000FF000000 UL) << 8 | (value & 0x000000FF00000000 UL) >> 8 | (value & 0x0000FF0000000000 UL) >> 24 | (value & 0x00FF000000000000 UL) >> 40 | (value & 0xFF00000000000000 UL) >> 56 ; } }
从上面的代码可以看出,我们为了确保对时的快速,选择了UDP而非TCP。
其次通过解析NTP网址返回的NTP报文信息(e.g. 服务器接受消息时间和服务器返回消息时间),我们可以计算出客户端和服务器的时间差,从而计算出正确的网络UTC时间。
网路延时 = (客户端接收时间 - 客户端发送时间) - (服务器返回消息时间 - 服务器接受消息时间)
时间差 = 服务器接受消息时间 - 客户端发送时间 - 网络延时 / 2 = ((服务器接受消息时间 - 客户端发送时间) + (服务器返回消息时间 - 客户端接收时间)) / 2
当前同步服务器时间 = 客户端接收时间 + 时间差
对时成功后,我们把对时的UTC时间设置到我们本地时间工具类里供访问使用:
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 71 72 73 74 75 76 77 78 79 80 81 * Description: TimeHelper.cs * Author: TonyTang * Create Date: 2022/07/16 */ using System;using System.Collections;using System.Collections.Generic;using UnityEngine;public static class TimeHelper { private static DateTime? mSyncUTCTime; private static DateTime? mSyncLocalTime; public static void SetNowUTCTime (DateTime? nowDateTime ) { mSyncUTCTime = nowDateTime; mSyncLocalTime = nowDateTime != null ? ((DateTime)nowDateTime).ToLocalTime() : nowDateTime; if (mSyncUTCTime != null ) { Debug.Log($"同步当前UTC时间:{mSyncUTCTime.ToString()}" ); Debug.Log($"同步当前时区时间:{mSyncLocalTime.ToString()}" ); } else { Debug.Log($"清空当前同步时间!" ); } } public static DateTime GetNowUTCTime ( ) { return mSyncUTCTime != null ? (DateTime)mSyncUTCTime : GetLocalNowUTCTime(); } public static DateTime GetNowLocalTime ( ) { return mSyncUTCTime != null ? ((DateTime)mSyncUTCTime).ToLocalTime() : GetLocalNowTime(); } public static DateTime GetLocalNowUTCTime ( ) { return DateTime.UtcNow; } public static DateTime GetLocalNowTime ( ) { return DateTime.Now; } }
来看看实战效果:
我把本地时间同步关闭,并修改时间从2022/07/22 00:06到2022/07/24/03:06时间
同步时间前:
同步时间后:
从上面可以看到通过NTP的对时后,我们成功获取到了网络的UTC时间。
但上面的代码还有一个问题,对时后的UTC时间是固定的,我们无法向DateTime.Now或DateTime.UtcNow的方式实时访问同步的UTC时间。
这个时候我们需要通过网络同步时间+本地运行时间=本地最新网络同步时间 的方式计算出最新的本地网络同步时间,毕竟我们整个游戏开发过程中并不想一直去通过短连接同步网络时间。
关于本地运行时间 Unity给我们提供了一个无关Time.scale变化的一个运行时间,那就是Time.realtimeSinceStartup(标识游戏启动开始算起经历的时间s,不受Time.scale影响)
当前同步网络时间公式:
当前同步网络时间 = 网络对时 + (本地运行时长 - 对时时本地运行时长)
最终代码如下:
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 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 * Description: TimeHelper.cs * Author: TonyTang * Create Date: 2022/07/16 */ using System;using System.Collections;using System.Collections.Generic;using UnityEngine;public static class TimeHelper { private static DateTime? mSyncUTCTime; private static DateTime? mSyncLocalTime; private static float mSyncRealtime; public static void SetNowUTCTime (DateTime? nowDateTime ) { mSyncUTCTime = nowDateTime; mSyncLocalTime = nowDateTime != null ? ((DateTime)nowDateTime).ToLocalTime() : nowDateTime; if (mSyncUTCTime != null ) { mSyncRealtime = Time.realtimeSinceStartup; Debug.Log($"同步时间时本地运行时间:{mSyncRealtime}" ); Debug.Log($"同步当前UTC时间:{mSyncUTCTime.ToString()}" ); Debug.Log($"同步当前时区时间:{mSyncLocalTime.ToString()}" ); } else { Debug.Log($"清空当前同步时间!" ); } } public static DateTime GetNowUTCTime ( ) { if (mSyncUTCTime != null ) { var syncUTCTime = (DateTime)mSyncUTCTime; return syncUTCTime.AddSeconds(Time.realtimeSinceStartup - mSyncRealtime); } else { return GetLocalNowUTCTime(); } } public static DateTime GetNowLocalTime ( ) { if (mSyncLocalTime != null ) { var syncLocalTime = (DateTime)mSyncLocalTime; return syncLocalTime.AddSeconds(Time.realtimeSinceStartup - mSyncRealtime); } else { return GetLocalNowTime(); } } public static DateTime GetLocalNowUTCTime ( ) { return DateTime.UtcNow; } public static DateTime GetLocalNowTime ( ) { return DateTime.Now; } }
可以看到我们通过记录同步网络时间时的本地真实运行时间,在下一次获取当前网络时间时通过当前真实运行时间 - 同步网络时本地真实运行时间得出同步时间后真实运行的时间,从而计算出当前网络最新时间。
成功同步时间以后,如果我们制作的游戏要支持多个国家的话,我们需要统一游戏里的时间计算,这个时候我们会用到UTC时间和时间戳的概念。结合前面时间戳的介绍,我知道Unix时间戳是基于1970年1月1日0点0分0秒,那么通过网络UTC对时后,我们可以成功计算出网络对时的时间戳:
当前时间戳 = 当前同步网络时间 - Unix时间戳基准时间(1970/1/1)
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 private static DateTime UnixBaseTime = new DateTime(1970 , 1 , 1 , 0 , 0 , 0 , DateTimeKind.Utc);var offsetTimeSpan = (DateTime)mSyncUTCTime - UnixBaseTime;mSyncUTCTimeStamp = (long )offsetTimeSpan.TotalSeconds; Debug.Log($"同步时间UTC时间戳:{mSyncUTCTimeStamp}" ); public static long GetNowUTCTimeStamp ( ) { var nowUTCTime = GetNowUTCTime(); var offsetSeconds = nowUTCTime - UnixBaseTime; return (long )offsetSeconds.TotalSeconds; } public static long GetLocalNowUTCTimeStamp ( ) { return DateTimeOffset.UtcNow.ToUnixTimeSeconds(); }
同步UTC时间前时间戳:
同步UTC时间后时间戳:
可以看到我们通过和Unix时间戳基准时间(1970年1月1月0时0分0秒)成功计算出了当前对时的UTC时间戳
重点知识
同步网络对网络要求不高,可以采用UDP
NTP报文里有服务器相关获得请求时间和服务求返回请求时间,通过这些数据的解析我们能计算出真实的网络时间
NTP里的时间戳是基于1900年1月1日,而非像Unix时间戳基于1970年1月1日
本地最新网络时间需要通过记录对时时的运行时间来计算最新的当前网络时间
网络时间同步没必要一直同步,可以采用短连接在必要的时候同步一次
引用 网络时间同步是怎么实现的?怎样消除延迟带来的影响?
统一标准时间(UTC)
网络时间协议(Network Time Protocol, 缩写NTP)
通过NTP协议进行时间同步
ntp原理及客户端实现
客户端秒级时间同步方案
Unix时间戳
Github SyncTime