查询参数
上一章结尾,我们看到一个名字对不上的路由参数被框架当成了"查询字符串"。本节就来正式认识它:查询参数(query parameter),也就是 URL 中 ? 后面的 key=value 部分,例如 /todos?done=false&page=2。
它们通常用来筛选、排序、分页——不改变"访问的是哪个资源",只改变"怎么看这个资源"。
本节最终的完整代码:
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
List<Todo> todos =
[
new(1, "Buy milk", false),
new(2, "Write weekly report", true),
new(3, "Clean the litter box", false),
new(4, "Learn ASP.NET Core", false),
new(5, "Schedule a checkup", true),
];
app.MapGet("/todos", (bool? done, int page = 1, int pageSize = 2) =>
{
var result = done is null ? todos : todos.Where(t => t.Done == done);
return result.Skip((page - 1) * pageSize).Take(pageSize);
});
app.MapGet("/todos/search", (string keyword) =>
todos.Where(t => t.Title.Contains(keyword)));
app.MapGet("/todos/batch", (int[] id) =>
todos.Where(t => id.Contains(t.Id)));
app.Run();
record Todo(int Id, string Title, bool Done);第 15~22 行准备了一个内存中的待办事项列表作为演示数据。它用了「C# 速览」中介绍的集合表达式,new(1, "Buy milk", false) 省略了类型名,因为编译器能从 List<Todo> 推断出来。
运行与验证
dotnet run不带任何参数时,使用默认的分页设置(每页 2 条):
curl http://localhost:5080/todos[{"id":1,"title":"Buy milk","done":false},{"id":2,"title":"Write weekly report","done":true}]翻到第 2 页:
curl "http://localhost:5080/todos?page=2"[{"id":3,"title":"Clean the litter box","done":false},{"id":4,"title":"Learn ASP.NET Core","done":false}]只看未完成的,每页 10 条:
curl "http://localhost:5080/todos?done=false&pageSize=10"[{"id":1,"title":"Buy milk","done":false},{"id":3,"title":"Clean the litter box","done":false},{"id":4,"title":"Learn ASP.NET Core","done":false}]注意
URL 中包含 & 时,一定要用引号把整个地址括起来。否则终端会把 & 当作"在后台运行"的命令分隔符,后面的参数就丢了。
处理程序的参数就是查询参数
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
List<Todo> todos =
[
new(1, "Buy milk", false),
new(2, "Write weekly report", true),
new(3, "Clean the litter box", false),
new(4, "Learn ASP.NET Core", false),
new(5, "Schedule a checkup", true),
];
app.MapGet("/todos", (bool? done, int page = 1, int pageSize = 2) =>
{
var result = done is null ? todos : todos.Where(t => t.Done == done);
return result.Skip((page - 1) * pageSize).Take(pageSize);
});
app.MapGet("/todos/search", (string keyword) =>
todos.Where(t => t.Title.Contains(keyword)));
app.MapGet("/todos/batch", (int[] id) =>
todos.Where(t => id.Contains(t.Id)));
app.Run();
record Todo(int Id, string Title, bool Done);路由模板 "/todos" 里没有任何花括号,但处理程序有三个参数:done、page、pageSize。框架发现这些名字不在路由模板中,就会去查询字符串里找同名的值。
这就是上一章末尾那个现象的原因。对于本教程目前使用的普通参数——没有显式指定来源、没有注册为服务,也没有自定义绑定——可以先按下面的规则理解:
| 参数 | 来源 |
|---|---|
| 简单类型,且名字出现在路由模板中 | 路由 |
简单类型(int、string、bool、DateTime 等),且不在路由模板中 | 查询字符串 |
| 复杂类型(比如下一章的 record),且 HTTP 方法允许隐式请求体绑定 | 请求体(下一章) |
注意
GET、HEAD、OPTIONS、DELETE 不支持上表中的隐式请求体绑定。不要直接把本章 GET 端点的普通参数改成一个复杂对象;下一章会在 POST 端点中接收 JSON。注册为服务的类型和框架特殊类型也有其他绑定规则,在后续章节再介绍。
这套推断规则让最常见的写法最简短。当推断不符合你的意图时,也可以用特性(attribute)显式指定来源,比如 [FromQuery]、[FromRoute],我们在「Header 与 Cookie」一章会用到同类的 [FromHeader]。
提示
查询参数的名字不区分大小写:?Done=true&PAGESIZE=1 和 ?done=true&pageSize=1 效果相同。
必填与可选
同样是查询参数,done、page、pageSize 都是可选的,而下面这个端点的 keyword 是必填的:
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
List<Todo> todos =
[
new(1, "Buy milk", false),
new(2, "Write weekly report", true),
new(3, "Clean the litter box", false),
new(4, "Learn ASP.NET Core", false),
new(5, "Schedule a checkup", true),
];
app.MapGet("/todos", (bool? done, int page = 1, int pageSize = 2) =>
{
var result = done is null ? todos : todos.Where(t => t.Done == done);
return result.Skip((page - 1) * pageSize).Take(pageSize);
});
app.MapGet("/todos/search", (string keyword) =>
todos.Where(t => t.Title.Contains(keyword)));
app.MapGet("/todos/batch", (int[] id) =>
todos.Where(t => id.Contains(t.Id)));
app.Run();
record Todo(int Id, string Title, bool Done);区别完全来自参数的类型声明:
| 声明 | 含义 | 请求中没有这个参数时 |
|---|---|---|
string keyword | 不可为 null,没有默认值 | 返回 400 |
bool? done | 可为 null(?) | 收到 null |
int page = 1 | 有默认值 | 收到 1 |
不带 keyword 请求会得到:
curl -i http://localhost:5080/todos/searchHTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Microsoft.AspNetCore.Http.BadHttpRequestException: Required parameter "string keyword" was not provided from query string.这正是「C# 速览」中可空引用类型的用武之地:? 不仅告诉编译器"这里可能是 null",也告诉框架"这个参数可以不传"。你不需要再写任何额外的标注。
第 26 行利用了 done 可为 null 这一点:done is null 时表示"不筛选",返回全部;否则只保留 Done 等于 done 的项。
技术细节
int page = 1 这种写法是 Lambda 参数的默认值,从 C# 12 开始支持。ASP.NET Core 读取到默认值后,会把参数视为可选,并把默认值写进 OpenAPI 文档:/openapi/v1.json 中 page 参数的描述里会出现 "default": 1,keyword 则带有 "required": true。
类型不对时
和路由参数一样,查询参数也会按声明的类型转换,转换失败就返回 400:
curl -i "http://localhost:5080/todos?page=abc"HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Microsoft.AspNetCore.Http.BadHttpRequestException: Failed to bind parameter "int page" from "abc".done=yes 也会得到类似的错误,因为 bool 只接受 true 和 false。
FastAPI 对照
和 FastAPI 几乎完全一致:没有默认值的参数是必填的,Optional[bool] = None 对应 C# 的 bool? done,page: int = 1 对应 int page = 1。
数组参数
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
List<Todo> todos =
[
new(1, "Buy milk", false),
new(2, "Write weekly report", true),
new(3, "Clean the litter box", false),
new(4, "Learn ASP.NET Core", false),
new(5, "Schedule a checkup", true),
];
app.MapGet("/todos", (bool? done, int page = 1, int pageSize = 2) =>
{
var result = done is null ? todos : todos.Where(t => t.Done == done);
return result.Skip((page - 1) * pageSize).Take(pageSize);
});
app.MapGet("/todos/search", (string keyword) =>
todos.Where(t => t.Title.Contains(keyword)));
app.MapGet("/todos/batch", (int[] id) =>
todos.Where(t => id.Contains(t.Id)));
app.Run();
record Todo(int Id, string Title, bool Done);把参数声明为数组 int[] id,同一个参数名就可以在查询字符串中出现多次:
curl "http://localhost:5080/todos/batch?id=1&id=3&id=5"[{"id":1,"title":"Buy milk","done":false},{"id":3,"title":"Clean the litter box","done":false},{"id":5,"title":"Schedule a checkup","done":true}]一个 id 都不传时,id 是一个空数组,结果也是空的 []。数组参数总是可选的。
注意这里的参数名用的是单数 id 而不是 ids,因为它决定了 URL 里写的是 ?id=1&id=3。命名时要从调用方的角度考虑。
空格与 URL 编码
搜索带空格的关键字时,需要先做 URL 编码(URL encoding):空格会编码为 %20,所以 ASP.NET Core 在 URL 中写作 ASP.NET%20Core:
curl "http://localhost:5080/todos/search?keyword=ASP.NET%20Core"[{"id":4,"title":"Learn ASP.NET Core","done":false}]框架会在绑定参数前自动解码,所以处理程序收到的 keyword 是 "ASP.NET Core"。浏览器和 Scalar 文档页面会自动完成编码,在 /scalar 中直接输入带空格的关键字即可测试。
注意
本例中的关键字只包含 ASCII 字符和空格。空格必须按上面的形式写成 %20;直接将空格放入 URL 可能导致解析错误。也可以在 /scalar 页面中输入原始关键字,让浏览器处理编码。
总结
- 处理程序中不在路由模板里的简单类型参数,会自动从查询字符串绑定。
- 参数的类型声明决定了它是必填还是可选:普通类型必填,可空类型(
bool?)或带默认值(int page = 1)的参数可选。缺少必填参数或类型转换失败都返回 400。 - 数组参数(
int[] id)接收同名参数的多个值,形如?id=1&id=3。 - 查询参数名不区分大小写;非 ASCII 字符需要 URL 编码,框架会自动解码。
