مکانیزم ChangeToken در داتنت؛ مدیریت تغییرات واکنشی و ابطال کش بدون سربار اضافه
نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۶/۰۷ ۱۲:۲۵
آدرس: www.dntips.ir
چکیده: در اکوسیستم داتنت (.NET)، قابلیتهایی مانند بارگذاری مجدد پیکربندیها باreloadOnChange: true، پایش پویای تنظیمات از طریقIOptionsMonitorو ابطال حافظه نهان (MemoryCache) وابستگی عمیقی به یک مکانیزم درونی دارند:ChangeTokenدر فضای نامMicrosoft.Extensions.Primitives. بیشتر توسعهدهندگان این اینترفیس را مستقیماً بهکار نمیگیرند، در حالی که شناخت معماری تکباره (One-shot) و روش ثبت مجدد خودکار درChangeToken.OnChange()امکان پیادهسازی سیستمهای پایش فایل، چرخش گواهینامهها و اعتبارسنجی مجدد کش را با همان استاندارد و قابلیت اطمینان داخلی فریمورک فراهم میکند. در این مقاله به کالبدشکافیIChangeToken، نحوه کارکرد متد استاتیکOnChange، ساخت مکانیزمهای سفارشی باCancellationChangeTokenو بررسی مقایسهای کاربردهای بهینه و نامناسب آن میپردازیم.
IChangeToken. این انتزاع سبک، یک پیادهسازی استاندارد و بهینهسازیشده از الگوی طراحی ناظر (Observer Pattern) در سطح داتنت است. شناخت این ابزار به توسعهدهنده اجازه میدهد منطق واکنشی و اعلان تغییرات را بدون نیاز به سربار نظرسنجی دورهای (Polling) یا کتابخانههای سنگین جانبی توسعه دهد.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");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 برای لغو اشتراک برمیگرداند.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توصیه نمیشود و بهتر است خطاهای احتمالی درون کالبک مدیریت شوند تا فرآیند پایش مختل نگردد.
PhysicalFileProviderusing 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();CancellationChangeTokenCancellationChangeToken استفاده کرد: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ها را در قالب یک توکن واحد ادغام کنید که با فعال شدن هر کدام از آنها، وضعیت تغییر را گزارش کند.
| سناریوی مناسب (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 چرخه حیات پایش مداوم را بدون خطای انسانی ساده میسازد. شناخت این الگو به شما اجازه میدهد ماژولهای توسعهیافته خود را دقیقاً با همان استانداردهای رفتاری هسته داتنت پیادهسازی کنید.ConfigurationProvider استفاده میکند. این کلاس یک متد محافظتشده به نام OnReload() دارد که پشت صحنه با ساخت یک IChangeToken جدید و فعالسازی توکن قبلی، تغییرات را به سیستم IOptionsMonitor و کل زنجیره Configuration اعلام میکند.ChangeToken آورده شده است.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();
}
}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);
}
}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و دریافت آنی مقادیر باIOptionsMonitorvar 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; }
}ConfigurationProvider دارای فیلدی به نام ConfigurationReloadToken (پیادهسازی داخلی از IChangeToken) است.IConfigurationRoot ساخته میشود، متد GetReloadToken() همه پرووایدرها را رصد میکند.OnReload() در پرووایدر، توکن قدیمی Cancel شده و یک توکن جدید جایگزین میشود.IOptionsMonitor متوجه تغییر توکن شده و مدلِ CurrentValue را مجدداً از دیتای جدید IConfiguration پر میکند.