عنوان:

‫مکانیزم ChangeToken در دات‌نت؛ مدیریت تغییرات واکنشی و ابطال کش بدون سربار اضافه


نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۶/۰۷ ۱۲:۲۵
آدرس: www.dntips.ir
چکیده: در اکوسیستم دات‌نت (.NET)، قابلیت‌هایی مانند بارگذاری مجدد پیکربندی‌ها با reloadOnChange: true، پایش پویای تنظیمات از طریق IOptionsMonitor و ابطال حافظه نهان (MemoryCache) وابستگی عمیقی به یک مکانیزم درونی دارند: ChangeToken در فضای نام Microsoft.Extensions.Primitives. بیشتر توسعه‌دهندگان این اینترفیس را مستقیماً به‌کار نمی‌گیرند، در حالی که شناخت معماری تک‌باره (One-shot) و روش ثبت مجدد خودکار در ChangeToken.OnChange() امکان پیاده‌سازی سیستم‌های پایش فایل، چرخش گواهی‌نامه‌ها و اعتبارسنجی مجدد کش را با همان استاندارد و قابلیت اطمینان داخلی فریم‌ورک فراهم می‌کند. در این مقاله به کالبدشکافی IChangeToken، نحوه کارکرد متد استاتیک OnChange، ساخت مکانیزم‌های سفارشی با CancellationChangeToken و بررسی مقایسه‌ای کاربردهای بهینه و نامناسب آن می‌پردازیم.

مقدمه
توسعه‌دهندگان مدرن دات‌نت پیوسته از الگوهای واکنشی (Reactive) استفاده می‌کنند، بدون آنکه همواره با موتور محرک پشت صحنه آن‌ها درگیر شوند. زمانی که فایلی روی دیسک ویرایش می‌شود و تنظیمات بدون راه‌اندازی مجدد برنامه (Restart) به‌روز می‌گردند، یا یک ورودی کش وابسته به دیتابیس باطل می‌شود، یک واسط مشترک در حال مخابره پیام است: IChangeToken. این انتزاع سبک، یک پیاده‌سازی استاندارد و بهینه‌سازی‌شده از الگوی طراحی ناظر (Observer Pattern) در سطح دات‌نت است. شناخت این ابزار به توسعه‌دهنده اجازه می‌دهد منطق واکنشی و اعلان تغییرات را بدون نیاز به سربار نظرسنجی دوره‌ای (Polling) یا کتابخانه‌های سنگین جانبی توسعه دهد.

بخش‌های اصلی اکوسیستم که بر پایه ChangeToken کار می‌کنند

چهار مولفه پرکاربرد دات‌نت متکی بر این مکانیزم هستند:
  • بارگذاری مجدد پیکربندی (Configuration Reloading): قابلیت AddJsonFile با دریافت reloadOnChange: true، از طریق متد IFileProvider.Watch() یک توکن دریافت کرده و در صورت تغییر فایل، ساختار پیکربندی را بازسازی می‌کند.
  • پایش تنظیمات (IOptionsMonitor): از طریق IOptionsChangeTokenSource تغییرات را رصد می‌کند تا ویژگی CurrentValue همیشه آخرین مقدار را ارائه دهد.
  • انقضای کش حافظه (MemoryCache Expiration): متد AddExpirationToken در MemoryCacheEntryOptions این امکان را فراهم می‌سازد تا طول عمر داده درون کش مستقیماً به رخداد یک توکن متصل شود.
  • پایش فایل‌ها (File Watching): متد IFileProvider.Watch("*.json") مستقیماً یک IChangeToken برمی‌گرداند.

// ۱. بارگذاری مجدد تنظیمات
builder.Configuration.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);

// ۲. واکشی مقدار همواره تازه تنظیمات
app.MapGet("/config", (IOptionsMonitor<MyOptions> monitor) => monitor.CurrentValue);

// ۳. انقضای کش متصل به تغییرات فایل
var entry = cache.CreateEntry("report_data");
entry.AddExpirationToken(fileProvider.Watch("report.json"));

// ۴. دریافت مستقیم توکن پایش فایل
IChangeToken token = fileProvider.Watch("*.json");

