C# 和 VB.NET 的 SNTP 客户端
从客户端角度对 SNTP 进行完整概述和实现。

目录
概述
在我上一篇文章 使用 SNTP 进行日期验证 中,我展示了如何使用 **简单网络时间协议** (SNTP) 从服务器获取日期和时间,而不是依赖本地系统时间来检查应用程序的到期日期是否已过。由于精度要求较低,上一篇文章中协议的部分实现已足够,但我感觉我有点“作弊”了,所以本文将通过提供 SNTP 客户端的完整实现来纠正这一点,该实现除了可选的(且在特殊情况下不需要的)密钥标识符和消息摘要字段外,我只考虑单播模式(而非任播或多播)。
我附上了 C# 和 VB 格式的源代码。C# 代码经过广泛测试,应该是没有 bug 的——如果不是,请告诉我!
我不是 VB 程序员,所以 VB 代码应仅作为起点,但它似乎运行正常,并且还没有崩溃过。Components 项目必须以“移除整数溢出检查:开启”进行编译。如果您发现任何问题并有修复方法,请告诉我,我会相应地更新代码,但请理解我不会支持 VB 版本。它仅供您参考!
什么是 SNTP,它能为我做什么?
SNTP,顾名思义,是一种传输日期和时间信息的协议。主要目的是时间同步。例如,Windows 会(偶尔!)使用它来更新您的计算机时钟,但它也可以在 LAN 上使用,其中一台机器充当服务器,以确保所有客户端机器的时间与服务器完美“同步”,从而在时间关键型应用程序中相互同步。正如我在前面提到的文章中所做的那样,它也可以用于验证时间。实际上,任何使用日期/时间的应用程序都可以找到 SNTP 的用途。它使用 UTC(协调世界时)进行所有数据传输,.NET 方便地提供了方法可以在 UTC 和本地时间之间轻松转换。它在 123 端口上使用 UDP,但有些(非标准服务器)使用 TCP/HTTP 在不同端口上运行。由于它们是非标准的,因此在此被忽略。
如果您对 SNTP 的细节不感兴趣,请跳过本节的其余部分,继续前进。
SNTP 是一个简单的系统(在正常的单播模式下),它由客户端发送一个字节包,然后接收一个字节包。每个数据包由 48 个字节组成(如果使用密钥标识符和消息摘要,则为 68 个字节)。下表解释了每个字节的含义。
注意:RFCs 以大端格式列出这些,而我使用的是小端格式,因为它们是我们从 .NET 读取它们时的格式。
- 字节 0:包含三个值。
- **闰秒指示符** 包含在位 7 和 6 中。这表明是否要添加或删除闰秒。
- 用于位 5、4 和 3 的协议**版本号**。版本 3 和版本 4 是常用的,尽管 NTP 版本 4 尚未获得 RFC。之前的版本现在普遍被认为已过时。
- 剩余位 2、1 和 0 中的**模式**。在单播模式(这是本文中我仅考虑的模式)下,我们将其设置为 3 表示我们是客户端,并在接收时检查它是否为 4,以确保数据来自服务器。
- 字节 1
- 字节 2
- 字节 3
- 字节 4 - 7
- 字节 8 - 11
- 字节 12 - 15
- 如果版本 3:每个字节代表服务器参考源 IP 地址的八位字节。
- 如果版本 4:这应该是参考源的最新传输时间戳的 32 位整数,尽管在我所有的测试中,IP 地址都像版本 3 一样出现在这里!
- 字节 16 - 23
- 字节 24 - 31
- 字节 32 - 39
- 字节 40 - 47
**层**,或我们离主参考源的距离。
值 0 是未指定(这是实际的时钟源)。16 到 255 保留供将来使用。层 1 被认为是主要源,例如原子钟、GPS、无线电等。如果服务器将自身同步到层 1 服务器,则它就是层 2,因为它远离一步。这一直持续到 15。
**轮询间隔**,服务器与源重新同步之间的时间(秒)。
为了防止服务器被频繁请求淹没,建议不频繁进行轮询。我们需要为自己的客户端记住这一点,并且可能不应每 64 秒(推荐的“默认”值)检查同一服务器。实际时间通过 2 ^ 值计算。
**精度**,服务器时钟的精度。通过 2 ^ 值计算。
**根延迟**,到服务器(在层 1 中,如果它不是层 1 服务器)及其返回的主参考源的往返延迟。这是一个 32 位定点值,16 位用于整数部分,16 位用于小数部分,提供精细的精度。
**根散布**,相对于主参考源的名义误差。32 位,如根延迟所示。
**参考标识符**,这根据使用的版本和层以多种方式标识参考。如果它是层 1 源,则这是标识时钟类型的 4 个字符。如果是层 2 到 15(次要),则
**参考时间戳**,服务器时钟最后一次被校正的时间。这是一个 64 位定点值,32 位用于整数部分,32 位用于小数部分,提供极高的精度。事实上,这种精度远超 .NET 所能处理的范围,.NET 最多只能精确到 10 纳秒。
**发起时间戳**,请求从客户端发出到服务器的时间。我们在客户端不设置这个。相反,我们使用传输时间戳,服务器将其复制到其回复中的发起时间戳。64 位,与参考时间戳相同。
**接收时间戳**,请求到达服务器的时间。64 位,与参考时间戳相同。
**传输时间戳**,回复从服务器发出到客户端的时间,或者请求从客户端发出到服务器的时间。64 位,与参考时间戳相同。
这里是上面所有内容的图形化版本!
| Byte + 3 | Byte + 2 | Byte + 1 | Byte + 0 |
7 6 5 4 3 2 1 0 7 6 5 4 3 2 1 0 7 6 5 4 3 2 1 0 7 6 5 4 3 2 1 0
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Precision | Poll | Stratum |LI | VN |Mode | 0 - 3
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Root Delay | 4 - 7
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Root Dispersion | 8 - 11
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Reference Identifier | 12 - 15
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
| Reference Timestamp (64) | 16 - 23
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
| Originate Timestamp (64) | 24 - 31
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
| Receive Timestamp (64) | 32 - 39
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
| Transmit Timestamp (64) | 40 - 47
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Key Identifier (optional) (32) | 48 - 51
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
| |
| Message Digest (optional) (128) | 52 - 68
| |
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
还有一个相关的但未存储在数据包中的时间戳,尽管我在 SNTPData
类中为此创建了一个属性。
- **目的时间戳**,客户端接收响应数据包的时间。
延迟计算
RFC 提供了延迟计算的公式:
- 往返延迟 = (目的 - 发起) - (接收 - 传输)
- 本地时钟偏移 = ((接收 - 发起) + (传输 - 目的)) / 2
SNTPData
类中也有这些属性。
代码
SNTPClient
类/组件位于 DaveyM69.Components
命名空间中,所有相关类都位于 DaveyM69.Components.SNTP
中。它编译为 Components.dll,您可以在下载的 Release 目录中找到它,如果您不想自己构建的话。它使用 .NET Framework v2。
SNTPClient
SNTPClient
有一些属性来控制其行为。您可以设置是否更新本地时间、要使用的 NTP/SNTP 版本,当然还有要查询的远程服务器以及超时值。QueryServerAsync
方法是启动整个过程的方法。它会创建一个新的工作线程,该线程调用私有的 QueryServer
方法,在那里完成实际工作。
private QueryServerCompletedEventArgs QueryServer()
{
QueryServerCompletedEventArgs result =
new QueryServerCompletedEventArgs();
Initialize();
UdpClient client = null;
try
{
// Configure and connect the socket.
client = new UdpClient();
IPEndPoint ipEndPoint = RemoteSNTPServer.GetIPEndPoint();
client.Client.SendTimeout = Timeout;
client.Client.ReceiveTimeout = Timeout;
client.Connect(ipEndPoint);
// Send and receive the data, and save the completion DateTime.
SNTPData request = SNTPData.GetClientRequestPacket(VersionNumber);
client.Send(request, request.Length);
result.Data = client.Receive(ref ipEndPoint);
result.Data.DestinationDateTime = DateTime.Now.ToUniversalTime();
// Check the data
if (result.Data.Mode == Mode.Server)
{
result.Succeeded = true;
// Call other method(s) if needed
if (UpdateLocalDateTime)
{
UpdateTime(result.Data.LocalClockOffset);
result.LocalDateTimeUpdated = true;
}
}
else
{
result.ErrorData = new ErrorData(
"The response from the server was invalid.");
}
return result;
}
catch (Exception ex)
{
result.ErrorData = new ErrorData(ex);
return result;
}
finally
{
// Close the socket
if (client != null)
client.Close();
}
}
首先,我们初始化客户端,然后连接到服务器。然后我们发送一个请求数据包并等待响应,并保存接收到它时的时间。然后我们通过简单地检查字节 0 的 3 位来验证数据,以确保它设置为服务器(4)。如果一切正常,并且要更新本地日期和时间,我们将调用必要的方法。如您所见,查询的所有结果,包括任何错误/异常(除了允许升级到宿主应用程序的线程异常),都存储在 QueryServerCompletedEventArgs
实例中。这会被传回原始线程,并引发 QueryServerCompleted
事件以及这些参数。
这是 VB 中的相同方法
Private Function QueryServer() As QueryServerCompletedEventArgs
Dim result As QueryServerCompletedEventArgs = _
New QueryServerCompletedEventArgs()
Initialize()
Dim client As UdpClient = Nothing
Try
' Configure and connect the socket.
client = New UdpClient()
Dim ipEndPoint As IPEndPoint = RemoteSNTPServer.GetIPEndPoint()
client.Client.SendTimeout = Timeout
client.Client.ReceiveTimeout = Timeout
client.Connect(ipEndPoint)
' Send and receive the data, and save the completion DateTime.
Dim request As SNTPData = SNTPData.GetClientRequestPacket(VersionNumber)
client.Send(request, request.Length)
result.Data = client.Receive(ipEndPoint)
result.Data.DestinationDateTime = DateTime.Now.ToUniversalTime()
' Check the data
If result.Data.Mode = Mode.Server Then
result.Succeeded = True
' Call other method(s) if needed
If (UpdateLocalDateTime) Then
UpdateTime(result.Data.LocalClockOffset)
result.LocalDateTimeUpdated = True
End If
Else
result.ErrorData = _
New ErrorData("The response from the server was invalid.")
End If
Return result
Catch ex As Exception
result.ErrorData = New ErrorData(ex)
Return result
Finally
If client IsNot Nothing Then
' Close the socket
client.Close()
End If
End Try
End Function
除了上述之外,还有一个静态属性 Now
(以及重载的 GetNow
方法),它同步检索服务器的当前日期和时间。
RemoteSNTPServer
这个类非常简单,本质上只包含服务器的主机名和端口。我在其中包含了许多服务器作为静态只读字段,以便可以在代码中轻松使用它们。您应该选择一个在地理上离您较近的服务器,最好是层 1 或层 2,尽管由于路径上的所有时间戳都会被记录下来,并且延迟会相应计算以产生偏移量,所以实际上差别不大。
SNTPData
这个类代表了我上面介绍的 48(可能 68)字节的数据包。因为它实际上只是一个字节数组,所以我实现了相应的转换运算符。这个类的大多数操作都是不言自明的。唯一有点复杂的部分是将 64 位定点时间戳转换为 System.DateTime
并再转换回来。我必须感谢 Luc Pattyn 在此方面的协助!问题解决后,实际的方法相当简单。这些方法的代码如下,应该能保持大约 1 个滴答(0.00000001 秒)的精度,但显然舍入误差以及系统执行时间更新等所需的时间使得这种精度无法实现,但它应该在几微秒内是准确的。
private DateTime TimestampToDateTime(int startIndex)
{
UInt64 seconds = 0;
for (int i = 0; i <= 3; i++)
seconds = (seconds << 8) | data[startIndex + i];
UInt64 fractions = 0;
for (int i = 4; i <= 7; i++)
fractions = (fractions << 8) | data[startIndex + i];
UInt64 ticks = (seconds * TicksPerSecond) +
((fractions * TicksPerSecond) / 0x100000000L);
return Epoch + TimeSpan.FromTicks((Int64)ticks);
}
private void DateTimeToTimestamp(DateTime dateTime, int startIndex)
{
UInt64 ticks = (UInt64)(dateTime - Epoch).Ticks;
UInt64 seconds = ticks / TicksPerSecond;
UInt64 fractions = ((ticks % TicksPerSecond) * 0x100000000L) / TicksPerSecond;
for (int i = 3; i >= 0; i--)
{
data[startIndex + i] = (byte)seconds;
seconds = seconds >> 8;
}
for (int i = 7; i >= 4; i--)
{
data[startIndex + i] = (byte)fractions;
fractions = fractions >> 8;
}
}
这个类中一个重要的方法是静态的 GetClientRequestPacket
。这个方法创建一个新的 SNTPData
实例,设置模式和版本号位,并将当前系统时间(转换为 UTC)放入传输时间戳。
internal static SNTPData GetClientRequestPacket(VersionNumber versionNumber)
{
SNTPData packet = new SNTPData();
packet.Mode = Mode.Client;
packet.VersionNumber = versionNumber;
packet.TransmitDateTime = DateTime.Now.ToUniversalTime();
return packet;
}
类图
这是一个类图,显示了上面类中通常会被您的应用程序使用的公共部分(为保持紧凑,此处未显示所有内容)。
正在使用
我已尽力使其易于使用。在您的代码中实例化一个 SNTPClient
(或者将其拖放到 Form
上,因为它也派生自 System.Component
),如果您想确保成功,请订阅 QueryServerCompleted
事件,或者检查任何数据,然后调用 QueryServerAsync
。这就是查询默认服务器并更新系统日期和时间所需要做的全部!我包含了一个演示应用程序来展示我在这篇文章中包含的内容,以及一些我没有包含的其他内容。
结论
我认为我已经从客户端的角度涵盖了 SNTP,并希望您觉得 SNTPClient
有用。在我的下一篇文章中,我计划创建一个 NTP/SNTP 服务器来补充这个客户端。
参考文献
其他实现
Valer BOCAN 已经写过一篇关于“C# SNTP 客户端”的文章。虽然他的文章评分很高,但我认为他的文章缺乏解释,并且我在他的代码中发现了一些问题。因此,在我看来,一篇更完整/更深入的文章和代码是必要的。他的代码被用来帮助我理解 RFCs 中的一些含糊之处,通过研究他的实现,以及显然存在一些相似之处,因为我们正在实现相同的协议,但没有抄袭!
致谢
- Luc Pattyn 协助我解决了在转换时间戳时遇到的问题,他在将此移植到 VB.NET 时遇到的一些问题,以及他在论坛上持续的出现。
- Valer BOCAN 撰写的现有文章。
- Sarah(我的“重要的人”),感谢她在我研究主题和编写本文/代码时让我失去了一个星期,也感谢她完成了校对这种枯燥的工作,以确保文章内容可读!
历史
- 2009 年 7 月 19 日:初始版本
- 2009 年 7 月 21 日:更新文章