共用方式為


最小 API 應用程式中的參數繫結

注意

這不是這篇文章的最新版本。 如需目前的版本,請參閱 本文的 .NET 9 版本。

警告

不再支援此版本的 ASP.NET Core。 如需詳細資訊,請參閱 .NET 和 .NET Core 支持原則。 如需目前的版本,請參閱 本文的 .NET 9 版本。

重要

這些發行前產品的相關資訊在產品正式發行前可能會有大幅修改。 Microsoft 對此處提供的資訊,不做任何明確或隱含的瑕疵擔保。

如需目前的版本,請參閱 本文的 .NET 9 版本。

參數繫結是將要求資料轉換成依路由處理常式表示的強型別參數的程序。 繫結來源會決定參數的繫結來源位置。 繫結來源可以是明確或根據 HTTP 方法和參數型別推斷。

支援的繫結來源:

  • 路由值
  • 查詢字串
  • 頁首
  • 本文 (JSON 格式)
  • 表單值
  • 依相依性插入提供的服務
  • 自訂

下列 GET 路由處理常式會使用以下其中一些參數繫結來源:

var builder = WebApplication.CreateBuilder(args);

// Added as 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 { }

下表顯示上述範例中所使用參數與關聯繫結來源之間的關聯性。

參數 繫結來源
id 路由值
page 來回應
customHeader 標頭
service 由相依性插入提供

HTTP 方法 GETHEADOPTIONSDELETE 不會隱含從本文繫結。 若要從本文 (JSON 格式) 繫結這些 HTTP 方法,請明確繫結 [FromBody] 或從 HttpRequest 讀取。

下列範例 POST 路由處理常式會針對 person 參數使用本文 (JSON 格式) 的繫結來源:

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

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

record Person(string Name, int Age);

上述範例中的參數全都會自動從要求資料繫結。 為了示範參數繫結所提供的便利性,下列路由處理常式將示範如何直接從要求讀取要求資料:

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>();

    // ...
});

明確參數繫結

屬性可用來明確宣告參數繫結的來源位置。

using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

// Added as 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);
參數 繫結來源
id 具有名稱 id 的路由值
page 具有名稱 "p" 的查詢字串
service 由相依性插入提供
contentType 具有名稱 "Content-Type" 的標頭

從表單值明確繫結

[FromForm] 屬性會繫結表單值:

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();
});

// Remaining code removed for brevity.

替代方式是使用具有的自訂型別屬性以 [FromForm] 註解的 [AsParameters] 屬性。 例如,下列程式碼會從表單值繫結至 NewTodoRequest 記錄結構的屬性:

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();
});

// Remaining code removed for brevity.
public record struct NewTodoRequest([FromForm] string Name,
    [FromForm] Visibility Visibility, IFormFile? Attachment);

如需詳細資訊,請參閱本文稍後的 AsParameters 一節。

完整的範例程式碼位於 AspNetCore.Docs.Samples 存放庫中。

從 IFormFile 和 IFormFileCollection 保護繫結

您可以透過 [FromForm] 使用 IFormFileIFormFileCollection 支援複雜的表單繫結:

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();

// Generate a form with an anti-forgery token and an /upload endpoint.
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($"Your file with the description:" +
        $" {fileUploadForm.Description} has been uploaded successfully");
});

app.Run();

[FromForm] 繫結至要求的參數包含防偽權杖。 處理要求時,會驗證防偽權杖。 如需詳細資訊,請參閱使用基本 API 進行防偽

如需詳細資訊,請參閱基本 API 中的表單繫結

完整的範例程式碼位於 AspNetCore.Docs.Samples 存放庫中。

具有相依性插入的參數繫結

將型別設定為服務時,基本 API 的參數繫結會透過相依性插入來繫結參數。 不需要將 [FromServices] 屬性明確套用至參數。 在下列程式碼中,這兩個動作都會傳回時間:

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();

選擇性參數

路由處理常式中宣告的參數會視為必要:

  • 如果要求符合路由,則只有在要求中提供所有必要的參數時,路由處理常式才會執行。
  • 無法提供所有必要參數會導致錯誤。
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

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

app.Run();
URI result
/products?pageNumber=3 已傳回 3 項
/products BadHttpRequestException:查詢字串中未提供必要的參數 "int pageNumber"。
/products/1 HTTP 404 錯誤,沒有相符的路由

若要將 pageNumber 設為選用,請將型別定義為選用或提供預設值:

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();
URI result
/products?pageNumber=3 已傳回 3 項
/products 已傳回 1 項
/products2 已傳回 1 項

上述可為 Null 且預設值適用所有來源:

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

app.MapPost("/products", (Product? product) => { });

app.Run();

如果未傳送任何要求本文,上述程式碼會使用 Null 產品呼叫方法。

注意:如果提供無效資料且參數可為 Null,則路由處理常式不會執行。

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

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

app.Run();
URI result
/products?pageNumber=3 3 傳回
/products 1 傳回
/products?pageNumber=two BadHttpRequestException:無法從 "two" 繫結參數 "Nullable<int> pageNumber"
/products/two HTTP 404 錯誤,沒有相符的路由

如需詳細資訊,請參閱繫結失敗一節。

特殊型別

下列型別會在沒有明確屬性的情況下繫結:

  • HttpContext:保留目前 HTTP 要求或回應所有相關資訊的內容:

    app.MapGet("/", (HttpContext context) => context.Response.WriteAsync("Hello World"));
    
  • HttpRequestHttpResponse:HTTP 要求和 HTTP 回應:

    app.MapGet("/", (HttpRequest request, HttpResponse response) =>
        response.WriteAsync($"Hello World {request.Query["name"]}"));
    
  • CancellationToken:與目前 HTTP 要求相關聯的取消權杖:

    app.MapGet("/", async (CancellationToken cancellationToken) => 
        await MakeLongRunningRequestAsync(cancellationToken));
    
  • ClaimsPrincipal:與要求相關聯的使用者,繫結自 HttpContext.User

    app.MapGet("/", (ClaimsPrincipal user) => user.Identity.Name);
    

將要求本文繫結為 StreamPipeReader

要求本文可以繫結為 StreamPipeReader,以有效率地支援使用者必須處理資料的案例,並:

  • 將資料儲存至 Blob 儲存體,或將資料加入佇列提供者的佇列。
  • 使用背景工作處理序或雲端函式處理儲存的資料。

例如,資料可能會加入佇列至 Azure 佇列儲存體,或儲存在 Azure Blob 儲存體中。

下列程式碼會實作背景佇列:

