Nguon: Microsoft Learn · .NET 8.0

Liên kết tham số (Parameter Binding) trong ứng dụng Minimal API

Nguồn: Parameter binding in Minimal API applications

Parameter binding (liên kết tham số) là quá trình chuyển đổi dữ liệu từ request (yêu cầu) thành các tham số có kiểu dữ liệu mạnh (strongly typed) được khai báo trong route handler (bộ xử lý tuyến đường). Binding source (nguồn liên kết) xác định nơi các tham số được lấy từ đó. Binding source có thể được khai báo tường minh hoặc được suy ra dựa trên HTTP method (phương thức HTTP) và kiểu tham số.

Các binding source được hỗ trợ:

Route handler GET sau đây sử dụng một số binding source này:

csharp
var builder = WebApplication.CreateBuilder(args);

// Thêm như service
builder.Services.AddSingleton<Service>();

var app = builder.Build();

app.MapGet("/{id}", (int id,
                     int page,
                     [FromHeader(Name = "X-CUSTOM-HEADER")] string customHeader,
                     Service service) => { });

class Service { }

Bảng sau thể hiện mối quan hệ giữa các tham số trong ví dụ trên và binding source tương ứng:

Tham sốBinding Source
idroute value
pagequery string
customHeaderheader
serviceĐược cung cấp bởi dependency injection

Các HTTP method GET, HEAD, OPTIONS, và DELETE không tự động liên kết từ body. Để liên kết từ body (dạng JSON) cho các HTTP method này, hãy khai báo tường minh với [[FromBody]] hoặc đọc từ HttpRequest.

Route handler POST sau đây sử dụng binding source body (dạng JSON) cho tham số person:

csharp
var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.MapPost("/", (Person person) => { });

record Person(string Name, int Age);

Các tham số trong ví dụ trên đều được liên kết tự động từ dữ liệu request. Để thấy sự tiện lợi của parameter binding, các route handler sau cho thấy cách đọc dữ liệu request trực tiếp:

csharp
app.MapGet("/{id}", (HttpRequest request) =>
{
    var id = request.RouteValues["id"];
    var page = request.Query["page"];
    var customHeader = request.Headers["X-CUSTOM-HEADER"];

    // ...
});

app.MapPost("/", async (HttpRequest request) =>
{
    var person = await request.ReadFromJsonAsync<Person>();

    // ...
});

Liên kết tham số tường minh (Explicit Parameter Binding)

Có thể dùng attribute (thuộc tính) để khai báo tường minh nơi tham số được liên kết:

csharp
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

// Thêm như service
builder.Services.AddSingleton<Service>();

var app = builder.Build();

app.MapGet("/{id}", ([FromRoute] int id,
                     [FromQuery(Name = "p")] int page,
                     [FromServices] Service service,
                     [FromHeader(Name = "Content-Type")] string contentType) 
                     => {});

class Service { }

record Person(string Name, int Age);
Tham sốBinding Source
idroute value với tên id
pagequery string với tên "p"
serviceĐược cung cấp bởi dependency injection
contentTypeheader với tên "Content-Type"

Liên kết tường minh từ form values

Attribute [FromForm] liên kết form values:

csharp
app.MapPost("/todos", async ([FromForm] string name,
    [FromForm] Visibility visibility, IFormFile? attachment, TodoDb db) =>
{
    var todo = new Todo
    {
        Name = name,
        Visibility = visibility
    };

    if (attachment is not null)
    {
        var attachmentName = Path.GetRandomFileName();

        using var stream = File.Create(Path.Combine("wwwroot", attachmentName));
        await attachment.CopyToAsync(stream);
    }

    db.Todos.Add(todo);
    await db.SaveChangesAsync();

    return Results.Ok();
});

// Phần còn lại đã được lược bỏ.

Một cách khác là dùng attribute [AsParameters] với kiểu tùy chỉnh có các thuộc tính được gắn [FromForm]. Ví dụ sau liên kết từ form values sang các thuộc tính của record struct NewTodoRequest:

csharp
app.MapPost("/ap/todos", async ([AsParameters] NewTodoRequest request, TodoDb db) =>
{
    var todo = new Todo
    {
        Name = request.Name,
        Visibility = request.Visibility
    };

    if (request.Attachment is not null)
    {
        var attachmentName = Path.GetRandomFileName();

        using var stream = File.Create(Path.Combine("wwwroot", attachmentName));
        await request.Attachment.CopyToAsync(stream);

        todo.Attachment = attachmentName;
    }

    db.Todos.Add(todo);
    await db.SaveChangesAsync();

    return Results.Ok();
});

