经过昨晚的调整,结果达到预期。

背景

我桌子这里浇花的A4工控机,里面时间比实际时间要慢5分钟,导致我设定每天9点开始的浇花动作,实际执行时间都变成了9点5分。例如我在9点2分登录A4,查看时间是这样:

从星尘代理节点性能数据也可以看到偏差297秒(A4浇花工控机):

从昨天星尘监控调用链,也可以看到A4在自己的9点执行,实际上服务器是9点5分。


解决方案

星尘AppClient有个GetNow()方法,提供基于服务器时间的本地时间。它决定于登录和每次心跳,都计算服务器的时间差Span。然后GetNow()的时候,本质上就是取本机时间加上时间差Span。

初期的想法,是给TimerX增加一个GetNow委托,配置星尘后,这个委托指向AppClient.GetNow()。TimerX内部根据Cron表达式计算下一次执行时间的时候,就用GetNow()来取代原来的DateTime.Now。实际写代码的时候,发现这个方案有点Low。想起新版dotNet好像有个时间提供者。

经查资料,时间提供者TimeProvider正合我意。里面的关键是GetUtcNow,默认返回DateTime.UtcNow。给TimerX加上TimeProvider并默认指向TimeProvider.System,也就是SystemTimeProvider。

在星尘里,初始化星尘工厂后,直接修改定时器调度器的时间提供者为新的StarTimeProvider,代码如下:

[MemberNotNullWhen(true, nameof(_client))]
private Boolean Valid()
{
    //if (Server.IsNullOrEmpty()) throw new ArgumentNullException(nameof(Server));
    //if (AppId.IsNullOrEmpty()) throw new ArgumentNullException(nameof(AppId));

    if (Server.IsNullOrEmpty() || AppId.IsNullOrEmpty()) return false;

    if (_client == null)
    {
        //if (!AppId.IsNullOrEmpty()) _tokenFilter = new TokenHttpFilter
        //{
        //    UserName = AppId,
        //    Password = Secret,
        //    ClientId = ClientId,
        //};

        var client = new AppClient(Server)
        {
            Factory = this,
            AppId = AppId,
            AppName = AppName,
            Secret = Secret,
            ClientId = ClientId,
            NodeCode = Local?.Info?.Code,
            //Filter = _tokenFilter,
            //UseWebSocket = true,

            Log = Log,
        };

#if !NET40
        // 设置全局定时调度器的时间提供者,借助服务器时间差,以获得更准确的时间。避免本地时间偏差导致定时任务执行时间不准确
        TimerScheduler.GlobalTimeProvider = new StarTimeProvider { Client = client };
#endif

        //var set = StarSetting.Current;
        //if (set.Debug) client.Log = XTrace.Log;
        client.WriteInfoEvent("应用启动", $"pid={Process.GetCurrentProcess().Id}");

        _client = client;

        InitTracer();

        client.Tracer = _tracer;
        client.Start();

        // 注册StarServer环境变量,子进程共享
        Environment.SetEnvironmentVariable("StarServer", Server);
    }

    return true;
}

星尘时间提供者如下:

internal class StarTimeProvider : TimeProvider
{
    public ClientBase Client { get; set; } = null!;

    public override DateTimeOffset GetUtcNow() => Client != null ? Client.GetNow().ToUniversalTime() : base.GetUtcNow();
}

发布修改版星尘,修改浇花程序A4Flower(源码),编译后,再通过星尘平台把程序发布到A4工控机里面。今天早上9点正,A4准时开始浇花(调用链),也即是开头的截图。

总结

星尘时间提供者里面的ClientBase,来自于NewLife.Remoting,那么这个时间提供者是否应该由Remoting提供?所以,实际上使用服务器时间做定时任务,并不一定需要依赖星尘,依赖其它基于Remoting框架的项目也是可以的,例如IoT网关,它就有这个需求,不要因本机时间偏差而导致Cron绝对定时器执行时间有偏差。

说改就改:

/// <summary>基于服务器时间差的时间提供者</summary>
public class ServerTimeProvider : TimeProvider
{
    /// <summary>客户端</summary>
    public ClientBase Client { get; set; } = null!;

    /// <summary>获取UTC时间</summary>
    /// <returns></returns>
    public override DateTimeOffset GetUtcNow() => Client != null ? DateTime.UtcNow.Add(Client.Span) : base.GetUtcNow();
}

由于AppClient继承ClientBase,其GetNow()就来自于后者。因此ClientBase干脆直接提供时间差Span属性,在服务器时间差提供者里面,可以用DateTime.UtcNow.Add(Span),避免时间反复折算。

至此,使用TimeProvider完美解决Cron绝对定时时间不准的问题!