using System.Text.Json;
using System.Threading.Channels;

namespace BackgroundQueueService;

class BackgroundQueue : BackgroundService
{
    private readonly Channel<ReadOnlyMemory<byte>> _queue;
    private readonly ILogger<BackgroundQueue> _logger;

    public BackgroundQueue(Channel<ReadOnlyMemory<byte>> queue,
                               ILogger<BackgroundQueue> logger)
    {
        _queue = queue;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var dataStream in _queue.Reader.ReadAllAsync(stoppingToken))
        {
            try
            {
                var person = JsonSerializer.Deserialize<Person>(dataStream.Span)!;
                _logger.LogInformation($"{person.Name} is {person.Age} " +
                                       $"years and from {person.Country}");
            }
            catch (Exception ex)
            {
                _logger.LogError(ex.Message);
            }
        }
    }
}

class Person
{
    public string Name { get; set; } = String.Empty;
    public int Age { get; set; }
    public string Country { get; set; } = String.Empty;
}

下列程式碼會將要求本文繫結至 Stream

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

    // We're not above the message size and we have a content length, or
    // we're a chunked request and we're going to read up to the maxMessageSize + 1. 
    // We add one to the message size so that we can detect when a chunked request body
    // is bigger than our configured max.
    var readSize = (int?)req.ContentLength ?? (maxMessageSize + 1);

    var buffer = new byte[readSize];

    // Read at least that many bytes from the body.
    var read = await body.ReadAtLeastAsync(buffer, readSize, throwOnEndOfStream: false);

    // We read more than the max, so this is a bad request.
    if (read > maxMessageSize)
    {
        return Results.BadRequest();
    }

    // Attempt to send the buffer to the background queue.
    if (queue.Writer.TryWrite(buffer.AsMemory(0..read)))
    {
        return Results.Accepted();
    }

    // We couldn't accept the message since we're overloaded.
    return Results.StatusCode(StatusCodes.Status429TooManyRequests);
});

下列程式碼會顯示完整的 Program.cs 檔案:

using System.Threading.Channels;
using BackgroundQueueService;

var builder = WebApplication.CreateBuilder(args);
// The max memory to use for the upload endpoint on this instance.
var maxMemory = 500 * 1024 * 1024;

// The max size of a single message, staying below the default LOH size of 85K.
var maxMessageSize = 80 * 1024;

// The max size of the queue based on those restrictions
var maxQueueSize = maxMemory / maxMessageSize;

// Create a channel to send data to the background queue.
builder.Services.AddSingleton<Channel<ReadOnlyMemory<byte>>>((_) =>
                     Channel.CreateBounded<ReadOnlyMemory<byte>>(maxQueueSize));

// Create a background queue service.
builder.Services.AddHostedService<BackgroundQueue>();
var app = builder.Build();

// curl --request POST 'https://localhost:<port>/register' --header 'Content-Type: application/json' --data-raw '{ "Name":"Samson", "Age": 23, "Country":"Nigeria" }'
// curl --request POST "https://localhost:<port>/register" --header "Content-Type: application/json" --data-raw "{ \"Name\":\"Samson\", \"Age\": 23, \"Country\":\"Nigeria\" }"
app.MapPost("/register", async (HttpRequest req, Stream body,
                                 Channel<ReadOnlyMemory<byte>> queue) =>
{
    if (req.ContentLength is not null && req.ContentLength > maxMessageSize)
    {
        return Results.BadRequest();
    }

    // We're not above the message size and we have a content length, or
    // we're a chunked request and we're going to read up to the maxMessageSize + 1. 
    // We add one to the message size so that we can detect when a chunked request body
    // is bigger than our configured max.
    var readSize = (int?)req.ContentLength ?? (maxMessageSize + 1);

    var buffer = new byte[readSize];

    // Read at least that many bytes from the body.
    var read = await body.ReadAtLeastAsync(buffer, readSize, throwOnEndOfStream: false);

    // We read more than the max, so this is a bad request.
    if (read > maxMessageSize)
    {
        return Results.BadRequest();
    }

    // Attempt to send the buffer to the background queue.
    if (queue.Writer.TryWrite(buffer.AsMemory(0..read)))
    {
        return Results.Accepted();
    }

    // We couldn't accept the message since we're overloaded.
    return Results.StatusCode(StatusCodes.Status429TooManyRequests);
});

app.Run();
  • 讀取資料時,StreamHttpRequest.Body 是相同的物件。
  • 預設不會緩衝處理要求本文。 讀取本文之後,將無法倒轉。 無法讀取資料流多次。
  • StreamPipeReader 無法在基本動作處理常式之外使用,因為基礎緩衝區將會受到處置或重複使用。

使用 IFormFile 和 IFormFileCollection 上傳檔案

下列程式碼會使用 IFormFileIFormFileCollection 來上傳檔案:

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();

支援使用授權標頭用戶端憑證或 cookie 標頭的已驗證檔案上傳要求。

使用 IFormCollection、IFormFile 和 IFormFileCollection 繫結至表單

支援使用 IFormCollectionIFormFileIFormFileCollection 從表單型參數繫結。 OpenAPI 中繼資料會推斷為表單參數,可支援與 Swagger UI 的整合。

下列程式碼會使用來自 IFormFile 型別的推斷繫結來上傳檔案:

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

var builder = WebApplication.CreateBuilder();

builder.Services.AddAntiforgery();

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

string GetOrCreateFilePath(string fileName, string filesDirectory = "uploadFiles")
{
    var directoryPath = Path.Combine(app.Environment.ContentRootPath, filesDirectory);
    Directory.CreateDirectory(directoryPath);
    return Path.Combine(directoryPath, fileName);
}

async Task UploadFileWithName(IFormFile file, string fileSaveName)
{
    var filePath = GetOrCreateFilePath(fileSaveName);
    await using var fileStream = new FileStream(filePath, FileMode.Create);
    await file.CopyToAsync(fileStream);
}

app.MapGet("/", (HttpContext context, IAntiforgery antiforgery) =>
{
    var token = antiforgery.GetAndStoreTokens(context);
    var html = $"""
      <html>
        <body>
          <form action="/upload" method="POST" enctype="multipart/form-data">
            <input name="{token.FormFieldName}" type="hidden" value="{token.RequestToken}"/>
            <input type="file" name="file" placeholder="Upload an image..." accept=".jpg, 
                                                                            .jpeg, .png" />
            <input type="submit" />
          </form> 
        </body>
      </html>
    """;

    return Results.Content(html, "text/html");
});

