8.7 - Logging với Serilog
Tóm tắt: Khác biệt giữa log dùng được và log vô dụng nằm ở đúng một ký tự: dấu
$.LogInformation($"Customer {id} created")tạo ra một chuỗi đã nấu chín — bạn chỉ tìm kiếm được bằng văn bản.LogInformation("Customer {CustomerId} created", id)giữCustomerIdthành một trường có cấu trúc, nên bạn truy vấn đượcCustomerId = 42trên hàng triệu dòng log. Đó là toàn bộ ý nghĩa của structured logging. Serilog thêm vào: sink (ghi đi đâu), enricher (thêm ngữ cảnh chung), vàUseSerilogRequestLogging()— thứ thay mười dòng log framework mỗi request bằng một dòng tóm tắt có đủ thông tin.
Mục tiêu bài học
Sau bài này bạn có thể:
- Giải thích vì sao nội suy chuỗi phá hỏng log có cấu trúc.
- Chọn đúng log level cho từng tình huống.
- Dùng scope để gắn ngữ cảnh cho một nhóm log.
- Cấu hình Serilog từ
appsettings.json. - Biết chắc những gì không bao giờ được ghi vào log.
Nội dung bài học
8.7.1 — Message template, không phải nội suy chuỗi
// SAI — chuỗi đã nấu chín, không còn trường nào
logger.LogInformation($"Customer {customer.Id} created by {userId}");
// ĐÚNG — CustomerId và UserId là trường truy vấn được
logger.LogInformation("Customer {CustomerId} created by {UserId}",
customer.Id, userId);Dòng thứ hai sinh ra JSON:
{
"@t": "2026-09-24T10:15:00Z",
"@mt": "Customer {CustomerId} created by {UserId}",
"CustomerId": 42,
"UserId": "u-77"
}Bạn truy vấn được CustomerId = 42 trong Seq, Elasticsearch hay Loki. Với bản nội suy, bạn chỉ có một chuỗi và phải tìm bằng văn bản — đúng lúc đang cần tìm nhanh nhất.
Còn hai lý do nữa:
- Hiệu năng. Nội suy chuỗi luôn chạy, kể cả khi
Debugđang tắt. Message template chỉ nối khi log thật sự được ghi. - Gom nhóm. Cùng một
@mtlà cùng một loại sự kiện, nên hệ thống log đếm được "sự kiện này xảy ra 12.000 lần hôm nay".
Hai quy ước đặt tên: PascalCase cho placeholder, và tiền tố @ khi muốn Serilog ghi cả đối tượng thay vì gọi ToString():
logger.LogInformation("Nhận đơn hàng {@Order}", order); // ghi cả objectCẩn thận với @: nó ghi toàn bộ thuộc tính, nên rất dễ đưa dữ liệu nhạy cảm vào log.
Bật analyzer để không ai quên:
<PackageReference Include="SerilogAnalyzer" Version="*" PrivateAssets="all" />8.7.2 — Log level
| Level | Nghĩa | Ví dụ |
|---|---|---|
Trace | Chi tiết tới mức lộ nội bộ | Giá trị biến trong vòng lặp |
Debug | Thông tin chẩn đoán cho dev | Cache hit/miss, SQL sinh ra |
Information | Sự kiện nghiệp vụ đáng ghi | Đơn hàng được tạo, người dùng đăng nhập |
Warning | Bất thường nhưng đã xử lý được | Retry lần 2, cache không kết nối được nên đi thẳng DB |
Error | Một thao tác thất bại | Không lưu được đơn hàng |
Critical | Ứng dụng không phục vụ được | Mất kết nối database, sắp hết bộ nhớ |
Ba nhầm lẫn hay gặp:
Errorcho mọi exception. Người dùng nhập sai dữ liệu là chuyện bình thường, không phải sự cố.OperationCanceledExceptiondo client ngắt kết nối cũng vậy (bài 6.7). Log mứcErrorcho chúng làm dashboard đầy báo động giả cho tới khi không ai nhìn nó nữa.Informationcho mọi thứ. Log mứcInformationtrong một vòng lặp 10.000 phần tử là chi phí thật và là nhiễu thật.Criticalkhông bao giờ dùng. NếuCriticalkhông nối với cảnh báo gọi điện cho ai đó, nó chỉ làErrorviết hoa.
Đặt mức theo namespace:
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
}
}
}Ba dòng Override này cắt phần lớn nhiễu: EF Core log mọi câu SQL ở mức Information, và HttpClient log mọi request đi ra.
8.7.3 — Cấu hình
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.Seqbuilder.Host.UseSerilog((ctx, services, config) => config
.ReadFrom.Configuration(ctx.Configuration) // doc tu appsettings.json
.ReadFrom.Services(services) // enricher dang ky trong DI
.Enrich.FromLogContext() // BAT BUOC cho scope
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName());Đặt cấu hình trong appsettings.json để đổi được theo môi trường mà không build lại:
{
"Serilog": {
"Using": ["Serilog.Sinks.Console", "Serilog.Sinks.Seq"],
"MinimumLevel": { "Default": "Information" },
"WriteTo": [
{ "Name": "Console", "Args": { "formatter": "Serilog.Formatting.Compact.CompactJsonFormatter, Serilog.Formatting.Compact" } },
{ "Name": "Seq", "Args": { "serverUrl": "http://seq:5341" } }
]
}
}Chọn sink theo nơi triển khai:
- Trong container → chỉ ghi ra Console dạng JSON. Docker, Kubernetes và cloud đều thu thập stdout. Ghi ra file trong container là ghi vào một lớp sẽ biến mất, và nó còn làm image phình ra.
- Ngoài container → thêm
Serilog.Sinks.FilevớirollingIntervalvàretainedFileCountLimit. - Tập trung → Seq (nhẹ, dễ dựng), Elasticsearch, hoặc sink của nhà cung cấp cloud.
CompactJsonFormatter gọn hơn đáng kể so với JsonFormatter mặc định — đáng dùng khi log đi qua mạng.
Đường đi của một sự kiện log qua cấu hình ở trên:
8.7.4 — UseSerilogRequestLogging
app.UseSerilogRequestLogging(options =>
{
options.MessageTemplate =
"HTTP {RequestMethod} {RequestPath} -> {StatusCode} trong {Elapsed:0.0}ms";
options.GetLevel = (httpContext, elapsed, ex) =>
ex is not null ? LogEventLevel.Error
: httpContext.Response.StatusCode > 499 ? LogEventLevel.Error
: elapsed > 3000 ? LogEventLevel.Warning
: httpContext.Request.Path.StartsWithSegments("/health")
? LogEventLevel.Verbose
: LogEventLevel.Information;
options.EnrichDiagnosticContext = (diag, http) =>
{
diag.Set("UserId", http.User.FindFirst("sub")?.Value);
diag.Set("UserAgent", http.Request.Headers.UserAgent.ToString());
};
});Nó thay khoảng mười dòng log mà ASP.NET Core sinh ra cho mỗi request bằng một dòng có đủ method, path, status, thời gian và những gì bạn thêm vào.
GetLevel là phần đáng giá nhất: health check mỗi 10 giây sẽ chiếm phần lớn log của bạn nếu không hạ mức nó xuống, còn request chậm tự nổi lên Warning mà không cần middleware riêng.
Đặt nó sớm trong pipeline, ngay sau middleware xử lý lỗi và correlation ID (bài 8.2).
8.7.5 — Scope
using (logger.BeginScope(new Dictionary<string, object>
{
["OrderId"] = order.Id,
["TenantId"] = tenant.Id
}))
{
logger.LogInformation("Bắt đầu xử lý");
await ValidateAsync(order, ct); // mọi log bên trong đều có OrderId và TenantId
await ChargeAsync(order, ct);
logger.LogInformation("Hoàn tất");
}Scope gắn thuộc tính vào mọi log phát sinh bên trong nó, kể cả từ lớp khác ở sâu bên dưới. Đó là cách bạn lọc ra toàn bộ dấu vết của một đơn hàng qua nhiều lớp mà không phải truyền orderId vào từng lời gọi log.
Cần Enrich.FromLogContext() trong cấu hình, nếu không scope bị bỏ qua trong im lặng.
Serilog còn có LogContext.PushProperty(...) làm điều tương tự ở tầng Serilog — dùng nó trong middleware, như ở CorrelationIdMiddleware.
8.7.6 — Không bao giờ ghi vào log
Log thường được gửi sang hệ thống bên thứ ba, giữ nhiều tháng, và nhiều người đọc được hơn database. Không bao giờ ghi:
- Mật khẩu, kể cả mật khẩu sai người dùng vừa gõ.
- Token, API key, connection string.
- Số thẻ đầy đủ, CVV.
- Số CMND/CCCD, thông tin y tế.
- Toàn bộ request body của endpoint đăng nhập hoặc thanh toán.
// SAI — @ ghi toàn bộ thuộc tính, kể cả Password
logger.LogInformation("Dang nhap {@Request}", loginRequest);
// DUNG
logger.LogInformation("Dang nhap {Email}", loginRequest.Email);Một tầng bảo vệ đáng thêm là destructuring policy, để cùng một quy tắc áp cho mọi chỗ:
config.Destructure.ByTransforming<LoginRequest>(r => new { r.Email });Và nhớ rằng URL cũng vào log: đừng đặt token trong query string.
8.7.7 — Rà lại code của bạn
Danh sách rà soát logging
- Không còn lời gọi log nào dùng nội suy chuỗi với dấu $.
- Placeholder đặt tên PascalCase và nhất quán giữa các lớp.
- MinimumLevel có Override cho Microsoft.AspNetCore và EF Core Database.Command.
- Lỗi do người dùng nhập sai không được log ở mức Error.
- OperationCanceledException do client ngắt không log ở mức Error.
- Trong container chỉ ghi Console dạng JSON, không ghi file.
- UseSerilogRequestLogging được bật và hạ mức cho health check.
- Enrich.FromLogContext() có trong cấu hình để scope hoạt động.
- Không có mật khẩu, token hay số thẻ nào xuất hiện trong log.
- Không dùng {@Object} cho model có chứa trường nhạy cảm.
Bài tập áp dụng
Bài 1 — Message template so với nội suy chuỗi
Ghi cùng một sự kiện bằng nội suy chuỗi và bằng message template, rồi thử truy vấn theo CustomerId. Ghi lại cách nào tìm được.
Tiêu chí hoàn thành: bạn truy vấn được theo giá trị trường, và nêu được vì sao cách kia không thể.
Gợi ý và lời giải — Bài 1
Gợi ý. Nội suy chuỗi được thực hiện bởi trình biên dịch, trước khi ILogger nhìn thấy gì. Vậy thứ ILogger nhận được là gì?
Lời giải.
var customerId = 42;
var tong = 1_500_000m;
// Cách 1 — nội suy chuỗi
_logger.LogInformation($"Đã tạo đơn cho khách {customerId}, tổng {tong}");
// Cách 2 — message template
_logger.LogInformation("Đã tạo đơn cho khách {CustomerId}, tổng {Tong}", customerId, tong);Cùng một dòng hiện ra trên console. Nhưng JSON gửi tới hệ thống log hoàn toàn khác:
// Cách 1
{
"@t": "2026-09-24T09:12:03.121Z",
"@l": "Information",
"@m": "Đã tạo đơn cho khách 42, tổng 1500000"
}
// Cách 2
{
"@t": "2026-09-24T09:12:03.121Z",
"@l": "Information",
"@mt": "Đã tạo đơn cho khách {CustomerId}, tổng {Tong}",
"CustomerId": 42,
"Tong": 1500000
}Truy vấn trong Seq:
Cách 1: CustomerId = 42 -> KHÔNG TÌM THẤY (trường không tồn tại)
@m like '%khách 42%' -> tìm được, nhưng cũng khớp "khách 420", "khách 421"
Cách 2: CustomerId = 42 -> chính xác
Tong > 1000000 -> so sánh SỐ đượcVì sao cách 1 không thể. Trình biên dịch nối chuỗi trước khi gọi ILogger, nên logger chỉ nhận được một chuỗi phẳng. Không có gì để tách ra thành trường. Thông tin về cấu trúc đã mất vĩnh viễn ngay tại dòng code đó.
Bốn lợi ích của message template, ngoài việc truy vấn được:
- Nhóm được các dòng log cùng loại. Trường
@mtgiống nhau cho mọi lần gọi, nên đếm được "sự kiện này xảy ra bao nhiêu lần" dù giá trị khác nhau. - So sánh theo kiểu dữ liệu.
Tong > 1000000hoạt động vìTongđược lưu là số, không phải chuỗi. - Không tính chuỗi khi mức log bị tắt. Với nội suy, chuỗi luôn được nối kể cả khi
Debugđang tắt — lãng phí trong vòng lặp nóng. - Bảo vệ khỏi log injection. Giá trị được lưu thành trường riêng chứ không trộn vào thông điệp, nên không ai chèn được dòng log giả bằng cách nhập ký tự xuống dòng.
Quy ước đặt tên. Dùng PascalCase cho tên trường trong template và giữ nhất quán toàn hệ thống. Nếu chỗ này ghi {CustomerId} và chỗ kia ghi {customerId}, chúng thành hai trường khác nhau và truy vấn sẽ sót.
Bật cảnh báo tự động. Gói SerilogAnalyzer hoặc quy tắc CA2254 phát hiện nội suy chuỗi trong lời gọi log:
<WarningsAsErrors>$(WarningsAsErrors);CA2254</WarningsAsErrors>Đây là một trong những quy tắc đáng biến thành lỗi nhất, vì hậu quả chỉ lộ ra khi bạn cần điều tra sự cố — lúc đã quá muộn để sửa.
Bài 2 — Đếm nhiễu từ health check
Chạy ứng dụng 5 phút với health check mỗi 10 giây và không cấu hình mức log. Đếm số dòng log. Bật cấu hình hạ health check xuống mức thấp và đếm lại.
Tiêu chí hoàn thành: bạn có hai con số và nêu được vì sao nhiễu trong log là một vấn đề thật.
Gợi ý và lời giải — Bài 2
Gợi ý. Kubernetes gọi cả liveness và readiness probe. Với hai probe mỗi 10 giây trên 3 thể hiện, hãy tính xem một ngày có bao nhiêu lần gọi.
Lời giải — bản mặc định:
app.UseSerilogRequestLogging();Sau 5 phút với hai probe mỗi 10 giây:
5 phút = 300 giây
300 / 10 = 30 lần mỗi probe
2 probe = 60 dòng logQuy ra một ngày, với 3 thể hiện:
60 dòng / 5 phút × 288 (số khoảng 5 phút trong ngày) × 3 thể hiện
= khoảng 51.840 dòng log mỗi ngày, chỉ riêng health checkBản có cấu hình:
app.UseSerilogRequestLogging(o =>
{
o.GetLevel = (ctx, elapsed, ex) =>
ex is not null ? LogEventLevel.Error
: ctx.Response.StatusCode >= 500 ? LogEventLevel.Error
: ctx.Request.Path.StartsWithSegments("/health") ? LogEventLevel.Verbose
: elapsed > 3000 ? LogEventLevel.Warning
: LogEventLevel.Information;
});Với mức tối thiểu là Information, các dòng Verbose bị loại bỏ trước khi ghi:
60 dòng -> 0 dòngVì sao nhiễu là vấn đề thật — bốn lý do:
- Chi phí lưu trữ. Các dịch vụ log tính tiền theo dung lượng. 50.000 dòng vô nghĩa mỗi ngày là chi phí thuần, và nó nhân lên theo số môi trường.
- Che lấp dòng quan trọng. Khi cuộn log tìm một lỗi, 95% những gì bạn thấy là health check. Điều này làm chậm việc điều tra đúng lúc cần nhanh nhất.
- Làm hỏng thống kê. Tính độ trễ trung bình mà gộp cả health check — vốn luôn nhanh — sẽ cho con số đẹp hơn thực tế đáng kể.
- Tạo thói quen bỏ qua log. Đây là hậu quả lâu dài và tệ nhất: khi log đầy nhiễu, người ta ngừng đọc nó.
Cấu hình mức log theo nguồn — quan trọng không kém:
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
}
}
}Dòng thứ hai trong Override đặc biệt đáng giá: ở mức Information, EF Core ghi toàn bộ câu SQL của mọi truy vấn. Rất hữu ích khi phát triển, và là một nguồn nhiễu khổng lồ trên production — chưa kể nó có thể ghi cả giá trị tham số, tức là dữ liệu người dùng.
Quy tắc chọn mức log:
| Mức | Dùng cho | Có nên bật ở production |
|---|---|---|
Verbose / Trace | Chi tiết gỡ rối sâu | Không |
Debug | Thông tin cho lập trình viên | Không |
Information | Sự kiện nghiệp vụ đáng lưu | Có |
Warning | Bất thường nhưng xử lý được | Có |
Error | Thất bại cần người xem | Có |
Fatal | Ứng dụng không chạy tiếp được | Có |
Phép thử cho mức Information: dòng này có giúp tôi hiểu chuyện gì đã xảy ra với một request cụ thể không? Không thì hạ xuống Debug.
Bài 3 — Rò rỉ dữ liệu nhạy cảm qua toán tử @
Ghi log một LoginRequest bằng {@Request} và xác nhận mật khẩu xuất hiện trong output. Sửa bằng chính sách phân rã và kiểm chứng lại.
Tiêu chí hoàn thành: bạn nêu được vì sao đây là vi phạm nghiêm trọng, không chỉ là một bất cẩn.
Gợi ý và lời giải — Bài 3
Gợi ý. Toán tử @ trong Serilog yêu cầu phân rã đối tượng thành các trường thay vì gọi ToString(). Nó phân rã mọi thuộc tính công khai.
Lời giải — bản rò rỉ:
public record LoginRequest(string Email, string Password);
_logger.LogInformation("Đăng nhập: {@Request}", req);Output:
{
"@mt": "Đăng nhập: {@Request}",
"Request": {
"Email": "[email protected]",
"Password": "MatKhauThat@123",
"$type": "LoginRequest"
}
}Mật khẩu nguyên văn nằm trong log.
Vì sao đây là vi phạm nghiêm trọng, không chỉ bất cẩn:
- Log được lưu lâu hơn bạn nghĩ. Thường 30 tới 90 ngày, sao lưu có thể lâu hơn nữa.
- Log được nhiều người xem hơn database. Lập trình viên, vận hành, hỗ trợ — nhiều người có quyền đọc log mà không có quyền đọc bảng người dùng.
- Log thường rời khỏi hệ thống của bạn. Gửi tới dịch vụ bên thứ ba, đi qua nhiều tầng thu thập, và có thể nằm ở vùng địa lý khác.
- Vi phạm quy định về dữ liệu cá nhân. Ghi mật khẩu dạng thô vi phạm mọi tiêu chuẩn bảo mật, và là điểm bị ghi nhận ngay trong mọi cuộc kiểm toán.
- Không thể xoá chọn lọc. Khi phát hiện, bạn phải xoá cả khối log của khoảng thời gian đó — mất luôn thông tin cần cho việc điều tra.
Và hậu quả trực tiếp: mọi mật khẩu đã lọt vào log phải được coi là đã lộ, nên phải buộc toàn bộ người dùng đổi mật khẩu.
Sửa bằng chính sách phân rã:
public class SensitiveDataPolicy : IDestructuringPolicy
{
private static readonly HashSet<string> MaskedFields = new(StringComparer.OrdinalIgnoreCase)
{
"Password", "Token", "AccessToken", "RefreshToken",
"Secret", "ApiKey", "CreditCard", "Cvv", "BankCardNumber", "NationalId"
};
public bool TryDestructure(object value, ILogEventPropertyValueFactory f,
out LogEventPropertyValue? result)
{
var props = value.GetType().GetProperties()
.Select(p => new LogEventProperty(
p.Name,
MaskedFields.Contains(p.Name)
? new ScalarValue("***")
: f.CreatePropertyValue(p.GetValue(value))));
result = new StructureValue(props);
return true;
}
}
Log.Logger = new LoggerConfiguration()
.Destructure.With<SensitiveDataPolicy>()
.CreateLogger();{ "Request": { "Email": "[email protected]", "Password": "***" } }Ba lớp phòng vệ, nên có cả ba:
Đừng ghi log cả đối tượng. Ghi đúng những trường cần:
csharp_logger.LogInformation("Đăng nhập: {Email}", req.Email);Đây là lớp tốt nhất — không có gì để rò rỉ.
Chính sách phân rã như trên, làm lưới an toàn cho những chỗ ai đó quên.
Quét log định kỳ tìm mẫu giống mật khẩu, số thẻ, token. Nhiều dịch vụ log có sẵn tính năng này.
Danh sách không bao giờ được ghi vào log:
| Loại | Ví dụ |
|---|---|
| Thông tin xác thực | mật khẩu, token, khoá API, chuỗi kết nối |
| Dữ liệu tài chính | số thẻ, mã CVV, số tài khoản |
| Định danh cá nhân | căn cước, hộ chiếu, mã số thuế cá nhân |
| Dữ liệu sức khoẻ | bất kỳ thông tin y tế nào |
| Nội dung riêng tư | nội dung tin nhắn, email của người dùng |
Một chi tiết dễ quên: chuỗi kết nối trong thông báo lỗi. Khi kết nối database thất bại, một số driver đưa nguyên chuỗi kết nối — gồm cả mật khẩu — vào thông báo ngoại lệ. Nếu bạn ghi ex.ToString() vào log, mật khẩu database nằm trong log. Hãy đặt Persist Security Info=false trong chuỗi kết nối và rà lại các chỗ ghi ngoại lệ.
Tự kiểm tra
Vì sao không dùng nội suy chuỗi khi log?
Vì nó tạo ra một chuỗi đã nấu chín nên bạn chỉ tìm kiếm được bằng văn bản, không truy vấn được theo trường. Nó cũng luôn chạy kể cả khi mức log đó đang tắt, và làm mất khả năng gom nhóm các sự kiện cùng loại theo message template.
Tiền tố @ trong placeholder có tác dụng gì?
Nó bảo Serilog ghi cả đối tượng thành các thuộc tính thay vì gọi ToString. Nhưng nó ghi toàn bộ thuộc tính nên rất dễ đưa dữ liệu nhạy cảm như mật khẩu hay token vào log; với model có trường nhạy cảm thì nên dùng destructuring policy.
Những nhầm lẫn nào hay gặp với log level?
Ba cái. Dùng Error cho mọi exception, trong khi người dùng nhập sai hay client ngắt kết nối là chuyện bình thường, và làm vậy khiến dashboard đầy báo động giả. Dùng Information cho mọi thứ kể cả trong vòng lặp lớn. Và không bao giờ dùng Critical, trong khi nếu Critical không nối với cảnh báo thật thì nó chỉ là Error viết hoa.
Nên ghi log ra đâu khi chạy trong container?
Chỉ ghi ra Console dạng JSON, vì Docker, Kubernetes và cloud đều thu thập stdout. Ghi ra file trong container là ghi vào một lớp sẽ biến mất khi container bị thay, và nó còn làm image phình ra.
UseSerilogRequestLogging mang lại gì?
Nó thay khoảng mười dòng log mà ASP.NET Core sinh cho mỗi request bằng một dòng có đủ method, path, status và thời gian. Phần đáng giá nhất là GetLevel, cho phép hạ mức health check xuống rất thấp và tự nâng request chậm lên Warning mà không cần middleware riêng.
Scope trong logging để làm gì?
Nó gắn thuộc tính vào mọi log phát sinh bên trong nó, kể cả từ lớp khác ở sâu bên dưới, nên bạn lọc được toàn bộ dấu vết của một đơn hàng qua nhiều lớp mà không phải truyền id vào từng lời gọi log. Cần Enrich.FromLogContext trong cấu hình, nếu không scope bị bỏ qua trong im lặng.
Kết luận
Ba điều đáng nhớ nhất:
- Dấu
$phá hỏng log. Dùng message template với placeholder có tên. Errorlà cho sự cố, không phải cho người dùng nhập sai hay client ngắt kết nối.- Log được nhiều người đọc hơn database. Không có mật khẩu, token hay số thẻ trong đó.
Tham khảo
- Logging in .NET Core and ASP.NET Core
- High-performance logging in .NET
- Serilog — structured logging for .NET
- Serilog.AspNetCore on GitHub
Điều hướng
- Bài trước: 8.6 — Configuration System
- Bài tiếp theo: 8.8 — Global Exception Handling
- Về module: Trang mục lục
Bài liên quan
- Vì sao EF Core bắn 201 query cho 1 màn hình danh sách? Cách sửa N+1 — Một màn hình 200 dòng mà log SQL ghi 201 câu lệnh là dấu hiệu của N+1 query.