.NET 中的最少 API:构建轻量级 HTTP API
如果您已经使用 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 时,请跳过控制器并进行最小化。你可能不会回去。
快乐编码!