app.MapPost("/upload", async Task<Results<Ok<string>,
   BadRequest<string>>> (IFormFile file, HttpContext context, IAntiforgery antiforgery) =>
{
    var fileSaveName = Guid.NewGuid().ToString("N") + Path.GetExtension(file.FileName);
    await UploadFileWithName(file, fileSaveName);
    return TypedResults.Ok("File uploaded successfully!");
});

app.Run();

警告:實作表單時,應用程式必須防止 跨網站偽造要求 (XSRF/CSRF) 攻擊。 在上述程式碼中,IAntiforgery 服務可藉由產生和驗證防偽造權杖來防止 XSRF 攻擊:

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

var builder = WebApplication.CreateBuilder();

builder.Services.AddAntiforgery();

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

string GetOrCreateFilePath(string fileName, string filesDirectory = "uploadFiles")
{
    var directoryPath = Path.Combine(app.Environment.ContentRootPath, filesDirectory);
    Directory.CreateDirectory(directoryPath);
    return Path.Combine(directoryPath, fileName);
}

async Task UploadFileWithName(IFormFile file, string fileSaveName)
{
    var filePath = GetOrCreateFilePath(fileSaveName);
    await using var fileStream = new FileStream(filePath, FileMode.Create);
    await file.CopyToAsync(fileStream);
}

app.MapGet("/", (HttpContext context, IAntiforgery antiforgery) =>
{
    var token = antiforgery.GetAndStoreTokens(context);
    var html = $"""
      <html>
        <body>
          <form action="/upload" method="POST" enctype="multipart/form-data">
            <input name="{token.FormFieldName}" type="hidden" value="{token.RequestToken}"/>
            <input type="file" name="file" placeholder="Upload an image..." accept=".jpg, 
                                                                            .jpeg, .png" />
            <input type="submit" />
          </form> 
        </body>
      </html>
    """;

    return Results.Content(html, "text/html");
});

app.MapPost("/upload", async Task<Results<Ok<string>,
   BadRequest<string>>> (IFormFile file, HttpContext context, IAntiforgery antiforgery) =>
{
    var fileSaveName = Guid.NewGuid().ToString("N") + Path.GetExtension(file.FileName);
    await UploadFileWithName(file, fileSaveName);
    return TypedResults.Ok("File uploaded successfully!");
});

app.Run();

如需 XSRF 攻擊的詳細資訊,請參閱使用基本 API 的防偽造功能

如需詳細資訊,請參閱基本 API 中的表單繫結

繫結至表單中的集合和複雜型別

繫結支援下列項目:

  • 集合,例如清單字典
  • 複雜類型,例如 TodoProject

下列程式碼顯示:

  • 將多部分表單輸入繫結至複雜物件的基本端點。
  • 如何使用防偽服務來支援反偽權杖的產生和驗證。
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAntiforgery();

var app = builder.Build();

app.UseAntiforgery();

app.MapGet("/", (HttpContext context, IAntiforgery antiforgery) =>
{
    var token = antiforgery.GetAndStoreTokens(context);
    var html = $"""
        <html><body>
           <form action="/todo" method="POST" enctype="multipart/form-data">
               <input name="{token.FormFieldName}" 
                                type="hidden" value="{token.RequestToken}" />
               <input type="text" name="name" />
               <input type="date" name="dueDate" />
               <input type="checkbox" name="isCompleted" value="true" />
               <input type="submit" />
               <input name="isCompleted" type="hidden" value="false" /> 
           </form>
        </body></html>
    """;
    return Results.Content(html, "text/html");
});

app.MapPost("/todo", async Task<Results<Ok<Todo>, BadRequest<string>>> 
               ([FromForm] Todo todo, HttpContext context, IAntiforgery antiforgery) =>
{
    try
    {
        await antiforgery.ValidateRequestAsync(context);
        return TypedResults.Ok(todo);
    }
    catch (AntiforgeryValidationException e)
    {
        return TypedResults.BadRequest("Invalid antiforgery token");
    }
});

app.Run();

class Todo
{
    public string Name { get; set; } = string.Empty;
    public bool IsCompleted { get; set; } = false;
    public DateTime DueDate { get; set; } = DateTime.Now.Add(TimeSpan.FromDays(1));
}

在上述程式碼中:

  • 目標參數必須[FromForm] 屬性標註,以釐清應從 JSON 本文讀取的參數。
  • 對於使用要求委派產生器編譯的基本 API,支援從複雜或集合型別繫結。
  • 標記會顯示額外的隱藏輸入,其名稱為 isCompleted,且值為 false。 如果在提交表單時核取 isCompleted 核取方塊,則 truefalse 兩個值都會當作值提交。 如果未核取此核取方塊,則只會提交隱藏的輸入值 false。 ASP.NET Core 模型繫結程序在繫結到 bool 值時僅讀取第一個值,這會導致核取的核取方塊為 true,未核取的核取方塊為 false

提交至上述端點之表單資料的範例如下所示:

__RequestVerificationToken: CfDJ8Bveip67DklJm5vI2PF2VOUZ594RC8kcGWpTnVV17zCLZi1yrs-CSz426ZRRrQnEJ0gybB0AD7hTU-0EGJXDU-OaJaktgAtWLIaaEWMOWCkoxYYm-9U9eLV7INSUrQ6yBHqdMEE_aJpD4AI72gYiCqc
name: Walk the dog
dueDate: 2024-04-06
isCompleted: true
isCompleted: false

從標頭和查詢字串繫結陣列和字串值

下列程式碼會示範將查詢字串繫結至簡單型別、字串陣列和 StringValues 的陣列:

// Bind query string values to a primitive type array.
// GET  /tags?q=1&q=2&q=3
app.MapGet("/tags", (int[] q) =>
                      $"tag1: {q[0]} , tag2: {q[1]}, tag3: {q[2]}");

// Bind to a string array.
// GET /tags2?names=john&names=jack&names=jane
app.MapGet("/tags2", (string[] names) =>
            $"tag1: {names[0]} , tag2: {names[1]}, tag3: {names[2]}");

// Bind to StringValues.
// GET /tags3?names=john&names=jack&names=jane
app.MapGet("/tags3", (StringValues names) =>
            $"tag1: {names[0]} , tag2: {names[1]}, tag3: {names[2]}");

型別已實作 TryParse 時,支援將查詢字串或標頭值繫結至複雜型別的陣列。 下列程式碼會繫結至字串陣列,並傳回具有指定標記的所有項目:

