8.8 - Global Exception Handling
Tóm tắt: Không có xử lý lỗi tập trung, mỗi controller tự
try/catchtheo một kiểu, và client nhận về mỗi chỗ một định dạng lỗi khác nhau.IExceptionHandler(.NET 8+) gom việc đó về một nơi: khai báo một lần rằngNotFoundExceptionlà 404 vàValidationExceptionlà 422, rồi mọi action đều đúng. Hai nguyên tắc không thương lượng: log đầy đủ ở server, trả tối thiểu cho client — stack trace tiết lộ cấu trúc thư mục, tên thư viện và phiên bản; vàUseExceptionHandler()phải đứng đầu pipeline, vì nó chỉ bắt được exception từ những lớp bên trong nó.
Mục tiêu bài học
Sau bài này bạn có thể:
- Cài
IExceptionHandlervà xếp chuỗi nhiều handler theo thứ tự. - Thiết kế cây exception nghiệp vụ ánh xạ sang mã HTTP.
- Phân biệt mức log phù hợp cho từng loại exception.
- Giải thích những gì không bao giờ được lọt vào phản hồi lỗi.
- Biết loại exception nào handler không bắt được.
Nội dung bài học
8.8.1 — IExceptionHandler
public sealed class GlobalExceptionHandler(
ILogger<GlobalExceptionHandler> logger,
IHostEnvironment env,
IProblemDetailsService problemDetailsService) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext context, Exception exception, CancellationToken ct)
{
var (status, title) = exception switch
{
NotFoundException => (StatusCodes.Status404NotFound, "Không tìm thấy"),
DomainValidationException => (StatusCodes.Status422UnprocessableEntity, "Dữ liệu không hợp lệ"),
ConflictException => (StatusCodes.Status409Conflict, "Xung đột trạng thái"),
UnauthorizedAccessException => (StatusCodes.Status403Forbidden, "Không đủ quyền"),
_ => (StatusCodes.Status500InternalServerError, "Lỗi hệ thống")
};
// Lỗi của hệ thống mới là sự cố; lỗi nghiệp vụ chỉ là cảnh báo
if (status >= 500)
logger.LogError(exception, "Lỗi chưa xử lý tại {Path}", context.Request.Path);
else
logger.LogWarning(exception, "Lỗi nghiệp vụ tại {Path}", context.Request.Path);
context.Response.StatusCode = status;
return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = context,
Exception = exception,
ProblemDetails = new ProblemDetails
{
Status = status,
Title = title,
// CHI lo chi tiet ngoai Production
Detail = env.IsProduction() ? null : exception.Message,
Instance = context.Request.Path,
Extensions = { ["traceId"] = Activity.Current?.Id ?? context.TraceIdentifier }
}
});
}
}builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
var app = builder.Build();
app.UseExceptionHandler(); // DAU pipelineBa chi tiết đáng chú ý:
IProblemDetailsServiceápCustomizeProblemDetailsbạn cấu hình ở bài 8.5, nên định dạng lỗi thống nhất với lỗi validation.- Mức log phân theo mã. 500 là sự cố của bạn; 404 và 422 là hành vi bình thường của hệ thống. Log tất cả ở
Errorthì cảnh báo mất ý nghĩa. traceIdnối phản hồi lỗi với log. Người dùng báo lỗi kèmtraceIdlà bạn tìm ra log trong vài giây.
8.8.2 — Chuỗi nhiều handler
builder.Services.AddExceptionHandler<ValidationExceptionHandler>(); // chạy trước
builder.Services.AddExceptionHandler<DatabaseExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>(); // bắt tất cảChúng chạy theo thứ tự đăng ký, và dừng ở handler đầu tiên trả true. Trả false nghĩa là "không phải việc của tôi, chuyển tiếp".
public sealed class ValidationExceptionHandler(IProblemDetailsService pds) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext ctx, Exception ex, CancellationToken ct)
{
if (ex is not DomainValidationException v) return false; // nhường cho handler sau
ctx.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
return await pds.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = ctx,
ProblemDetails = new ValidationProblemDetails(v.Errors)
{
Status = StatusCodes.Status422UnprocessableEntity,
Title = "Dữ liệu không hợp lệ"
}
});
}
}Handler bắt-tất-cả phải đăng ký cuối cùng. Nếu không handler nào trả true, exception tiếp tục lan lên và ASP.NET Core trả 500 mặc định.
8.8.3 — Cây exception nghiệp vụ
public abstract class DomainException(string message) : Exception(message);
public sealed class NotFoundException(string entity, object id)
: DomainException($"Không tìm thấy {entity} với id '{id}'")
{
public string Entity { get; } = entity;
public object Id { get; } = id;
}
public sealed class ConflictException(string message) : DomainException(message);
public sealed class DomainValidationException(IDictionary<string, string[]> errors)
: DomainException("Dữ liệu không hợp lệ")
{
public IReadOnlyDictionary<string, string[]> Errors { get; } = errors.AsReadOnly();
}Lớp cơ sở DomainException cho phép phân biệt lỗi dự kiến được với lỗi hạ tầng bằng một dòng:
var status = exception switch
{
NotFoundException => 404,
ConflictException => 409,
DomainValidationException => 422,
DomainException => 400, // moi loi nghiep vu khac
_ => 500 // ha tang: SQL, mang, bug
};Nhưng đừng dùng exception cho luồng thông thường. Ném exception tốn kém hơn trả về một kết quả khoảng vài trăm lần, và với thứ xảy ra thường xuyên như "không tìm thấy", điều đó có thể nhìn thấy được trên biểu đồ.
// Tot hon cho truong hop binh thuong
var customer = await service.GetByIdAsync(id, ct);
return customer is null ? NotFound() : Ok(customer);Exception dành cho những gì thật sự bất thường: vi phạm ràng buộc sâu trong tầng domain, trạng thái không thể xảy ra, lỗi hạ tầng.
8.8.4 — Không bao giờ lọt ra ngoài
// SAI — lo cau truc noi bo
return Problem(detail: exception.ToString());Stack trace tiết lộ đường dẫn file nguồn trên máy build, namespace và cấu trúc dự án, tên cùng phiên bản thư viện (kèm CVE đã biết), và đôi khi cả giá trị tham số trong thông điệp exception.
Thông điệp exception cũng nguy hiểm không kém:
SqlException: Login failed for user 'crm_app'. Server 'prod-sql-01.internal'Một dòng đó cho kẻ tấn công tên user database, tên máy chủ và biết rằng bạn dùng SQL Server.
Nguyên tắc: client nhận title, status, traceId. Còn lại nằm trong log.
Trên Development thì trả exception.Message cho tiện — đó là lý do có env.IsProduction() ở trên. Đừng nhầm nó với UseDeveloperExceptionPage(), thứ tuyệt đối không được bật ở Production.
8.8.5 — Những gì handler không bắt được
UseExceptionHandler() chỉ bắt exception từ các lớp bên trong nó. Bốn trường hợp nằm ngoài:
- Exception trong middleware đăng ký trước nó. Vì vậy nó phải đứng đầu — sau
UseForwardedHeaders()là hợp lý nhất. - Exception sau khi response đã bắt đầu. Không đổi được status code nữa (bài 8.2); ASP.NET Core chỉ có thể ngắt kết nối. Đây là lý do stream một
IAsyncEnumerablelớn có rủi ro (bài 6.6). - Exception trong
BackgroundService. Nó không đi qua pipeline HTTP. Phải tựtry/catchquanh từng công việc (bài 6.8). async void. Exception đi thẳng lên và làm sập tiến trình (bài 6.2).
Với trường hợp 3 và 4, thêm lưới an toàn ở Program.cs:
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
logger.LogCritical(e.ExceptionObject as Exception, "Unhandled exception, tien trinh sap dung");
TaskScheduler.UnobservedTaskException += (_, e) =>
{
logger.LogError(e.Exception, "Unobserved task exception");
e.SetObserved();
};8.8.6 — Rà lại code của bạn
Danh sách rà soát xử lý lỗi
- UseExceptionHandler() đứng đầu pipeline, chỉ sau UseForwardedHeaders().
- Không còn try/catch lặp lại trong controller cho lỗi đã có handler.
- Handler bắt-tất-cả được đăng ký cuối cùng.
- Lỗi nghiệp vụ log mức Warning, lỗi hệ thống log mức Error.
- Phản hồi lỗi ở Production không chứa stack trace hay message của exception.
- Mọi phản hồi lỗi có traceId nối được với log.
- Không dùng exception cho luồng thông thường như không tìm thấy.
- BackgroundService có try/catch riêng cho từng công việc.
- Program.cs có handler cho UnhandledException và UnobservedTaskException.
Bài tập áp dụng
Bài 1 — Thứ tự đường ống quyết định handler có chạy hay không
Đặt UseExceptionHandler() sau một middleware ném ngoại lệ, xác nhận client nhận trang lỗi mặc định thay vì ProblemDetails. Chuyển lên đầu và kiểm chứng lại.
Tiêu chí hoàn thành: bạn giải thích được bằng mô hình đường ống hai chiều ở bài 8.2.
Gợi ý và lời giải — Bài 1
Gợi ý. Ngoại lệ lan ngược ra ngoài theo ngăn xếp gọi. Chỉ middleware bọc ngoài chỗ ném mới có cơ hội bắt.
Lời giải — bản sai:
var app = builder.Build();
app.Use(async (ctx, next) =>
{
if (ctx.Request.Path == "/loi") throw new InvalidOperationException("nổ ở đây");
await next();
});
app.UseExceptionHandler(); // quá muộn — nằm BÊN TRONG middleware trên
app.MapControllers();curl -i localhost:5000/loiỞ Development, bạn nhận trang lỗi chi tiết của developer. Ở Production, một phản hồi 500 trống rỗng — không ProblemDetails, không mã tương quan.
Bản đúng:
app.UseExceptionHandler(); // ĐẦU TIÊN — bọc ngoài mọi thứ
app.Use(async (ctx, next) => { /* ... */ });
app.MapControllers();{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "Đã xảy ra lỗi khi xử lý yêu cầu",
"status": 500,
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}Giải thích bằng mô hình đường ống. Nhớ lại cấu trúc lồng nhau:
Sai: [middleware ném] ( [UseExceptionHandler] ( [MapControllers] ) )
ngoại lệ ném ở LỚP NGOÀI -> không có ai bọc nó -> lọt ra ngoài
Đúng: [UseExceptionHandler] ( [middleware ném] ( [MapControllers] ) )
ngoại lệ ném ở lớp trong -> lan ra và bị lớp ngoài bắtCùng một nguyên tắc với khối try/catch: catch chỉ bắt được những gì ném bên trong khối try tương ứng.
Thứ tự chuẩn — UseExceptionHandler luôn là dòng đầu tiên:
var app = builder.Build();
app.UseExceptionHandler(); // 1. bọc ngoài cùng
app.UseHttpsRedirection();
app.UseRouting();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();Ba loại ngoại lệ mà UseExceptionHandler không bắt được:
| Loại | Vì sao | Xử lý bằng |
|---|---|---|
| Ném trong chính exception handler | Không có lớp nào bọc nó | Giữ handler cực đơn giản |
| Ném sau khi phản hồi đã bắt đầu | Không sửa được phản hồi đang gửi | Kiểm tra Response.HasStarted |
Ném lúc khởi động, trước app.Run() | Đường ống chưa tồn tại | try/catch quanh Program.Main |
Loại thứ hai đáng chú ý: nếu một endpoint đã ghi một phần body rồi mới ném, ProblemDetails không thể được gửi vì header đã đi — đúng vấn đề HasStarted ở bài 8.2. Client nhận một phản hồi cụt, và đây là một trong những lỗi khó chẩn đoán nhất.
Bắt lỗi khởi động:
try
{
var app = builder.Build();
// ...
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Ứng dụng không khởi động được");
throw;
}
finally
{
Log.CloseAndFlush(); // quan trọng — đẩy hết log ra trước khi thoát
}Không có try/catch này, một lỗi khởi động chỉ để lại dấu vết trong output của tiến trình — thứ có thể đã biến mất khi container bị xoá.
Bài 2 — Rò rỉ thông tin qua thông báo lỗi
Cấu hình handler trả exception.ToString(), gây một lỗi kết nối database bằng mật khẩu sai, và ghi lại chính xác những thông tin lọt ra ngoài.
Tiêu chí hoàn thành: bạn liệt kê được ít nhất năm loại thông tin nội bộ bị lộ.
Gợi ý và lời giải — Bài 2
Gợi ý. Đừng chỉ nhìn thông báo lỗi. Hãy đọc toàn bộ chuỗi mà ToString() trả về, gồm cả dấu vết ngăn xếp và ngoại lệ bên trong.
Lời giải — bản rò rỉ:
app.UseExceptionHandler(a => a.Run(async ctx =>
{
var ex = ctx.Features.Get<IExceptionHandlerFeature>()?.Error;
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsync(ex?.ToString() ?? ""); // RÒ RỈ
}));Phản hồi khi sai mật khẩu database:
Npgsql.PostgresException (0x80004005): 28P01: password authentication failed
for user "crm_prod_user"
at Npgsql.Internal.NpgsqlConnector.<Authenticate>g__AuthenticateSASL|...
at Crm.Infrastructure.EfOrderRepository.LayTheoIdAsync(Int32 id, CancellationToken ct)
in /home/deploy/src/Crm.Infrastructure/EfOrderRepository.cs:line 47
at Crm.Application.OrderService.XuLyAsync(...)
in /home/deploy/src/Crm.Application/OrderService.cs:line 112
at Crm.Api.Controllers.OrdersController.Tao(...)
in /home/deploy/src/Crm.Api/Controllers/OrdersController.cs:line 38
Severity: FATAL, SqlState: 28P01, Routine: auth_failed, File: auth.c, Line: 326Bảy loại thông tin bị lộ:
| # | Thông tin | Kẻ tấn công dùng để |
|---|---|---|
| 1 | Loại database: PostgreSQL, và phiên bản driver | Chọn đúng bộ khai thác |
| 2 | Tên tài khoản database: crm_prod_user | Nửa đầu của cặp thông tin đăng nhập |
| 3 | Đường dẫn máy chủ: /home/deploy/src/... | Suy ra cấu trúc triển khai, người dùng hệ thống |
| 4 | Cấu trúc namespace và tên lớp | Vẽ bản đồ kiến trúc nội bộ |
| 5 | Số dòng mã nguồn | Xác nhận phiên bản, đối chiếu với mã nguồn công khai nếu có |
| 6 | Mã lỗi nội bộ của PostgreSQL và tên file nguồn của nó | Xác định chính xác phiên bản database |
| 7 | Toàn bộ chuỗi gọi từ controller xuống repository | Hiểu luồng xử lý để tìm điểm yếu |
Không thông tin nào trong bảng trên có ích cho người dùng hợp lệ. Tất cả đều có ích cho người đang dò tìm.
Bản đúng — tách hai luồng thông tin:
public sealed class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> log)
: IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext ctx, Exception ex, CancellationToken ct)
{
var traceId = Activity.Current?.Id ?? ctx.TraceIdentifier;
// ĐẦY ĐỦ vào log phía server
log.LogError(ex, "Lỗi chưa xử lý. TraceId={TraceId}, Path={Path}",
traceId, ctx.Request.Path);
// CHUNG CHUNG cho client
var pd = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "Đã xảy ra lỗi khi xử lý yêu cầu",
Detail = "Vui lòng thử lại. Nếu vẫn lỗi, liên hệ hỗ trợ kèm mã tra cứu.",
Extensions = { ["traceId"] = traceId }
};
ctx.Response.StatusCode = pd.Status.Value;
await ctx.Response.WriteAsJsonAsync(pd, ct);
return true;
}
}
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();Nguyên tắc: log đầy đủ, trả về tối thiểu, nối hai bên bằng mã tra cứu. Người dùng báo lỗi kèm traceId, bộ phận hỗ trợ tra ra đúng chuỗi log trong vài giây — mà không tiết lộ gì ra ngoài.
Ngoại lệ nghiệp vụ thì khác. Chúng được thiết kế để người dùng đọc, nên trả chi tiết là đúng:
ex switch
{
NotFoundException e => (404, e.Message),
ValidationException e => (400, e.Message),
DuplicateEmailException => (409, "Email đã được sử dụng"),
_ => (500, "Đã xảy ra lỗi khi xử lý yêu cầu") // mặc định: giấu
}Điểm mấu chốt: danh sách cho phép, không phải danh sách cấm. Ngoại lệ chưa biết rơi vào nhánh mặc định và bị giấu — nên thêm một loại ngoại lệ mới sau này cũng không vô tình rò rỉ gì.
Kiểm tra dự án của bạn:
grep -rn "ex.ToString()\|ex.StackTrace\|exception.Message" --include=*.cs src/ \
| grep -iv "log\|logger"Mỗi kết quả là một chỗ cần rà: thông tin đó đi tới client hay chỉ vào log?
Bài 3 — Chi phí của ngoại lệ trong luồng thường
Viết hai endpoint "không tìm thấy", một ném NotFoundException và một trả NotFound(). Bắn 10.000 request vào mỗi cái và so sánh độ trễ phân vị 99.
Tiêu chí hoàn thành: bạn có hai con số và một kết luận cân bằng về việc khi nào chi phí đó đáng quan tâm.
Gợi ý và lời giải — Bài 3
Gợi ý. Bài này nối tiếp phép đo ở bài 4.9, nơi ngoại lệ chậm hơn TryParse 778 lần. Nhưng ở đây ngăn xếp sâu hơn nhiều, nên chi phí cũng khác.
Lời giải.
app.MapGet("/nem/{id:int}", (int id) =>
{
var kh = _repo.Lay(id);
if (kh is null) throw new NotFoundException($"Không tìm thấy khách hàng {id}");
return Results.Ok(kh);
});
app.MapGet("/tra/{id:int}", (int id) =>
{
var kh = _repo.Lay(id);
return kh is null ? Results.NotFound() : Results.Ok(kh);
});bombardier -c 50 -n 10000 http://localhost:5000/nem/99999
bombardier -c 50 -n 10000 http://localhost:5000/tra/99999Kết quả điển hình:
| Ném ngoại lệ | Trả NotFound() | |
|---|---|---|
| Thông lượng | thấp hơn rõ rệt | cao hơn |
| Độ trễ trung vị | cao hơn vài trăm micro-giây | thấp |
| Độ trễ phân vị 99 | cao hơn đáng kể | ổn định |
Chênh lệch ở phân vị 99 lớn hơn ở trung vị — vì ném ngoại lệ tạo thêm cấp phát bộ nhớ, và những lần thu gom rác kéo theo rơi vào phần đuôi của phân bố.
Vì sao ngăn xếp sâu làm chi phí tăng. Khi ném trong một endpoint ASP.NET Core, runtime phải đi ngược qua toàn bộ chuỗi middleware để tìm khối catch — thường là hàng chục khung ngăn xếp, so với vài khung trong một vòng lặp đơn giản.
Kết luận cân bằng — đây là phần chính của bài. Đừng kết luận "không bao giờ dùng ngoại lệ". Hãy hỏi: tình huống này có thường xuyên không?
| Tình huống | Tần suất | Nên dùng |
|---|---|---|
| Tra một mã khách hàng người dùng gõ tay | Sai thường xuyên | Trả NotFound() |
| Truy cập tài nguyên đã bị xoá | Thỉnh thoảng | Trả NotFound() |
| Mất kết nối database | Hiếm | Ngoại lệ |
| Lỗi lập trình, trạng thái không thể xảy ra | Không bao giờ nên xảy ra | Ngoại lệ |
Ngưỡng thực dụng. Nếu một tình huống xảy ra ở hơn 1% số request, hãy biểu diễn nó bằng giá trị trả về chứ đừng ném ngoại lệ. Dưới ngưỡng đó, chi phí không đáng kể so với một truy vấn database, và tính rõ ràng của code quan trọng hơn.
Mẫu Result<T> giữ được cả hai:
var result = await _service.GetAsync(id, ct);
return result.Kind switch
{
ResultKind.Success => Results.Ok(result.Value),
ResultKind.NotFound => Results.NotFound(),
ResultKind.RuleViolation => Results.BadRequest(result.Error),
_ => Results.StatusCode(500)
};Không ném ngoại lệ cho luồng bình thường, nhưng vẫn có xử lý ngoại lệ toàn cục làm lưới an toàn cho những gì thật sự bất thường.
Một điều đáng đo hơn cả tốc độ: ảnh hưởng tới log và cảnh báo. Mỗi ngoại lệ ném ra thường sinh một dòng log mức Error. Một endpoint trả "không tìm thấy" 10.000 lần mỗi ngày sẽ tạo 10.000 dòng lỗi giả, làm ngập log thật và khiến cảnh báo mất giá trị — đúng vấn đề đã bàn ở bài 2.4 về mã trạng thái.
Tự kiểm tra
IExceptionHandler giải quyết vấn đề gì?
Nó gom việc ánh xạ exception sang mã HTTP về một nơi, nên bạn khai báo một lần rằng NotFoundException là 404 và ValidationException là 422, rồi mọi action đều đúng. Không có nó thì mỗi controller tự try catch theo một kiểu và client nhận mỗi chỗ một định dạng lỗi khác nhau.
Nhiều IExceptionHandler chạy theo thứ tự nào?
Theo thứ tự đăng ký, và dừng ở handler đầu tiên trả về true. Trả false nghĩa là không phải việc của tôi, chuyển tiếp. Handler bắt tất cả phải đăng ký cuối cùng; nếu không handler nào trả true thì ASP.NET Core trả 500 mặc định.
Vì sao không trả stack trace cho client?
Vì nó tiết lộ đường dẫn file nguồn trên máy build, namespace và cấu trúc dự án, tên cùng phiên bản thư viện kèm CVE đã biết. Thông điệp exception cũng nguy hiểm: một SqlException có thể lộ tên user database và tên máy chủ nội bộ. Client chỉ nên nhận title, status và traceId.
Có nên dùng exception cho trường hợp không tìm thấy không?
Không, với thứ xảy ra thường xuyên thì nên trả về kết quả rỗng rồi để controller trả NotFound. Ném exception tốn kém hơn trả về kết quả khoảng vài trăm lần và có thể nhìn thấy được trên biểu đồ latency. Exception dành cho những gì thật sự bất thường.
UseExceptionHandler không bắt được những gì?
Bốn thứ. Exception trong middleware đăng ký trước nó, nên nó phải đứng đầu pipeline. Exception sau khi response đã bắt đầu, vì không đổi được status code nữa. Exception trong BackgroundService vì nó không đi qua pipeline HTTP. Và async void, vì exception đi thẳng lên và làm sập tiến trình.
Nên log exception ở mức nào?
Phân theo mã trạng thái: từ 500 trở lên là sự cố của bạn nên log mức Error, còn 404 và 422 là hành vi bình thường nên log mức Warning. Log tất cả ở mức Error khiến cảnh báo mất ý nghĩa và không ai còn nhìn dashboard nữa.
Kết luận
Ba điều đáng nhớ nhất:
UseExceptionHandler()đứng đầu pipeline — nó chỉ bắt được những lớp bên trong nó.- Log đầy đủ ở server, trả
title+status+traceIdcho client. - Exception không dành cho luồng thông thường. "Không tìm thấy" nên là một giá trị trả về.
Tham khảo
- Handle errors in ASP.NET Core
- Handle errors in ASP.NET Core web APIs
- IExceptionHandler interface
- Best practices for exceptions
Điều hướng
- Bài trước: 8.7 — Logging với Serilog
- Bài tiếp theo: 8.9 — Health Checks
- Về module: Trang mục lục