đọc một lần rồi giữ mãi (Singleton, không reload), IOptionsSnapshot đọc lại mỗi request (Scoped — không tiêm được vào Singleton), IOptionsMonitor là Singleton nhưng vẫn thấy giá trị mới nhất. Thứ đáng giá nhất trong bài là ValidateOnStart(): thiếu connection string thì ứng dụng không khởi động được, thay vì chạy bình thường rồi lỗi vào lúc có người gọi đúng endpoint.">
Skip to content

7.7 - Options Pattern ​

Tóm tắt: Options pattern biến cấu hình từ những chuỗi rời rạc (_config["Email:Port"]) thành một lớp có kiểu, kiểm tra được ngay lúc khởi động. Ba interface, và chọn sai là nguồn bug: IOptions<T> đọc một lần rồi giữ mãi (Singleton, không reload), IOptionsSnapshot<T> đọc lại mỗi request (Scoped — không tiêm được vào Singleton), IOptionsMonitor<T> là Singleton nhưng vẫn thấy giá trị mới nhất. Thứ đáng giá nhất trong bài là ValidateOnStart(): thiếu connection string thì ứng dụng không khởi động được, thay vì chạy bình thường rồi lỗi vào lúc có người gọi đúng endpoint.

Mục tiêu bài học ​

Sau bài này bạn có thể:

  • Bind một section cấu hình vào lớp POCO và validate nó.
  • Chọn đúng một trong ba interface theo lifetime và nhu cầu reload.
  • Viết validation phức tạp bằng IValidateOptions<T>.
  • Dùng named options cho nhiều cấu hình cùng kiểu.
  • Biết chỗ nào không được để secret.

Cần biết trước: appsettings.json và project Web API (bài 0.5), bài 7.4 (lifetime, captive dependency), bài 7.6 (Program.cs). Bài dùng attribute như [Required], [Range] trong namespace System.ComponentModel.DataAnnotations: đó là các nhãn gắn lên thuộc tính để mô tả luật hợp lệ ("bắt buộc có", "nằm trong khoảng"); bài 8.5 dùng lại chúng cho dữ liệu request.

Nội dung bài học ​

7.7.1 — Vấn đề với IConfiguration trực tiếp ​

csharp
public class EmailService(IConfiguration config)
{
    public async Task SendAsync(...)
    {
        var host = config["EmailSettings:Host"];           // string? — có thể null
        var port = int.Parse(config["EmailSettings:Port"]!); // ném lỗi lúc chạy
    }
}

Bốn vấn đề: gõ sai khoá thì nhận null chứ không phải lỗi biên dịch; mọi giá trị đều là string nên phải tự ép kiểu; không có cách nào biết cấu hình đủ hay chưa cho tới khi code chạy tới; và unit test phải dựng một IConfiguration giả.

7.7.2 — Bind vào POCO ​

json
{
  "EmailSettings": {
    "Host": "smtp.company.com",
    "Port": 587,
    "UseSsl": true,
    "SenderEmail": "[email protected]"
  }
}
csharp
public sealed class EmailSettings
{
    public const string SectionName = "EmailSettings";

    [Required]                 public string Host        { get; init; } = string.Empty;
    [Range(1, 65535)]          public int Port           { get; init; } = 587;
                               public bool UseSsl        { get; init; } = true;
    [Required, EmailAddress]   public string SenderEmail { get; set; } = string.Empty; // set: xem 7.7.4
}

Binder gán được cả thuộc tính init. Riêng SenderEmail dùng set vì ở mục 7.7.4 sẽ có code gán lại nó sau khi bind.

csharp
services.AddOptions<EmailSettings>()
    .Bind(configuration.GetSection(EmailSettings.SectionName))
    .ValidateDataAnnotations()
    .ValidateOnStart();          // <-- dòng quan trọng nhất trong bài

