由于计算机本地时间可能不准确,所以需要与授时服务器进行时间同步。Windows自带有定时同步功能,但是如前文所述,在某些情况下可能会失败。所以有必要开发一个工具能够定时同步,并且同步失败后能够进行重试。
在开发之前网上有一个NTPClock工具,这个工具是台湾人1998年开发的,开发语言是C++,如果简单的使用它来进行时间同步,完全没问题,但是如果要添加诸如重试或者更复杂的定时之类的功能,就需要自己开发了。
▲ NTPClock主要界面
一 需求分析
需求很简单,需要实现如下功能:
- 能够与NTP授时服务器,使用NTP协议进行时间同步。
- 支持手动时间同步、按照一定的频率定时进行同步,如果同步出错,进行重试。
- 能够维护NTP授时服务器列表,能够设置定时同步方案,并能保存设置。
二 具体实现
对于与NTP授时服务器进行同步,使用在前面一篇文章中介绍的NtpClient类即可,这里封装一下,添加重试功能,这个是同步方法,我们在调用的时候需要放到工作线程里去处理。
public class NtpClockHelper
{
public static bool GetDateTimeOffset(string url, out TimeSpan ts, out string errMsg)
{
ts = new TimeSpan(0);
errMsg = string.Empty;
int hits = 3;
int tries = 0;
for (int i = 0; i < hits; i++)
{
try
{
tries++;
IPAddress server = Dns.GetHostEntry(url).AddressList[0];
using (NtpClient client = new NtpClient(server))
{
try
{
ts = client.GetCorrectionOffset();
break;
}
catch (Exception ex)
{
errMsg = ex.Message;
}
}
}
catch (Exception e)
{
errMsg = e.Message;
}
if (tries == hits)
{
errMsg = $"尝试三次均失败,错误:{errMsg}";
return false;
}
Thread.Sleep(1000);
}
return true;
}
}
该方法的参数为NTP服务器地址,两个out参数,第一个为本地服务器如远程NTP服务器的时间差ts,第二个为错误新。方法内部使用NTPClient同步,如果出错重试三次,每次之间暂停1秒。如果该方法返回True则表示同步成功,将本地服务器时间加上本地与远程服务器时间差ts,计算后就得到了正确的北京时间,然后用前面文章里SystemTimeHelper类中的方法将该时间设置到本地。
我这里使用WPF实现这个工具。界面如下:
平平淡淡的WPF Grid布局,最上面放的是一个Label,用一个DispatcherTimer每隔500ms读取一下本机的时间,显示。中间一行左边是授时服务器的维护,可以直接进行添加、删除和设为默认操作。中间右边是定时设置,可以设置为手动和自动,如果设置为自动,则需要设置定时的开始时间和结束时间,以及定时同步的频率。最下面是同步的日志相关信息。
这里用到了两个DispatcherTimer控件,一个是上面用来每个500ms读取本机时间并显示,一个用来执行定时操作。
点击“立即同步”就是手动同步,手动同步的方法如下:
private void ManualSync()
{
string ntpServer = cmbAllNtpServers.Text.Trim();
if (!string.IsNullOrEmpty(ntpServer))
{
btnSync.Content = "同步中...";
btnSync.IsEnabled = false;
Task.Run(() =>
{
bool result = NtpClockHelper.GetDateTimeOffset(ntpServer, out TimeSpan ts, out string errMsg);
DateTime time;
if (result)
{
DateTime localTime = DateTime.Now;
TimeSpan diff = ts;
time = localTime.Add(diff);
SystemTimeHelper.SetLocalMachineTime(time);
logger.Log(LogLevel.Info, $"{time}-与【{ntpServer}】手动同步时间成功,时间相差:{diff.TotalMilliseconds}ms");
}
else
{
time = DateTime.Now;
logger.Log(LogLevel.Error, $"{time}-与【{ntpServer}】手动同步时间失败:{errMsg}");
}
return new ResultInfo
{ IsSuccess = result, Time = time, ErrorMessage = errMsg, TsOffSet = ts, ServerName = ntpServer };
}).ContinueWith((Task<ResultInfo> task) =>
{
Dispatcher.Invoke(() =>
{
if (task.Result.IsSuccess)
{
DisplayDateTime(task.Result.Time);
AddToMsg(
$"{task.Result.Time}-与【{task.Result.ServerName}】手动同步时间成功,时差:{task.Result.TsOffSet.TotalMilliseconds}ms\r\n",
Status.Success);
}
else
{
AddToMsg($"{task.Result.Time}-与【{task.Result.ServerName}】手动同步时间失败:{task.Result.ErrorMessage}\r\n",
Status.Failed);
}
btnSync.IsEnabled = true;
btnSync.Content = "立即同步";
});
});
}
}
这里需要注意的是,同步操作是同步且耗时的,需要将他放到工作线程里操作。这里的做法是放在一个Task里面处理,同步时间成功之后,调用方法将该时间设置到本机计算机,整个操作过程结束之后调用ContinueWith操作符,获取结果,然后更新UI界面,因为这里是工作线程,所以必须使用Dispatcher.Invoke方法,将操作封送到UI线程里处理。
定时操作,则需要使用DispatcherTimer来操作,因为这个Timer也是在UI线程上执行的,所以诸如同步时间以及设置时间等耗时操作,也需要跟前面手动同步那样,放在工作线程里处理,处理完成之后,再转回UI线程来更新界面。
private void AutoSync()
{
if (config.UpdateStartTime.TimeOfDay != config.UpdateEndTime.TimeOfDay && cmbUpdateInterval.SelectedValue is Frequency frequency)
{
SetUpdateTiming(config.UpdateStartTime.TimeOfDay, config.UpdateEndTime.TimeOfDay, frequency.Seconds);
}
}
private void SetUpdateTiming(TimeSpan start, TimeSpan end, int seconds)
{
DateTime beginTime = DateTime.Today.Add(start);
DateTime endTime = DateTime.Today.Add(end);
TimeSpan sleepTs;
if (beginTime > endTime)
{
endTime = endTime.AddDays(1);
}
DateTime dtNow = DateTime.Now;
if (dtNow < beginTime)
{
AddToMsg($"{DateTime.Now}-定时任务还没到启动时间,sleep to:{beginTime}\r\n");
logger.Log(LogLevel.Info, $"Sleep to {beginTime}");
sleepTs = beginTime - dtNow;
}
else if (dtNow >= beginTime && dtNow < endTime)
{
sleepTs = new TimeSpan(0);
}
else
{
sleepTs = beginTime.AddDays(1) - dtNow;
AddToMsg($"{DateTime.Now}-定时任务还没到启动时间,sleep to:{beginTime.AddDays(1)}\r\n");
logger.Log(LogLevel.Info, $"Sleep to {beginTime.AddDays(1)}");
}
StopTimer();
updateTimer = new DispatcherTimer()
{
Interval = sleepTs
};
updateTimer.Tag = new TimingParam(beginTime.TimeOfDay, endTime.TimeOfDay, seconds);
updateTimer.Tick += UpdateTimer_Tick;
updateTimer.Start();
}
private void StopTimer()
{
if (updateTimer != null && updateTimer.IsEnabled)
{
updateTimer.Tick -= UpdateTimer_Tick;
updateTimer.Stop();
}
}
private void UpdateTimer_Tick(object sender, EventArgs e)
{
//立即执行一次,然后判断时间,修改频率
string ntpServer = config.DefaultNtpServer;
btnSettingTiming.Content = "自动同步中...";
btnSettingTiming.IsEnabled = false;
updateTimer.Stop();
if (sender is DispatcherTimer dt)
{
if (dt.Tag is TimingParam tp)
{
DateTime now = DateTime.Now;
//如果当前时间已经超过结束时间,则直接sleep到下一次
if (now.TimeOfDay > tp.End)
{
DateTime sleepTo = DateTime.Today.AddDays(1).Add(tp.Start);
TimeSpan sleepTs = sleepTo - now;
AddToMsg($"{DateTime.Now}-当前时间已经超过自动更新的结束时间,sleep to:{sleepTo}\r\n");
logger.Log(LogLevel.Info, $"Sleep to {sleepTo}");
updateTimer.Interval = sleepTs;
updateTimer.Start();
return;
}
Task.Run(() =>
{
DateTime time;
bool result = NtpClockHelper.GetDateTimeOffset(ntpServer, out TimeSpan ts, out string errMsg);
if (result)
{
DateTime localTime = DateTime.Now;
TimeSpan diff = ts;
time = localTime.Add(diff);
SystemTimeHelper.SetLocalMachineTime(time);
logger.Log(LogLevel.Info, $"{time}-与【{ntpServer}】定时同步时间成功,时间相差:{diff.TotalMilliseconds}ms");
}
else
{
time = DateTime.Now;
logger.Log(LogLevel.Error, $"{time}-与【{ntpServer}】定时同步时间成功失败,{errMsg}");
}
return new ResultInfo
{
IsSuccess = result,
Time = time,
ErrorMessage = errMsg,
TsOffSet = ts,
ServerName = ntpServer
};
}).ContinueWith((Task<ResultInfo> task) =>
{
Dispatcher.Invoke(() =>
{
if (task.Result.IsSuccess)
{
DisplayDateTime(task.Result.Time);
AddToMsg($"{task.Result.Time}-与【{task.Result.ServerName}】定时同步时间成功,时间相差:{task.Result.TsOffSet.TotalMilliseconds}ms\r\n", Status.Success);
}
else
{
AddToMsg($"{task.Result.Time}-与【{task.Result.ServerName}】定时同步时间失败,{task.Result.ErrorMessage}\r\n", Status.Failed);
}
btnSettingTiming.IsEnabled = true;
btnSettingTiming.Content = "设置定时";
DateTime beginTime = DateTime.Today.Add(tp.Start);
DateTime endTime = DateTime.Today.Add(tp.End);
TimeSpan sleepTs;
if (tp.Start > tp.End)
{
endTime = endTime.AddDays(1);
}
DateTime dtNow = DateTime.Now;
if (dtNow < beginTime)
{
sleepTs = beginTime - dtNow;
AddToMsg($"{DateTime.Now}-定时任务还没到启动时间,sleep to:{beginTime}\r\n");
logger.Log(LogLevel.Info, $"Sleep to {beginTime} in callback");
}
else if (dtNow >= beginTime && dtNow < endTime)
{
sleepTs = new TimeSpan(0, 0, tp.Interval);
}
else
{
sleepTs = beginTime.AddDays(1) - dtNow;
AddToMsg($"{DateTime.Now}-当前时间已经超过自动更新的结束时间,sleep to:{beginTime.AddDays(1)}\r\n");
logger.Log(LogLevel.Info, $"Sleep to {beginTime.AddDays(1)} in callback");
}
updateTimer.Interval = sleepTs;
updateTimer.Start();
});
});
}
}
}
自动同步的关键方法如上,首先需要定义一个类型为DispatcherTimer的updateTimer控件。在设置完自动同步后,调用AutoSync方法,在该方法类型,实例化updateTimer,并设置其Tick回调和Interval,最后调用Start方法就会在Interval后执行第一次操作。在Tick回调方法中首先将该updateTimer停止,然后判断当前时间是否在设置的定时开始和结束时间之间,如果当前时间已经超过定时同步的结束时间,则计算下一次同步的时间,然后将该时间设置为updateTimer的Interval,然后启动。
如果当前时间小于结束时间,则首先进行一次时间同步和设置,然后在ContinueWith里,更新UI界面,并计算下一次执行的时间,然后启动DispatcherTimer。
最后程序需要拥有管理员权限,如何设置在前面一篇文章有介绍这里不赘述了。另外,每当程序启动的时候,立即同步一次。在同步过程中,如果同步成功,则用绿色信息网TextBlock中插入一条记录,否则用红色插入一条同步错误信息。
最最后,这个程序一般会一直运行,所以需要处理一下退出问题,这里禁用了窗体上的关闭按钮,增加了最小化到托盘功能,当点击最小化或者关闭的时候,程序缩到托盘里,只有在托盘里的弹出菜单中点击退出,才会退出。这里引用了一个HandyControl类库,使用起来也很简单,添加NuGet包,然后在名字空间里添加:
xmlns:hc="https://handyorg.github.io/handycontrol"
然后在窗体的Grid跟下面添加如下代码:
<hc:NotifyIcon x:Name="NotifyIconContextContent" Text="NTP校时" Visibility="Visible">
<hc:NotifyIcon.ContextMenu>
<ContextMenu>
<MenuItem Command="hc:ControlCommands.PushMainWindow2Top" Header="打开"/>
<MenuItem Command="hc:ControlCommands.ShutdownApp" Header="退出"/>
</ContextMenu>
</hc:NotifyIcon.ContextMenu>
<hc:Interaction.Triggers>
<hc:EventTrigger EventName="Click">
<hc:EventToCommand Command="hc:ControlCommands.PushMainWindow2Top"/>
</hc:EventTrigger>
</hc:Interaction.Triggers>
</hc:NotifyIcon>
这里“退出”和“打开”程序会自己处理,我们不需要编写任何代码。唯一需要做的就是重写窗体右上角的关闭按钮功能:
protected override void OnClosing(CancelEventArgs e)
{
this.Hide();
e.Cancel = true;
}