Skip to content

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.json trong 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 qua IOptions<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.json và 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ự:

  1. 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")
  2. 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:

InterfaceLifetimeNhận giá trị mới khi file đổi
IOptions<T>SingletonKhông bao giờ — tính một lần, giữ tới khi restart
IOptionsSnapshot<T>ScopedCó, ở scope (request) tiếp theo
IOptionsMonitor<T>SingletonCó, đọ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á:

text
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ất

Khoá 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}.jsonCó (reloadOnChange: true mặc định)
User secretsCó
Biến môi trườngKhông — đọc một lần lúc tiến trình khởi động
Tham số dòng lệnhKhông
Azure Key VaultChỉ 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, IOptionsMonitor cũng không giúp gì.
  • appsettings.json nằ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" ​

  1. Có biến môi trường hoặc tham số dòng lệnh trùng khoá không? Kiểm tra Crm__DisplayName trong môi trường của tiến trình. Nếu có, nó thắng.
  2. Có appsettings.{Environment}.json đè lên không? Sửa appsettings.json mà appsettings.Production.json có cùng khoá thì vô ích.
  3. 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.
  4. Service đọc qua interface nào? IOptions<T> thì phải restart. Cần đổi nóng thì chuyển sang IOptionsMonitor<T> (Singleton) hoặc IOptionsSnapshot<T> (Scoped).
  5. Service có chụp giá trị vào field không? _settings = monitor.CurrentValue trong 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ó.

csharp
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ỗiTriệu chứngSửa
Dùng IOptions<T> cho giá trị cần đổi nóngSửa file không có tác dụng tới khi restartIOptionsMonitor<T> hoặc IOptionsSnapshot<T>
Biến môi trường trùng khoá còn sót lạiSửa file, restart, vẫn không đổiXoá biến môi trường, hoặc đổi giá trị ở đó
Sửa appsettings.json trên máy dev, mong container đổi theoContainer không đổiBuild lại image hoặc dùng biến môi trường
Đọc CurrentValue hai lần trong một thao tácHai 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ặc IOptionsSnapshot<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:

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

text
trước khi đổi: IOptions=Ban đầu | IOptionsMonitor=Ban đầu
sau khi đổi  : IOptions=Ban đầu | IOptionsMonitor=Đã đổi
IOptionsSnapshot (scope mới) = Đã đổi

IOptions 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.

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

bash
Crm__DisplayName="Từ biến môi trường" dotnet run --no-launch-profile

Với PowerShell:

powershell
$env:Crm__DisplayName = "Từ biến môi trường"; dotnet run --no-launch-profile

Kết quả trên .NET 10:

text
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ường

Cả 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.json có 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:

  1. IOptions<T> không bao giờ đọc lại; đổi nóng cần IOptionsMonitor<T> hoặc IOptionsSnapshot<T>.
  2. 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.
  3. 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 ​

Điều hướng ​

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