ValidateOnStart() chạy validation lúc khởi động chứ không phải lúc IOptions<T> được resolve lần đầu. Không có nó, ứng dụng khởi động bình thường với Host rỗng, và bạn phát hiện ra lúc có người bấm nút gửi email — thường là trên production.

const string SectionName nằm trong chính lớp đó để tên section và lớp không bao giờ lệch nhau.

Sơ đồ Options pattern: appsettings.json (nằm trong Git), user-secrets (development) và biến môi trường như EmailSettings__Password cùng bind vào lớp EmailSettings có Required, Range và ValidateDataAnnotations; ValidateOnStart kiểm tra lúc khởi động, cấu hình thiếu hoặc sai thì ứng dụng không chạy; service đọc cấu hình qua IOptions (Singleton, không đọc lại), IOptionsSnapshot (Scoped, mỗi request) hoặc IOptionsMonitor (Singleton, CurrentValue)Sơ đồ Options pattern: appsettings.json (nằm trong Git), user-secrets (development) và biến môi trường như EmailSettings__Password cùng bind vào lớp EmailSettings có Required, Range và ValidateDataAnnotations; ValidateOnStart kiểm tra lúc khởi động, cấu hình thiếu hoặc sai thì ứng dụng không chạy; service đọc cấu hình qua IOptions (Singleton, không đọc lại), IOptionsSnapshot (Scoped, mỗi request) hoặc IOptionsMonitor (Singleton, CurrentValue)

File gốc: nền sáng · nền tối

7.7.3 — Ba interface ​

LifetimeĐọc lại khi file đổiTiêm được vào
IOptions<T>SingletonKhôngMọi nơi
IOptionsSnapshot<T>ScopedCó, mỗi requestKhông tiêm được vào Singleton
IOptionsMonitor<T>SingletonCó, ngay lập tứcMọi nơi
csharp
// IOptions — mặc định, cho cấu hình không đổi
public class LeadService(IOptions<CrmSettings> options)
{
    private readonly CrmSettings _settings = options.Value;
}

// IOptionsSnapshot — đọc lại mỗi request
public class PricingService(IOptionsSnapshot<PricingSettings> options)
{
    public decimal Calculate(decimal amount)
        => amount * options.Value.TaxRate;     // .Value mỗi lần gọi
}

// IOptionsMonitor — Singleton nhưng vẫn thấy giá trị mới
public class EmailService(IOptionsMonitor<EmailSettings> monitor)
{
    public Task SendAsync(...)
    {
        var settings = monitor.CurrentValue;   // luôn là bản mới nhất
        ...
    }
}

Quy tắc chọn:

  • Cấu hình không đổi khi đang chạy (connection string, host) → IOptions<T>.
  • Cần đổi mà không restart, dùng trong service scoped (feature flag, thuế suất) → IOptionsSnapshot<T>.
  • Cần đổi mà không restart, nhưng service là Singleton → IOptionsMonitor<T>.

Tiêm IOptionsSnapshot<T> vào một Singleton là captive dependency (bài 7.4). Ở Development (kiểm tra bật mặc định), ứng dụng không khởi động được và báo Cannot consume scoped service ...IOptionsSnapshot... from singleton ... (Bài 2). Chỉ khi kiểm tra bị tắt, thường là ở Production, lỗi mới im lặng: snapshot bị giam và không bao giờ đọc lại cấu hình nữa.

Một bẫy nhỏ với OnChange:

csharp
monitor.OnChange(s => _logger.LogInformation("Cấu hình đổi: {Host}", s.Host));

OnChange có thể được gọi nhiều lần cho một lần sửa file, tuỳ trình soạn thảo và hệ điều hành (Bài 3 đo được 2 lần trên Windows 11). Nếu callback có tác dụng phụ (kết nối lại, xoá cache), hãy tự chống trùng. Và OnChange trả về một IDisposable — không giữ và huỷ nó thì callback sống mãi.

7.7.4 — Validation phức tạp ​

