7.8 — 6. Options Pattern
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 đổi | Tiêm được vào | |
|---|---|---|---|
IOptions<T> | Singleton | Không | Mọi nơi |
IOptionsSnapshot<T> | Scoped | Có, mỗi request | Không tiêm được vào Singleton |
IOptionsMonitor<T> | Singleton | Có, ngay lập tức | Mọ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:
- 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.
- 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.
- 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. - 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:
| Interface | Vòng đời | Đọc lại khi file đổi | Hỗ trợ named options | Dùng cho |
|---|---|---|---|---|
IOptions<T> | Singleton | Không | Không | Cấu hình không bao giờ đổi lúc chạy |
IOptionsSnapshot<T> | Scoped | Có, mỗi request một lần | Có | Service Scoped và Transient |
IOptionsMonitor<T> | Singleton | Có, ngay lập tức | Có | Service Singleton |
Ba điểm dễ sai:
-
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. -
Phải đọc
CurrentValuemỗ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, ...);
} -
IOptionsSnapshot<T>tính lại một lần cho mỗi phạm vi. Trong cùng một request, đọcValuenhiề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.