// GET /todoitems/tags?tags=home&tags=work
app.MapGet("/todoitems/tags", async (Tag[] tags, TodoDb db) =>
{
    return await db.Todos
        .Where(t => tags.Select(i => i.Name).Contains(t.Tag.Name))
        .ToListAsync();
});

下列程式碼會顯示模型和必要的 TryParse 實作:

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

    // This is an owned entity. 
    public Tag Tag { get; set; } = new();
}

[Owned]
public class Tag
{
    public string? Name { get; set; } = "n/a";

    public static bool TryParse(string? name, out Tag tag)
    {
        if (name is null)
        {
            tag = default!;
            return false;
        }

        tag = new Tag { Name = name };
        return true;
    }
}

下列程式碼會繫結至 int 陣列:

// GET /todoitems/query-string-ids?ids=1&ids=3
app.MapGet("/todoitems/query-string-ids", async (int[] ids, TodoDb db) =>
{
    return await db.Todos
        .Where(t => ids.Contains(t.Id))
        .ToListAsync();
});

若要測試上述程式碼,請新增下列端點以將 Todo 項目填入資料庫:

// POST /todoitems/batch
app.MapPost("/todoitems/batch", async (Todo[] todos, TodoDb db) =>
{
    await db.Todos.AddRangeAsync(todos);
    await db.SaveChangesAsync();

    return Results.Ok(todos);
});

使用 HttpRepl 之類的工具,將下列資料傳遞至先前的端點:

[
    {
        "id": 1,
        "name": "Have Breakfast",
        "isComplete": true,
        "tag": {
            "name": "home"
        }
    },
    {
        "id": 2,
        "name": "Have Lunch",
        "isComplete": true,
        "tag": {
            "name": "work"
        }
    },
    {
        "id": 3,
        "name": "Have Supper",
        "isComplete": true,
        "tag": {
            "name": "home"
        }
    },
    {
        "id": 4,
        "name": "Have Snacks",
        "isComplete": true,
        "tag": {
            "name": "N/A"
        }
    }
]

下列程式碼會繫結至標頭索引鍵 X-Todo-Id,並傳回具有相符 Id 值的 Todo 項目:

// GET /todoitems/header-ids
// The keys of the headers should all be X-Todo-Id with different values
app.MapGet("/todoitems/header-ids", async ([FromHeader(Name = "X-Todo-Id")] int[] ids, TodoDb db) =>
{
    return await db.Todos
        .Where(t => ids.Contains(t.Id))
        .ToListAsync();
});

注意

從查詢字串繫結 string[] 時,沒有任何相符的查詢字串值會導致空陣列,而不是 Null 值。

具有 [AsParameters] 引數清單的參數繫結

AsParametersAttribute 可實現型別的簡單參數繫結,而不是複雜或遞迴模型繫結。

請考慮下列程式碼:

using Microsoft.EntityFrameworkCore;
using TodoApi.Models;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
var app = builder.Build();

app.MapGet("/todoitems", async (TodoDb db) =>
    await db.Todos.Select(x => new TodoItemDTO(x)).ToListAsync());

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

考慮下列 GET 端點:

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 可用來取代上述強調顯示的參數:

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

重構的 GET 端點會使用上述 struct 搭配 AsParameters 屬性:

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());

下列程式碼會顯示應用程式中的其他端點:

app.MapPost("/todoitems", async (TodoItemDTO Dto, TodoDb Db) =>
{
    var todoItem = new Todo
    {
        IsComplete = Dto.IsComplete,
        Name = Dto.Name
    };

    Db.Todos.Add(todoItem);
    await Db.SaveChangesAsync();

    return Results.Created($"/todoitems/{todoItem.Id}", new TodoItemDTO(todoItem));
});

app.MapPut("/todoitems/{id}", async (int Id, TodoItemDTO Dto, TodoDb Db) =>
{
    var todo = await Db.Todos.FindAsync(Id);

    if (todo is null) return Results.NotFound();

    todo.Name = Dto.Name;
    todo.IsComplete = Dto.IsComplete;

    await Db.SaveChangesAsync();

    return Results.NoContent();
});

app.MapDelete("/todoitems/{id}", async (int Id, TodoDb Db) =>
{
    if (await Db.Todos.FindAsync(Id) is Todo todo)
    {
        Db.Todos.Remove(todo);
        await Db.SaveChangesAsync();
        return Results.Ok(new TodoItemDTO(todo));
    }

    return Results.NotFound();
});

下列類別可用來重構參數清單:

class CreateTodoItemRequest
{
    public TodoItemDTO Dto { get; set; } = default!;
    public TodoDb Db { get; set; } = default!;
}

class EditTodoItemRequest
{
    public int Id { get; set; }
    public TodoItemDTO Dto { get; set; } = default!;
    public TodoDb Db { get; set; } = default!;
}

下列程式碼會顯示使用 AsParameters 和上述 struct 與類別的重構端點:

app.MapPost("/ap/todoitems", async ([AsParameters] CreateTodoItemRequest request) =>
{
    var todoItem = new Todo
    {
        IsComplete = request.Dto.IsComplete,
        Name = request.Dto.Name
    };

    request.Db.Todos.Add(todoItem);
    await request.Db.SaveChangesAsync();

    return Results.Created($"/todoitems/{todoItem.Id}", new TodoItemDTO(todoItem));
});

app.MapPut("/ap/todoitems/{id}", async ([AsParameters] EditTodoItemRequest request) =>
{
    var todo = await request.Db.Todos.FindAsync(request.Id);

    if (todo is null) return Results.NotFound();

    todo.Name = request.Dto.Name;
    todo.IsComplete = request.Dto.IsComplete;

    await request.Db.SaveChangesAsync();

    return Results.NoContent();
});

app.MapDelete("/ap/todoitems/{id}", async ([AsParameters] TodoItemRequest request) =>
{
    if (await request.Db.Todos.FindAsync(request.Id) is Todo todo)
    {
        request.Db.Todos.Remove(todo);
        await request.Db.SaveChangesAsync();
        return Results.Ok(new TodoItemDTO(todo));
    }

    return Results.NotFound();
});

下列 record 型別可用來取代上述參數:

record TodoItemRequest(int Id, TodoDb Db);
record CreateTodoItemRequest(TodoItemDTO Dto, TodoDb Db);
record EditTodoItemRequest(int Id, TodoItemDTO Dto, TodoDb Db);

使用 struct 搭配 AsParameters 比使用 record 型別更有效果。

AspNetCore.Docs.Samples 存放庫中的完整範例程式碼

自訂繫結

