8.3 - Routing
Tóm tắt: Routing quyết định request nào chạy code nào, và nó xảy ra ở hai bước tách biệt:
UseRouting()chọn endpoint,MapControllers()chạy endpoint — mọi middleware nằm giữa hai bước đó đều biết endpoint nào sắp chạy. Thứ đáng học kỹ nhất là route constraint:{id:int}không chỉ để đẹp, nó khiến/customers/abctrả 404 thay vì đi vào action rồi nổ khi ép kiểu. Và khi nhiều route cùng khớp, ASP.NET Core không chọn cái đăng ký trước — nó chấm điểm theo độ cụ thể, nên/customers/activethắng/customers/{id}bất kể thứ tự.
Mục tiêu bài học
Sau bài này bạn có thể:
- Thiết kế route REST với attribute routing.
- Dùng constraint để chặn dữ liệu sai ngay ở tầng định tuyến.
- Giải thích cách ASP.NET Core chọn khi nhiều route cùng khớp.
- Triển khai versioning cho API.
- Chọn giữa controller và Minimal API có lý do.
Nội dung bài học
8.3.1 — Hai bước của routing
app.UseRouting(); // buoc 1: CHON endpoint, chua chay
app.UseAuthorization(); // biết endpoint rồi -> đọc được [Authorize] trên nó
app.MapControllers(); // buoc 2: CHAY endpointTách làm hai bước là có chủ đích: middleware nằm giữa có thể đọc metadata của endpoint sắp chạy. Đó là cách UseAuthorization() biết action nào cần quyền gì mà không phải tự phân tích URL.
![Sơ đồ hai bước của routing: Request đi qua các middleware đầu pipeline như ExceptionHandler và StaticFiles, lúc này GetEndpoint() còn trả null. UseRouting() là bước 1, chọn endpoint; nếu không route nào khớp, ví dụ /customers/abc với constraint id:guid, kết quả là 404. Sau UseRouting(), middleware của bạn và UseAuthorization() đọc được metadata của endpoint như [Authorize]. MapControllers() là bước 2, chạy action đã chọn. Khi nhiều route cùng khớp, route cụ thể nhất thắng, không theo thứ tự khai báo.](/assets/routing-two-steps.BnbEmlMj.png)
![Sơ đồ hai bước của routing: Request đi qua các middleware đầu pipeline như ExceptionHandler và StaticFiles, lúc này GetEndpoint() còn trả null. UseRouting() là bước 1, chọn endpoint; nếu không route nào khớp, ví dụ /customers/abc với constraint id:guid, kết quả là 404. Sau UseRouting(), middleware của bạn và UseAuthorization() đọc được metadata của endpoint như [Authorize]. MapControllers() là bước 2, chạy action đã chọn. Khi nhiều route cùng khớp, route cụ thể nhất thắng, không theo thứ tự khai báo.](/assets/routing-two-steps-dark.DdtX3KRC.png)
app.UseRouting();
app.Use(async (ctx, next) =>
{
var name = ctx.GetEndpoint()?.DisplayName; // null nếu đặt trước UseRouting
...
await next(ctx);
});8.3.2 — Attribute routing
[ApiController]
[Route("api/v1/[controller]")] // => api/v1/customers
public class CustomersController : ControllerBase
{
[HttpGet] // GET api/v1/customers
public Task<IActionResult> GetAll([FromQuery] PagedRequest request, CancellationToken ct);
[HttpGet("{id:guid}", Name = "GetCustomer")] // GET api/v1/customers/{guid}
public Task<IActionResult> GetById(Guid id, CancellationToken ct);
[HttpGet("{id:guid}/orders")] // GET api/v1/customers/{guid}/orders
public Task<IActionResult> GetOrders(Guid id, CancellationToken ct);
[HttpPost] // POST api/v1/customers
public Task<IActionResult> Create(CreateCustomerRequest request, CancellationToken ct);
[HttpPut("{id:guid}")] // PUT api/v1/customers/{guid}
public Task<IActionResult> Update(Guid id, UpdateCustomerRequest request, CancellationToken ct);
[HttpDelete("{id:guid}")] // DELETE api/v1/customers/{guid}
public Task<IActionResult> Delete(Guid id, CancellationToken ct);
}Ba điểm đáng chú ý:
[controller]là token thay bằng tên lớp bỏ hậu tốController. Tiện, nhưng nghĩa là đổi tên lớp là đổi URL — một thay đổi phá vỡ hợp đồng API mà refactor tool sẽ vui vẻ làm hộ bạn. Với API công khai, viết thẳng[Route("api/v1/customers")].Name = "GetCustomer"cho phépCreatedAtRoute("GetCustomer", ...)sinh URL mà không nối chuỗi.- Route lồng nhau phản ánh quan hệ sở hữu:
/customers/{id}/ordersnói rằng order thuộc về customer. Đừng lồng quá hai tầng —/customers/{a}/orders/{b}/lines/{c}khó đọc và khó phân quyền.
Route tuyệt đối (bắt đầu bằng /) bỏ qua [Route] ở lớp:
[HttpGet("/health/customers")] // KHÔNG có tiền tố api/v18.3.3 — Constraint chặn lỗi sớm
| Constraint | Ví dụ | Khớp |
|---|---|---|
int | {id:int} | Số nguyên |
guid | {id:guid} | GUID |
long, decimal, bool, datetime | {d:datetime} | Theo kiểu |
min(n), max(n), range(a,b) | {page:min(1)} | Giá trị số |
minlength(n), maxlength(n), length(n) | {code:length(3)} | Độ dài chuỗi |
alpha | {name:alpha} | Chỉ chữ cái |
regex(...) | {code:regex(^[A-Z]{{3}}$)} | Biểu thức chính quy |
required | {id:required} | Không rỗng |
[HttpGet("{id:guid}")] // /customers/abc -> 404, không vào action
[HttpGet("page/{n:int:min(1)}")] // ghép nhiều constraint bằng dấu hai chấmĐiểm quan trọng: constraint sai thì trả 404, không phải 400. Về REST điều đó hợp lý — không có tài nguyên nào ở URL đó. Nhưng nó có nghĩa là client nhận 404 mà không biết mình gõ sai định dạng. Với những chỗ cần thông báo rõ, nhận string rồi validate trong action để trả 400 kèm lý do.
Trong regex, dấu { và } phải nhân đôi vì route template đã dùng chúng.
Constraint không phải cơ chế bảo mật hay validation nghiệp vụ — nó chỉ lọc định dạng. Validation thật ở bài 8.5.
8.3.4 — Khi nhiều route cùng khớp
[HttpGet("{id:guid}")] // /customers/{guid}
[HttpGet("active")] // /customers/activeASP.NET Core không chọn theo thứ tự đăng ký. Nó chấm điểm theo độ cụ thể, ưu tiên giảm dần:
- Đoạn chữ cố định (
active) thắng tham số. - Tham số có constraint (
{id:guid}) thắng tham số trần. - Tham số trần (
{id}) thắng catch-all. - Catch-all (
{**rest}) thua tất cả.
Nên /customers/active luôn vào đúng action dù nó đăng ký sau.
Khi hai route cùng điểm, bạn nhận AmbiguousMatchException lúc chạy — không phải lúc biên dịch:
[HttpGet("{id:int}")] public IActionResult A(int id);
[HttpGet("{code:int}")] public IActionResult B(int code); // cùng điểm -> némGỡ bằng [HttpGet("...", Order = -1)] (số nhỏ chạy trước), nhưng tốt hơn là sửa thiết kế route cho không mơ hồ.
8.3.5 — Versioning
dotnet add package Asp.Versioning.Mvcbuilder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true; // tra header api-supported-versions
}).AddMvc();[ApiVersion("1.0")]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/customers")]
public class CustomersController : ControllerBase
{
[HttpGet, MapToApiVersion("1.0")]
public Task<IActionResult> GetAllV1(CancellationToken ct);
[HttpGet, MapToApiVersion("2.0")]
public Task<IActionResult> GetAllV2(CancellationToken ct);
}Ba cách đặt version, và mỗi cách có cái giá của nó:
| Cách | Ví dụ | Đánh đổi |
|---|---|---|
| URL | /api/v2/customers | Dễ thấy, dễ test bằng trình duyệt; nhưng URL của cùng một tài nguyên thay đổi theo version |
| Header | api-version: 2.0 | URL ổn định, đúng tinh thần REST; nhưng không test được bằng thanh địa chỉ |
| Query | ?api-version=2.0 | Dễ dùng; nhưng lẫn với tham số nghiệp vụ |
URL versioning phổ biến nhất trong thực tế vì nó hiển nhiên — người đọc log, người viết tài liệu và người debug đều thấy ngay.
8.3.6 — Minimal API hay controller
var customers = app.MapGroup("/api/v1/customers")
.WithTags("Customers")
.RequireAuthorization();
customers.MapGet("/", async (ICustomerService svc, CancellationToken ct)
=> Results.Ok(await svc.GetAllAsync(ct)));
customers.MapGet("/{id:guid}", async (Guid id, ICustomerService svc, CancellationToken ct)
=> await svc.GetByIdAsync(id, ct) is { } c ? Results.Ok(c) : Results.NotFound())
.WithName("GetCustomer");
customers.MapPost("/", async (CreateCustomerRequest req, ICustomerService svc, CancellationToken ct) =>
{
var c = await svc.CreateAsync(req, ct);
return Results.CreatedAtRoute("GetCustomer", new { id = c.Id }, c);
});MapGroup (.NET 7+) xoá phần lớn lý do cũ để chọn controller: nó cho bạn tiền tố chung, RequireAuthorization() chung, filter chung.
| Chọn Minimal API khi | Chọn controller khi |
|---|---|
| Service nhỏ, ít endpoint | API lớn, nhiều action cùng nhóm |
| Ưu tiên thời gian khởi động và thông lượng | Cần action filter, model binder tuỳ biến |
| Endpoint chủ yếu là chuyển tiếp | Cần [ApiController] với validation tự động |
| Team quen lập trình hàm | Team quen MVC, có sẵn quy ước |
Đừng trộn hai kiểu cho cùng một tài nguyên. Hai nơi định nghĩa /customers với hai quy ước phản hồi khác nhau là nợ kỹ thuật ngay từ ngày đầu.
8.3.7 — Rà lại code của bạn
Danh sách rà soát routing
- Mọi tham số route đều có constraint phù hợp kiểu dữ liệu.
- API công khai không dùng token [controller] trong route.
- Route có tên khi cần sinh URL bằng CreatedAtRoute.
- Không lồng tài nguyên quá hai tầng.
- Đã kiểm tra không có route nào mơ hồ gây AmbiguousMatchException.
- API có chiến lược version rõ ràng và nhất quán.
- Một tài nguyên chỉ được định nghĩa bằng một kiểu: controller hoặc Minimal API.
- Middleware cần biết endpoint được đặt sau UseRouting().
Bài tập áp dụng
Bài 1 — Constraint chặn lỗi sớm
Viết hai action, một dùng {id:guid} và một dùng {id}. Gọi cả hai với abc và ghi lại mã trạng thái cùng nơi lỗi xảy ra.
Tiêu chí hoàn thành: bạn chỉ ra được hai lỗi xảy ra ở hai tầng khác nhau của đường ống.
Gợi ý và lời giải — Bài 1
Gợi ý. Constraint được kiểm tra trong bước khớp route, tức trước khi action được chọn. Không có constraint thì việc chuyển đổi kiểu diễn ra ở bước gắn dữ liệu, muộn hơn.
Lời giải.
[HttpGet("v1/{id:guid}")]
public IActionResult CoConstraint(Guid id) => Ok(id);
[HttpGet("v2/{id}")]
public IActionResult KhongConstraint(Guid id) => Ok(id);curl -i localhost:5000/api/customers/v1/abc
curl -i localhost:5000/api/customers/v2/abcKết quả:
v1 có constraint | v2 không constraint | |
|---|---|---|
| Mã trạng thái | 404 Not Found | 400 Bad Request |
| Xảy ra ở tầng | Khớp route | Gắn dữ liệu |
| Action có chạy không | Không | Không |
| Thân phản hồi | Trống | ProblemDetails với lỗi theo trường |
404 với v1 là đúng. Constraint là một phần của định nghĩa route. Đường dẫn /v1/abc không khớp với route nào, nên theo đúng ngữ nghĩa HTTP, tài nguyên đó không tồn tại.
400 với v2 cũng đúng. Route khớp, nhưng giá trị abc không chuyển được thành Guid, nên yêu cầu sai định dạng.
Chọn cái nào — tuỳ vào điều bạn muốn nói với client:
| Muốn nói | Dùng |
|---|---|
| "Đường dẫn này không tồn tại" | Constraint, trả 404 |
| "Đường dẫn đúng, giá trị sai" | Không constraint, trả 400 kèm chi tiết |
Với API công khai, 400 kèm chi tiết thường hữu ích hơn — client biết mình sai ở đâu. Với route nội bộ hoặc khi muốn giấu cấu trúc, 404 an toàn hơn.
Lợi ích thật của constraint: phân giải nhập nhằng.
[HttpGet("{id:int}")] public IActionResult TheoId(int id) => Ok();
[HttpGet("{slug}")] public IActionResult TheoSlug(string slug) => Ok();Không có :int, hai route này nhập nhằng và ứng dụng ném AmbiguousMatchException. Với constraint, /42 vào action đầu và /khach-hang-vip vào action sau — routing tự phân biệt.
Các constraint hay dùng:
"{id:int}" // số nguyên
"{id:guid}" // GUID
"{id:min(1)}" // số nguyên từ 1
"{page:int:min(1)}" // ghép nhiều constraint
"{code:length(3)}" // đúng 3 ký tự
"{slug:regex(^[a-z0-9-]+$)}"// biểu thức chính quy
"{date:datetime}" // ngày giờCảnh báo về regex. Biểu thức chính quy phức tạp trong route được đánh giá cho mọi request đi qua, và một biểu thức viết kém có thể bị khai thác để gây quá tải CPU. Với quy tắc phức tạp, hãy xác thực ở tầng gắn dữ liệu hoặc ở tầng nghiệp vụ thay vì nhét vào route.
Viết constraint riêng khi cần:
public class BranchCodeConstraint : IRouteConstraint
{
private static readonly HashSet<string> Valid = new(StringComparer.OrdinalIgnoreCase)
{ "HN", "HCM", "DN", "CT" };
public bool Match(HttpContext? c, IRouter? r, string k,
RouteValueDictionary v, RouteDirection d)
=> v.TryGetValue(k, out var x) && x is string s && Valid.Contains(s);
}
builder.Services.Configure<RouteOptions>(o =>
o.ConstraintMap["branch"] = typeof(BranchCodeConstraint));
// Dùng: [HttpGet("{code:branch}/customers")]Bài 2 — Thứ tự khai báo không quyết định route nào thắng
Đăng ký [HttpGet("{id:guid}")] trước [HttpGet("active")], gọi /customers/active và xác nhận nó vào đúng action thứ hai.
Tiêu chí hoàn thành: bạn nêu được quy tắc chấm điểm mà routing dùng để chọn.
Gợi ý và lời giải — Bài 2
Gợi ý. Routing của ASP.NET Core không chọn theo thứ tự khai báo. Nó tính một điểm số cho từng route khớp được và chọn route cụ thể nhất.
Lời giải.
[ApiController]
[Route("api/customers")]
public class CustomersController : ControllerBase
{
[HttpGet("{id:guid}")] // khai báo TRƯỚC
public IActionResult TheoId(Guid id) => Ok($"theo id: {id}");
[HttpGet("active")] // khai báo SAU
public IActionResult DangHoatDong() => Ok("danh sách đang hoạt động");
}curl localhost:5000/api/customers/active
# "danh sách đang hoạt động" <- action thứ hai, dù khai báo sauQuy tắc chấm điểm — đoạn cụ thể thắng đoạn tham số:
| Loại đoạn route | Độ ưu tiên |
|---|---|
Văn bản cố định: active | Cao nhất |
Tham số có constraint: {id:guid} | Trung bình |
Tham số không constraint: {id} | Thấp |
Tham số bắt phần còn lại: {**path} | Thấp nhất |
active là văn bản cố định nên nó thắng {id:guid}, bất kể thứ tự khai báo. Đây là hành vi đúng và mong muốn: route càng cụ thể thì càng nên được ưu tiên.
Vì sao thiết kế như vậy. Nếu routing chọn theo thứ tự khai báo, việc thêm một action mới có thể vô tình "che" một action cũ, và thứ tự các phương thức trong file trở thành một phần của hành vi hệ thống — rất dễ vỡ khi ai đó sắp xếp lại code.
Khi nào cần Order. Chỉ khi hai route có cùng điểm số và bạn muốn một cái thắng:
[HttpGet("{slug}", Order = 1)] // xét sau
public IActionResult TheoSlug(string slug) => Ok();
[HttpGet("{id:int}", Order = 0)] // xét trước
public IActionResult TheoId(int id) => Ok();Nhưng trong ví dụ này Order là không cần thiết, vì {id:int} đã có constraint nên điểm cao hơn {slug}. Dùng Order khi chưa cần là dấu hiệu bạn đang chữa triệu chứng thay vì sửa thiết kế route.
Khi thật sự nhập nhằng — ứng dụng ném lúc chạy:
[HttpGet("{a}")] public IActionResult M1(string a) => Ok();
[HttpGet("{b}")] public IActionResult M2(string b) => Ok();AmbiguousMatchException: The request matched multiple endpoints. Matches:
CustomersController.M1
CustomersController.M2Chú ý: ngoại lệ này xảy ra lúc chạy, không phải lúc build — và chỉ khi có request thật đi tới đường dẫn đó. Với một route ít dùng, nó có thể nằm im nhiều tháng.
Phát hiện sớm bằng kiểm thử:
[Fact]
public void KhongCoRouteNaoNhapNhang()
{
var nguon = _factory.Services.GetRequiredService<EndpointDataSource>();
var trung = nguon.Endpoints
.OfType<RouteEndpoint>()
.GroupBy(e => e.RoutePattern.RawText)
.Where(g => g.Count() > 1)
.ToList();
Assert.Empty(trung);
}Bài kiểm thử này chạy trong CI và bắt được nhập nhằng trước khi triển khai. Nó cũng là ví dụ tốt cho việc kiểm thử tích hợp bắt được lớp lỗi mà unit test không chạm tới.
Xem toàn bộ route đã đăng ký khi cần gỡ rối:
app.MapGet("/routes", (EndpointDataSource ds) =>
ds.Endpoints.OfType<RouteEndpoint>()
.Select(e => new { e.RoutePattern.RawText, e.Order, e.DisplayName }));Chỉ bật ở môi trường Development.
Bài 3 — Route nhập nhằng và hai cách gỡ
Tạo hai action có route cùng điểm số, chạy ứng dụng và ghi lại AmbiguousMatchException. Gỡ bằng Order rồi bằng cách sửa thiết kế, so sánh hai cách.
Tiêu chí hoàn thành: bạn nêu được vì sao sửa thiết kế tốt hơn dùng Order.
Gợi ý và lời giải — Bài 3
Gợi ý. Order khiến routing chọn được, nhưng nó không trả lời câu hỏi: vì sao hai đường dẫn khác nhau về ý nghĩa lại trông giống hệt nhau?
Lời giải — tình huống nhập nhằng thật:
[HttpGet("{id}")]
public async Task<IActionResult> TheoId(string id) { } // /customers/42
[HttpGet("{email}")]
public async Task<IActionResult> TheoEmail(string email) { } // /customers/[email protected]AmbiguousMatchException: The request matched multiple endpoints.Cách 1 — dùng Order:
[HttpGet("{id}", Order = 0)]
public async Task<IActionResult> TheoId(string id) { }
[HttpGet("{email}", Order = 1)]
public async Task<IActionResult> TheoEmail(string email) { }Ứng dụng chạy được. Nhưng action thứ hai không bao giờ được gọi tới — mọi thứ đều khớp {id} trước. Bạn vừa đổi một ngoại lệ ồn ào thành một action chết im lặng.
Cách 2 — thêm constraint để chúng thật sự khác nhau:
[HttpGet("{id:int}")]
public async Task<IActionResult> TheoId(int id) { }
[HttpGet("by-email/{email}")]
public async Task<IActionResult> TheoEmail(string email) { }Cách 3 — sửa thiết kế, và đây là cách đúng nhất:
[HttpGet("{id:int}")]
public async Task<IActionResult> TheoId(int id) { }
[HttpGet]
public async Task<IActionResult> Tim([FromQuery] string? email, [FromQuery] string? ten,
[FromQuery] int page = 1, [FromQuery] int size = 20) { }Vì sao cách 3 tốt nhất. Nó phản ánh đúng ngữ nghĩa REST ở bài 9.1:
- Đường dẫn định danh một tài nguyên cụ thể:
/customers/42. - Tham số truy vấn dùng để lọc một tập hợp:
/[email protected].
Tìm theo email về bản chất là lọc, không phải định danh — nên nó thuộc về tham số truy vấn. Nhập nhằng biến mất không phải vì ta thêm mẹo, mà vì thiết kế đã đúng.
So sánh ba cách:
Order | Constraint / đổi đường dẫn | Sửa thiết kế | |
|---|---|---|---|
| Hết ngoại lệ | Có | Có | Có |
| Cả hai action dùng được | Không | Có | Có |
| Đường dẫn phản ánh ngữ nghĩa | Không | Một phần | Có |
| Mở rộng thêm tiêu chí lọc | Thêm action mới | Thêm action mới | Thêm tham số |
Dòng cuối đáng chú ý: với cách 3, thêm tìm theo số điện thoại chỉ là thêm một tham số, không phải thêm một endpoint.
Nguyên tắc chung. Nhập nhằng route hầu như luôn là triệu chứng của thiết kế API chưa rõ ràng, không phải một vấn đề kỹ thuật cần mẹo để vượt qua. Khi gặp nó, câu hỏi đầu tiên nên là "hai đường dẫn này khác nhau về ý nghĩa ở chỗ nào?" — và nếu câu trả lời khó diễn đạt, thiết kế cần sửa.
Order nên được dành cho những trường hợp hiếm hoi mà bạn thật sự muốn một route bắt phần còn lại xếp sau cùng, ví dụ route dự phòng cho ứng dụng một trang:
app.MapFallbackToFile("index.html"); // luôn xét cuối cùngTự kiểm tra
Vì sao routing tách thành UseRouting và MapControllers?
Để mọi middleware nằm giữa hai bước đó biết endpoint nào sắp chạy và đọc được metadata của nó. Đó là cách UseAuthorization biết action nào cần quyền gì mà không phải tự phân tích URL. Middleware đặt trước UseRouting thì GetEndpoint trả về null.
Route constraint sai thì trả mã lỗi nào?
404 chứ không phải 400, vì về mặt REST thì không có tài nguyên nào ở URL đó. Nghĩa là client nhận 404 mà không biết mình gõ sai định dạng. Với những chỗ cần thông báo rõ thì nhận string rồi validate trong action để trả 400 kèm lý do.
Khi nhiều route cùng khớp thì ASP.NET Core chọn thế nào?
Không theo thứ tự đăng ký mà theo độ cụ thể: đoạn chữ cố định thắng tham số, tham số có constraint thắng tham số trần, tham số trần thắng catch-all. Nên /customers/active luôn vào đúng action dù đăng ký sau /customers/{id}.
AmbiguousMatchException xảy ra khi nào?
Khi hai route có cùng điểm số cụ thể, và nó xảy ra lúc chạy chứ không phải lúc biên dịch. Có thể gỡ bằng thuộc tính Order với số nhỏ chạy trước, nhưng tốt hơn là sửa thiết kế route cho không mơ hồ.
Dùng token [controller] trong route có vấn đề gì?
Nó lấy tên lớp bỏ hậu tố Controller, nên đổi tên lớp là đổi URL. Đó là một thay đổi phá vỡ hợp đồng API mà công cụ refactor sẽ vui vẻ làm hộ bạn mà không cảnh báo. Với API công khai thì nên viết thẳng đường dẫn.
Chọn Minimal API hay controller?
Minimal API hợp với service nhỏ, ít endpoint, ưu tiên thời gian khởi động và thông lượng; MapGroup từ .NET 7 đã xoá phần lớn lý do cũ để chọn controller. Controller hợp với API lớn cần action filter, model binder tuỳ biến hoặc validation tự động của ApiController. Quan trọng nhất là đừng trộn hai kiểu cho cùng một tài nguyên.
Kết luận
Ba điều đáng nhớ nhất:
- Constraint chặn dữ liệu sai ở tầng định tuyến — nhưng nó trả 404, không phải 400.
- Độ cụ thể quyết định, không phải thứ tự đăng ký.
- Chọn một kiểu cho mỗi tài nguyên — controller hoặc Minimal API, đừng trộn.
Tham khảo
- Routing in ASP.NET Core
- Routing to controller actions in ASP.NET Core
- Minimal APIs overview
- Route groups in Minimal APIs
Điều hướng
- Bài trước: 8.2 — Request Pipeline và Middleware
- Bài tiếp theo: 8.4 — Controllers và Actions
- Về module: Trang mục lục