کالبدشکافی اینترفیس IChangeToken
ساختار این اینترفیس بسیار فشرده و متمرکز است:
public interface IChangeToken
{
    bool HasChanged { get; }
    bool ActiveChangeCallbacks { get; }
    IDisposable RegisterChangeCallback(Action<object?> callback, object? state);
}
  • طراحی تک‌باره (HasChanged): این خصوصیت پس از وقوع تغییر، برابر با true می‌شود و هرگز به false بازنمی‌گردد. یعنی هر نمونه از توکن تنها برای نمایش یک‌بار رخداد تغییر معتبر است (One-shot Lifecycle).
  • نقش ActiveChangeCallbacks: مشخص می‌کند که آیا توکن به‌صورت فعال (Push) کال‌بک شما را اجرا می‌کند یا اینکه مصرف‌کننده باید به‌صورت دستی خصوصیت HasChanged را استعلام (Poll) کند. در اکثر پیاده‌سازی‌های رسمی دات‌نت، مقدار این فیلد true است.
  • ثبت کال‌بک (RegisterChangeCallback): تابعی را برای اجرا در لحظه وقوع تغییر متصل کرده و شیء IDisposable برای لغو اشتراک برمی‌گرداند.

سازوکار ChangeToken.OnChange: چرخه خودکار ثبت مجدد
به‌دلیل رفتار تک‌باره IChangeToken، اگر مستقیماً از RegisterChangeCallback استفاده کنید، موظفید پس از هر بار وقوع رخداد، توکن جدیدی درخواست کرده و کال‌بک را مجدداً روی آن ثبت کنید؛ در غیر این صورت پایش متوقف خواهد شد.
کلاس استاتیک ChangeToken متد کمکی OnChange را برای مدیریت این چرخه ارائه می‌دهد:
public static IDisposable OnChange(
    Func<IChangeToken?> changeTokenProducer,
    Action changeTokenConsumer);
نحوه عملکرد داخلی:
- متد OnChange ابتدا تولیدکننده توکن (changeTokenProducer) را فراخوانی می‌کند.
- کال‌بکِ داخلیِ خود را روی توکن ثبت می‌نماید.
با وقوع تغییر:
  • ابتدا توکن جدیدی را از changeTokenProducer دریافت و ثبت‌نام را تجدید می‌کند (Re-registration).
  • سپس تابع مصرف‌کننده شما (changeTokenConsumer) را صدا می‌زند.
- با فراخوانی Dispose روی خروجی OnChange، کل زنجیره متوقف می‌شود.

نکته تکمیلی (مدیریت Exception و Threading): کال‌بک‌های ChangeToken معمولاً روی یک نخ کمکی یا نخ فراخواننده منبع تغییر (مانند رویداد FileSystemWatcher) اجرا می‌شوند. بنابراین اجرای منطق‌های طولانی و بلاک‌کننده در بخش Consumer توصیه نمی‌شود و بهتر است خطاهای احتمالی درون کال‌بک مدیریت شوند تا فرآیند پایش مختل نگردد.

پیاده‌سازی عملی: پایش پوشه و توکن‌های سفارشی
۱. پایش سیستم فایل باPhysicalFileProvider
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Primitives;

var watchPath = Path.Combine(Path.GetTempPath(), "file-drop");
Directory.CreateDirectory(watchPath);

using var fileProvider = new PhysicalFileProvider(watchPath);

// OnChange به طور خودکار پس از هر رویداد، توکن جدیدی از تولیدکننده دریافت می‌کند
using var watcher = ChangeToken.OnChange(
    () => fileProvider.Watch("*.*"),
    () => Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] تغییر در فایل‌های پوشه رخ داد.")
);

Console.WriteLine("سیستم در حال پایش است. برای توقف، کلیدی را فشار دهید...");
Console.ReadKey();

۲. ساخت توکن سفارشی باCancellationChangeToken
برای زمان‌هایی که منبع تغییرات یک فایل نیست (مانند پایش دیتابیس، دریافت وب‌هوک، یا چرخش توکن‌های امنیتی)، می‌توان از CancellationChangeToken استفاده کرد:
using Microsoft.Extensions.Primitives;

// شبیه‌سازی سرویس مدیریت اعلان‌های سفارشی
public class CustomNotificationService : IDisposable
{
    private CancellationTokenSource _cts = new();

    public IChangeToken GetChangeToken() => new CancellationChangeToken(_cts.Token);

    public void NotifyChange()
    {
        var oldCts = Interlocked.Exchange(ref _cts, new CancellationTokenSource());
        oldCts.Cancel();
        oldCts.Dispose();
    }

