عنوان:

‫مدیریت قطعی زمان در NET.: چرا باید استفاده از DateTime.UtcNow را ممنوع کنیم؟


نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۳/۱۹ ۱۰:۰۵
آدرس: www.dntips.ir
یکی از چالش‌برانگیزترین خطاهای نرم‌افزاری، خطاهایی هستند که فقط در آخرین روز ماه، در لحظه تغییر ساعت رسمی (Daylight Saving Time)، یا در کسری از ثانیه بامداد رخ می‌دهند؛ جاهایی که یک جابجایی کوچک در زمان، توکن‌های معتبر را باطل می‌کند یا فرآیندهای دوره‌ای را به اشتباه می‌اندازد. وجه مشترک همه این خطاها یک چیز است: زمان در کد هاردکد (Hardcoded) شده و هیچ‌کس نتوانسته آن را به طور قطعی تست کند.
استفاده مستقیم از ابزارهایی مانند DateTime.UtcNow یک فراخوانی ساده و بی‌ضرر نیست، بلکه ایجاد یک وابستگی پنهان (Hidden Dependency) به ساعت سخت‌افزاری سرور است. این وابستگی، تست‌پذیری قطعی (Deterministic Testing) را غیرممکن کرده و پایداری سیستم را به شانس واگذار می‌کند.
با معرفی TimeProvider در .NET 8، مایکروسافت یک انتزاع (Abstraction) رسمی، پایدار و درون‌ساخت برای مدیریت زمان ارائه داده است تا به این کابوس پایان دهد. در این مقاله بررسی می‌کنیم که چرا روش‌های قدیمی منسوخ شده‌اند، چگونه با ابزار تحلیل کد (Analyzer) استفاده از آن‌ها را ممنوع کنیم و چطور به یک معماری تست‌پذیر و قابل اطمینان دست یابیم.

مشکل اساسی DateTime.UtcNow و رویکردهای منسوخ
کد زیر را که برای بررسی انقضای توکن نوشته شده است در نظر بگیرید:
public bool IsTokenExpired(DateTime issuedAt, TimeSpan validity) 
{ 
    return DateTime.UtcNow > issuedAt + validity; 
}
این کد در ظاهر درست است، اما از نظر متدولوژی توسعه، غیرقابل تست است. برای نوشتن یک تست واحد (Unit Test) جهت اعتبارسنجی انقضای ۱۵ دقیقه‌ای این توکن، توسعه‌دهندگان معمولاً به یکی از راه‌حل‌های زیر متوسل می‌شوند:
  • وابستگی به زمان واقعی: ساخت توکنی که ۱۵ دقیقه قبل صادر شده و انتظار برای گذر زمان واقعی در محیط تست (ایجاد تست‌های شکننده یا Flaky Tests).
  • استفاده از Delegateها: تزریق یک پارامتر به صورت Func و پاس دادن () => DateTime.UtcNow در محیط عملیات (یک راه‌حل موقت و غیراستاندارد).
  • ساخت اینترفیس‌های شخصی: ایجاد اینترفیسی مانند IClock یا ISystemClock (اختراع دوباره چرخ در هر پروژه).
  • رها کردن تست: عدم نوشتن تست و امید به عملکرد صحیح در محیط Production (رایج‌ترین و خطرناک‌ترین رویکرد).

مرگ الگوهای سفارشی و ISystemClock
سال‌هاست که تیم‌های مجرب از اینترفیس ابداعی خود یا اینترفیس رسمی ISystemClock در ASP.NET Core استفاده می‌کنند:
public interface IClock 
{ 
    DateTime UtcNow { get; } 
}
اگرچه این الگو در گذشته کارآمد بود، اما با ارائه .NET 8 کاملاً استانداردسازی شده است. مایکروسافت ISystemClock را در ASP.NET Core منسوخ (Deprecated) کرده و TimeProvider را جایگزین آن نموده است. ادامه استفاده از ابزارهای موازی یا شخصی، صرفاً باعث سردرگمی توسعه‌دهندگان جدید و نگهداری کدهای زائد می‌شود. وقتی پلتفرم حرکت کرده است، معماری سیستم شما نیز باید همگام شود.

