.NET 中的最少 API:构建轻量级 HTTP API

· 7 分钟阅读

如果您已经使用 ASP.NET Core 构建 API 一段时间,您可能非常熟悉基于控制器的方法:创建一个控制器类,用属性装饰它,通过构造函数注入您的服务,并连接您的路由。它有效,而且效果很好——但有时感觉就像你在写很多仪式,相当于“接受这个请求,做一些事情,返回一个响应”。

这正是 Minimal API 想要解决的问题。 .NET 6 中引入的最小 API 允许您使用很少的样板文件定义 HTTP 端点。没有控制器,没有属性,没有启动类杂耍——只是以干净、函数式的方式直接路由到处理程序映射。

在这篇文章中,我想引导您了解有关最小 API 的所有信息:从第一个端点一直到组织大型应用程序、处理身份验证、验证、OpenAPI 文档和性能注意事项。让我们开始吧。

为什么要使用最少的 API?

在我们开始之前,让我们先谈谈为什么您会选择最小 API 而不是传统的基于控制器的方法。

当您需要一个结构化的、固执己见的框架时,控制器非常有用。它们为您提供模型绑定、过滤器、内容协商以及开箱即用的清晰的关注点分离。对于拥有数十名开发人员的大型企业应用程序,控制器强制执行的一致性可能是一个真正的好处。

另一方面,最少的 API 在您需要时会大放异彩:

  • 更少样板 — 没有控制器类,没有 [ApiController] 属性,没有单独的启动配置。
  • 更快的启动 — 启动时基于反射的操作更少。
  • 更简单的心理模型 - 路线直接映射到处理程序。就是这样。
  • 微服务友好 - 当您的 API 有 5-10 个端点时,完整的控制器设置可能会让人觉得大材小用。

好消息是,这不是一个非此即彼的决定。您可以在同一项目中混合使用控制器和 Minimal API。但是,一旦您适应了最低限度的方法,您可能会发现自己使用它的频率比您预期的要高。

开始使用

让我们从头开始创建一个 Minimal API。如果您安装了 .NET SDK,则非常简单:

dotnet new web -n MyMinimalApi
cd MyMinimalApi

这会给你一个看起来像这样的 Program.cs

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

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

app.Run();

就是这样。这是一个有效的 API。没有 Startup.cs,没有控制器类,没有路由配置。你运行 dotnet run,点击 http://localhost:5000,然后你会得到“Hello World!”后退。这里的简单性就是重点。

让我们用经典的 todo API 让它变得更有用:

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

var todos = new List<Todo>();

app.MapGet("/todos", () => todos);

app.MapGet("/todos/{id:int}", (int id) =>
    todos.FirstOrDefault(t => t.Id == id) is { } todo
        ? Results.Ok(todo)
        : Results.NotFound());

app.MapPost("/todos", (Todo todo) =>
{
    todos.Add(todo);
    return Results.Created($"/todos/{todo.Id}", todo);
});

app.MapDelete("/todos/{id:int}", (int id) =>
{
    var todo = todos.FirstOrDefault(t => t.Id == id);
    if (todo is null) return Results.NotFound();
    todos.Remove(todo);
    return Results.NoContent();
});

app.Run();

record Todo(int Id, string Title, bool IsComplete);

我们在 30 行以内拥有完整的 CRUD。这就是最小方法的力量。

路由处理程序

路由处理程序是当请求与路由匹配时执行的函数。您有多种定义它们的选项。

Lambda 表达式

最常见的方法以及您将在大多数示例中看到的内容:

app.MapGet("/hello", () => "Hello!");
app.MapGet("/hello/{name}", (string name) => $"Hello, {name}!");

方法组

对于更复杂的逻辑,您可以指向命名方法:

app.MapGet("/products", GetProducts);
app.MapPost("/products", CreateProduct);

static IResult GetProducts(AppDbContext db)
    => Results.Ok(db.Products.ToList());

