8.6 - Configuration System
Tóm tắt: Hệ thống cấu hình của .NET là một từ điển phẳng
string → string?được nhiều provider xếp chồng lên nhau, provider sau đè provider trước. Mọi thứ khác chỉ là lớp tiện ích trên đó — kể cả cấu trúc phân cấp trong JSON, thứ thực ra được làm phẳng thành khoá"Email:Smtp:Port". Hiểu điều này giải thích ngay hai thứ hay gây bối rối: vì sao biến môi trường viết hai dấu gạch dưới (Email__Smtp__Port) — vì nhiều shell không cho dấu hai chấm trong tên biến — và vì sao mảng trong JSON không hợp nhất được, mà bị thay theo từng chỉ số.
Mục tiêu bài học
Sau bài này bạn có thể:
- Liệt kê các provider mặc định và thứ tự ưu tiên của chúng.
- Ánh xạ đúng một khoá phân cấp sang tên biến môi trường.
- Dự đoán kết quả khi hai nguồn cùng khai báo một mảng.
- Đặt secret đúng chỗ cho từng môi trường.
- Chẩn đoán "vì sao giá trị này không phải cái tôi đặt".
Nội dung bài học
8.6.1 — Từ điển phẳng và thứ tự ưu tiên
{ "Email": { "Smtp": { "Port": 587 } } }trở thành đúng một mục:
"Email:Smtp:Port" = "587"Mọi giá trị đều là string. Việc ép về int hay bool xảy ra lúc bind.
Thứ tự provider mà CreateBuilder đăng ký, sau đè trước:
| # | Provider | Ghi chú |
|---|---|---|
| 1 | appsettings.json | Cơ sở, commit vào Git |
| 2 | appsettings.{Environment}.json | Theo môi trường |
| 3 | User Secrets | Chỉ Development |
| 4 | Biến môi trường | Cách container cấu hình |
| 5 | Tham số dòng lệnh | Cao nhất, tiện để thử |
dotnet run --CrmSettings:MaxPageSize=500Thêm nguồn riêng thì builder.Configuration.AddXxx(...), và vị trí gọi quyết định độ ưu tiên — thêm sau nghĩa là đè lên.
8.6.2 — Biến môi trường và hai dấu gạch dưới
# Khoa: ConnectionStrings:CrmDb
ConnectionStrings__CrmDb="Server=db;Database=Crm;..."
# Khoa: Email:Smtp:Port
Email__Smtp__Port=587Dấu hai chấm không dùng được trong tên biến môi trường trên nhiều shell và trên Linux, nên .NET quy ước hai dấu gạch dưới thay cho nó. Một dấu gạch dưới không có tác dụng — Email_Smtp_Port tạo ra một khoá hoàn toàn khác và bị bỏ qua trong im lặng.
Đây là cách Docker và Kubernetes cấu hình ứng dụng:
env:
- name: ConnectionStrings__CrmDb
valueFrom:
secretKeyRef: { name: crm-secrets, key: connection-string }
- name: ASPNETCORE_ENVIRONMENT
value: ProductionChi tiết ở Module 15 — Docker và Deployment.
8.6.3 — Mảng: cái bẫy thật sự
// appsettings.json
{ "AllowedOrigins": ["https://app.com", "https://admin.app.com"] }Mảng cũng bị làm phẳng theo chỉ số:
"AllowedOrigins:0" = "https://app.com"
"AllowedOrigins:1" = "https://admin.app.com"Hệ quả: nguồn sau không thay cả mảng mà chỉ đè từng chỉ số.
// appsettings.Production.json
{ "AllowedOrigins": ["https://prod.com"] }Kết quả không phải ["https://prod.com"] mà là:
["https://prod.com", "https://admin.app.com"]Vì chỉ khoá :0 bị đè, còn :1 vẫn còn nguyên từ file gốc. Đây là nguồn của những lỗi CORS và whitelist rất khó hiểu: một domain lẽ ra đã bị xoá vẫn được chấp nhận trên production.
Ba cách tránh:
- Đừng khai báo mảng ở
appsettings.jsongốc — chỉ khai ở file theo môi trường. - Đặt lại từng chỉ số cho đủ, kể cả bằng chuỗi rỗng.
- Dùng chuỗi ngăn cách rồi tự tách:
"AllowedOrigins": "https://prod.com;https://x.com".
8.6.4 — Đọc cấu hình
// Không khuyến khích — stringly-typed
var port = builder.Configuration.GetValue<int>("Email:Smtp:Port");
var conn = builder.Configuration.GetConnectionString("CrmDb");
// Khuyến khích — options pattern
builder.Services.AddOptions<CrmSettings>()
.Bind(builder.Configuration.GetSection(CrmSettings.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();GetConnectionString("CrmDb") là lối tắt cho ["ConnectionStrings:CrmDb"].
Toàn bộ options pattern — ba interface, validation, named options — đã nói kỹ ở bài 7.7. Điểm cần nhớ lại: ValidateOnStart() trên mọi AddOptions, để cấu hình thiếu chặn khởi động thay vì chặn người dùng.
Tiêm IConfiguration vào service là dấu hiệu chưa dùng options pattern. Nó chỉ thuộc về Program.cs và các extension method đăng ký.
8.6.5 — Reload
// appsettings.json của bạn được theo dõi mặc địnhCreateBuilder bật reloadOnChange: true cho file JSON — sửa file là cấu hình đổi, không cần restart. Nhưng:
IOptions<T>không thấy thay đổi đó; chỉIOptionsSnapshot<T>vàIOptionsMonitor<T>thấy.- Biến môi trường không reload. Chúng được đọc một lần lúc khởi động — đổi biến là phải restart tiến trình.
- Trong container, file cấu hình thường nằm trong image nên cũng không đổi được khi đang chạy.
Nghĩa là: trong triển khai container điển hình, reload gần như không có tác dụng. Muốn đổi cấu hình khi đang chạy thì cần một provider hỗ trợ thật, như Azure App Configuration hoặc một feature flag service.
8.6.6 — Secret
# Development — lưu ngoài repo, theo UserSecretsId trong .csproj
dotnet user-secrets init
dotnet user-secrets set "ConnectionStrings:CrmDb" "Server=...;Password=..."
dotnet user-secrets listUser Secrets lưu ở ~/.microsoft/usersecrets/<id>/secrets.json — ngoài cây mã nguồn, nên không thể commit nhầm. Nhưng nó không mã hoá; nó chỉ giải quyết vấn đề "lỡ tay git add".
| Môi trường | Nơi để secret |
|---|---|
| Development | User Secrets |
| CI/CD | Secret của pipeline (GitHub Actions secrets, biến bảo vệ) |
| Container | Biến môi trường từ secret của orchestrator |
| Cloud | Azure Key Vault, AWS Secrets Manager |
Quy tắc không thương lượng: appsettings.json nằm trong Git, nên không có mật khẩu, API key hay connection string có thông tin đăng nhập ở đó. Giá trị mẫu thì để rỗng, không để giá trị thật của môi trường dev chung.
8.6.7 — Chẩn đoán "giá trị này ở đâu ra"
// Chi dung khi debug — in ra nguon cua tung khoa
var root = (IConfigurationRoot)builder.Configuration;
Console.WriteLine(root.GetDebugView());GetDebugView() in mọi khoá kèm provider nào đã cho giá trị cuối cùng. Nó giải quyết trong 5 giây câu hỏi "vì sao connection string trên staging không phải cái tôi đặt".
Đừng để nó chạy ở Production: nó in cả secret ra output.
8.6.8 — Rà lại code của bạn
Danh sách rà soát cấu hình
- Không có mật khẩu hay API key nào trong appsettings.json đã commit.
- Biến môi trường dùng đúng hai dấu gạch dưới cho khoá phân cấp.
- Mảng cấu hình chỉ khai báo ở một nơi, không ở cả file gốc lẫn file môi trường.
- Không có service nào tiêm IConfiguration để đọc giá trị lẻ.
- Mọi AddOptions đều có ValidateOnStart().
- Biết rõ cấu hình nào cần reload và đã dùng IOptionsMonitor cho chúng.
- GetDebugView() không chạy ở Production.
- Mỗi môi trường triển khai có nơi để secret rõ ràng, không dùng file.
Bài tập áp dụng
Bài 1 — Bẫy mảng trong cấu hình
Khai AllowedOrigins với 2 phần tử ở appsettings.json và 1 phần tử ở appsettings.Production.json. Chạy ở môi trường Production, in ra mảng cuối cùng.
Tiêu chí hoàn thành: bạn ghi lại đúng kết quả — và nó không phải điều bạn mong đợi.
Gợi ý và lời giải — Bài 1
Gợi ý. Hệ thống cấu hình của .NET làm phẳng mọi thứ thành một từ điển khoá–giá trị. Hãy nghĩ xem một mảng được làm phẳng thành gì, rồi nghĩ xem việc "ghi đè" diễn ra trên cái gì.
Lời giải.
// appsettings.json
{ "AllowedOrigins": [ "https://a.com", "https://b.com" ] }
// appsettings.Production.json
{ "AllowedOrigins": [ "https://prod.com" ] }var ds = config.GetSection("AllowedOrigins").Get<string[]>();
Console.WriteLine($"[{string.Join(", ", ds)}]");Kết quả đo thật trên .NET 9:
KẾT QUẢ: [https://prod.com, https://b.com] (2 phần tử)Không phải [prod.com]. Kết quả là hỗn hợp của hai file.
Vì sao — nhìn vào cấu trúc phẳng bên dưới:
AllowedOrigins:0 = https://prod.com
AllowedOrigins:1 = https://b.comMảng được làm phẳng thành các khoá có chỉ số. File Production chỉ định nghĩa AllowedOrigins:0, nên nó ghi đè phần tử đầu. AllowedOrigins:1 từ file gốc không bị đụng tới và vẫn còn nguyên.
Đây là lỗ hổng bảo mật thật. Trong ví dụ trên, bạn nghĩ mình đã giới hạn CORS chỉ cho prod.com. Thực tế b.com — có thể là một tên miền thử nghiệm, hoặc một tên miền đã bị bỏ và có thể bị người khác đăng ký lại — vẫn được cho phép trên production.
Cùng vấn đề với mọi danh sách mang tính bảo mật: tên miền được phép, địa chỉ IP trong danh sách trắng, phạm vi quyền, nhà cung cấp xác thực.
Ba cách xử lý:
// Cách 1 — xoá section trước khi nạp file môi trường (thủ công, dễ quên)
// Cách 2 — dùng chuỗi ngăn cách thay vì mảng
// appsettings.json: "AllowedOrigins": "https://a.com;https://b.com"
// appsettings.Production.json: "AllowedOrigins": "https://prod.com"
var ds = config["AllowedOrigins"]!.Split(';', StringSplitOptions.RemoveEmptyEntries);
// Cách 3 — dùng đối tượng có khoá thay vì mảng// appsettings.json
{ "AllowedOrigins": { "a": "https://a.com", "b": "https://b.com" } }
// appsettings.Production.json — ghi đè hoàn toàn bằng cách đặt null cho khoá không dùng
{ "AllowedOrigins": { "a": null, "b": null, "prod": "https://prod.com" } }Cách 2 là thực dụng nhất cho danh sách đơn giản: ghi đè hoạt động đúng như trực giác, vì nó chỉ là một chuỗi.
Cách phát hiện trong dự án đang có:
grep -l '\[' appsettings*.json | while read f; do
echo "=== $f"; python3 -c "
import json,sys
d=json.load(open('$f'))
def w(o,p=''):
if isinstance(o,dict):
for k,v in o.items(): w(v, p+'/'+k)
elif isinstance(o,list): print(' MẢNG:', p, f'({len(o)} phần tử)')
"
doneMọi mảng xuất hiện ở nhiều hơn một file cấu hình đều là một bẫy tiềm tàng.
Kiểm chứng giá trị cuối cùng khi khởi động:
if (app.Environment.IsDevelopment())
Console.WriteLine(((IConfigurationRoot)app.Configuration).GetDebugView());GetDebugView() in ra mọi khoá, giá trị cuối cùng, và provider nào đã thắng — công cụ tốt nhất để trả lời câu hỏi "giá trị này ở đâu ra".
Bài 2 — Một dấu gạch dưới so với hai
Đặt Email_Smtp_Port=587 rồi Email__Smtp__Port=587. In giá trị Configuration["Email:Smtp:Port"] trong mỗi trường hợp.
Tiêu chí hoàn thành: bạn nêu được vì sao .NET dùng hai dấu gạch dưới thay vì dấu hai chấm.
Gợi ý và lời giải — Bài 2
Gợi ý. Khoá cấu hình trong .NET dùng dấu hai chấm để phân cấp: Email:Smtp:Port. Nhưng tên biến môi trường thì bị giới hạn bởi hệ điều hành.
Lời giải.
export Email_Smtp_Port=111 # MỘT gạch dưới
export Email__Smtp__Port=587 # HAI gạch dướiConsole.WriteLine(config["Email:Smtp:Port"]);Kết quả đo thật trên .NET 9:
Email:Smtp:Port = 587Biến một gạch dưới hoàn toàn bị bỏ qua — nó được nạp thành khoá Email_Smtp_Port, một khoá phẳng không liên quan gì tới Email:Smtp:Port.
Vì sao hai gạch dưới. Nhiều hệ vỏ và hệ điều hành không cho phép dấu hai chấm trong tên biến môi trường:
export Email:Smtp:Port=587
# bash: export: `Email:Smtp:Port=587': not a valid identifierVì vậy .NET quy ước dùng __ làm ký tự thay thế cho :. Một gạch dưới được giữ nguyên vì nó là ký tự hợp lệ trong chính tên khoá.
Vì sao lỗi này khó phát hiện:
- Không có cảnh báo. Biến sai vẫn được nạp, chỉ là thành một khoá khác mà không ai đọc.
- Giá trị mặc định vẫn hoạt động. Ứng dụng chạy bình thường với giá trị trong
appsettings.json, nên mọi thứ trông ổn. - Chỉ lộ ra khi giá trị thật sự khác. Nếu bạn đặt biến môi trường để trỏ sang một máy chủ SMTP khác, email vẫn gửi qua máy chủ cũ — và bạn chỉ phát hiện khi ai đó kiểm tra kỹ.
Chẩn đoán nhanh:
// In mọi khoá đến từ biến môi trường
foreach (var kv in config.AsEnumerable().OrderBy(x => x.Key))
if (Environment.GetEnvironmentVariable(kv.Key.Replace(":", "__")) is not null)
Console.WriteLine($"{kv.Key} = {kv.Value} (từ biến môi trường)");Hoặc dùng GetDebugView() như bài 1 — nó ghi rõ provider nguồn cho từng khoá.
Trong Docker Compose và Kubernetes:
# docker-compose.yml
environment:
- Email__Smtp__Port=587
- ConnectionStrings__Crm=Host=db;Database=crm
# Kubernetes
env:
- name: Email__Smtp__Port
value: "587"Một chi tiết về tiền tố. Bạn có thể yêu cầu chỉ nạp các biến có tiền tố nhất định:
builder.Configuration.AddEnvironmentVariables(prefix: "CRM_");
// CRM_Email__Smtp__Port=587 -> Email:Smtp:PortTiền tố được cắt bỏ sau khi nạp. Cách này hữu ích để tránh xung đột với biến của hệ thống, nhưng nhớ rằng nó thay đổi tên biến cần đặt — và đó lại là một nguồn nhầm lẫn nữa nếu không ghi vào tài liệu.
Quy tắc. Đặt tên biến môi trường vào tài liệu triển khai dưới dạng chính xác từng ký tự, và kiểm chứng bằng GetDebugView() ở môi trường thử nghiệm trước khi lên production.
Bài 3 — Truy nguồn giá trị cấu hình
Đặt cùng một khoá ở cả bốn nguồn với bốn giá trị khác nhau, chạy GetDebugView() và xác nhận provider thắng cuộc.
Tiêu chí hoàn thành: bạn nêu đúng thứ tự ưu tiên và giải thích được vì sao nó được thiết kế như vậy.
Gợi ý và lời giải — Bài 3
Gợi ý. Nguyên tắc: nguồn nạp sau ghi đè nguồn nạp trước. Vậy câu hỏi trở thành: CreateBuilder nạp các nguồn theo thứ tự nào?
Lời giải — thiết lập:
// appsettings.json
{ "Demo": { "Value": "from-appsettings" } }
// appsettings.Development.json
{ "Demo": { "Value": "from-appsettings-development" } }dotnet user-secrets set "Demo:Value" "from-user-secrets"
export Demo__Value="from-env-var"
dotnet run -- --Demo:Value="from-command-line"Console.WriteLine(config["Demo:Value"]);
Console.WriteLine(((IConfigurationRoot)config).GetDebugView());Kết quả:
from-command-line
Demo:
Value=from-command-line (CommandLineConfigurationProvider)GetDebugView() ghi rõ provider nào đã thắng trong dấu ngoặc.
Thứ tự ưu tiên đầy đủ của WebApplication.CreateBuilder — từ thấp tới cao:
| # | Nguồn | Khi nào dùng |
|---|---|---|
| 1 | appsettings.json | Giá trị mặc định cho mọi môi trường |
| 2 | appsettings.{Environment}.json | Khác biệt theo môi trường |
| 3 | User Secrets (chỉ Development) | Bí mật khi phát triển, nằm ngoài thư mục dự án |
| 4 | Biến môi trường | Cấu hình khi triển khai |
| 5 | Tham số dòng lệnh | Ghi đè tạm thời khi chạy thử |
Vì sao thiết kế như vậy — thứ tự phản ánh mức độ "cụ thể":
Chung nhất, nằm trong kho code -> appsettings.json
Cụ thể theo môi trường, vẫn trong kho -> appsettings.Production.json
Cụ thể theo máy của lập trình viên -> user secrets
Cụ thể theo lần triển khai -> biến môi trường
Cụ thể theo lần chạy -> dòng lệnhCàng cụ thể thì càng thắng. Nhờ vậy bạn đặt giá trị mặc định an toàn trong kho code, và ghi đè khi triển khai mà không sửa file nào.
Ba hệ quả thực tế:
Bí mật không bao giờ nằm trong kho code. Chúng đến từ biến môi trường hoặc kho bí mật — tầng 4, thắng mọi thứ trong kho.
Cùng một image Docker chạy được ở mọi môi trường. Chỉ đổi biến môi trường, không build lại. Đây là nguyên tắc thứ ba của Twelve-Factor App, và là nền của mọi quy trình triển khai hiện đại.
Gỡ rối nhanh bằng dòng lệnh. Nghi ngờ một giá trị cấu hình gây lỗi? Chạy lại với
--Khoa:Con=giatriđể thử mà không đụng vào gì.
Thêm nguồn riêng. Ví dụ đọc bí mật từ Azure Key Vault, chèn vào đúng vị trí ưu tiên:
builder.Configuration.AddAzureKeyVault(
new Uri($"https://{tenVault}.vault.azure.net/"),
new DefaultAzureCredential());Nó được nạp sau các nguồn mặc định nên thắng chúng — đúng ý cho bí mật.
Một cảnh báo về GetDebugView(). Nó in mọi giá trị, bao gồm cả mật khẩu và chuỗi kết nối. Chỉ dùng ở môi trường Development, hoặc lọc bớt:
var view = ((IConfigurationRoot)config).GetDebugView(ctx =>
ctx.Key.Contains("Password", StringComparison.OrdinalIgnoreCase) ||
ctx.Key.Contains("Secret", StringComparison.OrdinalIgnoreCase)
? "***" : ctx.Value);Tự kiểm tra
Hệ thống cấu hình .NET lưu dữ liệu như thế nào?
Là một từ điển phẳng ánh xạ chuỗi sang chuỗi, được nhiều provider xếp chồng và provider sau đè provider trước. Cấu trúc phân cấp trong JSON bị làm phẳng thành khoá nối bằng dấu hai chấm, và mọi giá trị đều là chuỗi cho tới lúc bind.
Vì sao biến môi trường dùng hai dấu gạch dưới?
Vì dấu hai chấm không dùng được trong tên biến môi trường trên nhiều shell và trên Linux, nên .NET quy ước hai dấu gạch dưới thay cho nó. Một dấu gạch dưới không có tác dụng, nó tạo ra một khoá hoàn toàn khác và bị bỏ qua trong im lặng.
Mảng trong cấu hình có bẫy gì?
Mảng bị làm phẳng theo chỉ số, nên nguồn sau không thay cả mảng mà chỉ đè từng chỉ số. Khai 2 phần tử ở file gốc và 1 phần tử ở file môi trường thì kết quả là phần tử đầu bị thay còn phần tử thứ hai vẫn còn nguyên. Đây là nguồn của những lỗi CORS khó hiểu khi một domain lẽ ra đã bị xoá vẫn được chấp nhận.
Thứ tự ưu tiên của các provider mặc định là gì?
Từ thấp đến cao: appsettings.json, appsettings theo môi trường, User Secrets chỉ ở Development, biến môi trường, rồi tham số dòng lệnh. Provider thêm sau đè provider thêm trước, nên vị trí gọi AddXxx quyết định độ ưu tiên.
Reload cấu hình hoạt động tới đâu?
File JSON được theo dõi mặc định nên sửa file là cấu hình đổi, nhưng IOptions không thấy thay đổi, chỉ IOptionsSnapshot và IOptionsMonitor thấy. Biến môi trường thì không reload, chúng chỉ đọc một lần lúc khởi động. Trong container thì file nằm trong image nên reload gần như không có tác dụng.
User Secrets có mã hoá không?
Không. Nó chỉ lưu file ra ngoài cây mã nguồn, ở thư mục usersecrets trong home, nên bạn không thể commit nhầm. Nó giải quyết vấn đề lỡ tay git add chứ không phải vấn đề bảo mật; production thì dùng biến môi trường từ secret của orchestrator hoặc Key Vault.
Kết luận
Ba điều đáng nhớ nhất:
- Cấu hình là một từ điển phẳng — cấu trúc phân cấp chỉ là quy ước đặt tên khoá.
- Mảng không hợp nhất, chúng bị đè theo chỉ số. Khai mảng ở một nơi duy nhất.
appsettings.jsonnằm trong Git. Secret đi đường khác, tuỳ môi trường.
Tham khảo
- Configuration in ASP.NET Core
- Configuration in .NET
- Safe storage of app secrets in development
- Options pattern in ASP.NET Core
Điều hướng
- Bài trước: 8.5 — Model Validation
- Bài tiếp theo: 8.7 — Logging với Serilog
- Về module: Trang mục lục