آشنایی با TimeProvider و FakeTimeProvider
کلاس انتزاعی TimeProvider در فضای نام System قرار دارد و سطح دسترسی آن عمداً کوچک و بهینه نگه داشته شده است:
public abstract class TimeProvider 
{ 
    public static TimeProvider System { get; } 
 
    public virtual DateTimeOffset GetUtcNow(); 
    public virtual DateTimeOffset GetLocalNow(); 
    public virtual TimeZoneInfo LocalTimeZone { get; } 
    public virtual long GetTimestamp(); 
    public virtual long TimestampFrequency { get; } 
    public virtual ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period); 
}
نکته برای پروژه‌های قدیمی‌تر: کلاس TimeProvider به صورت بومی از .NET 8 در دسترس است. با این حال، اگر پروژه‌ای روی .NET 6 یا .NET 7 دارید، نیازی به بازنویسی ساختار ندارید؛ می‌توانید با نصب پکیج NuGet رسمی Microsoft.Bcl.TimeProvider دقیقاً از همین قابلیت‌ها بهره‌مند شوید.

نکته کلیدی اینجاست که TimeProvider فقط یک پوسته دور DateTime نیست؛ این کلاس ساخت لایه‌های زمان‌سنج (ITimer) را نیز کنترل می‌کند. این یعنی رفتارهای مبتنی بر زمانِ شبکه یا تاخیرها نیز بدون نیاز به ترفندهای پیچیده Threading، کاملاً تست‌پذیر می‌شوند.

بازنویسی نمونه توکن با الگوهای جدید
تغییر کلاس بالا به یک ساختار تست‌پذیر به سادگی تزریق وابستگی (Dependency Injection) است:
public class TokenValidator 
{ 
    private readonly TimeProvider _time; 
 
    public TokenValidator(TimeProvider time) 
    { 
        _time = time; 
    } 
 
    public bool IsTokenExpired(DateTimeOffset issuedAt, TimeSpan validity) 
    { 
        return _time.GetUtcNow() > issuedAt + validity; 
    } 
}
ثبت این وابستگی در کدهای عملیاتی (Program.cs) تنها به یک خط کد نیاز دارد:
services.AddSingleton(TimeProvider.System);

تست قطعی و بدون تاخیر با FakeTimeProvider
مایکروسافت پکیج رسمی Microsoft.Extensions.TimeProvider.Testing را دقیقا برای سناریوهای تست ارائه داده است که کلاس FakeTimeProvider را در اختیار ما می‌گذارد. به نحوه تست بدون ثانیه‌ای تاخیر واقعی دقت کنید:
var fakeTime = new FakeTimeProvider(); 
fakeTime.SetUtcNow(new DateTimeOffset(2026, 6, 9, 12, 0, 0, TimeSpan.Zero)); 
 
var validator = new TokenValidator(fakeTime); 
var issuedAt = fakeTime.GetUtcNow().AddMinutes(-16); 
 
// به طور قطعی مشخص است که توکن منقضی شده است
Assert.True(validator.IsTokenExpired(issuedAt, TimeSpan.FromMinutes(15)));
شما حتی می‌توانید زمان را به صورت دستی جلو ببرید:
fakeTime.Advance(TimeSpan.FromMinutes(30));
این قابلیت برای تست سناریوهایی مانند تمدید نشست‌ها (Session Renewal)، استراتژی‌های بازامپدانس (Retry Backoff) و انقضای اجاره‌ها (Lease Expiry) که در آن‌ها زمان باید در طول یک توالی از عملیات‌ها جلو برود، معجزه می‌کند.

حل مشکل پنهان زمان‌سنج‌ها (Timers)
بحث مدیریت زمان معمولاً به UtcNow ختم می‌شود، اما TimeProvider مشکل سخت‌تری را حل می‌کند: کنترل زمان‌سنج‌ها و تاخیرها بدون معطل نگه داشتن دیتاسنتر یا سیستم تست.
مکانیزم پویای رویکرد کدهای جدید را در اعمال سیاست Retry با ضریب افزایش نمایی (Exponential Backoff) ببینید:
public async Task RetryAsync(Func<Task> operation, TimeProvider time) 
{ 
    for (int attempt = 0; attempt < 3; attempt++) 
    { 
        try 
        { 
            await operation(); 
            return; 
        } 
        catch 
        { 
            var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); 
            // ایجاد توکن لغو مبتنی بر زمان از طریق واگذاری به سرویس ارائه‌دهنده زمان
            await Task.Delay(delay, time.CreateCancellationTokenSource(delay).Token); 
        } 
    } 
}
برای تست این بخش، نیازی نیست تست واحد ما چند ثانیه واقعاً منتظر بماند (که باعث طولانی شدن کل فرآیند CI/CD شود). با FakeTimeProvider می‌توان زمان را به صورت مجازی جلو برد تا رفتارهای درونی تحریک شوند:
var fakeTime = new FakeTimeProvider(); 
var retryTask = RetryAsync(failingOperation, fakeTime); 
 
fakeTime.Advance(TimeSpan.FromSeconds(1));  // شبیه‌سازی و تحریک تلاش مجدد اول 
fakeTime.Advance(TimeSpan.FromSeconds(2));  // شبیه‌سازی و تحریک تلاش مجدد دوم 
fakeTime.Advance(TimeSpan.FromSeconds(4));  // شبیه‌سازی و تحریک تلاش مجدد سوم 
 
await retryTask;
تست‌های شما بلافاصله، بدون ترفندهایی مثل Task.Delay(100) یا قفل کردن Thread اجرا می‌شوند.

ضمانت اجرایی: ممنوع کردن کدهای سنتی با تحلیل‌گرهای Roslyn
توصیه به تیم برای عدم استفاده از کدهای سنتی کافی نیست؛ برای اطمینان از اینکه هیچ کد مخربی وارد مخزن پلتفرم (Repository) نمی‌شود، باید قوانین سخت‌گیرانه‌ای در سطح کامپایلر وضع کرد. برای این کار از پکیج رسمی ابزار تحلیل‌گر Microsoft.CodeAnalysis.BannedApiAnalyzers استفاده می‌کنیم.
ابتدا باید فایل قوانین با نام BannedSymbols.txt را ایجاد کرده و پیکربندی زیر را درون آن قرار دهیم. این فایل تمام راه‌های دسترسی مستقیم به ساعت سیستم (از جمله سیستم قدیمی ISystemClock) را مسدود می‌کند:
T:System.DateTime;استفاده مستقیم از ساختار زمان به دلیل ایجاد وابستگی مخفی ممنوع است. از TimeProvider استفاده کنید.
P:System.DateTime.UtcNow;دسترسی مستقیم به ساعت جهانی ممنوع است. لطفاً TimeProvider را تزریق کنید.
P:System.DateTime.Now;دسترسی مستقیم به ساعت محلی ممنوع است. لطفاً TimeProvider را تزریق کنید.
P:System.DateTimeOffset.UtcNow;دسترسی مستقیم به ساعت جهانی ممنوع است. لطفاً TimeProvider را تزریق کنید.
P:System.DateTimeOffset.Now;دسترسی مستقیم به ساعت محلی ممنوع است. لطفاً TimeProvider را تزریق کنید.
T:Microsoft.AspNetCore.Authentication.ISystemClock;این اینترفیس قدیمی و منسوخ شده است. به جای آن از TimeProvider استفاده کنید.
T:Microsoft.Extensions.Internal.ISystemClock;این اینترفیس قدیمی و منسوخ شده است. به جای آن از TimeProvider استفاده کنید.
سپس برای اعمال این فایل به پروژه به عنوان یک سند تحلیل، تکه کد زیر را به فایل پروژه خود (.csproj) یا فایل مشترک مدیریت ساختار پروژه‌ها (Directory.Build.props) اضافه کنید:
<ItemGroup>
  <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" PrivateAssets="all" IncludeAssets="runtime; build; native; contentfiles; analyzers; buildtransitive" />
</ItemGroup>