自訂參數繫結的方式有兩個:

  1. 針對路由、查詢和標頭繫結來源,可藉由新增型別的靜態 TryParse 方法來繫結自訂型別。
  2. 藉由在型別上實作 BindAsync 方法來控制繫結程序。

TryParse

TryParse 有兩個 API:

public static bool TryParse(string value, out T result);
public static bool TryParse(string value, IFormatProvider provider, out T result);

下列程式碼會以 URI /map?Point=12.3,10.1 顯示 Point: 12.3, 10.1

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)
    {
        // Format is "(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

BindAsync 有下列 API:

public static ValueTask<T?> BindAsync(HttpContext context, ParameterInfo parameter);
public static ValueTask<T?> BindAsync(HttpContext context);

下列程式碼會以 URI /products?SortBy=xyz&SortDir=Desc&Page=99 顯示 SortBy:xyz, SortDirection:Desc, CurrentPage:99

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
}

繫結失敗

繫結失敗時,架構會記錄偵錯訊息,並根據失敗模式將各種狀態碼傳回至用戶端。

失敗模式 可為 Null 的參數型別 繫結來源 狀態碼
{ParameterType}.TryParse 傳回 false route/query/header 400
{ParameterType}.BindAsync 傳回 null custom 400
{ParameterType}.BindAsync 擲回 不重要 custom 500
無法還原序列化 JSON 本文 不重要 本文 400
錯誤的內容類型 (非 application/json) 不重要 本文 415

繫結優先順序

從參數判斷繫結來源的規則:

  1. 如下順序在參數上定義的明確屬性 (From* 屬性):
    1. 路由值:[FromRoute]
    2. 查詢字串:[FromQuery]
    3. 標題:[FromHeader]
    4. 本文:[FromBody]
    5. 表單:[FromForm]
    6. 服務:[FromServices]
    7. 參數值:[AsParameters]
  2. 特殊型別
    1. HttpContext
    2. HttpRequestHttpContext.Request
    3. HttpResponseHttpContext.Response
    4. ClaimsPrincipalHttpContext.User
    5. CancellationTokenHttpContext.RequestAborted
    6. IFormCollectionHttpContext.Request.Form
    7. IFormFileCollectionHttpContext.Request.Form.Files
    8. IFormFileHttpContext.Request.Form.Files[paramName]
    9. StreamHttpContext.Request.Body
    10. PipeReaderHttpContext.Request.BodyReader
  3. 參數型別具有有效的靜態 BindAsync 方法。
  4. 參數型別是字串或具有有效的靜態 TryParse 方法。
    1. 如果參數名稱存在於路由範本中 (例如 app.Map("/todo/{id}", (int id) => {});),則會從路由繫結。
    2. 從查詢字串繫結。
  5. 如果參數型別是相依性插入所提供的服務,它會使用該服務作為來源。
  6. 參數來自本文。

設定本文繫結的 JSON 還原序列化選項

本文繫結來源會使用 System.Text.Json 進行還原序列化。 無法變更此預設值,但可以設定 JSON 序列化和還原序列化選項。

全域設定 JSON 還原序列化選項

您可以叫用 ConfigureHttpJsonOptions 來設定要全域套用至應用程式的選項。 下列範例包含公用欄位和格式 JSON 輸出。

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; }
}
// If the request body contains the following JSON:
//
// {"nameField":"Walk dog", "isComplete":false}
//
// The endpoint returns the following JSON:
//
// {
//    "name":"Walk dog",
//    "nameField":"Walk dog",
//    "isComplete":false
// }

由於範例程式碼會同時設定序列化和還原序列化,因此其可以讀取 NameField 並將 NameField 包含在輸出的 JSON 中。

設定端點的 JSON 還原序列化選項

ReadFromJsonAsync 具有會接受 JsonSerializerOptions 物件的多載。 下列範例包含公用欄位和格式 JSON 輸出。

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();

class Todo
{
    public string? Name { get; set; }
    public string? NameField;
    public bool IsComplete { get; set; }
}
// If the request body contains the following JSON:
//
// {"nameField":"Walk dog", "isComplete":false}
//
// The endpoint returns the following JSON:
//
// {
//    "name":"Walk dog",
//    "isComplete":false
// }

由於上述程式碼僅會將自訂選項套用至還原序列化,因此輸出 JSON 會排除 NameField

讀取要求本文

使用 HttpContextHttpRequest 參數直接讀取要求本文:

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();

上述 程式碼:

參數繫結是將要求資料轉換成依路由處理常式表示的強型別參數的程序。 繫結來源會決定參數的繫結來源位置。 繫結來源可以是明確或根據 HTTP 方法和參數型別推斷。

支援的繫結來源:

  • 路由值
  • 查詢字串
  • 頁首
  • 本文 (JSON 格式)
  • 依相依性插入提供的服務
  • 自訂

.NET 6 和 7 中原生支援從表單值繫結。

下列 GET 路由處理常式會使用以下其中一些參數繫結來源:

var builder = WebApplication.CreateBuilder(args);

// Added as 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 { }

下表顯示上述範例中所使用參數與關聯繫結來源之間的關聯性。

參數 繫結來源
id 路由值
page 來回應
customHeader 標頭
service 由相依性插入提供

HTTP 方法 GETHEADOPTIONSDELETE 不會隱含從本文繫結。 若要從本文 (JSON 格式) 繫結這些 HTTP 方法,請明確繫結 [FromBody] 或從 HttpRequest 讀取。

下列範例 POST 路由處理常式會針對 person 參數使用本文 (JSON 格式) 的繫結來源:

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

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

record Person(string Name, int Age);

上述範例中的參數全都會自動從要求資料繫結。 為了示範參數繫結所提供的便利性,下列路由處理常式將示範如何直接從要求讀取要求資料:

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>();

    // ...
});

明確參數繫結

屬性可用來明確宣告參數繫結的來源位置。

using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

// Added as 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);
參數 繫結來源
id 具有名稱 id 的路由值
page 具有名稱 "p" 的查詢字串
service 由相依性插入提供
contentType 具有名稱 "Content-Type" 的標頭

注意

.NET 6 和 7 中原生支援從表單值繫結。

具有相依性插入的參數繫結

將型別設定為服務時,基本 API 的參數繫結會透過相依性插入來繫結參數。 不需要將 [FromServices] 屬性明確套用至參數。 在下列程式碼中,這兩個動作都會傳回時間:

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();

選擇性參數

路由處理常式中宣告的參數會視為必要:

  • 如果要求符合路由,則只有在要求中提供所有必要的參數時,路由處理常式才會執行。
  • 無法提供所有必要參數會導致錯誤。
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

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

