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
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
{
"EmailSettings": {
"Host": "smtp.company.com",
"Port": 587,
"UseSsl": true,
"SenderEmail": "[email protected]"
}
}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.
services.AddOptions<EmailSettings>()
.Bind(configuration.GetSection(EmailSettings.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart(); // <-- dòng quan trọng nhất trong bàiValidateOnStart() 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.7.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.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:
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:
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.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ị:
// 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ớ:
Configuregán vào thuộc tính, nên thuộc tính đó phải cóset. Vớiinit, 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ênSenderEmailvẫn qua khiappsettings.jsonkhông có khoáSenderEmail. IOptions<T>là Singleton, nênConfigureEmailSettingsđượ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ẫnBuild()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:
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.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 để ở đó.
# 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:
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:
{
"Smtp": { "Port": 587, "FromAddress": "[email protected]" }
}Không có ValidateOnStart:
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:
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:
builder.Services.AddOptions<SmtpOptions>()
.Bind(builder.Configuration.GetSection(SmtpOptions.Section))
.ValidateDataAnnotations()
.ValidateOnStart();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:
- 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,
"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 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.
// 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:
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:
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: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, ...); }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.
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.
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):
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ứ haiHai 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:
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:
opts.OnChange(o => _cache.Set("smtp-host", o.Host)); // chạy mấy lần cũng như nhauBa điều bắt buộc khi dùng OnChange:
- Giữ và giải phóng đăng ký.
OnChangetrả về mộtIDisposable; bỏ nó đi là rò rỉ, đúng kiểu rò rỉ event ở bài 5.4. - 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
FileSystemWatcherchứ không trong ngữ cảnh request. - 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:
DOTNET_hostBuilder__reloadConfigOnChange=false dotnet run
# hoặc
dotnet run -- --hostBuilder:reloadConfigOnChange=falseKiểm chứng trên .NET 10 bằng cách in các nguồn JSON của builder.Configuration:
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:
ValidateOnStart()trên mọiAddOptions— lỗi cấu hình phải chặn khởi động, không phải chặn người dùng.IOptionsSnapshotlà Scoped. Trong Singleton thì dùngIOptionsMonitor.IValidateOptionsvàIConfigureOptionslà cách đúng để dùng service khi cấu hình, thay choBuildServiceProvider().
Tham khảo
- Options pattern in .NET
- Options pattern in ASP.NET Core
- Configuration in .NET
- Safe storage of app secrets in development
Điều hướng
- Bài trước: 7.6 — Program.cs và WebApplicationBuilder
- Bài tiếp theo: 7.8 — Keyed Services (.NET 8)
- Về module: Trang mục lục
Bài liên quan
- Singleton, Scoped hay Transient? Chọn sai là DbContext sống mãi — Ba lifetime trong DI container của ASP.NET Core khác nhau ở thời điểm tạo và thời điểm dispose instance.