static IResult CreateProduct(Product product, AppDbContext db)
{
    db.Products.Add(product);
    db.SaveChanges();
    return Results.Created($"/products/{product.Id}", product);
}
```对于除了一句台词之外的任何事情,这是我的首选方法。它使您的路由映射部分保持干净和可读 - 您可以一目了然地看到存在哪些端点,而无需费力地了解实现细节。

### 局部函数

您还可以使用本地函数,当您想让处理程序靠近其路由定义时,这非常有用:

```csharp
app.MapGet("/health", CheckHealth);

IResult CheckHealth()
{
    // Some health check logic
    return Results.Ok(new { Status = "Healthy", Timestamp = DateTime.UtcNow });
}

参数绑定

我真正欣赏 Minimal API 的原因之一是参数绑定的直观性。该框架根据上下文和类型确定从哪里提取值。

路由参数

app.MapGet("/users/{id:int}", (int id) => $"User {id}");
app.MapGet("/files/{*path}", (string path) => $"File: {path}"); // catch-all

查询字符串参数

app.MapGet("/search", (string? query, int page = 1, int pageSize = 20) =>
    Results.Ok(new { query, page, pageSize }));

可空类型成为可选参数。默认值完全按照您的预期工作。

请求正文

app.MapPost("/orders", (Order order) =>
{
    // 'order' is automatically deserialized from the JSON body
    return Results.Created($"/orders/{order.Id}", order);
});

标头和服务绑定

app.MapGet("/protected", (
    [FromHeader(Name = "X-Api-Key")] string apiKey,
    [FromServices] ILogger<Program> logger) =>
{
    logger.LogInformation("Request with API key: {Key}", apiKey[..4] + "****");
    return Results.Ok("Authorized");
});

HttpContext 和 HttpRequest

当您需要较低级别的访问权限时:

app.MapGet("/info", (HttpContext context) =>
{
    var userAgent = context.Request.Headers.UserAgent.ToString();
    var ip = context.Connection.RemoteIpAddress?.ToString();
    return Results.Ok(new { userAgent, ip });
});

使用 BindAsync 进行自定义绑定

对于复杂类型,您可以实现静态 BindAsync 方法:

public record PaginationParams(int Page, int PageSize)
{
    public static ValueTask<PaginationParams?> BindAsync(HttpContext context)
    {
        int.TryParse(context.Request.Query["page"], out var page);
        int.TryParse(context.Request.Query["pageSize"], out var pageSize);

        var result = new PaginationParams(
            Page: page > 0 ? page : 1,
            PageSize: pageSize > 0 ? Math.Min(pageSize, 100) : 20
        );

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

app.MapGet("/items", (PaginationParams pagination, AppDbContext db) =>
{
    var items = db.Items
        .Skip((pagination.Page - 1) * pagination.PageSize)
        .Take(pagination.PageSize)
        .ToList();

    return Results.Ok(items);
});

这是非常强大的。您定义一次绑定逻辑,然后在所有端点上重复使用它。

验证

与控制器相比,Minimal API 无法提供开箱即用的功能,其中一个领域是模型验证。没有自动 [Required][StringLength] 强制执行。但有一些干净的模式可以处理它。

手动验证

最简单的方法 - 只需在处理程序中验证:

app.MapPost("/users", (CreateUserRequest request) =>
{
    if (string.IsNullOrWhiteSpace(request.Email))
        return Results.BadRequest(new { Error = "Email is required" });

    if (request.Name?.Length > 100)
        return Results.BadRequest(new { Error = "Name must be 100 characters or less" });

    // Create user...
    return Results.Created($"/users/{request.Email}", request);
});

使用验证库

对于任何重要的事情,我建议使用 FluentValidation 或类似的库:

public class CreateUserValidator : AbstractValidator<CreateUserRequest>
{
    public CreateUserValidator()
    {
        RuleFor(x => x.Email).NotEmpty().EmailAddress();
        RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
        RuleFor(x => x.Age).InclusiveBetween(0, 150);
    }
}

您可以通过端点过滤器将其连接到您的端点,这将我们带到下一部分。

端点过滤器

端点过滤器是 Minimal API 的最佳功能之一。将它们视为中间件,但范围仅限于特定端点而不是整个管道。它们是在 .NET 7 中引入的,对于横切关注点来说它们非常有用。

基本过滤器

app.MapPost("/todos", (Todo todo) =>
{
    // Handle the request
    return Results.Created($"/todos/{todo.Id}", todo);
})
.AddEndpointFilter(async (context, next) =>
{
    var todo = context.GetArgument<Todo>(0);

    if (string.IsNullOrEmpty(todo.Title))
    {
        return Results.BadRequest(new { Error = "Title is required" });
    }

    return await next(context);
});

使用 FluentValidation 验证过滤器

这就是它真正强大的地方——可重用的验证过滤器:

public class ValidationFilter<T> : IEndpointFilter where T : class
{
    private readonly IValidator<T> _validator;

    public ValidationFilter(IValidator<T> validator)
    {
        _validator = validator;
    }

    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var model = context.Arguments
            .OfType<T>()
            .FirstOrDefault();

        if (model is null)
            return Results.BadRequest(new { Error = "Request body is required" });

        var result = await _validator.ValidateAsync(model);

        if (!result.IsValid)
        {
            var errors = result.Errors
                .GroupBy(e => e.PropertyName)
                .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());

            return Results.ValidationProblem(errors);
        }

        return await next(context);
    }
}