app.Run();
URI result
/products?pageNumber=3 已傳回 3 項
/products BadHttpRequestException:查詢字串未提供必要的參數 "int pageNumber"。
/products/1 HTTP 404 錯誤,沒有相符的路由

若要將 pageNumber 設為選用,請將型別定義為選用或提供預設值:

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();
URI result
/products?pageNumber=3 已傳回 3 項
/products 已傳回 1 項
/products2 已傳回 1 項

上述可為 Null 且預設值適用所有來源:

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

app.MapPost("/products", (Product? product) => { });

app.Run();

如果未傳送任何要求本文,上述程式碼會使用 Null 產品呼叫方法。

注意:如果提供無效資料且參數可為 Null,則路由處理常式不會執行。

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

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

app.Run();
URI result
/products?pageNumber=3 3 傳回
/products 1 傳回
/products?pageNumber=two BadHttpRequestException:無法從 "two" 繫結參數 "Nullable<int> pageNumber"
/products/two HTTP 404 錯誤,沒有相符的路由

如需詳細資訊,請參閱繫結失敗一節。

特殊型別

下列型別會在沒有明確屬性的情況下繫結:

  • HttpContext:保留目前 HTTP 要求或回應所有相關資訊的內容:

    app.MapGet("/", (HttpContext context) => context.Response.WriteAsync("Hello World"));
    
  • HttpRequestHttpResponse:HTTP 要求和 HTTP 回應:

    app.MapGet("/", (HttpRequest request, HttpResponse response) =>
        response.WriteAsync($"Hello World {request.Query["name"]}"));
    
  • CancellationToken:與目前 HTTP 要求相關聯的取消權杖:

    app.MapGet("/", async (CancellationToken cancellationToken) => 
        await MakeLongRunningRequestAsync(cancellationToken));
    
  • ClaimsPrincipal:與要求相關聯的使用者,繫結自 HttpContext.User

    app.MapGet("/", (ClaimsPrincipal user) => user.Identity.Name);
    

將要求本文繫結為 StreamPipeReader

要求本文可以繫結為 StreamPipeReader,以有效率地支援使用者必須處理資料的案例,並:

  • 將資料儲存至 Blob 儲存體,或將資料加入佇列提供者的佇列。
  • 使用背景工作處理序或雲端函式處理儲存的資料。

例如,資料可能會加入佇列至 Azure 佇列儲存體,或儲存在 Azure Blob 儲存體中。

下列程式碼會實作背景佇列:

using System.Text.Json;
using System.Threading.Channels;

namespace BackgroundQueueService;

class BackgroundQueue : BackgroundService
{
    private readonly Channel<ReadOnlyMemory<byte>> _queue;
    private readonly ILogger<BackgroundQueue> _logger;

    public BackgroundQueue(Channel<ReadOnlyMemory<byte>> queue,
                               ILogger<BackgroundQueue> logger)
    {
        _queue = queue;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var dataStream in _queue.Reader.ReadAllAsync(stoppingToken))
        {
            try
            {
                var person = JsonSerializer.Deserialize<Person>(dataStream.Span)!;
                _logger.LogInformation($"{person.Name} is {person.Age} " +
                                       $"years and from {person.Country}");
            }
            catch (Exception ex)
            {
                _logger.LogError(ex.Message);
            }
        }
    }
}

class Person
{
    public string Name { get; set; } = String.Empty;
    public int Age { get; set; }
    public string Country { get; set; } = String.Empty;
}

下列程式碼會將要求本文繫結至 Stream

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

    // We're not above the message size and we have a content length, or
    // we're a chunked request and we're going to read up to the maxMessageSize + 1. 
    // We add one to the message size so that we can detect when a chunked request body
    // is bigger than our configured max.
    var readSize = (int?)req.ContentLength ?? (maxMessageSize + 1);

    var buffer = new byte[readSize];

    // Read at least that many bytes from the body.
    var read = await body.ReadAtLeastAsync(buffer, readSize, throwOnEndOfStream: false);

    // We read more than the max, so this is a bad request.
    if (read > maxMessageSize)
    {
        return Results.BadRequest();
    }

    // Attempt to send the buffer to the background queue.
    if (queue.Writer.TryWrite(buffer.AsMemory(0..read)))
    {
        return Results.Accepted();
    }

    // We couldn't accept the message since we're overloaded.
    return Results.StatusCode(StatusCodes.Status429TooManyRequests);
});

下列程式碼會顯示完整的 Program.cs 檔案:

using System.Threading.Channels;
using BackgroundQueueService;

var builder = WebApplication.CreateBuilder(args);
// The max memory to use for the upload endpoint on this instance.
var maxMemory = 500 * 1024 * 1024;

// The max size of a single message, staying below the default LOH size of 85K.
var maxMessageSize = 80 * 1024;

// The max size of the queue based on those restrictions
var maxQueueSize = maxMemory / maxMessageSize;

// Create a channel to send data to the background queue.
builder.Services.AddSingleton<Channel<ReadOnlyMemory<byte>>>((_) =>
                     Channel.CreateBounded<ReadOnlyMemory<byte>>(maxQueueSize));

// Create a background queue service.
builder.Services.AddHostedService<BackgroundQueue>();
var app = builder.Build();

// curl --request POST 'https://localhost:<port>/register' --header 'Content-Type: application/json' --data-raw '{ "Name":"Samson", "Age": 23, "Country":"Nigeria" }'
// curl --request POST "https://localhost:<port>/register" --header "Content-Type: application/json" --data-raw "{ \"Name\":\"Samson\", \"Age\": 23, \"Country\":\"Nigeria\" }"
app.MapPost("/register", async (HttpRequest req, Stream body,
                                 Channel<ReadOnlyMemory<byte>> queue) =>
{
    if (req.ContentLength is not null && req.ContentLength > maxMessageSize)
    {
        return Results.BadRequest();
    }

    // We're not above the message size and we have a content length, or
    // we're a chunked request and we're going to read up to the maxMessageSize + 1. 
    // We add one to the message size so that we can detect when a chunked request body
    // is bigger than our configured max.
    var readSize = (int?)req.ContentLength ?? (maxMessageSize + 1);

    var buffer = new byte[readSize];

    // Read at least that many bytes from the body.
    var read = await body.ReadAtLeastAsync(buffer, readSize, throwOnEndOfStream: false);

    // We read more than the max, so this is a bad request.
    if (read > maxMessageSize)
    {
        return Results.BadRequest();
    }

    // Attempt to send the buffer to the background queue.
    if (queue.Writer.TryWrite(buffer.AsMemory(0..read)))
    {
        return Results.Accepted();
    }

    // We couldn't accept the message since we're overloaded.
    return Results.StatusCode(StatusCodes.Status429TooManyRequests);
});