// Phần còn lại đã được lược bỏ.
csharp
public record struct NewTodoRequest([FromForm] string Name,
    [FromForm] Visibility Visibility, IFormFile? Attachment);

Liên kết an toàn từ IFormFile và IFormFileCollection

Liên kết form phức tạp được hỗ trợ dùng IFormFileIFormFileCollection với [FromForm]:

csharp
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder();

builder.Services.AddAntiforgery();

var app = builder.Build();
app.UseAntiforgery();

// Tạo form với antiforgery token và endpoint /upload
app.MapGet("/", (HttpContext context, IAntiforgery antiforgery) =>
{
    var token = antiforgery.GetAndStoreTokens(context);
    var html = MyUtils.GenerateHtmlForm(token.FormFieldName, token.RequestToken!);
    return Results.Content(html, "text/html");
});

app.MapPost("/upload", async Task<Results<Ok<string>, BadRequest<string>>>
    ([FromForm] FileUploadForm fileUploadForm, HttpContext context,
                                                IAntiforgery antiforgery) =>
{
    await MyUtils.SaveFileWithName(fileUploadForm.FileDocument!,
              fileUploadForm.Name!, app.Environment.ContentRootPath);
    return TypedResults.Ok($"File của bạn với mô tả:" +
        $" {fileUploadForm.Description} đã được upload thành công");
});

app.Run();

Các tham số liên kết với request bằng [FromForm] bao gồm antiforgery token (mã chống giả mạo). Antiforgery token được kiểm tra khi xử lý request.

Liên kết tham số với dependency injection

Parameter binding cho Minimal APIs liên kết tham số thông qua dependency injection khi kiểu đó được cấu hình như service. Không cần phải khai báo tường minh attribute [FromServices] cho tham số. Trong đoạn code sau, cả hai action đều trả về thời gian:

csharp
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IDateTime, SystemDateTime>();

var app = builder.Build();

app.MapGet("/",   (               IDateTime dateTime) => dateTime.Now);
app.MapGet("/fs", ([FromServices] IDateTime dateTime) => dateTime.Now);
app.Run();

Tham số tùy chọn (Optional Parameters)

Các tham số được khai báo trong route handler được coi là bắt buộc:

csharp
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/products", (int pageNumber) => $"Requesting page {pageNumber}");

app.Run();
URIKết quả
/products?pageNumber=3Trả về 3
/productsBadHttpRequestException: Required parameter "int pageNumber" wasn't provided from query string.
/products/1Lỗi HTTP 404, không khớp route

Để làm pageNumber tùy chọn, định nghĩa kiểu là nullable hoặc cung cấp giá trị mặc định:

csharp
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/products", (int? pageNumber) => $"Requesting page {pageNumber ?? 1}");

string ListProducts(int pageNumber = 1) => $"Requesting page {pageNumber}";

app.MapGet("/products2", ListProducts);

app.Run();
URIKết quả
/products?pageNumber=3Trả về 3
/productsTrả về 1
/products2Trả về 1

LƯU Ý: Nếu dữ liệu không hợp lệ được cung cấp và tham số là nullable, route handler sẽ không chạy.

Các kiểu đặc biệt (Special Types)

Các kiểu sau được liên kết mà không cần attribute tường minh:

Liên kết request body dưới dạng Stream hoặc PipeReader

Request body có thể được liên kết dưới dạng Stream hoặc PipeReader để hỗ trợ hiệu quả các tình huống xử lý dữ liệu như lưu vào blob storage hoặc đẩy vào queue provider.

csharp
app.MapPost("/register", async (HttpRequest req, Stream body,
                                 Channel<ReadOnlyMemory<byte>> queue) =>
{
    if (req.ContentLength is not null && req.ContentLength > maxMessageSize)
    {
        return Results.BadRequest();
    }

    var readSize = (int?)req.ContentLength ?? (maxMessageSize + 1);
    var buffer = new byte[readSize];
    var read = await body.ReadAtLeastAsync(buffer, readSize, throwOnEndOfStream: false);

    if (read > maxMessageSize)
    {
        return Results.BadRequest();
    }

    if (queue.Writer.TryWrite(buffer.AsMemory(0..read)))
    {
        return Results.Accepted();
    }

    return Results.StatusCode(StatusCodes.Status429TooManyRequests);
});

