由于计算机本地时间可能不准确,所以需要与授时服务器进行时间同步。Windows自带有定时同步功能,但是如前文所述,在某些情况下可能会失败。所以有必要开发一个工具能够定时同步,并且同步失败后能够进行重试。

    在开发之前网上有一个NTPClock工具,这个工具是台湾人1998年开发的,开发语言是C++,如果简单的使用它来进行时间同步,完全没问题,但是如果要添加诸如重试或者更复杂的定时之类的功能,就需要自己开发了。

▲ NTPClock主要界面

一 需求分析


    需求很简单,需要实现如下功能:

  1. 能够与NTP授时服务器,使用NTP协议进行时间同步。
  2. 支持手动时间同步、按照一定的频率定时进行同步,如果同步出错,进行重试。
  3. 能够维护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;
}