7.12 - Đổi cấu hình mà ứng dụng không nhận
Tóm tắt: Tình huống rất hay gặp: sửa
appsettings.jsontrong lúc ứng dụng chạy, và giá trị vẫn là cũ. Hoặc một nửa hệ thống nhận giá trị mới còn nửa kia vẫn dùng giá trị cũ. Hai nguyên nhân hay gặp nhất: service đọc cấu hình quaIOptions<T>(đọc một lần, không bao giờ đọc lại), và giá trị đang bị một nguồn ưu tiên cao hơn đè lên, thường là biến môi trường, thứ không bao giờ được đọc lại. Bài này đo cả hai trên .NET 10 và cho một quy trình kiểm tra nhanh. Ba interface Options đã được dạy ở bài 7.7; ở đây chỉ nhắc phần cần cho tình huống.
Mục tiêu bài học
Sau bài này bạn có thể:
- Đo được interface Options nào nhận giá trị mới khi file đổi.
- Giải thích thứ tự ưu tiên giữa
appsettings.jsonvà biến môi trường, và vì sao biến môi trường không đọc lại được. - Biết nguồn cấu hình nào đọc lại được lúc chạy.
- Chẩn đoán "đổi cấu hình mà không có tác dụng" theo một danh sách kiểm tra.
Cần biết trước: bài 7.7 (ba interface Options, OnChange, ValidateOnStart).
Nội dung bài học
7.12.1 — Tình huống
Ứng dụng CRM có cấu hình Crm:DisplayName hiện trên tiêu đề trang. Người vận hành sửa appsettings.json trên server, chờ một phút, tải lại trang: vẫn tên cũ. Họ restart ứng dụng: vẫn tên cũ. Lần thứ hai mới là điều đáng lo, vì IOptions<T> không giải thích được nó.
Hai câu hỏi cần trả lời theo thứ tự:
- Service đang đọc cấu hình qua interface nào? (giải thích trường hợp "chạy rồi thì không đổi")
- Giá trị thật sự đến từ nguồn nào? (giải thích trường hợp "restart vẫn không đổi")
7.12.2 — Interface nào đọc lại
Nhắc lại từ bài 7.7:
| Interface | Lifetime | Nhận giá trị mới khi file đổi |
|---|---|---|
IOptions<T> | Singleton | Không bao giờ — tính một lần, giữ tới khi restart |
IOptionsSnapshot<T> | Scoped | Có, ở scope (request) tiếp theo |
IOptionsMonitor<T> | Singleton | Có, đọc CurrentValue là thấy ngay |
Bài 1 đo bảng này trên .NET 10. IOptions<T> không đổi là đúng thiết kế: phần lớn cấu hình (chuỗi kết nối, URL dịch vụ) không nên đổi giữa chừng, và restart là cách đổi rõ ràng nhất.
7.12.3 — Nguồn nào thắng
WebApplication.CreateBuilder nạp cấu hình từ nhiều nguồn theo thứ tự; nguồn nạp sau đè nguồn nạp trước khi trùng khoá:
1. appsettings.json
2. appsettings.{Environment}.json (ví dụ appsettings.Development.json)
3. user secrets (chỉ ở Development)
4. biến môi trường
5. tham số dòng lệnh <- ưu tiên cao nhấtKhoá Crm:DisplayName viết thành biến môi trường là Crm__DisplayName (hai dấu gạch dưới thay cho dấu hai chấm). Nếu biến đó tồn tại, sửa appsettings.json bao nhiêu cũng không có tác dụng, kể cả sau restart, vì biến môi trường được nạp sau và thắng. Bài 2 đo điều này.
Các nguồn cũng khác nhau ở chỗ có đọc lại lúc chạy hay không:
| Nguồn cấu hình | Đọc lại khi đổi lúc chạy |
|---|---|
appsettings.json, appsettings.{Environment}.json | Có (reloadOnChange: true mặc định) |
| User secrets | Có |
| Biến môi trường | Không — đọc một lần lúc tiến trình khởi động |
| Tham số dòng lệnh | Không |
| Azure Key Vault | Chỉ khi đặt ReloadInterval |
Hai hệ quả khi chạy trong container (Module 15):
- Cấu hình truyền bằng biến môi trường của container: đổi xong phải khởi động lại container,
IOptionsMonitorcũng không giúp gì. appsettings.jsonnằm bên trong image: sửa file trên máy bạn không đụng gì tới file trong container đang chạy. Muốn đổi thì build lại image, hoặc đưa cấu hình ra ngoài qua biến môi trường.
7.12.4 — Danh sách kiểm tra khi "đổi cấu hình mà không có tác dụng"
- Có biến môi trường hoặc tham số dòng lệnh trùng khoá không? Kiểm tra
Crm__DisplayNametrong môi trường của tiến trình. Nếu có, nó thắng. - Có
appsettings.{Environment}.jsonđè lên không? Sửaappsettings.jsonmàappsettings.Production.jsoncó cùng khoá thì vô ích. - Bạn sửa đúng file không? File được đọc nằm ở content root (thư mục chạy ứng dụng), không phải thư mục mã nguồn; trong container là file bên trong image.
- Service đọc qua interface nào?
IOptions<T>thì phải restart. Cần đổi nóng thì chuyển sangIOptionsMonitor<T>(Singleton) hoặcIOptionsSnapshot<T>(Scoped). - Service có chụp giá trị vào field không?
_settings = monitor.CurrentValuetrong constructor làm mất tác dụng của Monitor (bài 7.7).
Một cách nhanh để biết giá trị đến từ đâu: IConfigurationRoot.GetDebugView() in mọi khoá kèm nguồn đã cung cấp nó.
if (app.Environment.IsDevelopment())
Console.WriteLine(((IConfigurationRoot)app.Configuration).GetDebugView());Chỉ in ở Development: output có cả giá trị bí mật như mật khẩu, không được ghi ra log production.
7.12.5 — Lỗi thường gặp
| Lỗi | Triệu chứng | Sửa |
|---|---|---|
Dùng IOptions<T> cho giá trị cần đổi nóng | Sửa file không có tác dụng tới khi restart | IOptionsMonitor<T> hoặc IOptionsSnapshot<T> |
| Biến môi trường trùng khoá còn sót lại | Sửa file, restart, vẫn không đổi | Xoá biến môi trường, hoặc đổi giá trị ở đó |
Sửa appsettings.json trên máy dev, mong container đổi theo | Container không đổi | Build lại image hoặc dùng biến môi trường |
Đọc CurrentValue hai lần trong một thao tác | Hai lần đọc có thể cho hai giá trị | Đọc một lần vào biến cục bộ |
Danh sách rà soát
- Biết mọi nguồn cấu hình của ứng dụng và thứ tự ưu tiên của chúng.
- Giá trị cần đổi nóng đọc qua
IOptionsMonitor<T>hoặcIOptionsSnapshot<T>. - Cấu hình trong container đổi bằng biến môi trường kèm restart, không sửa file trong image.
-
GetDebugView()chỉ in ở Development.
Bài tập áp dụng
Bài 1 — Đo ba interface
Viết một chương trình đọc cùng một giá trị qua IOptions, IOptionsMonitor và IOptionsSnapshot, ghi đè appsettings.json trong lúc chạy, rồi in lại cả ba.
Tiêu chí hoàn thành: bạn đo được cả ba hành vi, khớp với bảng ở mục 7.12.2.
Gợi ý và lời giải — Bài 1
Gợi ý. Ghi vào đúng file ứng dụng đang đọc: Path.Combine(app.Environment.ContentRootPath, "appsettings.json"). Sau khi ghi, chờ một chút cho bộ theo dõi file kịp nạp lại.
Lời giải. Tạo project dotnet new web, đặt appsettings.json là { "Crm": { "DisplayName": "Ban đầu" } }, rồi viết Program.cs:
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<CrmOptions>(builder.Configuration.GetSection("Crm"));
builder.Services.AddSingleton<OptionsReader>();
var app = builder.Build();
var path = Path.Combine(app.Environment.ContentRootPath, "appsettings.json");
var reader = app.Services.GetRequiredService<OptionsReader>();
Console.WriteLine($"trước khi đổi: IOptions={reader.FromOptions()} | IOptionsMonitor={reader.FromMonitor()}");
File.WriteAllText(path, """{ "Crm": { "DisplayName": "Đã đổi" } }""");
await Task.Delay(1500); // chờ bộ theo dõi file
Console.WriteLine($"sau khi đổi : IOptions={reader.FromOptions()} | IOptionsMonitor={reader.FromMonitor()}");
using (var scope = app.Services.CreateScope())
{
var snapshot = scope.ServiceProvider.GetRequiredService<IOptionsSnapshot<CrmOptions>>();
Console.WriteLine($"IOptionsSnapshot (scope mới) = {snapshot.Value.DisplayName}");
}
File.WriteAllText(path, """{ "Crm": { "DisplayName": "Ban đầu" } }"""); // trả lại file cũ
public class CrmOptions { public string DisplayName { get; set; } = ""; }
public class OptionsReader(IOptions<CrmOptions> options, IOptionsMonitor<CrmOptions> monitor)
{
public string FromOptions() => options.Value.DisplayName;
public string FromMonitor() => monitor.CurrentValue.DisplayName;
}Chạy bằng dotnet run --no-launch-profile (để content root là thư mục project). Kết quả trên .NET 10:
trước khi đổi: IOptions=Ban đầu | IOptionsMonitor=Ban đầu
sau khi đổi : IOptions=Ban đầu | IOptionsMonitor=Đã đổi
IOptionsSnapshot (scope mới) = Đã đổiIOptions giữ giá trị cũ; IOptionsMonitor và một scope mới của IOptionsSnapshot thấy giá trị mới.
Một cái bẫy của IOptionsMonitor: giá trị có thể đổi giữa hai dòng code.
// Nguy hiểm — hai lần đọc có thể cho hai giá trị khác nhau
if (monitor.CurrentValue.FeatureEnabled)
await RunAsync(monitor.CurrentValue.Threshold);
// An toàn — chụp một lần cho cả thao tác
var settings = monitor.CurrentValue;
if (settings.FeatureEnabled)
await RunAsync(settings.Threshold);Bài 2 — Biến môi trường đè lên file
Chạy lại chương trình của Bài 1, lần này đặt biến môi trường Crm__DisplayName. Ghi lại cả ba giá trị trước và sau khi sửa file.
Tiêu chí hoàn thành: bạn giải thích được vì sao cả IOptionsMonitor cũng không đổi, và nêu được cách sửa trong tình huống ở mục 7.12.1.
Gợi ý và lời giải — Bài 2
Gợi ý. Biến môi trường được nạp sau appsettings.json, và không bao giờ được đọc lại.
Lời giải. Với Git Bash hoặc Linux/macOS:
Crm__DisplayName="Từ biến môi trường" dotnet run --no-launch-profileVới PowerShell:
$env:Crm__DisplayName = "Từ biến môi trường"; dotnet run --no-launch-profileKết quả trên .NET 10:
trước khi đổi: IOptions=Từ biến môi trường | IOptionsMonitor=Từ biến môi trường
sau khi đổi : IOptions=Từ biến môi trường | IOptionsMonitor=Từ biến môi trường
IOptionsSnapshot (scope mới) = Từ biến môi trườngCả ba đều không đổi. File appsettings.json có được nạp lại (bộ theo dõi file vẫn chạy), nhưng sau khi nạp lại, cấu hình vẫn được ghép theo thứ tự ưu tiên, và biến môi trường vẫn đứng sau, vẫn thắng. IOptionsMonitor làm đúng việc của nó: báo giá trị mới nhất của cấu hình đã ghép, và giá trị đó không đổi.
Áp vào tình huống ở mục 7.12.1. "Restart vẫn tên cũ" gần như chắc chắn là có biến môi trường Crm__DisplayName trên server (thường do ai đó đặt khi triển khai lần đầu rồi quên). Hai cách sửa:
- Đổi giá trị ở chính biến môi trường đó rồi restart: phù hợp với container, nơi biến môi trường là cách cấu hình chuẩn.
- Xoá biến môi trường để file
appsettings.jsoncó hiệu lực: phù hợp khi bạn muốn đổi nóng qua file.
Đừng để một khoá tồn tại ở nhiều nguồn mà không ai biết: chọn một nguồn "chính" cho mỗi môi trường, và ghi lại trong README của dự án.
Tự kiểm tra
Sửa appsettings.json lúc đang chạy, service dùng IOptions có nhận giá trị mới không?
Không. IOptions là singleton, tính giá trị một lần và giữ tới khi restart. Cần đổi nóng thì dùng IOptionsMonitor trong singleton hoặc IOptionsSnapshot trong service scoped.
Sửa file, restart, giá trị vẫn cũ. Nghi ngờ đầu tiên là gì?
Có một nguồn ưu tiên cao hơn đang đè lên khoá đó, thường là biến môi trường dạng Crm__DisplayName hoặc file appsettings.{Environment}.json. GetDebugView() của IConfigurationRoot cho biết giá trị đến từ nguồn nào.
Vì sao đổi biến môi trường của container mà IOptionsMonitor không nhận?
Vì biến môi trường được đọc một lần lúc tiến trình khởi động và không có cơ chế theo dõi thay đổi. Phải khởi động lại container.
Vì sao sửa appsettings.json trên máy mình không làm container đổi theo?
Vì file appsettings.json trong container là bản đã được đóng vào image lúc build. Muốn đổi thì build lại image, hoặc đưa giá trị đó ra biến môi trường của container.
Vì sao nên đọc CurrentValue một lần cho cả thao tác?
Vì IOptionsMonitor có thể đổi giá trị giữa hai lần đọc nếu file vừa được nạp lại, khiến một thao tác dùng nửa cấu hình cũ nửa cấu hình mới. Chụp vào biến cục bộ một lần thì cả thao tác thấy cùng một cấu hình.
Kết luận
Ba điều đáng nhớ nhất:
IOptions<T>không bao giờ đọc lại; đổi nóng cầnIOptionsMonitor<T>hoặcIOptionsSnapshot<T>.- Nguồn nạp sau thắng, và biến môi trường không bao giờ đọc lại. "Restart vẫn không đổi" thường là biến môi trường bị quên.
- Chẩn đoán theo danh sách: nguồn nào, file nào, interface nào, có chụp vào field không.
Tham khảo
- Configuration in ASP.NET Core — default configuration sources
- Options pattern in .NET
- Configuration in .NET — environment variables
Điều hướng
- Bài trước: 7.11 — Captive dependency làm lộ dữ liệu giữa tenant
- Bài tiếp theo: 7.13 — Ôn tập và đánh giá Module 7
- Về module: Trang mục lục