app.Run();
  • 讀取資料時,StreamHttpRequest.Body 是相同的物件。
  • 預設不會緩衝處理要求本文。 讀取本文之後,將無法倒轉。 無法讀取資料流多次。
  • StreamPipeReader 無法在基本動作處理常式之外使用,因為基礎緩衝區將會受到處置或重複使用。

使用 IFormFile 和 IFormFileCollection 上傳檔案

下列程式碼會使用 IFormFileIFormFileCollection 來上傳檔案:

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();

支援使用授權標頭用戶端憑證或 cookie 標頭的已驗證檔案上傳要求。

ASP.NET Core 7.0 中沒有防偽造功能的內建支援。 ASP.NET Core 8.0 和更新版本中提供防偽造功能。 不過,可以使用 IAntiforgery 服務來實作此功能。

從標頭和查詢字串繫結陣列和字串值

下列程式碼會示範將查詢字串繫結至簡單型別、字串陣列和 StringValues 的陣列:

// Bind query string values to a primitive type array.
// GET  /tags?q=1&q=2&q=3
app.MapGet("/tags", (int[] q) =>
                      $"tag1: {q[0]} , tag2: {q[1]}, tag3: {q[2]}");

// Bind to a string array.
// GET /tags2?names=john&names=jack&names=jane
app.MapGet("/tags2", (string[] names) =>
            $"tag1: {names[0]} , tag2: {names[1]}, tag3: {names[2]}");

// Bind to StringValues.
// GET /tags3?names=john&names=jack&names=jane
app.MapGet("/tags3", (StringValues names) =>
            $"tag1: {names[0]} , tag2: {names[1]}, tag3: {names[2]}");

型別已實作 TryParse 時,支援將查詢字串或標頭值繫結至複雜型別的陣列。 下列程式碼會繫結至字串陣列,並傳回具有指定標記的所有項目:

// GET /todoitems/tags?tags=home&tags=work
app.MapGet("/todoitems/tags", async (Tag[] tags, TodoDb db) =>
{
    return await db.Todos
        .Where(t => tags.Select(i => i.Name).Contains(t.Tag.Name))
        .ToListAsync();
});

下列程式碼會顯示模型和必要的 TryParse 實作:

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

    // This is an owned entity. 
    public Tag Tag { get; set; } = new();
}

[Owned]
public class Tag
{
    public string? Name { get; set; } = "n/a";

    public static bool TryParse(string? name, out Tag tag)
    {
        if (name is null)
        {
            tag = default!;
            return false;
        }

        tag = new Tag { Name = name };
        return true;
    }
}

下列程式碼會繫結至 int 陣列:

// GET /todoitems/query-string-ids?ids=1&ids=3
app.MapGet("/todoitems/query-string-ids", async (int[] ids, TodoDb db) =>
{
    return await db.Todos
        .Where(t => ids.Contains(t.Id))
        .ToListAsync();
});

若要測試上述程式碼,請新增下列端點以將 Todo 項目填入資料庫:

// POST /todoitems/batch
app.MapPost("/todoitems/batch", async (Todo[] todos, TodoDb db) =>
{
    await db.Todos.AddRangeAsync(todos);
    await db.SaveChangesAsync();

    return Results.Ok(todos);
});

使用 HttpRepl 之類的 API 測試工具,將下列資料傳遞至先前的端點:

[
    {
        "id": 1,
        "name": "Have Breakfast",
        "isComplete": true,
        "tag": {
            "name": "home"
        }
    },
    {
        "id": 2,
        "name": "Have Lunch",
        "isComplete": true,
        "tag": {
            "name": "work"
        }
    },
    {
        "id": 3,
        "name": "Have Supper",
        "isComplete": true,
        "tag": {
            "name": "home"
        }
    },
    {
        "id": 4,
        "name": "Have Snacks",
        "isComplete": true,
        "tag": {
            "name": "N/A"
        }
    }
]

下列程式碼會繫結至標頭索引鍵 X-Todo-Id,並傳回具有相符 Id 值的 Todo 項目:

// GET /todoitems/header-ids
// The keys of the headers should all be X-Todo-Id with different values
app.MapGet("/todoitems/header-ids", async ([FromHeader(Name = "X-Todo-Id")] int[] ids, TodoDb db) =>
{
    return await db.Todos
        .Where(t => ids.Contains(t.Id))
        .ToListAsync();
});

注意

從查詢字串繫結 string[] 時,沒有任何相符的查詢字串值會導致空陣列,而不是 Null 值。

具有 [AsParameters] 引數清單的參數繫結

AsParametersAttribute 可實現型別的簡單參數繫結,而不是複雜或遞迴模型繫結。

請考慮下列程式碼:

using Microsoft.EntityFrameworkCore;
using TodoApi.Models;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
var app = builder.Build();

app.MapGet("/todoitems", async (TodoDb db) =>
    await db.Todos.Select(x => new TodoItemDTO(x)).ToListAsync());

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

考慮下列 GET 端點:

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 可用來取代上述強調顯示的參數:

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

重構的 GET 端點會使用上述 struct 搭配 AsParameters 屬性:

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());

下列程式碼會顯示應用程式中的其他端點:

app.MapPost("/todoitems", async (TodoItemDTO Dto, TodoDb Db) =>
{
    var todoItem = new Todo
    {
        IsComplete = Dto.IsComplete,
        Name = Dto.Name
    };

    Db.Todos.Add(todoItem);
    await Db.SaveChangesAsync();

    return Results.Created($"/todoitems/{todoItem.Id}", new TodoItemDTO(todoItem));
});

app.MapPut("/todoitems/{id}", async (int Id, TodoItemDTO Dto, TodoDb Db) =>
{
    var todo = await Db.Todos.FindAsync(Id);

    if (todo is null) return Results.NotFound();

    todo.Name = Dto.Name;
    todo.IsComplete = Dto.IsComplete;

    await Db.SaveChangesAsync();

    return Results.NoContent();
});

app.MapDelete("/todoitems/{id}", async (int Id, TodoDb Db) =>
{
    if (await Db.Todos.FindAsync(Id) is Todo todo)
    {
        Db.Todos.Remove(todo);
        await Db.SaveChangesAsync();
        return Results.Ok(new TodoItemDTO(todo));
    }

    return Results.NotFound();
});