Upload file dùng IFormFile và IFormFileCollection

Upload file dùng IFormFileIFormFileCollection trong Minimal APIs yêu cầu encoding multipart/form-data. Tên tham số trong route handler phải khớp với tên form field trong request.

csharp
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.MapPost("/upload", async (IFormFile file) =>
{
    var tempFile = Path.GetTempFileName();
    app.Logger.LogInformation(tempFile);
    using var stream = File.OpenWrite(tempFile);
    await file.CopyToAsync(stream);
});

app.MapPost("/upload_many", async (IFormFileCollection myFiles) =>
{
    foreach (var file in myFiles)
    {
        var tempFile = Path.GetTempFileName();
        app.Logger.LogInformation(tempFile);
        using var stream = File.OpenWrite(tempFile);
        await file.CopyToAsync(stream);
    }
});

app.Run();

Hành vi liên kết IFormFile collection

Kiểu tham sốGiá trị được liên kếtTuân theo tên tham số?
IFormFileCollectionTất cả file trong HttpContext.Request.Form.FilesKhông
IFormFileFile duy nhất có tên form field khớp với tên tham số
IReadOnlyList<IFormFile>Tất cả file có tên form field khớp với tên tham số
Các kiểu IFormFile collection khácKhông hỗ trợN/A

Liên kết arrays và string từ headers và query strings

csharp
// Liên kết query string values vào mảng kiểu nguyên thủy.
// GET /tags?q=1&q=2&q=3
app.MapGet("/tags", (int[] q) =>
                      $"tag1: {q[0]} , tag2: {q[1]}, tag3: {q[2]}");

// Liên kết vào string array.
// GET /tags2?names=john&names=jack&names=jane
app.MapGet("/tags2", (string[] names) =>
            $"tag1: {names[0]} , tag2: {names[1]}, tag3: {names[2]}");

// Liên kết vào StringValues.
// GET /tags3?names=john&names=jack&names=jane
app.MapGet("/tags3", (StringValues names) =>
            $"tag1: {names[0]} , tag2: {names[1]}, tag3: {names[2]}");

Liên kết tham số cho danh sách đối số với [AsParameters]

AsParametersAttribute cho phép liên kết tham số đơn giản vào kiểu dữ liệu mà không cần model binding phức tạp.

csharp
app.MapGet("/todoitems/{id}",
                             async (int Id, TodoDb Db) =>
    await Db.Todos.FindAsync(Id)
        is Todo todo
            ? Results.Ok(new TodoItemDTO(todo))
            : Results.NotFound());

Struct sau có thể thay thế các tham số trên:

csharp
struct TodoItemRequest
{
    public int Id { get; set; }
    public TodoDb Db { get; set; }
}

Endpoint GET được tái cấu trúc dùng struct với attribute [AsParameters]:

csharp
app.MapGet("/ap/todoitems/{id}",
                                async ([AsParameters] TodoItemRequest request) =>
    await request.Db.Todos.FindAsync(request.Id)
        is Todo todo
            ? Results.Ok(new TodoItemDTO(todo))
            : Results.NotFound());

Liên kết tùy chỉnh (Custom Binding)

Có ba cách để tùy chỉnh parameter binding:

  1. Với route, query và header binding source, liên kết kiểu tùy chỉnh bằng cách thêm phương thức static TryParse cho kiểu đó.
  2. Kiểm soát quá trình liên kết bằng cách triển khai phương thức BindAsync trên kiểu.
  3. Với các tình huống nâng cao, triển khai interface IBindableFromHttpContext<TSelf>.

TryParse

csharp
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// GET /map?Point=12.3,10.1
app.MapGet("/map", (Point point) => $"Point: {point.X}, {point.Y}");

app.Run();

public class Point
{
    public double X { get; set; }
    public double Y { get; set; }

    public static bool TryParse(string? value, IFormatProvider? provider,
                                out Point? point)
    {
        // Định dạng là "(12.3,10.1)"
        var trimmedValue = value?.TrimStart('(').TrimEnd(')');
        var segments = trimmedValue?.Split(',',
                StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        if (segments?.Length == 2
            && double.TryParse(segments[0], out var x)
            && double.TryParse(segments[1], out var y))
        {
            point = new Point { X = x, Y = y };
            return true;
        }

        point = null;
        return false;
    }
}

BindAsync

csharp
using System.Reflection;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// GET /products?SortBy=xyz&SortDir=Desc&Page=99
app.MapGet("/products", (PagingData pageData) => $"SortBy:{pageData.SortBy}, " +
       $"SortDirection:{pageData.SortDirection}, CurrentPage:{pageData.CurrentPage}");

app.Run();

public class PagingData
{
    public string? SortBy { get; init; }
    public SortDirection SortDirection { get; init; }
    public int CurrentPage { get; init; } = 1;

