Chuyển tới nội dung chính

7.8 — 6. 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.

Nội dung bài học​

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

public class EmailService(IConfiguration config)
{
public async Task SendAsync(...)
{
var host = config["EmailSettings:Host"]; // string? — co the null
var port = int.Parse(config["EmailSettings:Port"]!); // nem luc chay
}
}

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.8.2 — Bind vào POCO​

{
"EmailSettings": {
"Host": "smtp.company.com",
"Port": 587,
"UseSsl": true,
"SenderEmail": "crm@company.com"
}
}
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; init; } = string.Empty;
}
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.

7.8.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
// 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.5) — nó bị giam và không bao giờ đọc lại nữa, im lặng.

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

monitor.OnChange(s => _logger.LogInformation("Config doi: {Host}", s.Host));

OnChange thường bắn hai lần cho một lần sửa file, vì nhiều trình soạn thảo ghi file theo hai bước. 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.8.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:

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:

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.7: 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ị:

public sealed class ConfigureEmailSettings(ITenantContext tenant) : IConfigureOptions<EmailSettings>
{
public void Configure(EmailSettings options) => options.SenderEmail = tenant.SenderEmail;
}

7.8.5 — Named options​

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

services.Configure<ApiClientSettings>("crm",       configuration.GetSection("Apis:Crm"));
services.Configure<ApiClientSettings>("analytics", configuration.GetSection("Apis:Analytics"));
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.8.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 để ở đó.

# 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.8.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:

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; } = "";
}

Không có ValidateOnStart:

builder.Services.AddOptions<SmtpOptions>()
.Bind(builder.Configuration.GetSection(SmtpOptions.Section))
.ValidateDataAnnotations();
info: Now listening on: http://localhost:5000     <- ỨNG DỤNG KHỞI ĐỘNG BÌNH THƯỜNG

... 3 giờ sau, khi có người dùng đăng ký tài khoản ...

fail: Microsoft.AspNetCore.Diagnostics[1]
OptionsValidationException: DataAnnotation validation failed for
'SmtpOptions' members: 'Host' with the error: 'Smtp:Host là bắt buộc'.

Có ValidateOnStart:

builder.Services.AddOptions<SmtpOptions>()
.Bind(builder.Configuration.GetSection(SmtpOptions.Section))
.ValidateDataAnnotations()
.ValidateOnStart();
Unhandled exception. 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)

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:

builder.Services.AddOptions<SmtpOptions>()
.Bind(builder.Configuration.GetSection(SmtpOptions.Section))
.ValidateDataAnnotations()
.Validate(o => o.Port != 25 || o.UseTls == false,
"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:

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

return loi.Count > 0 ? ValidateOptionsResult.Fail(loi) : 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, sửa appsettings.json khi ứng dụng đang chạy và xác nhận giá trị không đổi. Chuyển sang IOptionsMonitor<T> và xác nhận nó đổ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.5.

Lời giải.

// 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>();

Với ValidateScopes bật, ứng dụng không khởi động được:

InvalidOperationException: Cannot consume scoped service
'IOptionsSnapshot`1[SmtpOptions]' from singleton 'IEmailSender'.

Nếu tắt kiểm tra, ứng dụng chạy nhưng opts.Value đóng băng ở giá trị đọc được lần đầu. Sửa appsettings.json không có tác dụng gì.

Đúng:

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:

    // 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:

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 ý. Vấn đề không nằm ở .NET mà ở cách trình soạn thảo lưu file. Hãy tìm hiểu xem VS Code làm gì khi bạn nhấn lưu.

Lời giải.

public class SmtpWatcher : IDisposable
{
private readonly IDisposable? _dangKy;

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

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

Sửa appsettings.json một lần và lưu:

info: Cấu hình SMTP đổi: smtp.moi.com
info: Cấu hình SMTP đổi: smtp.moi.com
info: Cấu hình SMTP đổi: smtp.moi.com

Ba lần cho một lần lưu.

Vì sao. Nhiều trình soạn thảo không ghi đè file trực tiếp mà thực hiện một chuỗi thao tác để bảo đảm an toàn:

1. Ghi nội dung mới vào file tạm
2. Xoá file gốc -> FileSystemWatcher bắn sự kiện Deleted
3. Đổi tên file tạm -> bắn sự kiện Created
4. Cập nhật thời gian sửa -> bắn sự kiện Changed

FileSystemWatcher bên dưới IConfiguration thấy cả ba và kích hoạt OnChange mỗi lần. Số lần chính xác phụ thuộc vào trình soạn thảo và hệ điều hành — nên đừ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 ba lần. Với thao tác không idempotent, đó là ba lần tác dụng phụ.

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

public sealed class SmtpWatcher : IDisposable
{
private readonly IDisposable? _dangKy;
private readonly Timer _timer;
private SmtpOptions? _choXuLy;

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

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

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

Mỗi sự kiện đặt lại bộ đếm 500 mili-giây. Ba 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 ba 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:

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.5.
  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.9 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 pod, nên theo dõi file không có ích mà chỉ thêm rủi ro:

builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false);

Tự kiểm tra​

Câu hỏi thường gặp

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: snapshot bị giam trong Singleton và không bao giờ đọc lại cấu hình nữa. Nó xảy ra im lặng, không có lỗi nào, chỉ là cấu hình trông như không đổi. 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​