下列類別可用來重構參數清單:

class CreateTodoItemRequest
{
    public TodoItemDTO Dto { get; set; } = default!;
    public TodoDb Db { get; set; } = default!;
}

class EditTodoItemRequest
{
    public int Id { get; set; }
    public TodoItemDTO Dto { get; set; } = default!;
    public TodoDb Db { get; set; } = default!;
}

下列程式碼會顯示使用 AsParameters 和上述 struct 與類別的重構端點:

app.MapPost("/ap/todoitems", async ([AsParameters] CreateTodoItemRequest request) =>
{
    var todoItem = new Todo
    {
        IsComplete = request.Dto.IsComplete,
        Name = request.Dto.Name
    };

    request.Db.Todos.Add(todoItem);
    await request.Db.SaveChangesAsync();

    return Results.Created($"/todoitems/{todoItem.Id}", new TodoItemDTO(todoItem));
});

app.MapPut("/ap/todoitems/{id}", async ([AsParameters] EditTodoItemRequest request) =>
{
    var todo = await request.Db.Todos.FindAsync(request.Id);

    if (todo is null) return Results.NotFound();

    todo.Name = request.Dto.Name;
    todo.IsComplete = request.Dto.IsComplete;

    await request.Db.SaveChangesAsync();

    return Results.NoContent();
});

app.MapDelete("/ap/todoitems/{id}", async ([AsParameters] TodoItemRequest request) =>
{
    if (await request.Db.Todos.FindAsync(request.Id) is Todo todo)
    {
        request.Db.Todos.Remove(todo);
        await request.Db.SaveChangesAsync();
        return Results.Ok(new TodoItemDTO(todo));
    }

    return Results.NotFound();
});

下列 record 型別可用來取代上述參數:

record TodoItemRequest(int Id, TodoDb Db);
record CreateTodoItemRequest(TodoItemDTO Dto, TodoDb Db);
record EditTodoItemRequest(int Id, TodoItemDTO Dto, TodoDb Db);

使用 struct 搭配 AsParameters 比使用 record 型別更有效果。

AspNetCore.Docs.Samples 存放庫中的完整範例程式碼

自訂繫結

自訂參數繫結的方式有兩個:

  1. 針對路由、查詢和標頭繫結來源,可藉由新增型別的靜態 TryParse 方法來繫結自訂型別。
  2. 藉由在型別上實作 BindAsync 方法來控制繫結程序。

TryParse

TryParse 有兩個 API:

public static bool TryParse(string value, out T result);
public static bool TryParse(string value, IFormatProvider provider, out T result);

下列程式碼會以 URI /map?Point=12.3,10.1 顯示 Point: 12.3, 10.1

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)
    {
        // Format is "(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

BindAsync 有下列 API:

public static ValueTask<T?> BindAsync(HttpContext context, ParameterInfo parameter);
public static ValueTask<T?> BindAsync(HttpContext context);

下列程式碼會以 URI /products?SortBy=xyz&SortDir=Desc&Page=99 顯示 SortBy:xyz, SortDirection:Desc, CurrentPage:99

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
}

繫結失敗

繫結失敗時,架構會記錄偵錯訊息,並根據失敗模式將各種狀態碼傳回至用戶端。

失敗模式 可為 Null 的參數型別 繫結來源 狀態碼
{ParameterType}.TryParse 傳回 false route/query/header 400
{ParameterType}.BindAsync 傳回 null custom 400
{ParameterType}.BindAsync 擲回 沒有關係 custom 500
無法還原序列化 JSON 本文 沒有關係 本文 400
錯誤的內容類型 (非 application/json) 沒有關係 本文 415

繫結優先順序

從參數判斷繫結來源的規則:

  1. 如下順序在參數上定義的明確屬性 (From* 屬性):
    1. 路由值:[FromRoute]
    2. 查詢字串:[FromQuery]
    3. 標題:[FromHeader]
    4. 本文:[FromBody]
    5. 服務:[FromServices]
    6. 參數值:[AsParameters]
  2. 特殊型別
    1. HttpContext
    2. HttpRequestHttpContext.Request
    3. HttpResponseHttpContext.Response
    4. ClaimsPrincipalHttpContext.User
    5. CancellationTokenHttpContext.RequestAborted
    6. IFormFileCollectionHttpContext.Request.Form.Files
    7. IFormFileHttpContext.Request.Form.Files[paramName]
    8. StreamHttpContext.Request.Body
    9. PipeReaderHttpContext.Request.BodyReader
  3. 參數型別具有有效的靜態 BindAsync 方法。
  4. 參數型別是字串或具有有效的靜態 TryParse 方法。
    1. 如果參數名稱存在於路由範本中。 在 app.Map("/todo/{id}", (int id) => {}); 中,id 是由路由繫結的。
    2. 從查詢字串繫結。
  5. 如果參數型別是相依性插入所提供的服務,它會使用該服務作為來源。
  6. 參數來自本文。

設定本文繫結的 JSON 還原序列化選項

本文繫結來源會使用 System.Text.Json 進行還原序列化。 無法變更此預設值,但可以設定 JSON 序列化和還原序列化選項。

全域設定 JSON 還原序列化選項

您可以叫用 ConfigureHttpJsonOptions 來設定要全域套用至應用程式的選項。 下列範例包含公用欄位和格式 JSON 輸出。

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; }
}
// If the request body contains the following JSON:
//
// {"nameField":"Walk dog", "isComplete":false}
//
// The endpoint returns the following JSON:
//
// {
//    "name":"Walk dog",
//    "nameField":"Walk dog",
//    "isComplete":false
// }

由於範例程式碼會同時設定序列化和還原序列化,因此其可以讀取 NameField 並將 NameField 包含在輸出的 JSON 中。

設定端點的 JSON 還原序列化選項

ReadFromJsonAsync 具有會接受 JsonSerializerOptions 物件的多載。 下列範例包含公用欄位和格式 JSON 輸出。

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();

class Todo
{
    public string? Name { get; set; }
    public string? NameField;
    public bool IsComplete { get; set; }
}
// If the request body contains the following JSON:
//
// {"nameField":"Walk dog", "isComplete":false}
//
// The endpoint returns the following JSON:
//
// {
//    "name":"Walk dog",
//    "isComplete":false
// }

由於上述程式碼僅會將自訂選項套用至還原序列化,因此輸出 JSON 會排除 NameField

讀取要求本文

使用 HttpContextHttpRequest 參數直接讀取要求本文:

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();

上述 程式碼: