一、从痛点说起
在 ASP.NET Core Minimal API 出现之前,Controller 时代的返回类型写法早已让人习惯了模糊性:
bash
// Controller 时代 ------ 返回类型完全不透明
public IActionResult GetUser(int id)
{
if (id <= 0) return BadRequest("Invalid ID");
var user = _repo.Find(id);
if (user == null) return NotFound();
return Ok(user);
}
这段代码的问题在于:Swagger/OpenAPI 不知道这个接口到底会返回什么 ,必须靠 [ProducesResponseType] 手动标注。
Minimal API 引入后,问题依然存在:
bash
// 早期 Minimal API ------ 同样不透明
app.MapGet("/user/{id}", (int id) =>
{
if (id <= 0) return Results.BadRequest("Invalid ID");
// ...
});
Results<T1, T2> 就是为解决这个问题而生的 联合类型(Union Type)。
二、Results<T1, T2> 是什么?
一句话 :
Results<T1, T2>是一个 值类型联合体,它携带了类型信息,使框架能在编译期 / 反射期自动推断出接口所有可能的响应类型,从而自动生成 OpenAPI 文档。
bash
app.MapGet("/user/{id}", (int id) =>
{
if (id <= 0) return TypedResults.BadRequest("Invalid ID");
var user = _repo.Find(id);
if (user == null) return TypedResults.NotFound();
return TypedResults.Ok(user);
})
// 🎉 无需任何 Attribute,Swagger 自动识别 200 / 400 / 404
返回类型签名:
bash
Results<Ok<User>, BadRequest<string>, NotFound>
三、核心类型体系
3.1 整体类图
bash
IResult
└── IValueHttpResult (有返回值)
└── IStatusCodeHttpResult (有状态码)
└── IEndpointMetadataProvider (提供 OpenAPI 元数据)
TypedResults.Ok<T> ──实现──▶ IResult, IValueHttpResult<T>, IStatusCodeHttpResult
TypedResults.NotFound ──实现──▶ IResult, IStatusCodeHttpResult
TypedResults.BadRequest<T> ──▶ IResult, IValueHttpResult<T>, IStatusCodeHttpResult
Results<T1, T2> ──持有──▶ IResult(实际执行时委托给内部 IResult)
3.2 关键接口
bash
// 所有响应结果的根接口
public interface IResult
{
Task ExecuteAsync(HttpContext httpContext);
}
// 携带值的结果
public interface IValueHttpResult<out TValue>
{
TValue? Value { get; }
}
// 携带状态码的结果
public interface IStatusCodeHttpResult
{
int? StatusCode { get; }
}
// 🔑 最关键:提供 OpenAPI 元数据的接口
public interface IEndpointMetadataProvider
{
static abstract void PopulateMetadata(MethodInfo method, EndpointBuilder builder);
}
四、Results<T1, T2> 源码解析
4.1 结构定义(简化版)
ASP.NET Core 源码中,Results<T1, T2> 是一个 只读结构体(readonly struct):
bash
public readonly struct Results<TResult1, TResult2> : IResult
where TResult1 : IResult
where TResult2 : IResult
{
// 内部持有真正的 IResult 实例
private readonly IResult _activeResult;
// 私有构造(只能通过隐式转换创建)
private Results(IResult activeResult)
{
_activeResult = activeResult;
}
// ✅ 隐式转换:T1 → Results<T1,T2>
public static implicit operator Results<TResult1, TResult2>(TResult1 result)
=> new(result);
// ✅ 隐式转换:T2 → Results<T1,T2>
public static implicit operator Results<TResult1, TResult2>(TResult2 result)
=> new(result);
// ✅ 委托执行:转发给真正的 IResult
public Task ExecuteAsync(HttpContext httpContext)
=> _activeResult.ExecuteAsync(httpContext);
}
💡 关键设计 :使用
readonly struct避免堆分配,隐式转换让调用方代码自然流畅,无需new或强转。
4.2 为何能自动生成 OpenAPI 元数据?
Results<T1, T2> 还实现了 IEndpointMetadataProvider:
bash
public readonly struct Results<TResult1, TResult2> : IResult, IEndpointMetadataProvider
where TResult1 : IResult
where TResult2 : IResult
{
// 静态抽象方法实现(C# 11 特性)
static void IEndpointMetadataProvider.PopulateMetadata(
MethodInfo method, EndpointBuilder builder)
{
// 遍历每个泛型参数,如果它也实现了 IEndpointMetadataProvider
// 就递归调用其 PopulateMetadata
PopulateMetadataForType<TResult1>(method, builder);
PopulateMetadataForType<TResult2>(method, builder);
}
private static void PopulateMetadataForType<T>(
MethodInfo method, EndpointBuilder builder)
{
if (typeof(IEndpointMetadataProvider).IsAssignableFrom(typeof(T)))
{
// 利用反射调用 T 的静态 PopulateMetadata
T.PopulateMetadata(method, builder); // C# 11 静态抽象接口调用
}
}
}
每个具体结果类型(如 Ok<T>)都实现了自己的 PopulateMetadata:
bash
// Ok<T> 的元数据注入
public sealed class Ok<TValue> : IResult, IEndpointMetadataProvider, IStatusCodeHttpResult
{
static void IEndpointMetadataProvider.PopulateMetadata(
MethodInfo method, EndpointBuilder builder)
{
// 向 endpoint 注入:HTTP 200,响应体类型为 TValue
builder.Metadata.Add(
new ProducesResponseTypeMetadata(typeof(TValue), StatusCodes.Status200OK, "application/json")
);
}
}
五、完整执行流程
bash
HTTP 请求到达
│
▼
┌─────────────────────────────────┐
│ 路由匹配 (Route Matcher) │
└────────────────┬────────────────┘
│
▼
┌─────────────────────────────────┐
│ 终结点调度 (EndpointMiddleware) │
└────────────────┬────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ RequestDelegateFactory 调用 Handler │
│ │
│ async Task<Results<Ok<User>, NotFound>> Handler() │
│ { │
│ if (notFound) │
│ return TypedResults.NotFound(); ──────────┼──▶ 隐式转换为 Results<>
│ return TypedResults.Ok(user); ──────────┼──▶ 隐式转换为 Results<>
│ } │
└────────────────┬────────────────────────────────────┘
│ 返回 Results<Ok<User>, NotFound>
▼
┌─────────────────────────────────────────────────────┐
│ RequestDelegateFactory 自动生成的包装代码 │
│ │
│ var result = await handler(...); │
│ await result.ExecuteAsync(httpContext); ──────────┼──▶ 委托给内部 _activeResult
└────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ 具体 Result 执行 (e.g. Ok<User>) │
│ │
│ 1. httpContext.Response.StatusCode = 200 │
│ 2. httpContext.Response.ContentType = "app/json" │
│ 3. JsonSerializer.SerializeAsync(user, ...) │
└────────────────┬────────────────────────────────────┘
│
▼
HTTP 响应写出
六、RequestDelegateFactory:幕后英雄
Results<T1, T2> 能无缝工作,背后有一个关键角色:RequestDelegateFactory(RDF)。
它在应用启动时(非请求时)做两件事:
6.1 编译期:生成元数据
bash
// 伪代码,表达 RDF 的逻辑
internal static RequestDelegateResult Create(Delegate handler, ...)
{
var method = handler.Method;
var returnType = method.ReturnType; // e.g. Results<Ok<User>, NotFound>
// 如果返回类型实现了 IEndpointMetadataProvider
if (typeof(IEndpointMetadataProvider).IsAssignableFrom(returnType))
{
// 调用静态方法填充元数据 → Swagger 就知道响应类型了
IEndpointMetadataProvider.PopulateMetadata(returnType, method, endpointBuilder);
}
// 生成 RequestDelegate(运行时调用的委托)
var requestDelegate = CreateRequestDelegate(handler, ...);
return new RequestDelegateResult(requestDelegate, endpointBuilder.Metadata);
}
6.2 运行期:执行委托
bash
// RDF 为异步返回 IResult 的 Handler 生成的代码(简化)
async Task GeneratedRequestDelegate(HttpContext httpContext)
{
// 1. 参数绑定
var id = ...; // 从路由/查询/Body 绑定
// 2. 调用用户 Handler
var result = await userHandler(id);
// result 类型是 Results<Ok<User>, NotFound>
// 3. 调用 ExecuteAsync(多态分派给 _activeResult)
if (result is IResult r)
await r.ExecuteAsync(httpContext);
}
七、Results vs TypedResults 的区别
| 对比项 | Results(静态类) |
TypedResults(静态类) |
|---|---|---|
| 返回类型 | IResult(接口) |
具体类型,如 Ok<T> |
| OpenAPI 推断 | ❌ 无法自动推断 | ✅ 自动推断 |
| 单元测试 | 较难(需检查响应流) | ✅ 直接断言返回值 |
搭配 Results<> |
❌ 类型丢失 | ✅ 类型完整保留 |
bash
// ❌ 用 Results,类型信息丢失
app.MapGet("/a", () => Results.Ok(new User()));
// Swagger 不知道响应体是 User
// ✅ 用 TypedResults,类型信息保留
app.MapGet("/b", () => TypedResults.Ok(new User()));
// Swagger 自动生成 User 的 Schema
八、单元测试友好性
Results<T1, T2> 的一大优势是让 Handler 可以脱离 HTTP 环境进行单元测试:
bash
// 被测 Handler
static Results<Ok<User>, NotFound> GetUser(int id, IUserRepo repo)
{
var user = repo.Find(id);
return user is null
? TypedResults.NotFound()
: TypedResults.Ok(user);
}
// 单元测试
[Fact]
public void GetUser_ReturnsOk_WhenUserExists()
{
var repo = new FakeRepo(new User { Id = 1, Name = "Alice" });
var result = GetUser(1, repo);
// ✅ 直接断言类型,无需 Mock HttpContext
var ok = Assert.IsType<Ok<User>>(result.Result);
Assert.Equal("Alice", ok.Value!.Name);
}
[Fact]
public void GetUser_ReturnsNotFound_WhenMissing()
{
var repo = new FakeRepo(null);
var result = GetUser(99, repo);
Assert.IsType<NotFound>(result.Result); // ✅
}
💡 注意:访问内部值需要
result.Result属性(Results<>暴露的内部IResult)。
九、泛型参数扩展:T1 ~ T6
ASP.NET Core 提供了最多 6 个类型参数 的重载,均为独立的 struct 定义:
bash
Results<T1, T2>
Results<T1, T2, T3>
Results<T1, T2, T3, T4>
Results<T1, T2, T3, T4, T5>
Results<T1, T2, T3, T4, T5, T6>
每个都遵循相同模式:持有 IResult _activeResult,通过隐式转换赋值,调用时委托执行。
实际使用示例:
bash
app.MapPost("/user", async (CreateUserRequest req, IUserService svc)
=> (Results<Created<User>, BadRequest<ValidationProblemDetails>, Conflict, UnauthorizedHttpResult>)
await svc.CreateAsync(req));
十、 设计 亮点总结 ✨
| 设计点 | 技术手段 | 解决的问题 |
|---|---|---|
| 零堆分配 | readonly struct |
高频请求下的 GC 压力 |
| 自然语法 | 隐式转换运算符 | 无需显式 new 或强转 |
| 自动文档 | IEndpointMetadataProvider + C# 11 静态抽象接口 |
OpenAPI 零标注 |
| 可测试性 | 具体返回类型而非 IResult 接口 |
脱离 HTTP 环境断言 |
| 多态执行 | _activeResult.ExecuteAsync() 委托 |
运行时正确分派 |
| 编译安全 | 泛型约束 where T : IResult |
防止传入非法类型 |
十一、一个完整的实战示例 💻
bash
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
app.MapGet("/users/{id:int}", async Task<Results<Ok<UserDto>, NotFound, BadRequest<string>>>
(int id, IUserRepository repo) =>
{
if (id <= 0)
return TypedResults.BadRequest("ID 必须大于 0");
var user = await repo.FindAsync(id);
if (user is null)
return TypedResults.NotFound();
return TypedResults.Ok(new UserDto(user.Id, user.Name));
})
.WithName("GetUser")
.WithTags("Users");
// 🎉 Swagger 自动展示:200(UserDto) / 400(string) / 404
app.Run();
record UserDto(int Id, string Name);
生成的 Swagger UI 会自动出现:
bash
GET /users/{id}
Responses:
200 OK → UserDto { id: int, name: string }
400 Bad Request → string
404 Not Found → (no body)
总结
Results<T1, T2> 是 ASP.NET Core 在 Minimal API 上的一次精妙设计:
- 用
readonly struct+ 隐式转换 实现了轻量级的联合类型 - 用
IEndpointMetadataProvider+ 静态抽象接口 实现了零标注的 OpenAPI 元数据 - 用
RequestDelegateFactory在启动时完成元数据收集,在运行时完成透明的ExecuteAsync分派
它的出现让 Minimal API 真正做到了:代码即文档,类型即契约。