.NET 8 中的 TimeProvider

Intro

.NET 8 将引入一个时间抽象 TimeProvider,在之前的版本中遇到时间相关的逻辑一般想要 mock 时间如 (DateTime.UtcNow/DateTime.Now) 会非常的困难以后需要 mock 的逻辑使用 TimeProvider 就可以很容易进行 mock 了.

New API

新增的一些 API 如下:

public abstract class TimeProvider
{
    public static TimeProvider System { get; }
    protected TimeProvider() 
    public virtual DateTimeOffset GetUtcNow()
    public DateTimeOffset GetLocalNow()
    public virtual TimeZoneInfo LocalTimeZone { get; }
    public virtual long TimestampFrequency { get; }
    public virtual long GetTimestamp()
    public TimeSpan GetElapsedTime(long startingTimestamp)
    public TimeSpan GetElapsedTime(long startingTimestamp, long endingTimestamp)
    public virtual ITimer CreateTimer(TimerCallback callback, object? state,TimeSpan dueTime, TimeSpan period)
}

public interface ITimer : IDisposable, IAsyncDisposable
{
    bool Change(TimeSpan dueTime, TimeSpan period);
}

public partial class CancellationTokenSource : IDisposable
{
    public CancellationTokenSource(TimeSpan delay, TimeProvider timeProvider)
}

public sealed partial class PeriodicTimer : IDisposable
{
    public PeriodicTimer(TimeSpan period, TimeProvider timeProvider) 
}

public partial class Task : IAsyncResult, IDisposable
{
    public static Task Delay(System.TimeSpan delay, System.TimeProvider timeProvider)
    public static Task Delay(System.TimeSpan delay, System.TimeProvider timeProvider, System.Threading.CancellationToken cancellationToken)

    public Task WaitAsync(TimeSpan timeout, TimeProvider timeProvider)
    public Task WaitAsync(TimeSpan timeout, TimeProvider timeProvider, CancellationToken cancellationToken) 
}

public partial class Task<TResult> : Task
{
    public new Task<TResult> WaitAsync(TimeSpan timeout, TimeProvider timeProvider)
    public new Task<TResult> WaitAsync(TimeSpan timeout, TimeProvider timeProvider, CancellationToken cancellationToken) 
}

从上述 TimeProvider 是一个抽象类型,我们可以重写它的一些逻辑,下面我们来看一些示例:针对 CancellationTokenSource 和 Task.Delay/Task.WaitAsybc 也支持了 TimeProvider 的参数,这也意味着我们可以方便的控制一些 task 的等待时间等,针对 task 的超时也可以比较容易的进行 mock

Samples

来看第一个示例:

var now = TimeProvider.System.GetUtcNow();
Console.WriteLine(now.ToString());
Console.WriteLine(TimeProvider.System.GetLocalNow());
Console.WriteLine();

var mockTimeProvider = new MockTimeProvider(now.AddYears(100));
Console.WriteLine(mockTimeProvider.GetUtcNow());
Console.WriteLine(mockTimeProvider.GetLocalNow());
Console.WriteLine();

TimeProvider.System 是框架提供的,TimeProvider.System.GetUtcNow() 等同于我们直接使用 System.DateTime.UtcNow ,这里我们使用了一个 MockTimeProvider 来模拟mock 一个 TimeProvider,  实现如下:

file sealed class MockTimeProvider : TimeProvider
{
    private readonly DateTimeOffset _dateTimeOffset;

    public MockTimeProvider(DateTimeOffset dateTimeOffset)
    {
        _dateTimeOffset = dateTimeOffset;
    }

    public override DateTimeOffset GetUtcNow()
    {
        return _dateTimeOffset;
    }
    
    public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}

这里重写了 TimeProvider 中的 GetUtcNow() 直接返回了一个固定的时间,并且指定了 LocalTimeZone 为 TimeZoneInfo.Utc输出结果如下:

8/5/2023 15:21:39 +00:00
8/5/2023 23:21:39 +08:00

8/5/2123 15:21:39 +00:00
8/5/2123 15:21:39 +00:00

可以看到使用我们 mock 的 TimeProvider 输出的时间是我们输入的一个假的时间,并且 localNow 和 utcNow 是一致的,因为我们 mock 的 TimeProvider 指定了 local 时区也是 utc 时间让我们来看第二个示例:

var startTimestamp = TimeProvider.System.GetTimestamp();
Thread.Sleep(1000);
var elapsedTime = TimeProvider.System.GetElapsedTime(startTimestamp);
Console.WriteLine(elapsedTime);
Console.WriteLine();

var startTimestamp1 = mockTimeProvider.GetTimestamp();
Thread.Sleep(3000);
var elapsedTime1 = mockTimeProvider.GetElapsedTime(startTimestamp1);
Console.WriteLine(elapsedTime1);
Console.WriteLine();

这里我们使用 GetTimestamp() 来统计某个方法或操作的耗时,这的方法在 .NET 7 中引入到 Stopwatch 类型中,在 TimeProvider 中也引入这个方法TimeProvider.System.GetTimestamp()/TimeProvider.System.GetElapsedTime  等同于直接使用 Stopwatch.GetTimestamp()/Stopwatch.GetElapsedTime这里我们对 mockTimeProvider 做改一点改造,重写了一下 TimestampFrequency,将其扩大了 3 倍,这样我们的统计数来的耗时就会缩小 3 倍,让我们看一下输出结果,就比较清晰的理解了

file sealed class MockTimeProvider : TimeProvider
{
    public override long TimestampFrequency => Stopwatch.Frequency * 3;
}

输出结果如下:

00:00:01.0147489

00:00:01.0034881

可以看到,我们两次输出的结果都是 1 秒,尽管我们第二次 sleep 的时间是 3 秒而非 1 秒,因为我们重写了 TimestampFrequency 导致时间缩短了最后我们再看一下 Task.Delay 的示例:

Console.WriteLine(TimeProvider.System.GetUtcNow());
Task.Delay(TimeSpan.FromSeconds(1), mockTimeProvider).Wait();

Console.WriteLine(TimeProvider.System.GetUtcNow());

这里我们对 mockTimeProvider 继续做一些修改:Task.Delay 是利用了一个 Timer 到时间了修改 task 的状态,所以我们需要重写 CreateTimer 方法,首先我们实现一个 MockTimer,再重写方法

file sealed class MockTimer : ITimer
{
    private readonly ITimer _time;

    public MockTimer(
        TimerCallback callback,
        object? state,
        TimeSpan dueTime,
        TimeSpan period)
    {
        _time = TimeProvider.System.CreateTimer(callback, state, dueTime * 2, period);
    }
    
    public void Dispose()
    {
        _time.Dispose();
    }

    public ValueTask DisposeAsync()
    {
        return _time.DisposeAsync();
    }
    
    public bool Change(TimeSpan dueTime, TimeSpan period)
    {
        return _time.Change(dueTime, period);
    }
}

file sealed class MockTimeProvider : TimeProvider
{
    public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
    {
        return new MockTimer(callback, state, dueTime, period);
    }
}

输出结果如下:

8/5/2023 15:50:02 +00:00
8/5/2023 15:50:04 +00:00

可以看到,我们 Task.Delay 1 秒实际经过我们的 mock TimeProvider 变成了两秒因为我们 MockTimer 对 dueTime 做了两倍的处理

More

TimeProvider 的引入能够为基于时间的测试 mock 变得非常简单,如果你也遇到需要对时间进行 mock 的场景,可以尝试一下。

这不仅限于 .NET 8 及以后的框架,针对之前的框架微软提供了一个单独的 nuget package Microsoft.Bcl.TimeProvider,如果是之前的框架不好升级可以引用这个 nuget package 使用