7.6 - Program.cs và WebApplicationBuilder
Tóm tắt:
Program.cscó hai giai đoạn tách bạch màbuilder.Build()là ranh giới: trước nó bạn đăng ký, sau nó bạn cấu hình pipeline. Hiểu ranh giới này giải thích hầu hết lỗi khởi động: đăng ký sauBuild()làm ứng dụng némInvalidOperationExceptionngay lúc khởi động, và gọiBuildServiceProvider()giữa chừng tạo ra một container thứ hai với bộ singleton riêng — một nguồn bug mà bạn sẽ mất nhiều giờ để tìm ra. Trong giai đoạn hai, thứ tự middleware là ngữ nghĩa: đặtUseAuthorizationtrướcUseAuthenticationkhiến mọi request đều vô danh, và không có lỗi nào được báo.
Mục tiêu bài học
Sau bài này bạn có thể:
- Mô tả hai giai đoạn của
Program.csvà những gì hợp lệ ở mỗi giai đoạn. - Tổ chức đăng ký theo layer bằng extension method.
- Sắp đúng thứ tự middleware và giải thích hậu quả khi sai.
- Chạy migration hoặc seed lúc khởi động một cách đúng đắn.
- Biết vì sao
BuildServiceProvider()thủ công là lỗi.
Cần biết trước: Web API đầu tiên (bài 0.5), bài 7.2, bài 7.4, extension method (bài 5.5). Middleware là các khâu xử lý mà mọi request đi qua trước khi tới endpoint; bài 8.2 dạy kỹ. Xác thực (authentication) là xác định người gọi là ai, phân quyền (authorization) là kiểm tra người đó được làm gì; Module 10 dạy kỹ.
Nội dung bài học
7.6.1 — Hai giai đoạn
var builder = WebApplication.CreateBuilder(args);
// ===== GIAI ĐOẠN 1: ĐĂNG KÝ =====
builder.Services.AddControllers();
builder.Services.AddOpenApi(); // tài liệu OpenAPI, có sẵn trong template
builder.Services.AddInfrastructure(builder.Configuration); // extension tự viết, mục 7.6.3
builder.Services.AddApplication(); // extension tự viết, mục 7.6.3
var app = builder.Build(); // <-- RANH GIỚI. Danh sách đăng ký bị khoá.
// ===== GIAI ĐOẠN 2: PIPELINE =====
if (app.Environment.IsDevelopment())
app.MapOpenApi(); // /openapi/v1.json
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();Build() khoá danh sách builder.Services lại (chuyển sang chỉ đọc). Gọi builder.Services.Add... sau Build() vẫn biên dịch được, nhưng lúc chạy ném ngay InvalidOperationException: The service collection cannot be modified because it is read-only. (Bài 1 bên dưới có output đầy đủ). Lỗi nổ lúc khởi động và chỉ đúng dòng sai, nên dễ sửa; chỉ cần nhớ quy tắc: mọi đăng ký nằm trước Build().
WebApplication.CreateBuilder(args) đã làm sẵn khá nhiều: nạp appsettings.json, appsettings.{Environment}.json, biến môi trường, tham số dòng lệnh; cấu hình logging; đăng ký IConfiguration, IWebHostEnvironment, ILogger<T>.
7.6.2 — Đừng gọi BuildServiceProvider() thủ công
// SAI — tạo một container THỨ HAI
builder.Services.AddSingleton<ICache, InMemoryCache>();
var sp = builder.Services.BuildServiceProvider(); // container #2
var cache = sp.GetRequiredService<ICache>(); // singleton của container #2
builder.Services.AddSingleton(cache);Bạn vừa tạo hai container, mỗi cái có bộ Singleton riêng. Về sau, một phần ứng dụng dùng instance của container #1, phần khác dùng instance của container #2 — cùng một kiểu Singleton nhưng hai đối tượng khác nhau. Cache ghi ở một nơi, đọc ở nơi khác thì rỗng.
Tệ hơn: container #2 không bao giờ được Dispose, nên mọi IDisposable trong đó rò rỉ.
Trình phân tích mã (analyzer) của ASP.NET Core báo chuyện này lúc build, bằng cảnh báo ASP0000 trong output của dotnet build và trong tab Warnings của IDE (Bài 2 có nguyên văn). Cần cấu hình phụ thuộc vào một service khác thì dùng factory registration (bài 7.5) hoặc options pattern với IConfigureOptions<T> (bài 7.7) — chúng được resolve sau khi container dựng xong.
7.6.3 — Tách đăng ký theo layer
Khi Program.cs vượt 100 dòng đăng ký, nó trở thành nơi không ai muốn đụng vào. Tách bằng extension method, mỗi layer một file, nằm trong chính project của layer đó:
// Infrastructure/DependencyInjection.cs
public static class InfrastructureServiceCollectionExtensions
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<CrmDbContext>(options =>
options.UseNpgsql(configuration.GetConnectionString("Crm")));
services.AddScoped<ICustomerRepository, EfCustomerRepository>();
services.AddScoped<IUnitOfWork, EfUnitOfWork>();
services.AddOptions<EmailSettings>()
.Bind(configuration.GetSection(EmailSettings.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddSingleton<IEmailService, SmtpEmailService>();
return services; // trả về để nối chuỗi
}
}SQL Server
services.AddDbContext<CrmDbContext>(options =>
options.UseSqlServer(configuration.GetConnectionString("Crm")));UseNpgsql đến từ gói Npgsql.EntityFrameworkCore.PostgreSQL; DbContext và EF Core được dạy ở bài 8.10. Ở đây chỉ cần để ý: mọi thứ thuộc hạ tầng (database, email, cấu hình) nằm trong một extension.
// Application/DependencyInjection.cs
public static class ApplicationServiceCollectionExtensions
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddScoped<ICustomerService, CustomerService>();
services.AddScoped<ILeadService, LeadService>();
services.AddTransient<ILeadScorer, RuleBasedLeadScorer>();
return services;
}
}Ba lợi ích thật:
- Ranh giới layer hiện ra.
AddApplication()không nhậnIConfiguration— nếu một ngày nó cần, đó là tín hiệu layer application đang biết về hạ tầng. Program.csđọc được trong 30 giây.- Integration test dùng lại được: gọi đúng các extension đó rồi
Replacevài thứ.
Chỗ này thường bị làm quá tay thành AddCustomerModule(), AddLeadModule(), AddOrderModule()… Khi số extension method vượt số layer, bạn chỉ đang di chuyển sự lộn xộn.
7.6.4 — Thứ tự middleware là ngữ nghĩa
Giai đoạn 2 không phải danh sách bật/tắt — nó là thứ tự thực thi. Mỗi app.UseXxx() thêm một lớp vào pipeline, và request đi qua theo đúng thứ tự bạn viết.
app.UseExceptionHandler("/error"); // ngoài cùng — bắt lỗi của MỌI lớp bên trong
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseCors();
app.UseAuthentication(); // BẮT BUỘC trước Authorization
app.UseAuthorization();
app.MapControllers();Bốn hậu quả cụ thể khi sai thứ tự:
| Sai | Hậu quả |
|---|---|
UseAuthorization trước UseAuthentication | User luôn vô danh, mọi [Authorize] từ chối — không có lỗi nào được báo |
UseExceptionHandler đặt sau | Exception ở middleware phía trên nó không được bắt, client nhận trang lỗi mặc định |
UseCors sau UseAuthorization | Preflight request bị chặn trước khi tới CORS, trình duyệt báo lỗi CORS khó hiểu |
UseStaticFiles sau UseAuthentication | File tĩnh đi qua toàn bộ tầng xác thực, chậm không cần thiết |
Quy tắc dễ nhớ: ngoài vào trong — xử lý lỗi, bảo mật truyền tải, định tuyến, xác thực, phân quyền, rồi mới tới endpoint.
7.6.5 — Migration và seed lúc khởi động
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<CrmDbContext>();
await db.Database.MigrateAsync();
}
app.Run();CreateScope() là bắt buộc: app.Services là root provider, và lấy thẳng một service Scoped từ đó sẽ ném exception khi ValidateScopes bật — hoặc tệ hơn, giam nó nếu không bật (bài 7.4).
Với production nhiều instance, cách này có vấn đề: khi bạn chạy 3 bản của ứng dụng, cả ba cùng chạy migration một lúc. EF Core có khoá nên thường ổn, nhưng cách an toàn là tách migration thành bước riêng trong pipeline triển khai.
7.6.6 — Rà lại code của bạn
Danh sách rà soát Program.cs
- Mọi đăng ký nằm trước builder.Build().
- Không có lời gọi BuildServiceProvider() thủ công nào (cảnh báo ASP0000).
- Đăng ký được tách theo layer, mỗi extension method nằm trong project của layer đó.
- AddApplication() không nhận IConfiguration.
- UseAuthentication đứng trước UseAuthorization.
- UseExceptionHandler là middleware đầu tiên.
- UseCors đứng trước UseAuthorization.
- Migration lúc khởi động chạy trong một scope tạo bằng CreateScope().
- ValidateOnBuild và ValidateScopes được bật cho mọi môi trường.
Bài tập áp dụng
Bài 1 — Đăng ký sau Build()
Thêm một builder.Services.AddScoped<IFoo, Foo>() sau builder.Build(), rồi tiêm IFoo vào một endpoint. Ghi lại thông báo lỗi và thời điểm nó xuất hiện.
Tiêu chí hoàn thành: bạn nêu được vì sao ServiceCollection bị khoá sau Build().
Gợi ý và lời giải — Bài 1
Gợi ý. builder.Build() là ranh giới giữa hai giai đoạn: cấu hình và chạy. Hãy nghĩ xem điều gì sẽ xảy ra nếu container cho phép thêm đăng ký sau khi nó đã dựng xong đồ thị phụ thuộc và tạo ra các Singleton.
Lời giải. Tạo project dotnet new web, thêm file Foo.cs và sửa Program.cs:
// Foo.cs
public interface IFoo { }
public class Foo : IFoo { }// Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build(); // <- RANH GIỚI
builder.Services.AddScoped<IFoo, Foo>(); // quá muộn
app.MapGet("/", (IFoo foo) => "ok");
app.Run();Chạy trên .NET 10:
Unhandled exception. System.InvalidOperationException: The service collection cannot be modified because it is read-only.
at Microsoft.Extensions.DependencyInjection.ServiceCollection.ThrowReadOnlyException()
...
at Program.<Main>$(String[] args) in ...\Program.cs:line 5Thời điểm xuất hiện: ngay lúc khởi động, tại đúng dòng AddScoped. Ứng dụng không bao giờ tới được app.Run(), nên không có request nào được phục vụ. Tài liệu và câu trả lời cũ trên mạng đôi khi nói "đăng ký sau Build() chỉ im lặng không có tác dụng": trên .NET 10 điều đó không đúng.
Vì sao ServiceCollection bị khoá. Build() làm ba việc không thể hoàn tác:
1. Đọc toàn bộ ServiceCollection và dựng đồ thị phụ thuộc
2. Sinh mã khởi tạo tối ưu cho từng kiểu dịch vụ
3. Tạo phạm vi gốc, nơi các Singleton sẽ sốngNếu cho phép thêm đăng ký sau đó, container đã dựng sẽ không biết tới chúng: bạn có một đăng ký "trông như có" nhưng không bao giờ được dùng. ASP.NET Core chọn cách an toàn hơn là khoá danh sách và báo lỗi ngay.
Hai giai đoạn, và việc gì thuộc giai đoạn nào:
| Giai đoạn | Làm gì | Đối tượng |
|---|---|---|
Trước Build() | Đăng ký dịch vụ, đọc cấu hình, đặt tuỳ chọn | builder.Services, builder.Configuration |
Sau Build() | Xây đường ống middleware, ánh xạ endpoint, chạy | app.Use..., app.Map... |
Nhầm lẫn hay gặp nhất — cần một dịch vụ để cấu hình một dịch vụ khác:
// SAI — Build giữa chừng, tạo ra container thứ hai
var sp = builder.Services.BuildServiceProvider();
var opts = sp.GetRequiredService<IOptions<SmtpOptions>>();
builder.Services.AddSingleton<IEmailSender>(new SmtpSender(opts.Value.Host));Cách này tạo ra hai container, và mọi Singleton bị nhân đôi — bài 2 chứng minh hậu quả.
// ĐÚNG — dùng factory, container tự giải quyết đúng lúc
builder.Services.AddSingleton<IEmailSender>(sp =>
{
var opts = sp.GetRequiredService<IOptions<SmtpOptions>>();
return new SmtpSender(opts.Value.Host);
});Khi cần một dịch vụ sau khi ứng dụng đã dựng — ví dụ để chạy migration:
var app = builder.Build();
using (var scope = app.Services.CreateScope()) // dùng container THẬT
{
var db = scope.ServiceProvider.GetRequiredService<CrmDbContext>();
await db.Database.MigrateAsync();
}
app.Run();Lưu ý CreateScope() — không lấy trực tiếp từ app.Services, vì DbContext là Scoped và lấy từ phạm vi gốc sẽ ném lỗi hoặc gây rò rỉ như bài 7.4 trình bày.
Bài 2 — Hai container do gọi BuildServiceProvider() thủ công
Gọi BuildServiceProvider() giữa chừng, lấy ra một Singleton giữ một bộ đếm, rồi so sánh giá trị đó với giá trị lấy từ container thật (app.Services). Ghi lại hai con số khác nhau.
Tiêu chí hoàn thành: bạn chứng minh được có hai thể hiện của cùng một Singleton.
Gợi ý và lời giải — Bài 2
Gợi ý. Singleton nghĩa là "một thể hiện cho mỗi container", không phải "một thể hiện cho mỗi ứng dụng". Hai container thì hai thể hiện.
Lời giải.
// Counter.cs
public class Counter
{
public static int InstanceCount;
public readonly int Id;
public int Value;
public Counter() => Id = Interlocked.Increment(ref InstanceCount);
}// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<Counter>();
// SAI — dựng một container tạm
var tempProvider = builder.Services.BuildServiceProvider();
var c1 = tempProvider.GetRequiredService<Counter>();
c1.Value = 100;
Console.WriteLine($"container tạm : Counter#{c1.Id}, Value = {c1.Value}");
var app = builder.Build(); // container THẬT
var c2 = app.Services.GetRequiredService<Counter>();
Console.WriteLine($"container thật: Counter#{c2.Id}, Value = {c2.Value}");Kết quả chạy trên .NET 10:
container tạm : Counter#1, Value = 100
container thật: Counter#2, Value = 0Hai thể hiện khác nhau. Value = 100 đặt ở container tạm hoàn toàn vô hình với ứng dụng thật.
Ba hậu quả trong hệ thống thật:
- Trạng thái bị chia đôi. Bộ nhớ đệm, bộ đếm, hàng đợi trong bộ nhớ — mọi thứ ghi vào container tạm đều mất.
- Tài nguyên bị nhân đôi và rò rỉ. Container tạm cũng tạo ra các
Singletonnắm kết nối, nhóm luồng, bộ đếm thời gian — và không aiDisposecontainer tạm, nên chúng sống mãi. - Rất khó chẩn đoán. Triệu chứng là "cache không hoạt động" hoặc "cấu hình không có tác dụng", và không có gì trong log chỉ về nguyên nhân.
ASP.NET Core cảnh báo về việc này lúc biên dịch, không phải trong log lúc chạy. Output của dotnet build với đoạn code trên:
Program.cs(6,20): warning ASP0000: Calling 'BuildServiceProvider' from application code results in an additional copy of singleton services being created. Consider alternatives such as dependency injecting services as parameters to 'Configure'. (https://aka.ms/AA5k895)Cảnh báo dễ bị bỏ qua giữa hàng chục cảnh báo khác. Hai cách để không bỏ sót: biến nó thành lỗi biên dịch bằng cách thêm <WarningsAsErrors>ASP0000</WarningsAsErrors> vào <PropertyGroup> trong file .csproj, hoặc chủ động tìm trong mã nguồn:
grep -rn "BuildServiceProvider()" --include=*.cs src/ | grep -v TestHai tình huống hay dẫn tới lỗi này, và cách làm đúng:
// Tình huống 1: cần giá trị cấu hình để đăng ký dịch vụ
// SAI
var sp = builder.Services.BuildServiceProvider();
var opts = sp.GetRequiredService<IOptions<CacheOptions>>();
builder.Services.AddSingleton(new Cache(opts.Value.Size));
// ĐÚNG — đọc thẳng từ Configuration, hoặc dùng factory
var size = builder.Configuration.GetValue<int>("Cache:Size");
builder.Services.AddSingleton(new Cache(size));
// Tình huống 2: cần một dịch vụ khác để dựng dịch vụ này
// ĐÚNG — factory, container tự giải quyết thứ tự
builder.Services.AddSingleton<ICache>(sp =>
new Cache(sp.GetRequiredService<IOptions<CacheOptions>>().Value.Size));Ngoại lệ chính đáng: trong kiểm thử. Khi viết unit test cho chính phần đăng ký DI, gọi BuildServiceProvider() là hoàn toàn bình thường — vì đó là container duy nhất trong bài kiểm thử đó:
[Fact]
public void AllServices_CanBeResolved()
{
var services = new ServiceCollection();
services.AddCrmInfrastructure(config);
var sp = services.BuildServiceProvider(new ServiceProviderOptions
{
ValidateScopes = true,
ValidateOnBuild = true // cùng ValidateScopes: bắt captive dependency lúc build
});
Assert.NotNull(sp.GetService<IOrderRepository>());
}Bài kiểm thử dạng này rất đáng viết: nó bắt được lỗi đăng ký trong CI thay vì lúc triển khai.
Bài 3 — Đảo thứ tự middleware xác thực
Đổi chỗ UseAuthentication và UseAuthorization, gọi một endpoint yêu cầu đăng nhập với token hợp lệ. Ghi lại mã trạng thái và xác nhận log không có lỗi nào.
Tiêu chí hoàn thành: bạn giải thích được vì sao lỗi này "im lặng" và vì sao nó khó chẩn đoán hơn một ngoại lệ.
Gợi ý và lời giải — Bài 3
Gợi ý. UseAuthentication dựng danh tính người dùng từ token. UseAuthorization kiểm tra danh tính đó. Nếu chạy ngược thứ tự, cái thứ hai kiểm tra cái gì?
Lời giải. Bài dùng JWT, một chuỗi token có chữ ký mà client gửi trong header Authorization: Bearer ... để chứng minh mình là ai; Module 10 dạy kỹ. Để tự làm được ngay, chương trình dưới đây tự phát token ở /token. Tạo project dotnet new web, cài dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer, rồi viết Program.cs:
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
// Khoá ký token. CHỈ ĐỂ THỬ NGHIỆM — khoá thật đặt trong cấu hình bí mật (bài 7.7).
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("khoa-bi-mat-chi-de-thu-nghiem-dai-32-byte!!"));
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o => o.TokenValidationParameters = new()
{
ValidateIssuer = false, ValidateAudience = false, IssuerSigningKey = key
});
builder.Services.AddAuthorization();
var app = builder.Build();
if (args.Contains("--wrong"))
{
app.UseAuthorization(); // kiểm tra quyền TRƯỚC
app.UseAuthentication(); // dựng danh tính SAU
}
else
{
app.UseAuthentication();
app.UseAuthorization();
}
app.MapGet("/token", () => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity([new Claim("sub", "an")]),
Expires = DateTime.UtcNow.AddMinutes(30),
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256)
}));
app.MapGet("/customers", () => "danh sách khách hàng").RequireAuthorization(); // yêu cầu đăng nhập
app.Run();Chạy hai lần, mỗi lần lấy token rồi gọi /customers (thay 5000 bằng cổng in ra khi chạy):
dotnet run # thứ tự đúng
TOKEN=$(curl -s localhost:5000/token)
curl -i localhost:5000/customers -H "Authorization: Bearer $TOKEN"
dotnet run -- --wrong # thứ tự sai
TOKEN=$(curl -s localhost:5000/token)
curl -i localhost:5000/customers -H "Authorization: Bearer $TOKEN"Kết quả chạy trên .NET 10:
thứ tự đúng : HTTP/1.1 200 OK
thứ tự sai : HTTP/1.1 401 UnauthorizedVà log hoàn toàn sạch. Không ngoại lệ, không cảnh báo, không một dòng nào bất thường.
Vì sao im lặng. Khi UseAuthorization chạy, HttpContext.User vẫn là một ClaimsPrincipal rỗng — chưa ai điền vào. Middleware phân quyền làm đúng việc của nó: thấy người dùng ẩn danh, thấy endpoint yêu cầu đăng nhập, trả 401. Đó là hành vi hoàn toàn đúng với dữ liệu nó có.
Không có gì sai để mà báo. Sai nằm ở thứ tự, và thứ tự là thứ không middleware nào kiểm tra được.
Vì sao khó chẩn đoán hơn một ngoại lệ:
| Ngoại lệ | Lỗi thứ tự middleware | |
|---|---|---|
| Có dấu vết ngăn xếp | Có | Không |
| Chỉ ra dòng code sai | Có | Không |
| Xuất hiện trong log lỗi | Có | Không |
| Người điều tra bắt đầu từ | Thông báo lỗi | Không có gì |
Người gặp lỗi này sẽ đi kiểm tra token, kiểm tra khoá ký, kiểm tra thời hạn, kiểm tra cấu hình TokenValidationParameters — mọi thứ trừ thứ tự hai dòng trong Program.cs. Nhiều giờ có thể trôi qua.
Một chi tiết đã kiểm chứng trên .NET 10: nếu bạn không gọi UseAuthentication lẫn UseAuthorization, WebApplication tự thêm cả hai vào đúng thứ tự (token hợp lệ nhận 200). Nhưng khi bạn đã tự gọi (dù sai chỗ), nó tôn trọng vị trí bạn đặt, nên lỗi vẫn xảy ra.
Bốn lỗi thứ tự cùng loại:
| Đặt sai | Triệu chứng | Chẩn đoán nhầm thường gặp |
|---|---|---|
UseAuthorization trước UseAuthentication | Luôn 401 dù token đúng | "Token có vấn đề" |
UseCors sau UseAuthorization | Trình duyệt chặn, Postman chạy được | "Lỗi phía frontend" |
UseExceptionHandler không ở đầu | Lỗi không được bắt, trả trang lỗi mặc định | "Xử lý ngoại lệ không hoạt động" |
UseStaticFiles sau middleware chặn | Không phục vụ được file tĩnh | "File bị thiếu" |
Cột cuối cho thấy điểm chung: mọi triệu chứng đều dẫn người điều tra đi sai hướng.
Cách phòng — ba việc:
Chú thích lý do cho từng dòng. Không phải chú thích nó làm gì, mà chú thích vì sao nó ở đúng chỗ đó:
csharpapp.UseExceptionHandler(); // đầu tiên: bắt lỗi của MỌI middleware phía dưới app.UseHttpsRedirection(); app.UseRouting(); // trước Cors và Authorization: cần biết endpoint đích app.UseCors("Default"); app.UseAuthentication(); // dựng User — PHẢI trước Authorization app.UseAuthorization(); // kiểm tra quyền trên User vừa dựng app.MapControllers(); // cuối cùng: điểm kết thúc đường ốngGom vào một extension như bài 5.5 trình bày, để thứ tự chỉ tồn tại ở đúng một chỗ.
Viết kiểm thử tích hợp cho đúng tình huống này:
csharp[Fact] public async Task ValidToken_CanAccessAuthorizedEndpoint() { var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new("Bearer", CreateValidToken()); var res = await client.GetAsync("/api/v1/customers"); Assert.Equal(HttpStatusCode.OK, res.StatusCode); }
Bài kiểm thử này chạy qua toàn bộ đường ống nên nó bắt được lỗi thứ tự — thứ mà unit test không bao giờ bắt được. Đây chính là lý do kiểm thử tích hợp tồn tại, và bài 9.8 trình bày đầy đủ.
Tự kiểm tra
Program.cs có hai giai đoạn nào?
Giai đoạn đăng ký service trước builder.Build, và giai đoạn cấu hình pipeline sau đó. builder.Build là ranh giới, nó dựng container và khoá danh sách đăng ký. Đăng ký sau thời điểm này vẫn biên dịch được, nhưng lúc chạy ném ngay InvalidOperationException "The service collection cannot be modified because it is read-only" và ứng dụng không khởi động.
Vì sao không được gọi BuildServiceProvider() thủ công?
Vì nó tạo ra một container thứ hai với bộ Singleton riêng, nên cùng một kiểu Singleton sẽ có hai đối tượng khác nhau ở hai phần của ứng dụng. Container thứ hai cũng không bao giờ được Dispose nên mọi IDisposable trong đó rò rỉ. Analyzer báo cảnh báo ASP0000 lúc build, không phải trong log lúc chạy.
Tách đăng ký theo layer được lợi gì?
Ranh giới layer hiện ra, ví dụ AddApplication không nhận IConfiguration nên nếu một ngày nó cần thì đó là tín hiệu layer application đang biết về hạ tầng. Program.cs đọc được trong 30 giây. Và integration test dùng lại được các extension đó rồi Replace vài thứ.
Đặt UseAuthorization trước UseAuthentication thì sao?
User luôn vô danh nên mọi endpoint có Authorize đều từ chối, kể cả với token hợp lệ. Nguy hiểm ở chỗ không có lỗi nào được báo, chỉ là 401 hoặc 403 không giải thích được. Thứ tự middleware là ngữ nghĩa chứ không phải danh sách bật tắt.
Thứ tự middleware nên nhớ thế nào?
Ngoài vào trong: xử lý lỗi trước tiên để bắt được mọi lớp bên trong, rồi bảo mật truyền tải, file tĩnh, định tuyến, CORS, xác thực, phân quyền, cuối cùng mới tới endpoint.
Chạy migration lúc khởi động thế nào cho đúng?
Phải tạo scope bằng app.Services.CreateScope rồi lấy DbContext từ scope đó, vì app.Services là root provider và lấy thẳng service Scoped từ đó sẽ ném exception hoặc giam nó. Với production nhiều instance thì an toàn hơn là tách migration thành bước riêng trong pipeline triển khai.
Kết luận
Ba điều đáng nhớ nhất:
builder.Build()là ranh giới. Trước nó đăng ký, sau nó cấu hình pipeline.BuildServiceProvider()thủ công tạo container thứ hai — nguồn bug im lặng và rò rỉ.- Thứ tự middleware là ngữ nghĩa, và sai thứ tự thường không báo lỗi gì cả.
Tham khảo
- ASP.NET Core Middleware
- App startup in ASP.NET Core
- ASP0000: Do not call BuildServiceProvider
- Dependency injection in ASP.NET Core
Điều hướng
- Bài trước: 7.5 — Registering Services
- Bài tiếp theo: 7.7 — Options Pattern
- Về module: Trang mục lục
Bài liên quan
- Singleton, Scoped hay Transient? Chọn sai là DbContext sống mãi — Ba lifetime trong DI container của ASP.NET Core khác nhau ở thời điểm tạo và thời điểm dispose instance.