    public static ValueTask<PagingData?> BindAsync(HttpContext context,
                                                   ParameterInfo parameter)
    {
        const string sortByKey = "sortBy";
        const string sortDirectionKey = "sortDir";
        const string currentPageKey = "page";

        Enum.TryParse<SortDirection>(context.Request.Query[sortDirectionKey],
                                     ignoreCase: true, out var sortDirection);
        int.TryParse(context.Request.Query[currentPageKey], out var page);
        page = page == 0 ? 1 : page;

        var result = new PagingData
        {
            SortBy = context.Request.Query[sortByKey],
            SortDirection = sortDirection,
            CurrentPage = page
        };

        return ValueTask.FromResult<PagingData?>(result);
    }
}

public enum SortDirection
{
    Default,
    Asc,
    Desc
}

Lỗi liên kết (Binding Failures)

Khi binding thất bại, framework ghi log debug message và trả về status code khác nhau tùy theo loại lỗi:

Loại lỗiKiểu tham số nullableBinding SourceStatus code
{ParameterType}.TryParse trả về falseroute/query/header400
{ParameterType}.BindAsync trả về nullcustom400
{ParameterType}.BindAsync ném exceptionbất kỳcustom500
Lỗi deserialize JSON bodybất kỳbody400
Sai content type (không phải application/json)bất kỳbody415

Thứ tự ưu tiên liên kết (Binding Precedence)

Các quy tắc xác định binding source từ tham số:

  1. Attribute tường minh được định nghĩa trên tham số (From\* attributes) theo thứ tự sau:
  2. Route values: [FromRoute]
  3. Query string: [FromQuery]
  4. Header: [FromHeader]
  5. Body: [FromBody]
  6. Form: [FromForm]
  7. Service: [FromServices]
  8. Parameter values: [AsParameters]
  9. Các kiểu đặc biệt
  10. HttpContext
  11. HttpRequest
  12. HttpResponse
  13. ClaimsPrincipal
  14. CancellationToken
  15. IFormCollection
  16. IFormFileCollection
  17. IFormFile
  18. Stream
  19. PipeReader
  20. Kiểu tham số có phương thức BindAsync static hợp lệ.
  21. Kiểu tham số là string hoặc có phương thức TryParse static hợp lệ.
  22. Nếu kiểu tham số là service được cung cấp bởi dependency injection, sử dụng service đó.
  23. Tham số lấy từ body.

Cấu hình JSON deserialization options cho body binding

Cấu hình JSON deserialization options toàn cục

csharp
var builder = WebApplication.CreateBuilder(args);

builder.Services.ConfigureHttpJsonOptions(options => {
    options.SerializerOptions.WriteIndented = true;
    options.SerializerOptions.IncludeFields = true;
});

var app = builder.Build();

app.MapPost("/", (Todo todo) => {
    if (todo is not null) {
        todo.Name = todo.NameField;
    }
    return todo;
});

app.Run();

class Todo {
    public string? Name { get; set; }
    public string? NameField;
    public bool IsComplete { get; set; }
}

Cấu hình JSON deserialization options cho endpoint

csharp
using System.Text.Json;

var app = WebApplication.Create();

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { 
    IncludeFields = true, 
    WriteIndented = true
};

app.MapPost("/", async (HttpContext context) => {
    if (context.Request.HasJsonContentType()) {
        var todo = await context.Request.ReadFromJsonAsync<Todo>(options);
        if (todo is not null) {
            todo.Name = todo.NameField;
        }
        return Results.Ok(todo);
    }
    else {
        return Results.BadRequest();
    }
});

app.Run();

Đọc request body

Đọc request body trực tiếp dùng tham số HttpContext hoặc HttpRequest:

csharp
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapPost("/uploadstream", async (IConfiguration config, HttpRequest request) =>
{
    var filePath = Path.Combine(config["StoredFilesPath"], Path.GetRandomFileName());

    await using var writeStream = File.Create(filePath);
    await request.BodyReader.CopyToAsync(writeStream);
});

app.Run();