// Usage
app.MapPost("/users", (CreateUserRequest request) =>
{
    // Only reached if validation passes
    return Results.Created($"/users/{request.Email}", request);
})
.AddEndpointFilter<ValidationFilter<CreateUserRequest>>();

日志过滤器

app.MapGet("/products", (AppDbContext db) => db.Products.ToList())
    .AddEndpointFilter(async (context, next) =>
    {
        var logger = context.HttpContext
            .RequestServices.GetRequiredService<ILogger<Program>>();

        var sw = Stopwatch.StartNew();
        var result = await next(context);
        sw.Stop();

        logger.LogInformation("Endpoint executed in {Elapsed}ms", sw.ElapsedMilliseconds);

        return result;
    });

您可以链接多个过滤器,它们按照添加的顺序执行 - 就像中间件一样。

OpenAPI / Swagger 集成

良好的 API 文档不再是可选的。值得庆幸的是,Minimal API 通过 Microsoft.AspNetCore.OpenApi 包对 OpenAPI 提供一流的支持。

基本设置

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();

var app = builder.Build();

app.MapOpenApi();

app.MapGet("/todos", () => new List<Todo>())
    .WithName("GetTodos")
    .WithDescription("Retrieves all todo items")
    .WithTags("Todos");

app.Run();

丰富的端点元数据

您可以提供有关端点的详细信息:

app.MapPost("/todos", (Todo todo) =>
{
    return Results.Created($"/todos/{todo.Id}", todo);
})
.WithName("CreateTodo")
.WithDescription("Creates a new todo item")
.WithTags("Todos")
.Accepts<Todo>("application/json")
.Produces<Todo>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.ProducesValidationProblem();

使用 WithOpenApi 进行定制

为了对生成的 OpenAPI 文档进行细粒度控制:

app.MapGet("/todos/{id:int}", (int id) => Results.Ok(new Todo(id, "Sample", false)))
    .WithOpenApi(operation =>
    {
        operation.Summary = "Get a specific todo";
        operation.Description = "Retrieves a single todo item by its unique identifier.";
        operation.Parameters[0].Description = "The unique identifier of the todo item";
        return operation;
    });

这为您提供了与控制器上的 Swashbuckle XML 注释相同级别的文档控制,但采用了更明确、代码优先的方式。

身份验证和授权

保护最小 API 端点遵循与 ASP.NET Core 的其余部分相同的模式 - 您只是以不同的方式应用它们。

基本设置```csharp

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.Authority = “https://your-identity-provider.com”; options.Audience = “your-api”; });

builder.Services.AddAuthorizationBuilder() .AddPolicy(“AdminOnly”, policy => policy.RequireRole(“Admin”)) .AddPolicy(“PremiumUser”, policy => policy.RequireClaim(“subscription”, “premium”));

var app = builder.Build();

app.UseAuthentication(); app.UseAuthorization();


### 对端点应用授权

```csharp
// Require any authenticated user
app.MapGet("/profile", (ClaimsPrincipal user) =>
{
    return Results.Ok(new
    {
        Name = user.FindFirstValue(ClaimTypes.Name),
        Email = user.FindFirstValue(ClaimTypes.Email)
    });
}).RequireAuthorization();

// Require a specific policy
app.MapDelete("/admin/users/{id}", (int id) =>
{
    // Delete user logic
    return Results.NoContent();
}).RequireAuthorization("AdminOnly");

// Allow anonymous access
app.MapGet("/public/health", () => Results.Ok(new { Status = "Healthy" }))
    .AllowAnonymous();

请注意如何将 ClaimsPrincipal 直接注入到处理程序参数中 - 框架会处理其余的事情。这是让 Minimal API 感觉非常优雅的小事情之一。

组织大型 API

最小 API 房间里的大象是组织。当你的 Program.cs 有 50 个端点时,它就会变得一团糟。以下是我用来保持事情易于管理的模式。

路线组

路由组(在 .NET 7 中引入)允许您跨相关端点共享配置:

var todos = app.MapGroup("/todos")
    .WithTags("Todos")
    .RequireAuthorization();

todos.MapGet("/", (AppDbContext db) => db.Todos.ToListAsync());
todos.MapGet("/{id:int}", (int id, AppDbContext db) => db.Todos.FindAsync(id));
todos.MapPost("/", (Todo todo, AppDbContext db) =>
{
    db.Todos.Add(todo);
    db.SaveChangesAsync();
    return Results.Created($"/todos/{todo.Id}", todo);
});

组中的所有端点共享 /todos 前缀、Todos 标签和授权要求。干净的。

扩展方法

这是真正可扩展的模式。将每组端点移至其自己的静态类中:

// Endpoints/TodoEndpoints.cs
public static class TodoEndpoints
{
    public static RouteGroupBuilder MapTodoEndpoints(this WebApplication app)
    {
        var group = app.MapGroup("/todos").WithTags("Todos");

        group.MapGet("/", GetAll);
        group.MapGet("/{id:int}", GetById);
        group.MapPost("/", Create);
        group.MapPut("/{id:int}", Update);
        group.MapDelete("/{id:int}", Delete);

        return group;
    }

    private static async Task<IResult> GetAll(AppDbContext db)
        => Results.Ok(await db.Todos.ToListAsync());

    private static async Task<IResult> GetById(int id, AppDbContext db)
        => await db.Todos.FindAsync(id) is { } todo
            ? Results.Ok(todo)
            : Results.NotFound();

    private static async Task<IResult> Create(Todo todo, AppDbContext db)
    {
        db.Todos.Add(todo);
        await db.SaveChangesAsync();
        return Results.Created($"/todos/{todo.Id}", todo);
    }

    private static async Task<IResult> Update(int id, Todo updated, AppDbContext db)
    {
        var todo = await db.Todos.FindAsync(id);
        if (todo is null) return Results.NotFound();

        todo.Title = updated.Title;
        todo.IsComplete = updated.IsComplete;
        await db.SaveChangesAsync();

        return Results.Ok(todo);
    }

    private static async Task<IResult> Delete(int id, AppDbContext db)
    {
        var todo = await db.Todos.FindAsync(id);
        if (todo is null) return Results.NotFound();

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

        return Results.NoContent();
    }
}

// Program.cs — stays clean
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapTodoEndpoints();
app.MapUserEndpoints();
app.MapOrderEndpoints();

app.Run();

您的 Program.cs 成为 API 的目录。每个端点组都位于其自己的文件中。这是我推荐用于生产应用程序的方法。

卡特图书馆

如果您想要更多结构,Carter 库提供了基于模块的方法:

public class TodoModule : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        app.MapGet("/todos", async (AppDbContext db) =>
            await db.Todos.ToListAsync());

        app.MapPost("/todos", async (Todo todo, AppDbContext db) =>
        {
            db.Todos.Add(todo);
            await db.SaveChangesAsync();
            return Results.Created($"/todos/{todo.Id}", todo);
        });
    }
}

Carter 自动发现并注册所有模块。它是原始最小 API 方法和完整控制器之间的一个很好的中间立场。

TypedResults 和响应类型

从 .NET 7 开始,您可以使用 TypedResults 而不是 Results 进行类型安全响应。这看起来似乎是一个很小的变化,但它对 OpenAPI 文档和可测试性有真正的好处。

app.MapGet("/todos/{id:int}", async Task<Results<Ok<Todo>, NotFound>> (int id, AppDbContext db) =>
{
    var todo = await db.Todos.FindAsync(id);
    return todo is not null
        ? TypedResults.Ok(todo)
        : TypedResults.NotFound();
});

返回类型 Results<Ok<Todo>, NotFound> 明确告诉框架(和您的 OpenAPI 文档)该端点可以生成哪些响应类型。不再需要猜测,不再需要手动 Produces<>() 来处理基本情况。

对于多种可能的结果:

app.MapPost("/todos",
    async Task<Results<Created<Todo>, ValidationProblem, Conflict>>
    (Todo todo, AppDbContext db) =>
{
    if (string.IsNullOrEmpty(todo.Title))
        return TypedResults.ValidationProblem(
            new Dictionary<string, string[]>
            {
                { "Title", new[] { "Title is required" } }
            });

    if (await db.Todos.AnyAsync(t => t.Title == todo.Title))
        return TypedResults.Conflict();

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

    return TypedResults.Created($"/todos/{todo.Id}", todo);
});

我已经开始在所有新项目中使用 TypedResults。编译器会捕获声明的返回类型与实际返回的类型之间的不匹配,从而消除一整类运行时意外。

性能考虑因素

Minimal API 的卖点之一是性能,值得理解“为什么”它们更快。

**减少启动开销。**控制器严重依赖反射来发现端点、绑定模型和应用过滤器。最小 API 使用源生成器(从 .NET 7 开始)在编译时生成绑定代码。这意味着启动时的工作量更少,每个请求的内存分配也更少。

没有 MVC 管道。 基于控制器的 API 经历完整的 MVC 管道:操作选择、模型绑定、操作过滤器、结果执行。最小 API 会跳过所有这些,直接从路由到处理程序。

RequestDelegate 编译。 框架将您的 lambda 表达式编译为优化的 RequestDelegate 实例。如果您直接使用 HttpContext,生成的代码非常接近您手写的代码。

以下是一些最大化性能的实用技巧:

// Use AsNoTracking for read-only queries
app.MapGet("/products", async (AppDbContext db) =>
    await db.Products.AsNoTracking().ToListAsync());

// Return results directly — avoid unnecessary allocations
app.MapGet("/count", async (AppDbContext db) =>
    await db.Products.CountAsync());

// Use cancellation tokens for long-running operations
app.MapGet("/report", async (AppDbContext db, CancellationToken ct) =>
    await db.Orders
        .AsNoTracking()
        .GroupBy(o => o.Status)
        .Select(g => new { Status = g.Key, Count = g.Count() })
        .ToListAsync(ct));
```还值得一提的是,随着每个 .NET 版本的发布,控制器和 Minimal API 之间的性能差距不断缩小。对于大多数应用程序来说,这种差异不会成为瓶颈,而是数据库查询和外部服务调用。根据开发人员经验和项目需求而不是基准进行选择。

## 结论

自从在 .NET 6 中引入以来,最小 API 已经取得了长足的进步。最初的“hello world”演示功能已经成熟为生产 API 的合法选择。借助端点过滤器、路由组、类型化结果和可靠的 OpenAPI 支持,您拥有构建结构良好、可维护的服务所需的一切。

我的推荐?如果您正在启动一个新的 API 项目(尤其是微服务或专注的内部 API),请认真尝试最小化 API。使用扩展方法模式进行组织,依靠端点过滤器来解决横切问题,并利用 `TypedResults` 来实现类型安全。

对于现有的基于控制器的项目,不必急于迁移。这两种方法都很有效,您甚至可以同时使用它们。但下次您需要添加小型服务或快速内部 API 时,请跳过控制器并进行最小化。你可能不会回去。

快乐编码!