ValidateDataAnnotations() chỉ kiểm tra từng thuộc tính. Với luật liên quan nhiều thuộc tính:

csharp
services.AddOptions<EmailSettings>()
    .Bind(configuration.GetSection(EmailSettings.SectionName))
    .ValidateDataAnnotations()
    .Validate(s => !s.UseSsl || s.Port != 25, "SSL không dùng được với port 25")
    .ValidateOnStart();

Khi luật cần tới service khác, dùng IValidateOptions<T> — nó được resolve từ container:

csharp
public sealed class EmailSettingsValidator(IDnsChecker dns) : IValidateOptions<EmailSettings>
{
    public ValidateOptionsResult Validate(string? name, EmailSettings options)
    {
        if (!dns.CanResolve(options.Host))
            return ValidateOptionsResult.Fail($"Không phân giải được host {options.Host}");

        return ValidateOptionsResult.Success;
    }
}

services.AddSingleton<IValidateOptions<EmailSettings>, EmailSettingsValidator>();

Đây cũng là câu trả lời cho vấn đề ở bài 7.6: cần một service để cấu hình một service khác mà không gọi BuildServiceProvider(). IConfigureOptions<T> làm điều tương tự cho việc thiết lập giá trị:

csharp
// Chưa cấu hình SenderEmail thì lấy địa chỉ mặc định theo tên ứng dụng
public sealed class ConfigureEmailSettings(IHostEnvironment env) : IConfigureOptions<EmailSettings>
{
    public void Configure(EmailSettings options)
    {
        if (string.IsNullOrEmpty(options.SenderEmail))
            options.SenderEmail = $"no-reply@{env.ApplicationName.ToLowerInvariant()}.local";
    }
}

// Đăng ký SAU AddOptions<EmailSettings>().Bind(...) để chạy sau bước bind
services.AddSingleton<IConfigureOptions<EmailSettings>, ConfigureEmailSettings>();

Ba điểm cần nhớ:

  • Configure gán vào thuộc tính, nên thuộc tính đó phải có set. Với init, code không biên dịch được (lỗi CS8852).
  • Các bước cấu hình chạy theo thứ tự đăng ký, validation chạy sau cùng. Vì vậy [Required] trên SenderEmail vẫn qua khi appsettings.json không có khoá SenderEmail.
  • IOptions<T> là Singleton, nên ConfigureEmailSettings được tạo từ root container. Phụ thuộc của nó cũng phải là Singleton (IHostEnvironment, IConfiguration, IOptions<...> khác). Tiêm một service Scoped vào đây chính là captive dependency ở bài 7.4. Tệ hơn, kiểm tra scope của Development không bắt được trường hợp này: khi thử trên .NET 10 với một service Scoped trong constructor, ứng dụng vẫn Build() và chạy bình thường, service Scoped bị giam im lặng. Phải tự giữ quy tắc này khi viết code.

7.7.5 — Named options ​

Khi cần nhiều cấu hình cùng một kiểu:

csharp
services.Configure<ApiClientSettings>("crm",       configuration.GetSection("Apis:Crm"));
services.Configure<ApiClientSettings>("analytics", configuration.GetSection("Apis:Analytics"));
csharp
public class ReportService(IOptionsMonitor<ApiClientSettings> monitor)
{
    public async Task RunAsync()
    {
        var crm       = monitor.Get("crm");
        var analytics = monitor.Get("analytics");
    }
}

Chỉ IOptionsSnapshot<T> và IOptionsMonitor<T> có Get(name); IOptions<T> thì không.

7.7.6 — Secret không nằm trong appsettings.json ​

appsettings.json nằm trong Git. Mật khẩu, API key và connection string có thông tin đăng nhập không được để ở đó.

bash
# Development — lưu ngoài repo
dotnet user-secrets init
dotnet user-secrets set "EmailSettings:Password" "..."

Trên production dùng biến môi trường (EmailSettings__Password, hai dấu gạch dưới thay cho dấu hai chấm), Azure Key Vault, hoặc secret của hệ thống điều phối. Điểm hay của options pattern: code không đổi — mọi nguồn đều bind vào cùng một EmailSettings.

Xem Module 15 — Docker và Deployment về cách truyền secret vào container.

7.7.7 — Rà lại code của bạn ​

Danh sách rà soát Options Pattern

  • Không còn service nào tiêm IConfiguration để đọc giá trị lẻ.
  • Mọi AddOptions đều có ValidateOnStart().
  • Mỗi lớp settings có const SectionName nằm trong chính nó.
  • Không tiêm IOptionsSnapshot vào service Singleton.
  • Cấu hình cần reload trong Singleton dùng IOptionsMonitor.CurrentValue.
  • Callback OnChange có chống trùng và IDisposable của nó được giữ lại.
  • Luật liên quan nhiều thuộc tính dùng Validate hoặc IValidateOptions.
  • Không có mật khẩu hay API key nào trong appsettings.json đã commit.

Bài tập áp dụng ​

Bài 1 — Thấy giá trị của ValidateOnStart ​

Xoá một mục cấu hình bắt buộc khỏi appsettings.json rồi chạy ứng dụng có và không có ValidateOnStart(). Ghi lại thời điểm và nội dung lỗi trong mỗi trường hợp.

Tiêu chí hoàn thành: bạn nêu được vì sao "hỏng lúc khởi động" tốt hơn "hỏng lúc chạy", kể cả khi cả hai đều là hỏng.

Gợi ý và lời giải — Bài 1

Gợi ý. Không có ValidateOnStart, việc xác thực chỉ diễn ra khi IOptions<T> được lấy ra lần đầu — tức là khi có request chạm tới đường dẫn dùng nó.

Lời giải — cấu hình:

csharp
public class SmtpOptions
{
    public const string Section = "Smtp";

    [Required(ErrorMessage = "Smtp:Host là bắt buộc")]
    public string Host { get; init; } = "";

    [Range(1, 65535)]
    public int Port { get; init; }

    [Required, EmailAddress]
    public string FromAddress { get; init; } = "";

    public bool UseTls { get; init; }
    public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
}

appsettings.json thiếu Host:

json
{
  "Smtp": { "Port": 587, "FromAddress": "[email protected]" }
}

Không có ValidateOnStart:

csharp
builder.Services.AddOptions<SmtpOptions>()
    .Bind(builder.Configuration.GetSection(SmtpOptions.Section))
    .ValidateDataAnnotations();

Với một endpoint app.MapGet("/send", (IOptions<SmtpOptions> o) => $"Gửi qua {o.Value.Host}"), chạy trên .NET 10:

text
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5393        <- ỨNG DỤNG KHỞI ĐỘNG BÌNH THƯỜNG

... tới khi có người gọi GET /send ...

fail: Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware[1]
      An unhandled exception has occurred while executing the request.
      Microsoft.Extensions.Options.OptionsValidationException: DataAnnotation validation failed for 'SmtpOptions' members: 'Host' with the error: 'Smtp:Host là bắt buộc'.

Người gọi nhận mã 500.

Có ValidateOnStart:

csharp
builder.Services.AddOptions<SmtpOptions>()
    .Bind(builder.Configuration.GetSection(SmtpOptions.Section))
    .ValidateDataAnnotations()
    .ValidateOnStart();
text
fail: Microsoft.Extensions.Hosting.Internal.Host[11]
      Hosting failed to start
      Microsoft.Extensions.Options.OptionsValidationException: DataAnnotation validation failed for 'SmtpOptions' members: 'Host' with the error: 'Smtp:Host là bắt buộc'.

(ứng dụng KHÔNG khởi động, không có dòng "Now listening on")

Vì sao "hỏng lúc khởi động" tốt hơn — bốn lý do:

  1. Không có phiên bản hỏng nào nhận lưu lượng. Với triển khai luân phiên, thể hiện mới không qua được health check nên không được đưa vào vòng phục vụ, và thể hiện cũ vẫn chạy. Người dùng không hề bị ảnh hưởng.
  2. Lỗi xuất hiện ngay trong log triển khai, nơi người vừa bấm nút đang nhìn — chứ không phải ba giờ sau trong một biển log.
  3. Thông báo nói rõ thiếu gì. Không phải đi truy ngược từ một NullReferenceException ở tầng sâu.
  4. Không có trạng thái dở dang. Ứng dụng chưa từng phục vụ request nào, nên không có dữ liệu nửa vời để dọn.

Nguyên tắc chung: hỏng nhanh, hỏng sớm, hỏng ồn ào. Một ứng dụng khởi động được với cấu hình sai là một quả bom hẹn giờ.

Xác thực phức tạp hơn DataAnnotations:

csharp
builder.Services.AddOptions<SmtpOptions>()
    .Bind(builder.Configuration.GetSection(SmtpOptions.Section))
    .ValidateDataAnnotations()
    .Validate(o => o.Port != 25 || !o.UseTls,
              "Cổng 25 không dùng được với TLS")
    .ValidateOnStart();

Hoặc tách thành một lớp riêng khi logic dài:

csharp
public class SmtpOptionsValidator : IValidateOptions<SmtpOptions>
{
    public ValidateOptionsResult Validate(string? name, SmtpOptions o)
    {
        var errors = new List<string>();
        if (o.Port == 465 && !o.UseTls) errors.Add("Cổng 465 yêu cầu TLS");
        if (o.Timeout < TimeSpan.FromSeconds(1)) errors.Add("Timeout quá ngắn");

        return errors.Count > 0 ? ValidateOptionsResult.Fail(errors) : ValidateOptionsResult.Success;
    }
}

builder.Services.AddSingleton<IValidateOptions<SmtpOptions>, SmtpOptionsValidator>();

Một lưu ý. ValidateOnStart chỉ chạy khi host khởi động đầy đủ. Nếu bạn lấy IOptions<T> ra trong Program.cs trước app.Run(), việc xác thực có thể chạy sớm hơn ở đó — và đó cũng là kết quả tốt.

Bài 2 — Captive options ​

Tiêm IOptionsSnapshot<T> vào một Singleton và chạy ở Development, ghi lại lỗi. Chuyển sang IOptionsMonitor<T>, sửa appsettings.json khi ứng dụng đang chạy và xác nhận giá trị đổi.

Tiêu chí hoàn thành: bạn nêu đúng vòng đời của cả ba interface options.

Gợi ý và lời giải — Bài 2

Gợi ý. IOptionsSnapshot<T> được đăng ký là Scoped. Tiêm một Scoped vào một Singleton chính là captive dependency ở bài 7.4.

Lời giải.

csharp
// SAI — IOptionsSnapshot là Scoped, bị giam trong Singleton
public class EmailSender(IOptionsSnapshot<SmtpOptions> opts) : IEmailSender
{
    public Task SendAsync(...) => Send(opts.Value.Host, ...);
}
builder.Services.AddSingleton<IEmailSender, EmailSender>();

Ở Development (ValidateScopes và ValidateOnBuild bật mặc định), ứng dụng không khởi động được. Chạy trên .NET 10:

text
Unhandled exception. System.AggregateException: Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: IEmailSender Lifetime: Singleton ImplementationType: EmailSender': Cannot consume scoped service 'Microsoft.Extensions.Options.IOptionsSnapshot`1[SmtpOptions]' from singleton 'IEmailSender'.)

Chỉ khi kiểm tra bị tắt (thường là Production), ứng dụng mới chạy, và khi đó opts.Value đóng băng ở giá trị đọc được lần đầu. Sửa appsettings.json không có tác dụng gì, và không có lỗi nào báo cho bạn biết.

Đúng:

csharp
public class EmailSender(IOptionsMonitor<SmtpOptions> opts) : IEmailSender
{
    public Task SendAsync(...) => Send(opts.CurrentValue.Host, ...);   // đọc CurrentValue mỗi lần
}

Sửa file cấu hình, gọi lại, giá trị đã đổi.

Bảng ba interface:

InterfaceVòng đờiĐọc lại khi file đổiHỗ trợ named optionsDùng cho
IOptions<T>SingletonKhôngKhôngCấu hình không bao giờ đổi lúc chạy
IOptionsSnapshot<T>ScopedCó, mỗi request một lầnCóService Scoped và Transient
IOptionsMonitor<T>SingletonCó, ngay lập tứcCóService Singleton

Ba điểm dễ sai:

  1. IOptions<T> không bao giờ đọc lại. Nó đọc một lần lúc khởi tạo và giữ mãi. Đây là lựa chọn đúng cho chuỗi kết nối database — thứ mà đổi lúc chạy cũng không có tác dụng, vì nhóm kết nối đã được tạo.

  2. Phải đọc CurrentValue mỗi lần, không lưu lại:

    csharp
    // SAI — mất hết tác dụng của Monitor
    public class EmailSender(IOptionsMonitor<SmtpOptions> opts) : IEmailSender
    {
        private readonly SmtpOptions _o = opts.CurrentValue;   // đóng băng ngay
    }
    
    // ĐÚNG
    public class EmailSender(IOptionsMonitor<SmtpOptions> opts) : IEmailSender
    {
        public Task SendAsync(...) => Send(opts.CurrentValue.Host, ...);
    }
  3. IOptionsSnapshot<T> tính lại một lần cho mỗi phạm vi. Trong cùng một request, đọc Value nhiều lần cho cùng một giá trị — đó là điều bạn muốn, vì nó bảo đảm mọi phần của request nhìn thấy cùng một cấu hình.

Chọn cái nào — quy tắc thực dụng:

text
Service Singleton              -> IOptionsMonitor<T>
Service Scoped hoặc Transient  -> IOptionsSnapshot<T>
Cấu hình chắc chắn không đổi   -> IOptions<T>

Một cảnh báo về cấu hình đổi lúc chạy. Nghe hấp dẫn nhưng nó khiến hệ thống khó suy luận: hai request liên tiếp có thể chạy với hai cấu hình khác nhau, và việc tái hiện một sự cố trở nên khó hơn. Với phần lớn cấu hình, triển khai lại là cách đổi rõ ràng và an toàn hơn. Hãy dành khả năng đổi nóng cho những thứ thật sự cần nó, như công tắc bật tắt tính năng.

Bài 3 — OnChange bắn nhiều lần ​

Đăng ký một callback ghi log, sửa file cấu hình bằng trình soạn thảo và đếm số dòng log. Viết cơ chế chống trùng và kiểm chứng lại.

Tiêu chí hoàn thành: bạn giải thích được vì sao một lần lưu file lại sinh nhiều sự kiện.

Gợi ý và lời giải — Bài 3

Gợi ý. .NET theo dõi file bằng FileSystemWatcher, và nó chỉ biết những gì hệ điều hành báo lên. Một lần "lưu" của bạn có thể là nhiều thao tác ghi ở mức hệ điều hành.

Lời giải.

csharp
public class SmtpWatcher : IDisposable
{
    private readonly IDisposable? _registration;

    public SmtpWatcher(IOptionsMonitor<SmtpOptions> opts, ILogger<SmtpWatcher> log)
    {
        _registration = opts.OnChange(o => log.LogInformation("Cấu hình SMTP đổi: {Host}", o.Host));
    }

    public void Dispose() => _registration?.Dispose();
}

Để đo cho chắc, đếm số lần callback chạy rồi sửa appsettings.json bằng lệnh thay vì bằng tay. Chạy trên .NET 10, Windows 11, mỗi lần ghi file bằng PowerShell (Set-Content hoặc [IO.File]::WriteAllText):

text
OnChange lần 1: Host = smtp.moi.com
OnChange lần 2: Host = smtp.moi.com      <- lần ghi thứ nhất
OnChange lần 3: Host = smtp.moi2.com
OnChange lần 4: Host = smtp.moi2.com     <- lần ghi thứ hai

Hai lần cho một lần ghi file.

Vì sao. Hệ điều hành có thể báo nhiều sự kiện "file đã đổi" cho một lần ghi, ví dụ một lần khi nội dung được ghi và một lần khi thông tin file (thời gian sửa, kích thước) được cập nhật. Trình soạn thảo lưu theo kiểu ghi ra file tạm rồi đổi tên còn sinh thêm sự kiện. FileSystemWatcher bên dưới IConfiguration chuyển mỗi sự kiện thành một lần nạp lại và một lần gọi OnChange. Số lần chính xác phụ thuộc vào trình soạn thảo và hệ điều hành — đừng viết code dựa trên một con số cụ thể.

Hậu quả thực tế. Nếu callback làm việc nặng — tạo lại nhóm kết nối, gọi API, khởi động lại một dịch vụ nền — nó chạy nhiều lần. Với thao tác không idempotent (chạy lại cho kết quả khác), đó là nhiều lần tác dụng phụ.

Chống trùng bằng cách hoãn:

csharp
public sealed class SmtpWatcher : IDisposable
{
    private readonly IDisposable? _registration;
    private readonly Timer _timer;
    private SmtpOptions? _pending;

    public SmtpWatcher(IOptionsMonitor<SmtpOptions> opts, ILogger<SmtpWatcher> log)
    {
        _timer = new Timer(_ =>
        {
            var o = Interlocked.Exchange(ref _pending, null);
            if (o is not null) log.LogInformation("Cấu hình SMTP đổi: {Host}", o.Host);
        });

        _registration = opts.OnChange(o =>
        {
            _pending = o;
            _timer.Change(TimeSpan.FromMilliseconds(500), Timeout.InfiniteTimeSpan);
        });
    }

    public void Dispose() { _registration?.Dispose(); _timer.Dispose(); }
}

Mỗi sự kiện đặt lại bộ đếm 500 mili-giây. Nhiều sự kiện liên tiếp chỉ dẫn tới một lần xử lý, sau khi đợt thay đổi đã lắng.

Cách đơn giản hơn nếu callback rẻ. Bỏ qua trùng lặp và làm cho callback idempotent — chạy nhiều lần cũng cho cùng kết quả như chạy một lần. Đây thường là lựa chọn tốt hơn vì nó không cần thêm cơ chế nào:

csharp
opts.OnChange(o => _cache.Set("smtp-host", o.Host));    // chạy mấy lần cũng như nhau

Ba điều bắt buộc khi dùng OnChange:

  1. Giữ và giải phóng đăng ký. OnChange trả về một IDisposable; bỏ nó đi là rò rỉ, đúng kiểu rò rỉ event ở bài 5.4.
  2. Không ném ngoại lệ trong callback. Ngoại lệ ở đó có thể làm sập tiến trình, vì nó chạy trên luồng của FileSystemWatcher chứ không trong ngữ cảnh request.
  3. Không làm việc nặng hoặc chờ lâu. Callback chạy đồng bộ; việc nặng nên được đẩy sang một hàng đợi như bài 6.8 mô tả.

Cân nhắc tắt hẳn việc theo dõi file. Trong môi trường container, cấu hình đổi thường đi kèm với việc triển khai lại, nên theo dõi file không có ích mà chỉ thêm rủi ro.

Đừng làm bằng cách gọi thêm builder.Configuration.AddJsonFile("appsettings.json", reloadOnChange: false): CreateBuilder đã thêm một nguồn appsettings.json có reloadOnChange: true, dòng đó chỉ thêm nguồn thứ hai, và watcher của nguồn đầu vẫn chạy. Cách đúng là đặt khoá cấu hình hostBuilder:reloadConfigOnChange thành false trước khi builder được tạo, qua biến môi trường hoặc tham số dòng lệnh:

bash
DOTNET_hostBuilder__reloadConfigOnChange=false dotnet run
# hoặc
dotnet run -- --hostBuilder:reloadConfigOnChange=false

Kiểm chứng trên .NET 10 bằng cách in các nguồn JSON của builder.Configuration:

csharp
foreach (var src in builder.Configuration.Sources.OfType<JsonConfigurationSource>())
    Console.WriteLine($"{src.Path,-30} reloadOnChange = {src.ReloadOnChange}");

Không đặt gì thì mọi nguồn in reloadOnChange = True; đặt một trong hai cách trên thì mọi nguồn in False. (JsonConfigurationSource nằm trong namespace Microsoft.Extensions.Configuration.Json.)

Tự kiểm tra ​

Options pattern giải quyết vấn đề gì của IConfiguration?

Bốn vấn đề: gõ sai khoá chỉ nhận null chứ không có lỗi biên dịch, mọi giá trị đều là string nên phải tự ép kiểu, không biết cấu hình đủ hay chưa cho tới khi code chạy tới, và unit test phải dựng IConfiguration giả. Options bind cấu hình vào một lớp POCO có kiểu và kiểm tra được ngay lúc khởi động.

ValidateOnStart quan trọng ở chỗ nào?

Nó chạy validation lúc khởi động thay vì lúc IOptions được resolve lần đầu. Không có nó thì ứng dụng khởi động bình thường với cấu hình thiếu, và bạn chỉ phát hiện khi có người gọi đúng chức năng đó, thường là trên production.

Ba interface IOptions khác nhau thế nào?

IOptions là Singleton, đọc một lần và không reload. IOptionsSnapshot là Scoped, đọc lại mỗi request nên không tiêm được vào Singleton. IOptionsMonitor là Singleton nhưng CurrentValue luôn là bản mới nhất. Chọn theo cấu hình có đổi khi đang chạy không, và lifetime của service dùng nó.

Tiêm IOptionsSnapshot vào Singleton thì sao?

Đó là captive dependency. Ở Development, nơi ValidateScopes và ValidateOnBuild bật mặc định, ứng dụng không khởi động được và báo Cannot consume scoped service IOptionsSnapshot from singleton. Chỉ khi kiểm tra bị tắt, thường là Production, nó mới chạy im lặng: snapshot bị giam trong Singleton và không bao giờ đọc lại cấu hình nữa. Với Singleton thì dùng IOptionsMonitor.

Khi luật kiểm tra cần tới service khác thì làm sao?

Dùng IValidateOptions<T>, vì nó được resolve từ container nên tiêm được dependency. Đây cũng là câu trả lời cho nhu cầu dùng một service để cấu hình một service khác mà không phải gọi BuildServiceProvider thủ công; IConfigureOptions<T> làm điều tương tự cho việc thiết lập giá trị.

Secret nên để ở đâu?

Không để trong appsettings.json vì file đó nằm trong Git. Development dùng dotnet user-secrets, production dùng biến môi trường với hai dấu gạch dưới thay cho dấu hai chấm, Azure Key Vault hoặc secret của hệ thống điều phối. Điểm hay là code không đổi vì mọi nguồn đều bind vào cùng một lớp settings.

Kết luận ​

Ba điều đáng nhớ nhất:

  1. ValidateOnStart() trên mọi AddOptions — lỗi cấu hình phải chặn khởi động, không phải chặn người dùng.
  2. IOptionsSnapshot là Scoped. Trong Singleton thì dùng IOptionsMonitor.
  3. IValidateOptions và IConfigureOptions là cách đúng để dùng service khi cấu hình, thay cho BuildServiceProvider().

Tham khảo ​

Điều hướng ​

Bài liên quan ​

Phát hành theo giấy phép MIT.