<ItemGroup>
  <AdditionalFiles Include="$(MSBuildThisFileDirectory)BannedSymbols.txt" Link="Properties/BannedSymbols.txt" />
</ItemGroup>
پس از اعمال این تنظیمات، هرگونه تلاش برای استفاده از خصوصیات ممنوع شده، بلافاصله منجر به خطای زمان کامپایل (Compile-time Error) شده و از ورود کد ناامن به چرخه نرم‌افزاری جلوگیری می‌کند.

عبور از بن‌بست در کدهای میرا (Legacy)
تغییر ساختار در کدهایی که از الگوهای تزریق وابستگی پیروی نمی‌کنند یا متدهای استاتیک و کلاس‌های Sealed دارند، چالش‌برانگیز است. اگر در کدهای قدیمی خود امکان اصلاح امضای سازنده‌ها (Constructors) وجود ندارد، به عنوان یک راهکار گذار (و نه معماری هدف) می‌توانید از الگوی زمینه محیطی (Ambient Context) استفاده کنید:
public static class TimeContext 
{ 
    [ThreadStatic] 
    private static TimeProvider? _current; 
 
    public static TimeProvider Current 
    { 
        get => _current ?? TimeProvider.System; 
        set => _current = value; 
    } 
}
در متدهای تست، می‌توانید TimeContext.Current را با یک FakeTimeProvider مقداردهی کرده و در پایان تست (Teardown) آن را پاکسازی کنید. این کار به شما کمک می‌کند تا بدون بازنویسی کل سیستم، وابستگی‌های مخفی را کنترل کنید. اما در نظر داشته باشید که در اولین فرصت، این کدهارا به مدل تزریق سازنده ارجاع دهید.

قانون تکین (The One Rule)
یک قانون ساده برای توسعه وضع کنید: در هر بخشی از کدهای برنامه که قرار است تحت پوشش تست‌های واحد قرار گیرد، هرجا نیاز به استفاده از ساختارهای زمانی دارید، باید TimeProvider را تزریق کنید.
محدوده این تغییرات کوچک‌تر از آن چیزی است که فکر می‌کنید. بخش عمده‌ای از فراخوانی‌های زمانی در چند کلاس خاص متمرکز شده‌اند: سیستم‌های اعتبارسنجی توکن، مدیریت نشست‌ها، ثبت تاریخچه حسابرسی (Audit Logs) و زمان‌بندی کارهای دوره‌ای (Job Schedulers). با مهاجرت دادن همین بخش‌ها، ۹۰٪ مشکلات سیستم حل خواهد شد.
۱۰٪ باقی‌مانده مربوط به موجودیت‌های ذخیره‌سازی ساده مثل فیلدهای محض CreatedAt در زمان نگاشت دیتابیس یا فرمت‌های نمایشی است؛ این موارد نیازی به کنترل زمان ندارند و می‌توان آن‌ها را به همان صورت رها کرد.

نتیجه‌گیری
شما نمی‌توانید چیزی را که قادر به کنترل آن نیستید، تست کنید. زمان وجه تمایزی با سایر مولفه‌های پویای نرم‌افزار ندارد و باید انتزاع شود. TimeProvider در .NET یک ابزار آزمایشی یا فریم‌ورک فرعی نیست، بلکه اصلاح یک نقص طراحی قدیمی است که از نسخه‌های اولیه دات‌نت وجود داشته است.
هسته اصلی پلتفرم دات‌نت و پکیج‌های توسعه‌ای مایکروسافت همگی به این سمت کوچ کرده‌اند. اصلاح سازنده‌ها برای پذیرش این ارائه‌دهنده زمان، ریسک به همراه ندارد؛ بلکه برعکس، انتشار کدهایی که رفتارهای زمانی آن‌ها هرگز تست نشده، ریسک اصلیِ کسب‌وکار شماست. با مسدودسازی مسیرهای سنتی از طریق ابزارهای تحلیل Roslyn و بکارگیری سیستم مدیریت زمان جدید، سیستم خود را در برابر خطاهای ناگهانی نیمه‌شب بیمه کنید.