    public void Dispose() => _cts.Dispose();
}
نکته معماری: برای ترکیب چند منبع تغییر ناهمگن (مثلاً انقضای کش با تغییر فایل یا لغو ادمین)، کلاس CompositeChangeToken به شما اجازه می‌دهد مجموعه‌ای از IChangeTokenها را در قالب یک توکن واحد ادغام کنید که با فعال شدن هر کدام از آن‌ها، وضعیت تغییر را گزارش کند.

چه زمانی باید از ChangeToken استفاده کنیم؟

سناریوی مناسب (Use Cases)ابزار جایگزین بهتر (Alternatives)علت عدم پیشنهاد ChangeToken
ابطال حافظه نهان (Cache Invalidation)—یکپارچگی پیش‌فرض با IMemoryCache
بارگذاری مجدد تنظیمات و سورس‌های سفارشی—استاندارد سیستم پیکربندی دات‌نت
اعلان‌های بسیار پرتکرار (High-Frequency Streams)System.Threading.Channels یا Rxعدم پشتیبانی از Backpressure و صف‌بندی
ارتباط تضمینی، ماندگار یا بین‌سرویسیMessage Brokers (مانند RabbitMQ/Kafka)نبود قابلیت Retry، پایداری و Ordering
رویدادهای ساده درون‌برنامه‌ای تک‌مقصدیرویدادهای استاندارد C# (event)عدم نیاز به لایه انتزاعی توکن
نتیجه‌گیری
الگوی ChangeToken استاندارد رسمی فریم‌ورک دات‌نت برای هماهنگ‌سازی منطق‌های واکنشی با کمترین وابستگی و سربار ممکن است. معماری تک‌باره آن پیچیدگی‌های همگام‌سازی چندنخی را کاهش داده و متد ChangeToken.OnChange چرخه حیات پایش مداوم را بدون خطای انسانی ساده می‌سازد. شناخت این الگو به شما اجازه می‌دهد ماژول‌های توسعه‌یافته خود را دقیقاً با همان استانداردهای رفتاری هسته دات‌نت پیاده‌سازی کنید.
مطالب مشابه

نظرات

  • وحید نصیری در ۱۴۰۵/۰۶/۰۷ ۱۸:۳۹
    پیاده‌سازی یک Custom Configuration Provider با قابلیت بارگذاری مجدد زنده (Hot Reload)

    برای اینکار، دات‌نت از کلاس پایه ConfigurationProvider استفاده می‌کند. این کلاس یک متد محافظت‌شده به نام OnReload() دارد که پشت صحنه با ساخت یک IChangeToken جدید و فعال‌سازی توکن قبلی، تغییرات را به سیستم IOptionsMonitor و کل زنجیره Configuration اعلام می‌کند.
    در ادامه، نحوه پیاده‌سازی گام‌به‌گام یک تأمین‌کننده پیکربندی دیتابیس یا وب‌سرویس (به عنوان مثال یک API سرور تنظیمات) با پشتیبانی از ChangeToken آورده شده است.

    ۱. پیاده‌سازی ConfigurationProvider
    این کلاس مسئول خواندن داده‌ها و اعلام رخداد تغییر از طریق متد OnReload() است:
    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.Primitives;
    
    public class RemoteApiConfigurationProvider : ConfigurationProvider, IDisposable
    {
        private readonly string _endpointUrl;
        private readonly TimeSpan _refreshInterval;
        private readonly Timer _timer;
    
        public RemoteApiConfigurationProvider(string endpointUrl, TimeSpan refreshInterval)
        {
            _endpointUrl = endpointUrl;
            _refreshInterval = refreshInterval;
            
            // راه‌اندازی تایمر برای استعلام دوره‌ای (یا جایگزینی با Webhook/SignalR)
            _timer = new Timer(async _ => await CheckForUpdatesAsync(), null, _refreshInterval, _refreshInterval);
        }
    
        public override void Load()
        {
            // فراخوانی سنکرون در زمان استارتاپ اولیه اپلیکیشن
            Data = FetchDataFromRemoteSource();
        }
    
        private async Task CheckForUpdatesAsync()
        {
            try
            {
                var latestData = FetchDataFromRemoteSource();
    
                // در صورتی که داده‌ها تغییر کرده باشند
                if (HasConfigurationChanged(Data, latestData))
                {
                    Data = latestData;
    
                    // متد OnReload توکن قبلی را سیگنال کرده و به IOptionsMonitor اعلام می‌کند
                    OnReload();
                }
            }
            catch (Exception ex)
            {
                // مدیریت خطا برای جلوگیری از متوقف شدن تایمر
                Console.WriteLine($"Error reloading configuration: {ex.Message}");
            }
        }
    
        private IDictionary<string, string?> FetchDataFromRemoteSource()
        {
            // شبیه‌سازی دریافت داده از دیتابیس یا سرور پیکربندی
            return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
            {
                ["FeatureToggle:BetaDashboard"] = DateTime.UtcNow.Second % 2 == 0 ? "true" : "false",
                ["AppLimits:MaxConcurrentRequests"] = "100"
            };
        }
    
        private static bool HasConfigurationChanged(IDictionary<string, string?> oldData, IDictionary<string, string?> newData)
        {
            if (oldData.Count != newData.Count) return true;
    
            foreach (var kvp in oldData)
            {
                if (!newData.TryGetValue(kvp.Key, out var newVal) || newVal != kvp.Value)
                    return true;
            }
            return false;
        }
    
        public void Dispose()
        {
            _timer?.Dispose();
        }
    }

    ۲. پیاده‌سازی ConfigurationSource
    کلاس سورس (IConfigurationSource) به عنوان Factory عمل کرده و نمونه Provider را به اکوسیستم تزریق می‌کند:
    using Microsoft.Extensions.Configuration;
    
    public class RemoteApiConfigurationSource : IConfigurationSource
    {
        public string EndpointUrl { get; set; } = string.Empty;
        public TimeSpan RefreshInterval { get; set; } = TimeSpan.FromSeconds(10);
    
        public IConfigurationProvider Build(IConfigurationBuilder builder)
        {
            return new RemoteApiConfigurationProvider(EndpointUrl, RefreshInterval);
        }
    }

    ۳. ساخت Extension Method برای رجیستر آسان
    برای اتصال روان به ConfigurationManager در ASP.NET Core:
    using Microsoft.Extensions.Configuration;
    
    public static class RemoteConfigurationExtensions
    {
        public static IConfigurationBuilder AddRemoteApiConfiguration(
            this IConfigurationBuilder builder,
            string endpointUrl,
            TimeSpan? refreshInterval = null)
        {
            return builder.Add(new RemoteApiConfigurationSource
            {
                EndpointUrl = endpointUrl,
                RefreshInterval = refreshInterval ?? TimeSpan.FromSeconds(5)
            });
        }
    }

    ۴. استفاده درProgram.csو دریافت آنی مقادیر باIOptionsMonitor
    var builder = WebApplication.CreateBuilder(args);
    
    // ۱. اضافه کردن پرووایدر سفارشی به سیستم Configuration
    builder.Configuration.AddRemoteApiConfiguration("https://config.example.com/api", TimeSpan.FromSeconds(5));
    
    // ۲. مپ کردن بخش مربوطه به یک POCO کلاس
    builder.Services.Configure<FeatureToggleOptions>(builder.Configuration.GetSection("FeatureToggle"));
    
    var app = builder.Build();
    
    // ۳. تست دریافت مقدار با IOptionsMonitor (بدون ریستارت اپلیکیشن)
    app.MapGet("/features", (IOptionsMonitor<FeatureToggleOptions> options) =>
    {
        return Results.Ok(new
        {
            BetaDashboard = options.CurrentValue.BetaDashboard,
            Timestamp = DateTime.Now.ToString("T")
        });
    });
    
    app.Run();
    
    public class FeatureToggleOptions
    {
        public bool BetaDashboard { get; set; }
    }

    مکانیزم کارکرد پشت صحنه (چگونه IChangeToken درگیر می‌شود؟)
    • کلاس پایه ConfigurationProvider دارای فیلدی به نام ConfigurationReloadToken (پیاده‌سازی داخلی از IChangeToken) است.
    • زمانی که شیء IConfigurationRoot ساخته می‌شود، متد GetReloadToken() همه پرووایدرها را رصد می‌کند.
    • با فراخوانی OnReload() در پرووایدر، توکن قدیمی Cancel شده و یک توکن جدید جایگزین می‌شود.
    • IOptionsMonitor متوجه تغییر توکن شده و مدلِ CurrentValue را مجدداً از دیتای جدید IConfiguration پر می‌کند.