Vane.Dispatch 服务分发器:从零到实践
面向初级 .NET 开发人员的系统教程。全书围绕公开 API 能做什么、怎么用、为什么这样设计展开,所有示例都可直接抄进自己的工程运行。
卷首:怎么读这本书
这本书写给谁
你已经会写 C# 类和方法,能读懂泛型、特性(Attribute)、接口与 Task。你还没有写过框架级代码,看到「模型绑定」「过滤器管线」「内容协商」这类词会有一点发怵。这本书从一次最普通的方法调用出发,一步步把它变成一条可路由、可绑定、可拦截、可序列化的分发管线。
书中所有示例都能在 .NET 10 上编译。示例代码尽量保持完整,可以直接抄进控制台工程运行。
环境约定
| 项目 | 取值 |
|---|---|
| SDK | .NET 10(10.0.300 及以上) |
| 库目标框架 | net8.0 与 net10.0 双目标 |
| 依赖 | 只依赖 BCL,无第三方包 |
| 命名空间 | 全部公开类型统一在 Vane.Dispatch |
| 引入方式 | dotnet add package Vane.Dispatch,或在本地解决方案里引用库工程 |
本书的示例都按最小可运行工程组织。若你要验证某个结论,把示例抄进控制台工程跑一遍即可,不必准备复杂的宿主环境。
阅读路线
| 部分 | 章节 | 目标 |
|---|---|---|
| 一 建立模型 | 1 至 4 | 知道分发器解决什么问题,能跑通第一个程序,能说出一次调用的完整流程 |
| 二 路由与调用 | 5 至 7 | 会注册控制器与端点,会在五种调用外观里选合适的那种 |
| 三 模型绑定 | 8 至 10 | 能把字典、路径、请求头、正文、服务依赖变成方法实参 |
| 四 横切关注点 | 11 至 14 | 会写过滤器、校验、异常兜底与上下文访问 |
| 五 序列化与传输 | 15 至 17 | 会接自定义协议,会把引擎挂到 HTTP 宿主上,会用异步与取消 |
| 六 工程化 | 18 至 22 | 会接 DI 容器,懂 AOT 边界,会接编译期生成器,能独立排错,能从 MVC 迁移认知 |
| 附录 | A 至 D | API 速查、术语、示例索引、练习要点 |
第一部分 建立心智模型
第 1 章 为什么需要服务分发器
1.1 从一个手写 switch 说起
假设你在做一个订单系统,调用方传来一个字符串操作名和一组参数。最直觉的写法是这样。
csharp
public object Handle(string operation, Dictionary<string, object> args)
{
switch (operation)
{
case "Create":
return new OrderService().Create((string)args["title"]);
case "Query":
return new OrderService().Query((int)args["id"]);
default:
throw new Exception("未知操作");
}
}
这段代码能跑,问题会随功能增长逐个冒出来。
switch越来越长,新增操作要改调度代码。- 类型转换散落在各处,
(int)args["id"]在传字符串时才报错。 - 日志、鉴权、校验、异常包装要复制进每个 case。
- 想从进程内调用改成网络调用,返回值是
object,序列化边界模糊。 - 参数来自路径、请求头、正文时,取值逻辑与业务代码混在一起。
把这几件事拆开看,调度代码其实承担了五种彼此独立的职责。
| 职责 | 手写 switch 的做法 | 成熟管线的做法 |
|---|---|---|
| 找到该执行哪个方法 | 字符串分支 | 构建期生成路由表,运行期查表 |
| 把外部数据变成方法实参 | 手工强转 | 模型绑定与类型转换 |
| 执行前与执行后的横切逻辑 | 复制粘贴 | 过滤器管线 |
| 表达成功与失败 | 抛异常或返回 null | 统一响应对象与状态码 |
| 把结果交给调用方 | 直接返回 object | 进程内对象与跨进程字节双形态 |
服务分发器就是把这五件事收进一条可复用的管线,让业务代码只剩方法本身。
1.2 Vane.Dispatch 的定位
它是一个微内核。给它一条路由字符串和一组输入,它找到对应的 CLR 方法,绑定参数,跑过滤器,调用方法,把结果收进统一的响应对象。它不监听端口,不解析 HTTP 报文,不管理数据库连接,也不强制你用某个 DI 容器。
它唯一要求的外部协作是一个委托。
csharp
public delegate object ServiceResolver(Type serviceType);
给它一个类型,还它一个实例。这个委托可以包住 Activator.CreateInstance,可以包住 IServiceProvider,也可以包住任意自研容器。整个库与容器、传输层的耦合就压缩在这一行里。
1.3 什么时候该用,什么时候别用
辩证地看,零耦合既是它最大的优点,也是它最容易被误用的地方。
适合使用它的场景:
- 你在写 RPC 网关、消息处理器、游戏服务器指令分发、插件宿主,需要一套与协议无关的方法路由。
- 同一套业务方法既要被进程内强类型调用,又要被 HTTP 或二进制协议调用。
- 你希望横切逻辑只写一次,控制器、端点、代理调用共享同一条管线。
- 你在意 Native AOT 与裁剪,需要框架明确告知哪些入口依赖反射。
应当谨慎的场景:
- 你要做的是一个标准 REST Web API,需要路由约束、HTTP 动词语义、认证中间件、OpenAPI、文件上传。ASP.NET Core 已经把这些做得很完整,用 Vane 会让你自己补大量宿主代码。
- 你期待开箱即用的框架级体验。Vane 提供的是内核,传输层、鉴权、序列化策略都要宿主自己接。
- 你希望同名方法按参数签名重载。Vane 把重载视为构建期配置错误,见第 5 章。
一句话概括边界。引擎负责「调用哪段代码、怎么把数据喂给它、成败怎么表达」;「数据从哪种网络来、身份怎么认证、连接怎么管理」归宿主。
1.4 与熟悉的东西对照
| 你熟悉的 | Vane 里对应的 | 关键差别 |
|---|---|---|
ASP.NET Core Controller + [ApiController] |
任意类加类级 [Route] |
无需基类,无需 IActionResult,直接返回 POCO |
Minimal API 的 MapGet |
Map / MapGet / MapPost / Handle |
动词只是命名约定,不参与路由判定 |
| MVC 过滤器 | IFilter / IAsyncFilter / IExceptionFilter |
分类更扁平,OnExecuted 三态恒运行 |
IHttpContextAccessor |
IDispatchContextAccessor |
基于 AsyncLocal,未接入时零分配 |
| gRPC 等调用代理 | CreateProxy<T>() |
接口代理,本地与远程共用同一条分发管线 |
1.5 小结与练习
分发器的价值在于把路由、绑定、横切逻辑、错误模型从业务方法里剥离。Vane.Dispatch 把这条管线做得足够薄,代价是宿主需要自己承担传输层与框架级便利。
练习:列一个你写过的 switch 调度函数,把其中每种职责分别归入上面表格的五行,看看哪一行最臃肿。
第 2 章 环境准备与第一个程序
2.1 建工程
新建控制台工程,按仓库结构引用源码工程,或者直接用 NuGet 包。
bash
dotnet new console -n FirstDispatch -f net10.0
cd FirstDispatch
dotnet add package Vane.Dispatch
若你在仓库内学习,改用项目引用更方便。
xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../src/Vane.Dispatch/Vane.Dispatch.csproj" />
</ItemGroup>
</Project>
ProjectReference 的相对路径按你新建工程的位置调整。若工程不在仓库根下,改成实际相对路径即可。
仓库根 global.json 钉在 10.0.400。若本机 SDK 版本不匹配,从能解析该 SDK 的工作目录执行 dotnet 命令即可,示例索引里给过这个提示。
2.2 完整可运行的最小程序
把下面内容整体替换 Program.cs。C# 顶层语句要写在类型声明之前,所以 MathController 放在文件末尾。
csharp
using System;
using System.Collections.Generic;
using Vane.Dispatch;
// ① 组合根:告诉引擎「按类型去哪里拿实例」
ServiceResolver resolver = type =>
type.GetConstructor(Type.EmptyTypes)?.Invoke(null)
?? throw new MissingMethodException(type.FullName);
// ② 装配:注册控制器,构建期生成路由表
IDispatchEngine engine = resolver
.BuildDispatchEngine()
.MapDispatchable<MathController>()
.Build();
// ③ 外观之一:强类型快捷调用
int sum = engine.Dispatch<int>("math", "Add", new { a = 2, b = 3 });
Console.WriteLine($"Add(2,3) = {sum}");
// ④ 外观之二:完整请求对象
var request = new DispatchRequest
{
Route = "math/Square",
Payload = new Dictionary<string, object> { ["x"] = 6 },
ResultType = typeof(int),
};
DispatchResponse response = engine.Dispatch(request);
Console.WriteLine($"Square(6) = {response.Result}, status = {response.StatusCode}, success = {response.IsSuccess}");
// ⑤ 外观之三:接口代理
IMath proxy = engine.CreateProxy<IMath>("math");
Console.WriteLine($"proxy.Square(9) = {proxy.Square(9)}");
public interface IMath
{
int Square(int x);
}
[Route("math")]
public sealed class MathController
{
public int Add(int a, int b) => a + b;
public int Square(int x) => x * x;
}
预期输出:
text
Add(2,3) = 5
Square(6) = 36, status = 200, success = True
proxy.Square(9) = 81
2.3 逐行拆解
ServiceResolver 是一个委托。resolver.BuildDispatchEngine() 是扩展方法,返回 IDispatchBuilder。它还有一个重载接收 IServiceProvider,内部把 GetRequiredService 包成同样的委托。
MapDispatchable<MathController>() 把类型登记进待构建列表。Build() 在构建期反射扫描该类型的方法与特性,生成只读路由表,同时为每个方法编译一个表达式树调用器。构建完成后运行期分发不再反射调用方法。
Dispatch<int>("math", "Add", new { a = 2, b = 3 }) 是泛型快捷入口。三个参数分别是组名、操作名、参数对象。匿名对象会被收敛成字典,AOT 场景改用字典重载。
DispatchRequest 是完整管线入口。Route 决定去哪,Payload 放已解析好的具名参数。ResultType 是给宿主反序列化用的提示字段,引擎本身不读它;绑定依据的是方法元数据里的参数类型。Dispatch<TResult> 也不会回填这个字段。
CreateProxy<IMath>("math") 生成一个接口代理。这里显式传了组名,因为接口名 IMath 按命名约定会推导成 math,恰好能对上;若接口名与类级 [Route] 不一致,必须显式传组名,否则得到 404。第 7 章会详细讲。
2.4 第一个程序最常见的三个错误
| 症状 | 原因 | 修复 |
|---|---|---|
IsSuccess == false 且状态码 404 |
忘记 MapDispatchable<T>(),或路由字符串写错 |
检查注册与 组名/操作名 拼写,大小写不敏感 |
解析阶段抛 MissingMethodException |
ServiceResolver 无法构造控制器 |
补无参构造函数,或让容器能解析该类型 |
| 拆箱失败或类型转换异常 | TResult 与方法实际返回类型不一致 |
让 TResult 与返回类型严格一致,或读 response.Result 自行处理 |
还有一个容易被忽略的细节。第 ③ 步的匿名对象依赖反射提取属性,Native AOT 下会有警告。改用字典即可。
csharp
int sum2 = engine.Dispatch<int>("math", "Add",
new Dictionary<string, object> { ["a"] = 20, ["b"] = 22 });
2.5 小结与练习
一次最小分发只需要三样东西,一个提供实例的委托、一个带 [Route] 的控制器、一次 Build()。其余全部是可插拔的扩展点。
练习:给 MathController 加一个 Cube(int x) 方法,分别用 Dispatch<int>、DispatchRequest、Send<MathController, int> 三种方式调用它,比较三处代码的差别。
第 3 章 一次分发的完整旅程
这一章是全书最重要的一章。后面所有章节都在展开这里列出的某一步。
3.1 同步入口 Dispatch(DispatchRequest) 的十二步
| 步 | 阶段 | 具体动作 | 失败时 |
|---|---|---|---|
| 1 | 空值检查 | 请求为空或 Route 为空,直接构造失败响应 |
400 |
| 2 | 路由归一化 | 剥离首尾斜杠,/ping 与 ping 等价 |
无 |
| 3 | 建响应与上下文 | 新建 DispatchResponse,若接入了上下文访问器则创建 AsyncLocal 作用域 |
无 |
| 4 | 控制器路由查表 | RouteTable.TryGet 精确查 组名/操作名,大小写不敏感 |
未命中进入第 5 步 |
| 5 | 自由端点匹配 | 按路径段数分桶,逐段匹配模板,{name} 捕获进 RouteValues |
都未命中给 404 |
| 6 | 服务实例解析 | 经构建时注入的 ServiceResolver 解析控制器实例 |
解析失败归 500 |
| 7 | 模型绑定 | 逐个参数走绑定器链,错误聚合进 ModelState |
绑定失败归 400 |
| 8 | OnExecuting 过滤器 |
全局同步过滤器在前,操作级特性过滤器在后;校验过滤器在此写 ModelState 与 Canceled |
校验失败归 400,短路则不调操作 |
| 9 | 调用操作 | 表达式编译的调用器执行方法,同步方法直接返回 | 异常被捕获进上下文 |
| 10 | OnExecuted 三态收口 |
服务实例化之后,成功、异常、取消三种状态都执行;随后同步异常过滤器可置 ExceptionHandled |
未处理异常归 500,取消归 499 |
| 11 | 状态码与错误文本 | 失败时把异常消息补进 Error,ProblemDetails 惰性生成 |
无 |
| 12 | 输出序列化 | 成功且 Body 未写且 Result 非空时,按 Accept 选输出格式化器写入 Body 与 ContentType |
Accept 全不命中归 406 |
3.2 五个必须分清的边界
Payload 与 Body。Payload 是已经拆好的参数字典,适合进程内调用与 RPC 信封参数。Body 是 ReadOnlySequence<byte> 字节视图,适合 HTTP 正文、MessagePack、Protobuf。二者混用会让 [FromBody] 读不到数据。
Result 与 Body。Result 是 CLR 对象,进程内调用读它。Body 是 Result 的序列化视图,跨进程宿主读它。引擎不覆盖 Result,两者并存。
IsSuccess 与 StatusCode。IsSuccess 同时看内部失败标记、Error、Exception 与累积异常列表。只判断 StatusCode == 200 不足以判定成功。
Dispatch 与 DispatchAsync。同步路径没有令牌概念,取消语义只存在于异步路径。异步入口复用同一套同步内核,仅对返回 Task / ValueTask 的处理器与 IAsyncFilter 执行 await。
404 与 500。路由没找到属于调用方问题,归 404。操作内部抛异常属于服务问题,归 500。绑定与校验失败归 400。
3.3 管线里每个角色的职责
| 角色 | 负责什么 |
|---|---|
| 分发引擎 | 主流程、三态收口、输出序列化 |
| 装配器 | 默认组件、注册顺序与优先级 |
| 契约路由表 | 路由键注册、重复检测、精确查表 |
| 端点模板与调用器 | 路径段匹配、把方法编译成可调用的委托 |
| 命名解析 | 类名与方法名到路由键的推导 |
| 模型绑定 | 绑定来源判定、绑定器链、类型转换 |
| 过滤器与校验 | 执行前后拦截、短路、异常兜底 |
| 格式化器与选择器 | 输入输出协商、媒体类型与 q 值解析 |
| 请求、响应与异常 | 数据载体、状态码、错误表达 |
3.4 装配顺序的语义
DispatchBuilder 对可插拔组件用了两种装配方式,理解它能省掉很多调试时间。
AddModelBinder、AddInputFormatter、AddOutputFormatter、AddValueProvider 都执行 Insert(0, ...),新注册的排在最前。进程内不协商时,谁排第 0 位谁先命中。
AddGlobalFilter、AddGlobalAsyncFilter、AddGlobalExceptionFilter 执行 Add(...),按注册顺序串行。全局过滤器按接口注释排在操作级特性过滤器之前。
请求一旦带 Content-Type 或 Accept,FormatterSelector 按媒体类型与 q 值选择,注册顺序退回落优先级。第 15 章展开。
3.5 小结与练习
把一次调用想象成一条流水线。前半段解决「去哪里、拿什么参数」,中间段解决「能不能执行、执行了什么」,后半段解决「怎么表达结果」。调试时先定位故障落在哪一段,再去看对应源码。
练习:故意发一条未注册的路由,打印 IsSuccess、StatusCode、Error、Exception?.GetType().Name、ProblemDetails.Status,把这五个值与第 3.1 节表格对照。
第 4 章 核心对象速览
4.1 三个骨架类型
csharp
public delegate object ServiceResolver(Type serviceType);
public interface IDispatchBuilder { /* 装配方法,见附录 A */ }
public interface IDispatchEngine
{
DispatchResponse Dispatch(DispatchRequest request);
ValueTask<DispatchResponse> DispatchAsync(DispatchRequest request,
CancellationToken cancellationToken = default);
TResult Dispatch<TResult>(string serviceName, string operationName,
object requestArgs = null);
T CreateProxy<T>(string dispatchName = null) where T : class;
TResult Send<TService, TResult>(string action, object argument);
ServiceResolver Services { get; }
IActionDescriptor[] GetActionDescriptors(Type serviceType);
}
GetActionDescriptors(Type) 暴露构建期扁平化的动作元数据,包含组名、方法、过滤器、绑定信息、自动绑定开关。它适合做文档生成与调试面板。
4.2 DispatchRequest 字段全表
| 字段 | 类型 | 用途 |
|---|---|---|
Route |
string |
组名/操作名,首尾斜杠会被剥离 |
Payload |
object |
已解析的具名参数,常为 IDictionary<string, object> 或匿名对象 |
Arguments |
IDictionary<string, object> |
Payload 的字典视图,惰性收敛 |
Headers |
IDictionary<string, string> |
请求头,供 [FromHeader] 读取 |
RouteValues |
RouteValueDictionary |
路径捕获值,供 [FromRoute] 读取 |
Body |
ReadOnlySequence<byte> |
原始正文字节视图,供 [FromBody] 读取 |
Context |
IDictionary<string, object> |
请求级共享字典,首次访问才分配 |
Services |
ServiceResolver |
预留字段,引擎不读取;实际解析器由构建时注入,不能在请求级覆盖 |
ResultType |
Type |
进程内字段,目标 CLR 类型,禁止写入跨进程帧;引擎不读取 |
ValidateRequest |
bool |
预留开关,默认 true;当前版本引擎不读取,校验由 UseValidation() 装配 |
ContentType |
string |
入站媒体类型,触发输入协商 |
Accept |
string |
出站可接受媒体类型,可带 q 值 |
DeadlineUs |
long |
端到端 deadline,绝对 Unix 微秒,0 表示未设 |
TraceId / SpanId / ParentSpanId |
string |
调用链追踪三元组 |
ServiceName / ActionName |
string |
由 Route 拆出的只读派生属性 |
还有一个静态工厂,适合构造跨进程信封。
csharp
DispatchRequest.Create(string serviceName, string operationName, object args);
DispatchRequest.Create(string serviceName, string operationName,
IDictionary<string, object> args);
第二个重载避免匿名对象反射,AOT 友好。
4.3 DispatchResponse 字段全表
| 字段 | 类型 | 说明 |
|---|---|---|
Result |
object |
成功时的返回值,值类型装箱一次 |
Error |
string |
失败消息 |
Exception |
Exception |
原始异常,进程内可查 |
Exceptions |
Exception[] |
累积异常快照,无异常时返回共享空数组 |
StatusCode |
int |
默认 200 |
ContentType |
string |
实际选中的出站媒体类型 |
Body |
ReadOnlySequence<byte> |
Result 的序列化视图,可跨段 |
IsSuccess |
bool |
综合判定,见第 3.2 节 |
ProblemDetails |
ProblemDetails |
失败时惰性生成,成功时为 null |
公开工厂与失败出口。
csharp
DispatchResponse.Ok(object result);
DispatchResponse.Fail(string error);
DispatchResponse.Fail(string error, Exception exception);
void AddException(Exception ex);
Success 属性的 setter 是 internal。库外要表达失败,统一用 AddException、Fail、Ok,这样不会丢掉 Error 与异常提示。AddException 遇到 DispatchException 会把它的状态码保真写进响应,415 不会被降级成 400。
ProblemDetails 的形状是 RFC 7807 风格。
csharp
public sealed class ProblemDetails
{
public string Type { get; set; } = "about:blank";
public string Title { get; set; }
public int Status { get; set; }
public string Detail { get; set; }
public IDictionary<string, object> Extensions { get; }
}
4.4 状态码全表
状态码常量一共十个。
| 常量 | 值 | 触发场景 |
|---|---|---|
OK |
200 | 成功 |
BadRequest |
400 | 空请求、绑定失败、校验失败、显式业务失败 |
NotFound |
404 | 路由与端点均未命中 |
NotAcceptable |
406 | Accept 无任何输出格式化器能满足 |
UnsupportedMediaType |
415 | Content-Type 无任何输入格式化器能读 |
TooManyRequests |
429 | 过载保护快速拒绝,属宿主准入层 |
RequestCanceled |
499 | 异步取消,自定义状态码 |
InternalServerError |
500 | 操作未处理异常、服务实例创建失败 |
ServiceUnavailable |
503 | 临时过载或依赖不可用 |
GatewayTimeout |
504 | 端到端 deadline 到期,由宿主准入层在进业务前短路 |
429、503、504 属于跨进程治理层语义,引擎提供常量供宿主统一口径,进程内分发通常只会遇到 200、400、404、406、415、499、500。
4.5 控制器实例生命周期契约
有一条容易被忽略的约定。引擎每次分发经 ServiceResolver 解析控制器实例,分发结束后不负责释放,对 IDisposable 不主动调用。
实例所有权归解析器来源方。若经容器作用域解析,由作用域结束统一释放。若用 type => new X() 自建工厂,请自行释放或确保实例不持有瞬态资源。把引擎当成实例拥有者会误杀容器里的共享实例或单例。
4.6 小结与练习
DispatchRequest 描述输入,DispatchResponse 描述输出,两者中间是管线。记住三条最实用的边界,Payload 与 Body 分工不同,Result 与 Body 并存,IsSuccess 比 StatusCode 更可靠。
练习:构造一个 DispatchResponse.Fail("演示", new InvalidOperationException("boom")),打印 ProblemDetails 的五个字段,再对 DispatchResponse.Ok("ok") 取 ProblemDetails,确认它是 null。
第二部分 路由与调用外观
第 5 章 控制器与特性路由
5.1 类级 [Route] 一次表达三件事
csharp
[Route("orders")]
public sealed class OrderController
{
public string Get(int id) => $"order #{id}";
}
类级 [Route] 同时承担可分发声明、路由组名、默认自动绑定推断。它相当于把 MVC 里的 Controller 基类、[Route]、[ApiController] 三件事合成一个特性。控制器可以是任意类,无需基类,也无需实现接口。
组名解析有一套固定优先级。取类级首个非空 [Route] 模板;没有模板时取类名去掉 Controller 后缀。上例组名是 orders,完整路由为 orders/Get。若写成 [Route] 无参构造,组名退化为 Order。
5.2 方法级 [Route] 是操作名与别名
csharp
[Route("orders")]
[Route("order-api")]
public sealed class OrderController
{
[Route("fetch")]
[Route("get-by-id")]
public string Get(int id) => $"order #{id}";
}
方法级首个 [Route] 模板成为操作名,其余模板成为别名。类级多个 [Route] 同理,首个别名之外的模板成为组别名。于是下面四条路由指向同一个方法。
text
orders/fetch
orders/get-by-id
order-api/fetch
order-api/get-by-id
方法没有 [Route] 时,操作名就是方法名。
5.3 构建期的重复检测
路由表在构建期就做重复检测:若同一路由键已存在且指向不同方法,直接抛异常。这是 fail-fast 设计。
它带来两个实际后果。
第一,方法重载会被当成配置错误。Add(int, int) 与 Add(string, string) 会撞同一个操作名,请给方法起不同名字,或用方法级 [Route] 分配别名。
第二,MapGet("/x", fn) 与 MapPost("/x", fn) 会撞路由。动词在这套体系里只是命名约定,不参与路由判定。
5.4 大小写与斜杠
路由表查表大小写不敏感,orders/Get 与 ORDERS/get 等价。Dispatch 入口会剥离首尾斜杠,/ping 与 ping 等价。这两个归一化让宿主适配更省心。
5.5 [NoAutoBind]
csharp
[Route("binder")]
[NoAutoBind]
public sealed class BinderController
{
public int RouteVal([FromRoute] int id) => id;
public int QueryVal([FromQuery] int q) => q;
}
类级 [NoAutoBind] 关闭自动绑定推断,参数来源完全由显式 [From*] 与按名绑定链决定。写绑定教学示例或需要严格控制来源时用它。默认类级 [Route] 开启推断,规则见第 8 章。
5.6 批量扫描
csharp
var engine = resolver.BuildDispatchEngine()
.MapDispatchablesFromAssembly(typeof(OrderController).Assembly)
.Build();
它扫描程序集里全部带 [Route] 的类型。生产应用按自己的程序集边界使用,好处是新增控制器无需改组合根。两个代价要清楚。
- 依赖反射枚举类型,AOT 与裁剪下需要保留类型,调用点会看到
RequiresUnreferencedCode警告。 - 同一个程序集里若有多个示例控制器,容易撞路由。仓库的 D01 至 D08 示例集就刻意用显式
MapDispatchable<T>()避免跨文件撞名。
5.7 路由能力的边界
| 能力 | 支持情况 |
|---|---|
两级常量路由 组名/操作名 |
支持,O(1) 查表 |
| 组别名、操作别名 | 支持,类级与方法级多 [Route] |
端点模板变量段 {name} |
支持,仅自由端点,见第 6 章 |
| HTTP 动词约束 | 无 |
{id:int} 类型约束、默认值、可选段 |
无 |
| 同名方法按签名重载 | 视为重复路由,构建期报错 |
| 区域、命名路由、优先级消解 | 无 |
这些缺口来自定位取舍。契约路由天然唯一,构建期就能把冲突暴露出来,运行期无需候选集择优。若你的接口确实需要动词与约束,请在宿主层先做完 HTTP 语义解析,再把结果映射成一条 Vane 路由。
5.8 小结与练习
类级 [Route] 定组名与方法级定操作名,合起来就是一条字符串查表键。重复键在构建期报错,别把 MVC 的重载与动词习惯带进来。
练习:给一个控制器挂两个类级 [Route] 与两个方法级 [Route],枚举出所有可达路由字符串,再用 engine.Dispatch 逐一验证。
第 6 章 自由端点与路径模板
6.1 四种注册写法
自由端点有 Map、MapGet、MapPost 与 Handle 四种注册方式。MapGet 与 MapPost 内部都调用 Handle,动词只是声明约定。Map 有多个泛型重载,让 lambda 直接获得强类型参数。
csharp
var engine = resolver.BuildDispatchEngine()
.MapGet("mini/hello/{name}", (string name) => $"hello, {name}!")
.MapPost("mini/orders", (Order o) => $"order #{o.Id}")
.Map("mini/add", (int a, int b) => a + b)
.Handle("mini/ping", (Func<string>)(() => "pong"))
.Build();
Handle 接收 Delegate。lambda 需要显式目标类型时写 (Func<string>)(() => "pong"),或用 Map<TResult> 泛型重载省掉转换。
6.2 模板匹配与 {name}
端点模板按路径段数分桶,逐段匹配。常量段严格相等,{name} 段捕获到 RouteValueDictionary,再参与参数绑定。
csharp
var response = engine.Dispatch(new DispatchRequest
{
Route = "mini/hello/Alice",
ResultType = typeof(string),
});
// response.Result == "hello, Alice!"
占位符值无需在 Payload 里再放一份。宿主若已在上游解析过路径,也可以直接填 RouteValues。
csharp
var rvd = new RouteValueDictionary();
rvd["name"] = "Bob";
RouteValueDictionary 提供 Count、Keys、TryGetValue、Add 与索引器,键比较大小写不敏感。
6.3 匹配优先级
DispatchEngine.Dispatch 先查控制器路由表,未命中才尝试自由端点。两者都未命中给 404。自由端点与控制器共享全局过滤器、绑定器、格式化器与输出管线,差别只在激活方式,控制器经 ServiceResolver 取实例,委托端点直接调用。
6.4 什么时候选哪种形态
| 形态 | 适合 | 代价 |
|---|---|---|
控制器 [Route] |
成组业务、需要构造注入、需要操作级特性过滤器 | 需要定义类与方法 |
模板端点 MapGet |
少量轻量入口、路径带变量 | 无动作描述符,校验只覆盖模型级 |
自由委托 Handle |
把已有静态函数直接挂上 | 参数形状受委托签名约束 |
三者最终进同一张端点表概念下的分发流程,可以按模块混用。
6.5 小结与练习
自由端点让内核拥有了 Minimal API 风格的外壳,动词在这里没有 HTTP 语义。模板占位符进 RouteValues,随后照常参与绑定。
练习:注册 MapGet("shop/{category}/item/{id}", (string category, int id) => ...),分别用 Route 字符串与手工 RouteValues 两种方式调用,观察绑定结果。
第 7 章 五种调用外观
同一个操作可以有五种调用方式。前四种最终都落到 Dispatch(DispatchRequest) 这条完整管线上,第五种「强类型直达」是刻意留的例外(见 7.5)。仓库示例 D08 专门验证了前四种结果一致。
7.1 完整管线入口
csharp
DispatchResponse full = engine.Dispatch(new DispatchRequest
{
Route = "calc/Square",
Payload = new Dictionary<string, object> { ["x"] = 9 },
ResultType = typeof(int),
});
int d = (int)full.Result;
信息最全,能拿到状态码、异常、结构化错误体、序列化后的 Body。跨进程宿主与需要精细错误处理的场景用它。
7.2 强类型裸分发 Dispatch<TResult>
csharp
int a = engine.Dispatch<int>("calc", "Square", new { x = 9 });
int a2 = engine.Dispatch<int>("calc", "Square",
new Dictionary<string, object> { ["x"] = 9 });
它把 serviceName、operationName、requestArgs 桥接成 DispatchRequest,并把异常透传出来。匿名对象版会反射提取属性,字典版 AOT 友好。返回值与方法实际类型必须一致,否则拆箱失败。
这个方法是 IDispatchEngine 的默认接口方法:本地引擎与远程引擎共用同一份实现,服务名解析、请求构造、失败异常映射完全一致。第 22 章讲这次统一解决了什么。
7.3 按类型定位 Send<TService, TResult>
csharp
int b = engine.Send<CalcController, int>("Square", 9);
组名由控制器类型解析,省掉手写字符串。argument 就是「本次调用的唯一参数」:字典直接按键展开;非字典(原始值或单对象)经 __value 约定映射到操作参数,数组则按参数索引做位置映射。Send 内部仍走完整分发管线,因此过滤器、绑定、错误模型都生效。
注意它与 Dispatch<TResult> 的刻意分工:Send 面向「单值 / 字典」,Dispatch<TResult> 面向「具名对象的属性展开」。二者同为接口默认方法,统一委托 this.Dispatch 与同一个异常收口,本地与远程语义一致,详见第 22 章。
7.4 接口代理 CreateProxy<T>
csharp
public interface ICalc { int Square(int x); }
[Route("calc")]
public sealed class CalcController : ICalc
{
public int Square(int x) => x * x;
}
ICalc proxy = engine.CreateProxy<ICalc>("calc");
int c = proxy.Square(9); // 81
代理把接口调用转发成分发操作,走同一套路由、绑定与过滤器。它有两条实现路径:接入编译期生成器且契约是纯同步时优先用生成代理 (直调 Invoke,无 object[]、无反射分发);否则回退运行期 DispatchProxy 拦截器。四个约束要记牢。
- 只支持接口类型,传类会抛
ArgumentException。 - 生成代理路径不需要运行期代码生成,AOT 友好;回退路径标注了
RequiresDynamicCode,Native AOT 下不可用。 - 只有不传
dispatchName时才走生成代理:生成代码里的路由在编译期写死,无法承载运行时自定义名。显式传组名一律回退拦截器。 - 不传
dispatchName时按接口名推导组名,规则是去I前缀、去Controller/Service后缀、转小写。接口名与类级[Route]不一致时会 404,用CreateProxy<T>("calc")显式指定。
生成器接线是按程序集 生效的:使用 CreateProxy<T>() 的工程必须显式引用生成器,否则登记表恒为空,会静默回退拦截器------功能正常、测试全绿,但没有零分配收益,属于典型的「假绿」。第 22 章给出接线方式与诊断。
仓库 D08 里 ID08Calc 会推导成 d08calc,与 [Route("d08")] 不符,示例因此显式传了 "d08"。这是最常见的代理排错点。
7.5 强类型直达 Invoke<TArgs, TResult>
csharp
DispatchResult<int> r = engine.Invoke<int, int>("calc/Square", 9);
int value = r.Value;
TArgs 按值进入,TResult 原样返回,全程不经过 object[] 与 object 结果,值类型不装箱,也不分配 DispatchResponse。代价是它不做模型绑定、不做内容协商:参数由调用方直接给出,结果也不写 Body。
它适合进程内热路径。跨进程时字节边界必然带来一次序列化装箱,「零装箱」这个保证无法越界保真------这是物理约束,不是实现遗漏。远程引擎提供了同一个方法的实现:把参数按名展开后走通用分发,保住强类型接口契约与状态码、异常映射等全部可观测行为。
多参数用 ValueTuple 或具名载体。
csharp
var sum = engine.Invoke<(int, int), int>("calc/Add", (2, 3));
位置元组只在进程内按位映射;跨进程服务端按名绑定,应改用字段名与形参一致的具名载体,否则元组项名 Item1 / Item2 匹配不上形参。
Invoke 是接口里唯一「默认抛、由具体引擎重写」的方法:本地引擎给出零装箱实现,远程引擎给出按名展开加通用分发的实现,第三方引擎没重写时会得到 NotSupportedException。第 22 章展开生成代理如何自动走这条路径。
7.6 异步入口 DispatchAsync
csharp
DispatchResponse r = await engine.DispatchAsync(
new DispatchRequest { Route = "calc/Square",
Payload = new Dictionary<string, object> { ["x"] = 9 },
ResultType = typeof(int) },
CancellationToken.None);
它与前五种不在同一维度。7.1 至 7.5 是「怎么表达调用」,DispatchAsync 是「用哪条执行路径」。第 17 章展开。
7.7 选择建议
| 需求 | 选 |
|---|---|
宿主层、要完整错误模型与 Body |
Dispatch(DispatchRequest) |
| 进程内简单调用、参数已就绪 | Dispatch<TResult> 或 Send<TService, TResult> |
| 希望调用方只依赖接口契约 | CreateProxy<T> |
| 操作含 I/O、需要取消或异步过滤器 | DispatchAsync |
| Native AOT | 接好生成器后用纯同步契约的 CreateProxy<T>();或避开代理与匿名对象参数,优先字典加 Dispatch(DispatchRequest) |
7.8 小结与练习
五种外观的差异集中在调用侧的便利程度,内核是同一条(Invoke 例外,走直达管线)。别为了统一风格强行全用代理,也别在需要状态码的地方用裸分发。
练习:用 CreateProxy<T> 不传组名复现一次 404,再用显式组名修好,打印两次的 response.StatusCode 做对照。
第三部分 模型绑定
模型绑定回答一个问题。请求里的字符串、字节、字典项,怎么变成方法签名上的 int id、Order order、IGreeter greeter。
第 8 章 绑定源与自动推断
8.1 六种来源
绑定来源一共六个枚举值。
BindingSourceKind |
特性 | 数据来源 | 承载字段 |
|---|---|---|---|
Route |
[FromRoute] |
路径模板捕获值 | request.RouteValues |
Header |
[FromHeader] |
请求头 | request.Headers |
Query |
[FromQuery] |
具名参数与查询字典 | request.Payload / Arguments |
Body |
[FromBody] |
原始正文字节 | request.Body |
Services |
[FromServices] |
依赖服务 | ServiceResolver |
Default |
未标注 | 值提供器链递归折叠 | 多个来源按链序 |
注意 Query 在这个库里对应的是 PayloadValueProvider,它读的是请求里已解析好的参数字典。名字沿用 MVC 习惯,含义更接近「信封参数」。
8.2 一个覆盖全部来源的控制器
下面用 [NoAutoBind] 关掉推断,逐项显式声明。
csharp
using System;
using System.Buffers;
using Vane.Dispatch;
[Route("binder")]
[NoAutoBind]
public sealed class BinderController
{
public byte[] EchoBody([FromBody] byte[] data) => data;
public int BodyValue([FromBody] int n) => n * 2;
public string Service([FromServices] IGreeter dep) => dep.Greet();
public int QueryVal([FromQuery] int q) => q;
public string HeaderVal([FromHeader] string h) => h ?? "";
public int RouteVal([FromRoute] int id) => id;
public int DefaultAdd(string name, int count) => count + (name?.Length ?? 0);
}
public interface IGreeter { string Greet(); }
public sealed class Greeter : IGreeter
{
public string Greet() => "hello-from-deps";
}
调用侧分别填对应字段。
csharp
ServiceResolver resolver = type =>
type == typeof(IGreeter) ? new Greeter()
: type.GetConstructor(Type.EmptyTypes)?.Invoke(null)
?? throw new MissingMethodException(type.FullName);
var engine = resolver.BuildDispatchEngine()
.MapDispatchable<BinderController>()
.Build();
var q = engine.Dispatch(new DispatchRequest
{
Route = "binder/QueryVal",
Payload = new Dictionary<string, object> { ["q"] = 4 },
ResultType = typeof(int),
}); // q.Result == 4
var h = engine.Dispatch(new DispatchRequest
{
Route = "binder/HeaderVal",
Headers = new Dictionary<string, string> { ["h"] = "yo" },
}); // h.Result == "yo"
var rvd = new RouteValueDictionary();
rvd["id"] = 7;
var rt = engine.Dispatch(new DispatchRequest
{
Route = "binder/RouteVal",
RouteValues = rvd,
ResultType = typeof(int),
}); // rt.Result == 7
[FromBody] 读 Body 字节,先经 FormatterSelector.SelectInput 挑输入格式化器,再由格式化器反序列化成目标类型。无正文时 BodyModelBinder 回退到具名参数字典,方便进程内调用。
8.3 来源隔离
显式 [FromRoute]、[FromHeader]、[FromQuery] 严格从各自来源取值,不跨源泄漏。即使路由值里存在名为 h 的键,[FromHeader] string h 也不会命中它。
内置的三个值提供器各自声明了 SourceKind,HeaderValueProvider 为 Header,RouteValueProvider 为 Route,PayloadValueProvider 为 Query。宿主自定义 IValueProvider 若要参与 [From*] 匹配,需要实现 SourceKind 属性。基类 ValueProviderBase 是 internal,库外请直接实现 IValueProvider 接口。
8.4 自动绑定推断
类级 [Route] 默认开启按类型推断,对标 MVC 的 [ApiController]。判定依据是参数类型是否属于简单类型。
| 参数类型 | 推断结果 |
|---|---|
| 接口 | Services |
| 简单标量 | Default(走值提供器链,路由值优先) |
| 其余复杂类型与集合 | Body |
简单标量的白名单包含 BCL 基元类型、可空类型、枚举、string、Guid、DateTime、DateTimeOffset、decimal、char、TimeSpan、DateOnly、TimeOnly。判定依据不是 Type.IsPrimitive,因为 decimal 与 DateOnly 这类常用标量并不属于 primitive。
csharp
[Route("api")]
public sealed class ApiOrders
{
public string Create(Order o) => $"api#{o.Id}:{o.Title}"; // 复杂 DTO 自动走 Body
public string Greet(IGreeter g) => g.Greet(); // 接口自动走 Services
public int Double(int n) => n * 2; // 简单类型走参数字典
}
public sealed class Order
{
public int Id { get; set; }
public string Title { get; set; }
}
调用 Create 时只需给 Body 字节,JsonInputFormatter 会把它反序列化成 Order。
csharp
var resp = engine.Dispatch(new DispatchRequest
{
Route = "api/Create",
Body = new ReadOnlySequence<byte>(
Encoding.UTF8.GetBytes("{\"Id\":21,\"Title\":\"api-book\"}")),
});
自动绑定还带一条保护。请求正文为空且存在 [FromBody] 参数时给 400,实现在 DispatchEngine 的 IsAutoBindEnabled 分支。范围比 MVC 的 [ApiController] 窄,通用绑定与校验失败由 TryReturnBadRequest 统一收口。
8.5 属性级混合绑定
[From*] 可以挂在复杂对象的属性上,同一个 DTO 内不同属性来自不同源。仓库示例 D04 演示了这一点。
csharp
public sealed class MixedForm
{
[FromQuery] public int Tag { get; set; }
[FromHeader] public string FromHeaderVal { get; set; }
}
[Route("d04")]
[NoAutoBind]
public sealed class D04Controller
{
public string Mix(MixedForm f) => $"{f.Tag}|{f.FromHeaderVal}";
}
请求侧的键要带模型名前缀。
csharp
var mix = engine.Dispatch(new DispatchRequest
{
Route = "d04/Mix",
Payload = new Dictionary<string, object> { ["f.Tag"] = 5 },
Headers = new Dictionary<string, string> { ["f.FromHeaderVal"] = "hey" },
ResultType = typeof(string),
}); // mix.Result == "5|hey"
Payload 是 string 到 object 的字典,Headers 是 string 到 string 的字典,两者写反是新手常见错误。
8.6 小结与练习
显式 [From*] 给出确定的来源与隔离,[Route] 默认推断让常见签名少写标注,[NoAutoBind] 把控制权交回给按名绑定链。三者是同一套机制的不同松紧度。
练习:写一个方法同时接收 [FromRoute] int id、[FromHeader] string token、[FromQuery] string keyword、[FromServices] IClock clock,构造完整请求让它全部绑定成功,再故意删掉 Headers 观察结果。
第 9 章 复杂对象、集合与类型转换
9.1 默认绑定器链
默认装配顺序如下。
text
BodyModelBinder → ServicesModelBinder → ValueProviderModelBinder → DefaultModelBinder
CompositeModelBinder 按注册顺序挑首个 CanBind(info) 返回 true 的绑定器。AddModelBinder 用 Insert(0, binder) 把自定义绑定器插到链首,因此它优先于全部内置绑定器。
前三个绑定器各管一个明确来源。DefaultModelBinder 是兜底,同时承担 MVC 里复杂对象绑定器、集合绑定器、字典绑定器的职责。
9.2 点号前缀与递归绑定
复杂对象按 模型名.属性名 拼接键,嵌套继续加点号。
csharp
public sealed class Person
{
public string Name { get; set; }
public int Age { get; set; }
public Address Home { get; set; }
}
public sealed class Address
{
public string City { get; set; }
public int Zip { get; set; }
}
[Route("binder")]
[NoAutoBind]
public sealed class BinderController
{
public int ComplexSum(Person p) => p == null ? -1 : (p.Age + (p.Home?.Zip ?? 0));
}
csharp
var sum = engine.Dispatch(new DispatchRequest
{
Route = "binder/ComplexSum",
Payload = new Dictionary<string, object>
{
["p.Age"] = 30,
["p.Home.Zip"] = 100,
},
ResultType = typeof(int),
}); // sum.Result == 130
集合与数组用 模型名[索引] 前缀枚举,字典用 前缀.Key 与 前缀.Value 约定。这些命名与 Web API 的表单键习惯一致,宿主把查询串拆成字典时几乎不用额外适配。
9.3 类型转换
类型转换器负责把字符串与 JsonElement 收敛成目标 CLR 类型。它对十二种常用 BCL 标量有专用 Parse 快速路径,处理可空类型、枚举、Guid、DateTime。转换失败会作为绑定错误进入 ModelState,引擎随后统一给 400。
DefaultModelBinder 把类型元数据(属性、形状、构造器)按类型缓存,并用表达式树编译 setter 与构造器,避免每次请求都走反射赋值。这是「构建期多做、运行期少做」思路在绑定层的体现。
9.4 位置式参数 shim
Send<TService, TResult>("Add", new object[] { 2, 3 }) 这类调用没有参数名。DefaultModelBinder 通过 Arguments 里的 __value 数组按 ParameterBindingInfo.Index 做位置映射。这是 Vane 特有的便利,MVC 的多参绑定完全依赖命名。
9.5 ModelState
csharp
public sealed class ModelState
{
public IReadOnlyList<BindingError> Errors { get; }
public bool IsValid { get; }
public void AddError(string parameter, Exception ex);
}
public sealed class BindingError
{
public string Parameter { get; }
public Exception Exception { get; }
}
无错误时 Errors 返回共享空数组,不产生额外分配。绑定循环里捕获的错误与校验过滤器产生的错误都聚合到同一个 ModelState,随后 TryReturnBadRequest 把它翻译成 400 与 Error 文本。这条路径会保真 DispatchException.StatusCode,所以格式化器抛出的 415 不会被降级成 400。
9.6 常见的绑定失败原因
| 现象 | 排查方向 |
|---|---|
复杂对象为 null 或属性全为默认值 |
键缺少模型名前缀,或 [FromBody] 推断把对象引向了空的 Body |
[FromHeader] 取不到值 |
Headers 键大小写不敏感,但必须挂在 Headers 上,不能放 Payload |
[FromRoute] 取不到值 |
模板占位符需被端点捕获,或宿主直接填 RouteValues |
400 且 Error 提到参数绑定 |
类型转换失败,读 response.Error 与 Exception 定位具体参数 |
加了 [NoAutoBind] 后行为变化 |
接口参数不再自动从 Services 解析,需显式 [FromServices] |
9.7 小结与练习
绑定层的复杂度集中在 DefaultModelBinder。记住三条命名约定,复杂对象用点号,集合用方括号索引,字典用 Key / Value 后缀。
练习:构造一个含 List<Address> 与 Dictionary<string, int> 的 DTO,用扁平字典键把它填满,打印绑定后的对象。
第 10 章 扩展绑定管线
10.1 自定义值提供器
IValueProvider 只有两个方法。
csharp
public interface IValueProvider
{
bool ContainsPrefix(string prefix);
bool TryGetValue(string name, out string value);
}
ContainsPrefix 用于探测某个子路径是否存在,TryGetValue 返回字符串值,类型转换交给 ValueConverter。下面把请求 Context 里的用户声明接进绑定链。
csharp
public sealed class ClaimsValueProvider : IValueProvider
{
private readonly IDictionary<string, string> _claims;
public ClaimsValueProvider(IDictionary<string, string> claims)
=> _claims = claims ?? new Dictionary<string, string>(
StringComparer.OrdinalIgnoreCase);
public bool ContainsPrefix(string prefix)
{
if (string.IsNullOrEmpty(prefix)) return _claims.Count > 0;
foreach (var key in _claims.Keys)
{
if (string.Equals(key, prefix, StringComparison.OrdinalIgnoreCase))
return true;
if (key.Length > prefix.Length &&
(key[prefix.Length] == '.' || key[prefix.Length] == '[') &&
key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
public bool TryGetValue(string name, out string value)
{
if (name != null && _claims.TryGetValue(name, out var raw))
{
value = raw;
return true;
}
value = null;
return false;
}
}
装配时给它一个按请求生成实例的工厂。AddValueProvider 插到内置来源之前。
csharp
[Route("account")]
public sealed class AccountController
{
public string Profile(string userId, string role) => $"{userId}/{role}";
}
Func<DispatchRequest, IValueProvider> ClaimsFromContext() => request =>
{
IDictionary<string, string> claims = null;
if (request.Context != null &&
request.Context.TryGetValue("user", out var u) &&
u is IDictionary<string, string> map)
claims = map;
return new ClaimsValueProvider(claims);
};
var engine = resolver.BuildDispatchEngine()
.AddValueProvider(ClaimsFromContext())
.MapDispatchable<AccountController>()
.Build();
var resp = engine.Dispatch(new DispatchRequest
{
Route = "account/Profile",
Context = new Dictionary<string, object>
{
["user"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["userId"] = "u-1001",
["role"] = "admin",
},
},
}); // resp.Result == "u-1001/admin"
未命中时链序回退。没有 user 声明时,Profile 的参数会回退到 Payload 的具名绑定。这种「优先新来源,失败回落」的组合很适合注入租户、灰度标签、链路标识。
10.2 自定义模型绑定器
IModelBinder 定义两个方法。
csharp
public interface IModelBinder
{
bool CanBind(ParameterBindingInfo info);
object Bind(ParameterBindingInfo info, ModelBindingContext context);
}
ParameterBindingInfo 暴露 Name、Type、Source、Index。ModelBindingContext 暴露 Request、Services、ValueProviders、InputFormatters、ModelState、Binder、ModelName,并提供 GetValue(name)、GetValue(source, name)、HasPrefix(prefix)、WithModelName(name)。
下面这个绑定器把入参字符串大写化,来自示例 D04。
csharp
public sealed class CustomValue { public string Text; }
public sealed class UpperBinder : IModelBinder
{
public bool CanBind(ParameterBindingInfo info)
=> info.Type == typeof(CustomValue);
public object Bind(ParameterBindingInfo info, ModelBindingContext context)
=> new CustomValue
{
Text = (context.GetValue(info.Name) ?? "").ToUpperInvariant(),
};
}
[Route("binder")]
[NoAutoBind]
public sealed class BinderController
{
public string Custom(CustomValue v) => v.Text;
}
var engine = resolver.BuildDispatchEngine()
.MapDispatchable<BinderController>()
.AddModelBinder(new UpperBinder())
.Build();
var cust = engine.Dispatch(new DispatchRequest
{
Route = "binder/Custom",
Payload = new Dictionary<string, object> { ["v"] = "aBc" },
}); // cust.Result == "ABC"
CanBind 要写得足够窄。一个对所有类型都返回 true 的绑定器会吞掉整条内置链。
10.3 两种扩展点怎么选
| 需求 | 选 |
|---|---|
| 增加一个新的取值来源 | IValueProvider + AddValueProvider |
| 改变某个 CLR 类型到实参的构造方式 | IModelBinder + AddModelBinder |
| 改变正文的线格式 | IInputFormatter + AddInputFormatter,见第 15 章 |
三者的执行位置依次靠后。值提供器提供原料,绑定器决定怎么组装,格式化器决定怎么把字节变成对象。
10.4 小结与练习
值提供器与绑定器让宿主能在不修改内核的前提下扩展输入语义。两者都遵循「先声明能否处理,再执行处理」的模式,写窄一点最稳。
练习:实现一个 TenantId 值类型与对应绑定器,从 Headers["X-Tenant"] 构造它,并在 CanBind 里严格限定类型。
第四部分 横切关注点
第 11 章 过滤器管线
11.1 四个接口与两个特性基类
csharp
public interface IFilter
{
void OnExecuting(ActionExecutingContext context);
void OnExecuted(ActionExecutedContext context);
}
public interface IAsyncFilter
{
ValueTask OnExecutingAsync(ActionExecutingContext context,
CancellationToken cancellationToken);
ValueTask OnExecutedAsync(ActionExecutedContext context,
CancellationToken cancellationToken);
}
public interface IExceptionFilter
{
void OnException(ActionExecutedContext context);
}
public interface IAsyncExceptionFilter
{
Task OnExceptionAsync(ActionExecutedContext context,
CancellationToken cancellationToken);
}
想在方法上用特性标注,继承两个抽象类即可。
csharp
public sealed class LogAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext context)
=> Console.WriteLine($"开始 {context.Request.Route}");
public override void OnActionExecuted(ActionExecutedContext context)
=> Console.WriteLine($"结束 {context.Request.Route}, 异常 {context.Exception?.Message}");
}
public sealed class AsyncLogAttribute : AsyncActionFilterAttribute
{
public override async ValueTask OnActionExecutingAsync(
ActionExecutingContext context, CancellationToken ct)
{
await Task.Yield();
Console.WriteLine("异步前置");
}
public override ValueTask OnActionExecutedAsync(
ActionExecutedContext context, CancellationToken ct)
=> ValueTask.CompletedTask;
}
IExceptionFilter 与 IAsyncExceptionFilter 只能全局装配,没有特性形式。
11.2 两个上下文
csharp
public sealed class ActionExecutingContext
{
public DispatchRequest Request { get; }
public DispatchResponse Response { get; }
public IActionDescriptor ActionDescriptor { get; }
public IDictionary<string, object> Arguments { get; }
public IDictionary<string, object> BoundArguments { get; set; }
public ModelState ModelState { get; }
public bool Canceled { get; set; }
}
public sealed class ActionExecutedContext
{
public DispatchRequest Request { get; }
public DispatchResponse Response { get; }
public IActionDescriptor ActionDescriptor { get; }
public object Service { get; }
public Exception Exception { get; }
public bool Canceled { get; set; }
public bool ExceptionHandled { get; set; }
}
ActionExecutingContext.Canceled 置 true 会短路操作,对标 MVC 里 Result != null 的短路。BoundArguments 可以改写已绑定的实参。ActionExecutedContext 的 Exception、Service 由引擎写入,库外只读。
11.3 执行顺序
同步 Dispatch 路径的顺序如下。
text
参数绑定
→ 全局同步 OnExecuting
→ 操作级同步 OnExecuting
→ 调用操作
→ 全局同步 OnExecuted
→ 操作级同步 OnExecuted
→ 同步异常过滤器 OnException(仅存在未处理异常时)
→ 判定状态码
异步 DispatchAsync 路径多出两段:执行后异步过滤器与异步收口。
text
参数绑定
→ 全局同步 OnExecuting → 操作级同步 OnExecuting
→ 全局异步 OnExecutingAsync → 操作级异步 OnExecutingAsync
→ 调用操作(若为 Task / ValueTask 则 await)
→ 全局同步 OnExecuted → 操作级同步 OnExecuted
→ 全局异步 OnExecutedAsync → 操作级异步 OnExecutedAsync
→ 同步异常过滤器 → 异步异常过滤器
→ 判定状态码
执行后回调的跨族顺序值得记牢。同步 OnExecuted 先于异步 OnExecutedAsync,DispatchEngine.RunOnExecutedAsync 按这个次序串行。写依赖顺序的过滤器时请按上面列出的顺序,或干脆避免依赖跨族回调的先后。
IAsyncFilter 与 IAsyncExceptionFilter 只在 DispatchAsync 路径执行。同步 Dispatch 对它们零感知,仓库排错清单里专门列了这条。
11.4 三态 OnExecuted 与状态判定
收口阶段的判定逻辑很直接。
| 条件 | 结果 |
|---|---|
Exception != null 且 !ExceptionHandled |
500,response.Exception 保留原始异常 |
Canceled 且 !ExceptionHandled |
499 |
| 其余 | 成功 |
关键在于 OnExecuted 在成功、异常、取消三种状态下都会执行。日志、指标、耗时统计写在这里最可靠。过滤器还可以在 OnExecuted 里置 ExceptionHandled = true 并改写 response.Result,把异常转成一个业务结果,对标 MVC 异常过滤器吞掉异常并产出结果。
11.5 全局与操作级
csharp
public sealed class Counter : IFilter
{
public int Executing;
public int Executed;
public void OnExecuting(ActionExecutingContext c) => Executing++;
public void OnExecuted(ActionExecutedContext c) => Executed++;
}
var counter = new Counter();
var engine = resolver.BuildDispatchEngine()
.Map("cv/ping", () => "pong")
.AddGlobalFilter(counter)
.Build();
engine.Dispatch(new DispatchRequest { Route = "cv/ping" });
// counter.Executing == 1 且 counter.Executed == 1
自由端点同样识别挂在命名方法 (非 lambda)上的 ActionFilterAttribute / AsyncActionFilterAttribute,注册时由方法元数据收集,与控制器特性过滤器同构;lambda 无法携带特性,需用命名方法声明。全局过滤器按 Add 顺序串行,排在操作级特性过滤器之前。控制器这一级没有独立过滤器集合,需要类级共享逻辑时用全局过滤器加条件判断,或把特性挂在每个方法上。
11.6 小结与练习
过滤器是这本书里第一个「框架感」很强的概念。抓住三个要点,同步与异步两条路径分开,执行后回调三态恒运行,ExceptionHandled 是翻转成败的开关。
练习:写两个全局同步过滤器与一个方法级 [Log],让每个回调打印自己的名字,验证第 11.3 节的顺序;再让操作抛异常,确认 OnExecuted 依然执行。
第 12 章 数据校验
12.1 一键开启
UseValidation() 等价于 AddGlobalFilter(new ValidationFilter())。
csharp
using System.ComponentModel.DataAnnotations;
[Route("val")]
public sealed class ValCtrl
{
public string Greet([Required] string name) => "hi " + name;
}
var engine = resolver.BuildDispatchEngine()
.MapDispatchable<ValCtrl>()
.UseValidation()
.Build();
var ok = engine.Dispatch(new DispatchRequest
{
Route = "val/Greet",
Payload = new Dictionary<string, object> { ["name"] = "x" },
}); // IsSuccess == true, Result == "hi x"
var bad = engine.Dispatch(new DispatchRequest
{
Route = "val/Greet",
Payload = new Dictionary<string, object> { ["name"] = "" },
}); // IsSuccess == false, 400, 操作未执行
12.2 校验范围
ValidationFilter 读 ValidationAttribute 与 IValidatableObject,覆盖 [Required]、[Range]、[StringLength] 等标准特性,也支持模型级自定义规则。
csharp
public sealed class D05Input : IValidatableObject
{
[Range(1, 10)] public int Count { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext ctx)
{
if (Count == 7)
yield return new ValidationResult("演示用:7 不允许",
new[] { nameof(Count) });
}
}
控制器路径读 IActionDescriptor 做参数级校验,自由端点没有动作描述符,只做模型级校验。过滤器仅在确实存在校验特性时介入,反射结果有缓存,无校验动作的热路径不受影响。
12.3 失败怎么表达
校验失败时过滤器把错误写进 ModelState,并置 ActionExecutingContext.Canceled = true。引擎随后短路操作,TryReturnBadRequest 统一给 400 与 Error 文本。过滤器不直接写响应,这保证了绑定失败与校验失败走同一条出口。
未注册 ValidationFilter 时完全不做校验,零开销。这是「按需装配」原则的典型例子,与 MVC 默认自动校验的强约定形成对照。
12.4 小结与练习
校验是一个可插拔过滤器,默认不启用。它把 MVC 开发者熟悉的 DataAnnotations 语义搬到了无 HTTP 的管线上。
练习:给一个 DTO 同时加 [Required] 与 IValidatableObject,触发两条错误,观察 response.Error 是否聚合了它们。
第 13 章 异常与错误模型
13.1 异常在分发内收口
操作抛出的异常不会从 Dispatch(DispatchRequest) 冒泡出去。引擎把它捕获进 ActionExecutedContext.Exception,跑完 OnExecuted 与异常过滤器后再决定响应。
csharp
[Route("x")]
public sealed class ExpoController
{
public string Boom() => throw new InvalidOperationException("boom");
}
var resp = engine.Dispatch(new DispatchRequest { Route = "x/Boom" });
// resp.IsSuccess == false
// resp.StatusCode == 500
// resp.Error 含 "boom"
// resp.Exception 是原始 InvalidOperationException
进程内调用方读 resp.Exception 能拿到完整堆栈,跨进程宿主读 resp.ProblemDetails 拿结构化信息,两者共享同一个失败判定。
强类型快捷入口的语义不同。Dispatch<TResult>、Send、CreateProxy 会把失败转成 DispatchException 抛出,让调用方可以用常规 try/catch 处理。这一点在设计调用层时要分清。
13.2 异常过滤器
csharp
public sealed class BugFilter : IExceptionFilter
{
public void OnException(ActionExecutedContext context)
{
if (context.Exception is BusinessException)
{
context.ExceptionHandled = true;
context.Response.Result = "已按业务规则降级";
}
}
}
var engine = resolver.BuildDispatchEngine()
.MapDispatchable<ExpoController>()
.AddGlobalExceptionFilter(new BugFilter())
.Build();
三个触发条件缺一不可。操作执行阶段产生了异常,OnExecuted 已跑完,异常尚未被标记为已处理。绑定失败或无异常时不触发。仅注册过滤器还不够,必须在 OnException 里置 ExceptionHandled = true 才能免除 500 判定,这是手册 FAQ 的第一条。
异步版本 IAsyncExceptionFilter.OnExceptionAsync 返回 Task,适合写远程审计或日志 sink,只在 DispatchAsync 路径被 await。
13.3 结构化错误体
csharp
var failure = DispatchResponse.Fail("演示业务失败",
new InvalidOperationException("boom"));
failure.StatusCode = DispatchStatusCodes.BadRequest;
ProblemDetails pd = failure.ProblemDetails;
// pd.Status == 400
// pd.Type == "about:blank"
// pd.Title / pd.Detail 由状态码与错误文本生成
// pd.Extensions 含 exceptionType 等扩展项
DispatchResponse ok = DispatchResponse.Ok("ok");
// ok.ProblemDetails == null,成功零分配
ProblemDetails 在失败时惰性构造,成功路径不产生对象。跨进程宿主可以把它直接序列化进帧或 HTTP 响应。
13.4 状态码与 HTTP 的映射
499 是自定义状态码,借鉴 nginx 的 client closed request,引擎用它统一表达请求级取消。HTTP 没有这个标准码,宿主映射时可转成 499 原样透传,或按自身规范折算成 400 与 408。429、503、504 同样属于宿主与准入层的口径,引擎只提供常量。
DispatchException 携带 StatusCode,TryReturnBadRequest 保真这个值。于是格式化器抛 415、绑定抛 400、业务抛 500 可以沿同一条失败通道走而互不串味。
13.5 小结与练习
统一响应对象是这套错误模型的核心。异常既保留给进程内排查,也转译成可序列化的结构与状态码。
练习:让操作抛异常,分别用 Dispatch(DispatchRequest) 与 Dispatch<string> 调用,比较一个返回失败响应、一个抛 DispatchException 的差别。
第 14 章 当前分发上下文
14.1 问题
业务代码有时需要在任意时刻拿到当前请求,例如日志组件、审计切面、按需读原始 Body。把请求对象一路传参很笨重,静态全局又会污染并发。Vane 给的是注入式访问器,对标 IHttpContextAccessor。
csharp
public interface IDispatchContextAccessor
{
DispatchContext Context { get; set; }
}
public sealed class DispatchContext
{
public DispatchRequest Request { get; init; }
public DispatchResponse Response { get; init; }
public string TraceId { get; init; }
public string SpanId { get; init; }
public string ParentSpanId { get; init; }
}
14.2 接入与使用
csharp
var accessor = new DispatchContextAccessor();
var engine = resolver.BuildDispatchEngine()
.MapDispatchable<OrderController>()
.UseContextAccessor(accessor)
.Build();
[Route("order")]
public sealed class OrderController
{
public string Who(IDispatchContextAccessor acc)
=> acc.Context?.Request?.Route;
}
DispatchContextAccessor 基于 AsyncLocal。每次分发建立短暂作用域,作用域结束自动恢复外层值,异步 await 链上也会透传。控制器方法参数 IDispatchContextAccessor 是接口类型,自动绑定推断会把它解析为 Services,因此解析器需要能返回同一个 accessor 实例。
csharp
ServiceResolver resolver = type =>
type == typeof(IDispatchContextAccessor) ? accessor
: type.GetConstructor(Type.EmptyTypes)?.Invoke(null)
?? throw new MissingMethodException(type.FullName);
14.3 零负担设计
不调用 UseContextAccessor 时,引擎不创建 DispatchContext,也不写 AsyncLocal,这条路径不会为上下文额外分配。Request.Context 这个惰性字典适合在过滤器与处理器之间共享显式状态,访问器适合让深层代码隐式读取当前请求。两者用途不同,可以按需要择一。
14.4 小结与练习
上下文访问器解决的是深层代码的可达性问题,实现方式是依赖注入加 AsyncLocal,并刻意让未使用场景零成本。
练习:在全局过滤器里给 context.Request.Context["startedAt"] 写时间戳,在控制器里经访问器读出并打印耗时。
第五部分 序列化与传输
第 15 章 格式化器与内容协商
15.1 两个接口
csharp
public interface IInputFormatter
{
IReadOnlyList<string> SupportedMediaTypes => Array.Empty<string>();
bool CanRead(Type type);
object Read(ReadOnlySequence<byte> body, Type type);
}
public interface IOutputFormatter
{
IReadOnlyList<string> SupportedMediaTypes => Array.Empty<string>();
bool CanWrite(Type type);
ReadOnlyMemory<byte> Write(object value, Type type);
void Write(IBufferWriter<byte> writer, object value, Type type);
}
SupportedMediaTypes 是带默认实现的接口属性。老的第三方格式化器不实现它也能编译,此时返回空集合,只参与「无协商」时的回落,不参与按媒体类型的选择。这个设计让升级零改动。
CanRead / CanWrite 判定能否处理某个 CLR 类型,Read / Write 执行实际转换。IOutputFormatter 还提供 IBufferWriter<byte> 重载,适合直接写进宿主缓冲区。
15.2 内置格式化器与默认装配
| 类 | 方向 | CanRead / CanWrite |
声明的媒体类型 |
|---|---|---|---|
JsonInputFormatter |
输入 | 非字节类型 | application/json、application/problem+json、text/json、application/*+json |
JsonOutputFormatter |
输出 | 任意类型 | 同上一组 |
RawBytesFormatter |
双向 | 字节类型 | application/octet-stream |
FlatBinaryFormatter |
双向 | 字节类型 | 二进制,另实现 IBufferWriter 写法 |
默认装配如下。
text
输入: JsonInputFormatter → RawBytesFormatter
输出: JsonOutputFormatter
AddInputFormatter 与 AddOutputFormatter 都执行 Insert(0, ...)。不带协商头时,新注册的排在最前,优先级最高。带协商头时由 FormatterSelector 按媒体类型选择。
15.3 选择规则
格式化器选择器是全仓唯一的选择逻辑,供 RPC、REST、HTTP、RESP 等所有前端复用,避免同一格式化器在不同宿主里对应不同媒体类型。
| 请求头 | 行为 |
|---|---|
带 Content-Type |
BodyModelBinder 调 SelectInput,按媒体类型挑首个能读目标 CLR 类型的格式化器;全不命中抛 DispatchException(415) |
带 Accept |
DispatchEngine.TryWriteResponseBody 调 SelectOutput,按 q 值降序挑首个能写结果类型的格式化器;全不命中挂 406,命中时把媒体类型写进 response.ContentType |
未带、*/*、无法解析 |
不协商,回落注册顺序首个 CanRead / CanWrite 命中者,进程内调用行为与改造前一致 |
| 声明里带通配 | 只放行结构化后缀通配,例如 application/*+json;*/* 与 application/* 不会吞掉任意 Accept,防回显污染 |
格式化器未声明 SupportedMediaTypes |
只参与回落,不参与协商 |
媒体类型解析由 MediaTypeSyntax、ReadOnlyMediaTypeHeaderValue、HttpRuleParser、StringSegment 承担,逐行移植自 ASP.NET Core 的 MIT 实现,支持 type/*、*/*、*+suffix、参数与 q 值。MediaTypes 常量优先复用 BCL 的 System.Net.Mime.MediaTypeNames,BCL 未覆盖的 application/problem+json 等才补齐。
SupportedMediaTypes 只是声明,框架不设白名单。注册多少种格式化器就支持多少种媒体类型。
15.4 自定义格式化器完整示例
下面这个 text/plain 格式化器同时承担输入与输出,来自示例 D06。
csharp
using System.Buffers;
using System.Text;
using Vane.Dispatch;
public sealed class PlainFormatter : IInputFormatter, IOutputFormatter
{
public IReadOnlyList<string> SupportedMediaTypes => new[] { "text/plain" };
public bool CanRead(Type type) => type == typeof(string);
public object Read(ReadOnlySequence<byte> body, Type type)
=> Encoding.UTF8.GetString(body.ToArray());
public bool CanWrite(Type type) => type == typeof(string);
public ReadOnlyMemory<byte> Write(object value, Type type)
=> Encoding.UTF8.GetBytes((string)value);
}
[Route("d06")]
[NoAutoBind]
public sealed class D06Controller
{
public string Echo([FromBody] string text) => $"echo:{text}";
}
var formatter = new PlainFormatter();
var engine = resolver.BuildDispatchEngine()
.MapDispatchable<D06Controller>()
.AddInputFormatter(formatter)
.AddOutputFormatter(formatter)
.Build();
DispatchResponse ok = engine.Dispatch(new DispatchRequest
{
Route = "d06/Echo",
Body = new ReadOnlySequence<byte>(Encoding.UTF8.GetBytes("plain")),
ContentType = "text/plain",
Accept = "text/plain",
ResultType = typeof(string),
});
// ok.IsSuccess == true
// ok.ContentType == "text/plain"
// ok.Body.Length > 0
再发一个 ContentType = "application/x-vane-unknown" 的请求,SelectInput 返回 null,BodyModelBinder 抛 415。发一个 Accept = "application/x-msgpack" 且没有对应输出格式化器的请求,引擎挂 406。
15.5 接入二进制协议
csharp
public sealed class MsgPackInputFormatter : IInputFormatter
{
public IReadOnlyList<string> SupportedMediaTypes
=> new[] { "application/x-msgpack" };
public bool CanRead(Type type)
=> type != null && type.IsClass
&& type != typeof(string)
&& type != typeof(byte[])
&& type != typeof(ReadOnlyMemory<byte>);
public object Read(ReadOnlySequence<byte> body, Type type)
=> MessagePackSerializer.Deserialize(type, body.ToArray());
}
装配方式与上一节相同,AddInputFormatter(new MsgPackInputFormatter()) 即可。
这里有个值得指出的细节。使用手册里的示例用了字节类型判断辅助方法,但那个辅助类是非公开的,库外工程无法访问,照抄会编译失败。上面的手写判断是等价的可编译写法。手册示例往往取自测试工程内部,跨出仓库边界时要检查可见性。
CanRead 的范围要写窄,把不归自己管的类型留给内置 JSON 与原始字节链。
15.6 零拷贝 Body
成功响应且 Body 尚未写入且 Result 非空时,引擎调用 IOutputFormatter.Write 得到 ReadOnlyMemory<byte>,直接包成 ReadOnlySequence<byte> 视图,不再 ToArray() 复制一份。ReadOnlySequence 可以跨段,帧层能逐段直写。
DispatchResponse.Body 有显式写入标记 BodyWritten(internal)。宿主若自己写好了 Body,引擎跳过自序列化。
csharp
var written = DispatchResponse.Ok("payload");
written.Body = new ReadOnlySequence<byte>(Encoding.UTF8.GetBytes("payload"));
// written.Body.IsSingleSegment == true,引擎不再覆盖
这个零拷贝契约也是仓库决定不落地 IResultFilter 的原因。结果过滤器需要在结果产出后再拦截,往往要物化第二份缓冲。
15.7 小结与练习
格式化器把「线格式」从内核里隔离出去。带协商头按媒体类型选,不带则按注册顺序回落,两条路径的优先级不同。
练习:注册 JSON 与 text/plain 两个输出格式化器,分别用空 Accept、Accept: text/plain、Accept: application/x-msgpack 调用,记录三次的 StatusCode 与 ContentType。
第 16 章 把引擎挂到传输层
16.1 宿主要做的事
Vane 不监听端口。传输层负责把外部字节适配成 DispatchRequest,再把 DispatchResponse 写回。用 BCL 的 HttpListener 就能完成这件事,整个宿主约 150 行,没有引用任何第三方 HTTP 框架。
映射关系可以这样设计。
| HTTP 侧 | DispatchRequest 侧 |
|---|---|
| URL 路径 | Route(去掉前导斜杠) |
| 查询串 | Payload 字典 |
| 请求头 | Headers |
| 请求正文流 | Body(ReadOnlySequence<byte>) |
Content-Type |
ContentType |
Accept |
Accept |
| 追踪头 | TraceId / SpanId / ParentSpanId |
| 超时头 | DeadlineUs(绝对微秒) |
DispatchResponse 侧 |
HTTP 侧 |
|---|---|
StatusCode |
响应状态码,499 需要宿主自行折算 |
ContentType |
Content-Type 响应头 |
Body |
响应正文流,逐段写出 |
ProblemDetails |
失败时序列化成 application/problem+json |
16.2 一个最小骨架
csharp
using System.Buffers;
using System.Net;
using System.Text;
using System.Text.Json;
using Vane.Dispatch;
// engine 在启动时构建一次,复用同一个实例
var listener = new HttpListener();
listener.Prefixes.Add("http://localhost:8080/");
listener.Start();
while (true)
{
var http = await listener.GetContextAsync();
var req = http.Request;
var res = http.Response;
var payload = new Dictionary<string, object>();
foreach (string key in req.QueryString.AllKeys)
payload[key] = req.QueryString[key];
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (string key in req.Headers.AllKeys)
headers[key] = req.Headers[key];
byte[] raw;
using (var ms = new MemoryStream())
{
req.InputStream.CopyTo(ms);
raw = ms.ToArray();
}
DispatchResponse dispatchResponse = engine.Dispatch(new DispatchRequest
{
Route = req.Url.AbsolutePath.Trim('/'),
Payload = payload,
Headers = headers,
Body = new ReadOnlySequence<byte>(raw),
ContentType = req.ContentType,
Accept = req.Headers["Accept"],
});
res.StatusCode = dispatchResponse.StatusCode == 499
? 400
: dispatchResponse.StatusCode;
if (dispatchResponse.ContentType != null)
res.ContentType = dispatchResponse.ContentType;
if (!dispatchResponse.Body.IsEmpty)
{
foreach (var segment in dispatchResponse.Body)
{
res.OutputStream.Write(segment.Span);
}
}
else if (!dispatchResponse.IsSuccess)
{
byte[] problem = Encoding.UTF8.GetBytes(
JsonSerializer.Serialize(dispatchResponse.ProblemDetails));
res.ContentType = "application/problem+json";
res.OutputStream.Write(problem, 0, problem.Length);
}
res.Close();
}
这段代码刻意保持示意性质。生产宿主还要处理异常、并发、连接生命周期、请求体大小限制、认证、限流、TLS、日志,这些都不属于分发器的职责。
16.3 换一个传输层要改多少
改用二进制 RPC 帧时,映射表右侧保持不变,只换左侧的编解码。改用消息队列时,Route 可以从消息主题或消息头取,Payload 从消息体反序列化,响应写回回执队列。内核看到的始终是同一个 DispatchRequest。
这正是零传输耦合的实际含义,宿主承担协议差异,引擎承担调用语义。反过来看,如果你需要的全部是标准 HTTP 行为,自己补这一层并不划算,直接用 ASP.NET Core 更合适。
16.4 小结与练习
宿主是一层薄适配器。把协议字段映射到请求对象,把响应对象映射回协议字段,中间的路由、绑定、过滤、调用完全复用。
练习:把第 16.2 节的骨架改成一个「从控制台读一行字符串当路由」的宿主,观察引擎代码是否需要改动。
第 17 章 异步、取消与治理元数据
17.1 按需异步
csharp
ValueTask<DispatchResponse> DispatchAsync(
DispatchRequest request, CancellationToken cancellationToken = default);
DispatchAsync 复用同一套同步内核。路由查表、模型绑定、同步过滤器依旧同步执行,只有返回 Task / ValueTask 及其泛型的处理器会被 await,IAsyncFilter 与 IAsyncExceptionFilter 也只在这条路径执行。异步调用器惰性编译,纯同步应用不会常驻异步委托。
选择口径很清晰。纯 CPU 处理器用 Dispatch,它没有异步状态机开销;无参且无过滤器时框架侧不分配,带绑定或输出序列化仍会分配。处理器确实有 I/O 或需要异步过滤器时用 DispatchAsync。formatter、路由、绑定、值提供器都是纯 CPU 内存操作,套 async 只会增加状态机成本。
17.2 令牌注入
处理器末位的 CancellationToken 形参被视为注入,不参与绑定,由调用器在编译期识别。自由端点与控制器一致。
csharp
[Route("async")]
public sealed class AsyncController
{
public async Task<string> Fetch(string url, CancellationToken ct)
=> await httpClient.GetStringAsync(url, ct);
}
using var cts = new CancellationTokenSource();
cts.CancelAfter(20);
DispatchResponse r = await engine.DispatchAsync(
new DispatchRequest { Route = "async/Fetch",
Payload = new Dictionary<string, object> { ["url"] = "..." } },
cts.Token);
取消统一映射到 499 RequestCanceled。处理器忽略取消时,引擎在管线的后置点调用 ThrowIfCancellationRequested 强制收口。同步 Dispatch 没有令牌概念,取消语义只存在于异步路径。
示例 D07 的调用侧同时兼容两种形状,返回 499 响应或抛 OperationCanceledException 都算正确。写宿主时按这个思路做兼容更稳妥。
17.3 治理元数据
DispatchRequest 提供四个跨进程治理字段。
| 字段 | 含义 |
|---|---|
DeadlineUs |
端到端 deadline,Unix epoch 起算的绝对微秒,0 表示未设 |
TraceId |
调用链标识 |
SpanId |
当前跨度标识 |
ParentSpanId |
父跨度标识 |
csharp
long deadlineUs = (DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + 60_000) * 1000L;
var request = new DispatchRequest
{
Route = "async/Fetch",
DeadlineUs = deadlineUs,
TraceId = "trace-001",
SpanId = "span-client",
ParentSpanId = "span-parent",
};
DeadlineUs 用绝对微秒,写成相对超时会让请求一进服务端就判定过期。跨进程时它由客户端按调用超时推导写入帧头。引擎只透传这个字段并提供 504 常量,now > DeadlineUs 的短路判定在宿主准入层实现,进程内 Dispatch 不会因为 deadline 过期而自动返回 504。504 与 499 是两条不同路径,前者是 deadline 到期,后者是请求级取消。
接入 UseContextAccessor 后,DispatchContext 也会带上 TraceId、SpanId、ParentSpanId,供过滤器与日志组件读取。
17.4 小结与练习
异步是可选路径,不是默认形态。令牌只在异步路径生效,deadline 是跨进程治理字段,两者单位与触发阶段都不同。
练习:写一个 Task.Delay(2000, ct) 的处理器,用 CancelAfter(20) 触发取消,打印响应的 StatusCode 与 ProblemDetails.Status。
第六部分 工程化
第 18 章 依赖注入与生命周期
18.1 两种接入方式
最小路径是直接给委托。
csharp
ServiceResolver resolver = type =>
type.GetConstructor(Type.EmptyTypes)?.Invoke(null)
?? throw new MissingMethodException(type.FullName);
var engine = resolver.BuildDispatchEngine()
.MapDispatchable<OrderController>()
.Build();
已有 IServiceProvider 时用另一个重载。
csharp
var services = new ServiceCollection();
services.AddSingleton<IGreeter, Greeter>();
services.AddTransient<OrderController>();
IServiceProvider provider = services.BuildServiceProvider();
var engine = provider.BuildDispatchEngine()
.MapDispatchable<OrderController>()
.Build();
容器适配扩展内部把 provider.GetRequiredService(serviceType) 包成 ServiceResolver。内部的 GetRequiredService 辅助扩展是非公开的,库外无需也无法直接调用,用 provider.BuildDispatchEngine() 即可。
18.2 构造器注入
控制器由解析器构造,因此构造器参数可以由容器注入。
csharp
[Route("order")]
public sealed class OrderController
{
private readonly IGreeter _greeter;
public OrderController(IGreeter greeter) => _greeter = greeter;
public string Hello() => _greeter.Greet();
}
方法参数上的 [FromServices] 与自动推断出的接口参数走的是同一个解析器,适合按操作注入依赖。
18.3 作用域与释放
DispatchRequest.Services 是预留字段,当前引擎不读取,不能用它按请求覆盖解析器。解析器只在构建时注入一次。若确实需要按请求作用域,请在 ServiceResolver 委托内部自行创建并释放作用域,或由宿主在调用前后管理容器作用域;引擎只负责调用这个委托。
这个边界常被误读:ModelBindingContext.Services 与 [FromServices] 用的是同一个构建期解析器,与请求字段无关。
再次强调第 4.5 节的契约。引擎不释放控制器实例。用自建工厂 type => new X() 时,请自行释放或确保实例不持有瞬态资源。经容器作用域解析时,由作用域统一释放。把释放责任放到引擎会误杀单例。
DispatchContextAccessor 基于 AsyncLocal,天然跟随异步链路,不依赖容器作用域。
18.4 小结与练习
容器适配被压缩成一个委托。生命周期归容器,引擎只做解析,这个边界让 Scoped、Singleton、自建工厂都能共存。
练习:用 ServiceCollection 注册一个带构造参数的控制器,分别用 AddSingleton 与 AddTransient 分发两次,观察实例是否相同。
第 19 章 AOT、裁剪与性能
19.1 兼容标注
程序集声明了 IsTrimmable 与 IsAotCompatible。走反射或运行期代码生成的公开入口都有标注。
| 标注 | 含义 | 典型入口 |
|---|---|---|
RequiresUnreferencedCode |
反射读取类型成员,裁剪下需保留 | MapDispatchablesFromAssembly、Dispatch<TResult>(object)、Dispatch(DispatchRequest)、Send、JSON 格式化与校验 |
RequiresDynamicCode |
需要运行期生成代码 | CreateProxy<T> 的拦截器回退路径、Handle / Map / MapGet / MapPost、Build() |
DynamicallyAccessedMembers |
声明需要保留哪些成员 | CreateProxy<T> 的泛型参数标了 All |
这些标注的价值在于让裁剪与 NativeAOT 场景得到准确警告,避免静默破坏。
接入编译期生成器后要补一句:纯同步契约的 CreateProxy<T>() 与 MapDispatchable<T>() 会命中生成代码,不再需要运行期代理生成与方法反射。标注仍留在接口上,是因为回退路径依然存在。判断一个具体调用是否 AOT 友好,要看有没有生成代码命中,而不是只看签名。第 22 章展开。
19.2 AOT 友好写法清单
csharp
// 用字典构造请求,避开匿名对象反射
var request = DispatchRequest.Create("math", "Add",
new Dictionary<string, object> { ["a"] = 2, ["b"] = 3 });
request.ResultType = typeof(int);
DispatchResponse r = engine.Dispatch(request);
// 显式注册,避开程序集扫描
var engine = resolver.BuildDispatchEngine()
.MapDispatchable<MathController>()
.Build();
// JSON 配合 JsonSerializerContext 源生成,或改用二进制协议
// 避开 CreateProxy<T>() 与自由委托端点
有一点必须辩证地看。避开匿名参数分发与程序集扫描能显著减少警告,而 IDispatchBuilder.Build() 本身同时标了 RequiresUnreferencedCode 与 RequiresDynamicCode,因为构建期要反射控制器方法并表达式编译调用委托。也就是说,Native AOT 下整条构建链路都需要评估,上述清单能减少反射面,无法保证零警告。裁剪场景可以直接使用这些标注给出的提示做保留配置。以接口声明为准,别把「减少警告」理解成「完全消除」。
19.3 性能设计清单
| 手段 | 作用环节 |
|---|---|
| 路由表预构建,运行期 O(1) 精确查表 | 路由 |
| 表达式树编译调用器,热路径零反射调用 | 调用 |
| 异步调用器惰性编译,纯同步应用不付成本 | 调用 |
| 类型元数据缓存与编译 setter | 模型绑定 |
ProblemDetails 失败时惰性生成 |
响应 |
Request.Context 首次访问才分配 |
上下文 |
| 上下文访问器未接入时零分配 | 上下文 |
输出字节直接包 ReadOnlySequence 视图 |
输出序列化 |
同步快路径无 Task、无状态机 |
全链路 |
这套设计取舍可概括为「同步快路径 + 按需异步」。这套取舍成立的前提是绑定、路由、格式化都是纯 CPU 内存操作,没有可重叠的阻塞点。一旦操作真正做 I/O,就该切到 DispatchAsync。
19.4 性能上的常见误判
把异步当成性能万能药。纯 CPU 处理器套 async 只增加状态机与调度成本。
把程序集扫描的便利带进热路径。扫描发生在构建期,不会拖慢每次分发,真正的问题是 AOT 下的类型保留。
把零分配理解成绝对零分配。Result 是 object,值类型返回时仍会装箱一次;字典、字节缓冲由调用方提供。设计目标是让框架自身不额外制造分配。
19.5 小结与练习
AOT 标注是契约,告诉你哪里会用到反射与动态代码。性能设计走的是「构建期多做、运行期少做」加「按需装配」两条思路。
练习:对照附录 A 的两个公开接口,逐个方法数一数它带哪种标注,列出你自己的应用会经过的入口。
第 20 章 排错手册
| 症状 | 原因 | 修复 |
|---|---|---|
| 404 | 未注册控制器,或 组名/操作名 拼写不符 |
检查 MapDispatchable<T>() 与类级 [Route],大小写不敏感 |
CreateProxy<T> 返回 404 |
接口名推导的组名与 [Route] 不一致 |
用 CreateProxy<T>("实际组名") 显式指定 |
| 构建期抛重复路由异常 | 方法重载、MapGet 与 MapPost 用同一路径 |
给方法起不同名字,或用方法级 [Route] 分配别名 |
绑定出来是 null 或默认值 |
键缺模型名前缀,或 [FromBody] 指向空的 Body |
用 p.Age 形式;复杂 DTO 给 Body 字节,或补 [FromQuery] |
| 400 且提示参数绑定 | 类型转换失败 | 读 response.Error 与 Exception,核对字典值类型 |
| 校验没有生效 | 未注册校验过滤器 | 调 UseValidation() 或 AddGlobalFilter(new ValidationFilter()) |
| 异常过滤器没被调用 | 同步 Dispatch 不接异步异常过滤器;或无异常 |
用 DispatchAsync;确认操作确实抛了异常 |
| 注册了异常过滤器仍是 500 | 未置 ExceptionHandled = true |
在 OnException 里标记并按需改写 Response.Result |
| 415 | ContentType 无任何输入格式化器能读目标类型 |
注册对应 IInputFormatter,检查 SupportedMediaTypes 与 CanRead |
| 406 | Accept 无任何输出格式化器能写结果类型 |
注册对应 IOutputFormatter,或去掉 Accept 走回落 |
response.ContentType 为 null |
未写 Body(结果为空、失败、或宿主已写 Body) |
检查 IsSuccess、Result、BodyWritten 语义 |
response.Body 为空 |
Result 为 null,或响应失败,或没有输出格式化器 |
对照第 15.6 节的输出条件 |
IAsyncFilter 完全不执行 |
走的是同步 Dispatch |
改用 DispatchAsync |
| 同步路径 catch 不到取消 | 同步路径没有令牌 | 取消语义只在 DispatchAsync |
| 自定义格式化器编译失败 | 抄了 internal 的 ByteTypeHelper |
改成手写类型判断 |
DeadlineUs 一进服务端就过期 |
写成了相对超时 | 用绝对 Unix 微秒时间戳 |
Dispatch<TResult> 拆箱失败 |
泛型参数与方法返回类型不一致 | 保持一致,或读 response.Result |
| 控制器实例资源没释放 | 引擎不负责释放 | 由容器作用域或自建工厂管理 |
| SDK 版本不匹配 | 仓库 global.json 钉 10.0.400 |
用 .NET 10 SDK,或从能解析该 SDK 的工作目录执行 |
| CS0579 重复程序集特性 | 历史 obj 目录里的生成文件被默认 glob 二次编译 |
工程里排除 obj/** 与 bin/** |
| AOT 下出现 IL2026 / IL3050 警告 | 入口依赖反射或动态代码 | 按第 19.2 节清单收敛,并评估 Build() 本身的标注 |
排错时先判断故障落在管线的哪一段。404 属于路由段,400 属于绑定与校验段,415 / 406 属于协商段,500 属于操作执行段,499 属于异步收口段。分段后去读第 3.3 节表格里对应的源码文件。
第 21 章 从 MVC 迁移的认知对照
21.1 概念映射
| MVC 概念 | Vane 对应 |
|---|---|
ControllerBase 子类 |
任意类加类级 [Route] |
| Action 方法 | 普通实例方法 |
[Route] + [ApiController] |
类级 [Route](含自动绑定推断) |
[FromRoute] 等五类 |
同名特性,另加 BindingSourceKind.Default |
IActionFilter / IAsyncActionFilter |
IFilter / IAsyncFilter |
IExceptionFilter |
IExceptionFilter 与 OnExecuted 的 ExceptionHandled |
ModelStateDictionary |
ModelState |
IHttpContextAccessor |
IDispatchContextAccessor |
IActionResult / ObjectResult |
直接返回 POCO,成败由 DispatchResponse 表达 |
IServiceProvider |
ServiceResolver 委托,另提供 IServiceProvider 适配 |
Minimal API 的 Map* |
Map / MapGet / MapPost / Handle |
21.2 已经对齐的部分
自动绑定推断与空正文 400,UseValidation() 批量校验,三态恒运行的 OnExecuted,IExceptionFilter 与 ExceptionHandled,RFC 7807 形状的 ProblemDetails,AOT 兼容标注,内容协商的 415 与 406,以及 Accept 的 q 值处理。
21.3 明确不提供的部分
HTTP 动词约束,路由默认值与 {id:int} 约束,IActionResult 体系,鉴权 / 资源 / 结果三类独立过滤器,IFormFile 表单绑定,区域与命名路由,同名方法按签名重载。
这些缺口大多源于定位。没有 HTTP 就没有动词与表单语义,契约路由天然唯一就不需要候选集择优。直接返回 POCO 让进程内调用省掉一层结果对象,代价是跨进程状态码只能靠 DispatchResponse 表达。
21.4 Vane 特有的能力
Send<TService, TResult>、Dispatch<TResult>、Invoke<TArgs, TResult>、CreateProxy<T> 让进程内调用保持强类型甚至接口契约;前两个是接口默认方法,本地与远程共用同一份实现,跨进程调用的服务名解析、请求构造、异常映射与进程内完全一致。MVC 面向外部客户端,这类能力不存在于它的模型里。
21.5 迁移建议
- 先把 HTTP 语义留在宿主层,引擎只看
组名/操作名。 - 把路由当成字符串查表键,重载改成不同名字或别名。
- 过滤器只有同步与异步两套 action 级钩子,在
OnExecuted里读Exception与Canceled。 - 校验需要显式
UseValidation()。 - 容器经
ServiceResolver接入,生命周期归容器。 - 纯 CPU 用
Dispatch,有 I/O 与取消需求用DispatchAsync,取消结果 499。 - 进程内调用优先
Send与接口代理,宿主层用完整DispatchRequest。
21.6 辩证地看「对标」
对标 MVC 的目的是降低认知迁移成本,能力并不对等。早期的 MVC 语义对照报告是历史快照,其中若干格已被后续实现改写,内容协商就是最明显的一格。把对照报告当历史读、以实际运行结果为准,这个习惯比记住任何一条具体结论都有用。
反过来也成立。Vane 的简洁来自主动放弃,放弃的那部分恰好是 Web 框架里最复杂也最常被依赖的成熟度。选择它时,先确认你的宿主愿意承担这些责任。
第 22 章 编译期生成与强类型直达
前面 21 章讲的是「运行期怎么把一次调用送到方法」:查路由、绑参数、跑过滤器、收口结果。这一章讲后来补上的另一半------把能在编译期算好的东西提前算好,以及把 object 从热路径里拿掉。
三件事是一条线:源码生成器负责在编译期产出强类型代码,强类型直达管线负责让这些代码有地方可去,接口默认方法负责让本地与远程共用同一套语义。学完这一章,你会明白为什么有些测试「全绿却没测到东西」,以及零装箱的边界到底在哪里。
22.1 为什么要生成代码
先数一数旧路径里那些「每次都要重新算一遍」的东西。
| 入口 | 运行期要做什么 | 代价 |
|---|---|---|
CreateProxy<T>() |
运行期生成代理类型,把方法调用装进 object[],再拆回参数 |
代理类型生成、每次调用一次数组分配 |
MapDispatchable<T>() |
反射读方法签名,用表达式树编译调用委托 | 构建期反射,AOT 下要保留成员 |
Dispatch<TResult>(..., new { ... }) |
反射提取匿名对象属性,拼成参数字典 | 每次调用一次反射与字典 |
过滤器读 BoundArguments |
把已绑定的实参展开成 IDictionary<string, object> |
值类型逐个装箱 |
这些成本单看都不大,但契约形状其实是编译期就完全确定的:接口有哪些方法、方法有几个参数、参数叫什么名字、控制器方法是同步还是异步,写代码的那一刻就定死了。既然如此,为什么还要等运行期再算?
生成器的做法是把这些形状提前写成普通 C# 代码:接口方法对应一个强类型代理类,控制器方法对应一个直接调用器。运行期只做一次查表,不再反射、不再 object[]。
收益有两个,边界也有两个。
- 收益:进程内热路径零装箱、零额外分配;AOT 与裁剪下不再依赖类型保留。
- 边界一:只对能静态判定的契约生效。泛型接口、异步返回、属性或事件成员、
ref/out参数都要回退。 - 边界二:跨进程必然序列化,装箱省不掉。生成代码保住的是「框架不额外制造分配」。
22.2 接线:生成器是按程序集生效的
这是最容易踩的坑,先讲。
生成器以分析器(Analyzer)身份 接入,而且只对引用它的那个程序集生效。也就是说,CreateProxy<T>() 写在哪个工程里,那个工程就得显式引用生成器。框架工程、测试工程、业务工程各算各的。
xml
<ItemGroup>
<ProjectReference Include="<生成器工程路径>"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
OutputItemType="Analyzer" 让编译器把它当源生成器加载,ReferenceOutputAssembly="false" 表示不把生成器程序集当普通依赖引用------它只在编译期存在,不会进你的输出目录。
为什么这件事值得单开一节 :接线缺失时不会报错。生成代理登记表恒为空,CreateProxy<T>() 安静地回退运行时代理,功能完全正常,所有断言照样通过,但你要验证的零分配收益根本没发生。这类「测试全绿却是假绿」的失效模式,靠跑测试发现不了,只能靠检查编译产物或接线配置。
两个诊断帮你看清回退原因,都是 Info 级,不会让构建失败。
| 诊断号 | 触发 | 含义 |
|---|---|---|
VDGEN001 |
接口代理未走生成通道 | 契约含生成器不支持的成员形态,回退运行时代理;功能不受影响,仅无零分配收益 |
VDGEN002 |
端点未走生成调用器 | 方法形态不可直读,回退通用绑定管线;功能不受影响 |
诊断之所以定成 Info 而不是 Warning,是因为回退是安全的:功能一点不少,只是少了性能收益。把它当警告会逼着用户对所有提示直接关掉,反而丢了信息。
22.3 生成代理:CreateProxy<T> 的两条路径
生成器扫描整个编译里所有 CreateProxy<T>() 的调用点,取出 T 的接口定义,逐个判断能不能生成。
能生成的条件 (全部满足):接口非泛型、可访问、只含普通方法成员、方法非泛型、返回类型不是 Task / ValueTask、参与绑定的参数不超过 7 个、没有 ref / out 参数与 ref 返回。含属性或事件的接口也不生成。
异步返回为什么整体回退?因为契约声明的 Task<T> 与服务端实现返回的 ValueTask<T> 之间有一层适配,由运行时拦截器统一处理;生成代理要求两端类型严格一致,硬套会出现「本地生成通道」与「远程拦截器」两套异步语义。宁可整体回退,也不要两套语义。
生成出来的代理类在模块初始化 时把自己登记进代理登记表。运行期 CreateProxy<T>() 的分流很简单。
text
不传 dispatchName 且登记表里有 T → 用生成代理
其余情况 → 回退运行时拦截器
「不传 dispatchName」不是随手加的条件:生成代码里的路由在编译期就写死了,没法承载运行时传进来的自定义组名。所以一旦写 CreateProxy<ICalc>("calc"),就必然走回退路径。想用生成代理,得让接口名和类级 [Route] 自然对得上。
两条路径在调用侧的行为对比。
| 生成代理 | 运行时拦截器 | |
|---|---|---|
| 本地引擎 | 直调 Invoke<TArgs,TResult>,无 object[]、无反射分发 |
调用装进 object[],再走通用分发 |
| 远程引擎 | 经生成调用桥:参数字典、ResultType、取消令牌、失败异常映射 |
同一套拦截器逻辑 |
| Native AOT | 可用 | 标注 RequiresDynamicCode,不可用 |
注意远程分支并不「退化」:它仍然保住强类型接口契约、状态码保真与异常映射,只是把参数按名装进字典------跨进程本来就要序列化。生成代理在本地拿零分配,在远程拿统一语义,分工是刻意的。
22.4 生成端点:MapDispatchable<T> 的直达调用器
另一条生成器管线盯的是 MapDispatchable<T>() 的调用点,为控制器方法生成调用器,登记键是**(控制器类型, 方法名)**。这个键不参与路由归一化,所以命名规则调整不会让生成表和路由表错位。
登记表里有两种形态。
| 形态 | 何时用 | 特点 |
|---|---|---|
| 静态直达调用器 | 端点没有过滤器 | 零参数载体分配 |
| 调用器工厂 | 端点带过滤器 | 每个请求新建一个载体实例,不能跨请求复用 |
有过滤器时为什么不能复用?参数承载在载体实例的字段上,多个并发请求共用一个实例会互相踩。工厂形态保证一请求一实例。
三种情况会回退到通用绑定管线。
- 同一个(类型, 方法名)被登记多次------通常是重载方法各自带
[Route]别名。登记表把它标成歧义,该键一律回退,避免用错参数形态的调用器。 - 注册了自定义值提供器。生成代码按默认顺序直读参数,与自定义提供器的顺序可能不一致,此时引擎干脆不查生成表。
- 方法形态不可直读:泛型方法、
ref返回、ref struct参数或返回值、参数超过 8 个、ref/out参数等。此时报VDGEN002。
排查性能问题时,顺序应该是:先确认生成路径有没有命中,再看诊断,最后才谈别的。回退太安静了,不主动查根本发现不了。
22.5 强类型直达与 BoundArguments 惰性物化
7.5 已经见过 Invoke<TArgs,TResult> 的用法。这里讲它和过滤器怎么共存------这是设计上最巧的一处。
直达管线本来不做模型绑定,参数由调用方直接给出,值类型不装箱。可一旦挂了过滤器,过滤器可能要读 ActionExecutingContext.BoundArguments 看看实参是什么,甚至改写它。如果为了这个「可能」在每次调用前都把实参展开成 IDictionary<string, object>,零装箱就白做了。
做法是惰性物化 :引擎不预先展开,只登记一个展开器(或生成调用器)作为来源;过滤器第一次真正读 BoundArguments 时,才物化字典并装箱。
csharp
public sealed class LogArgumentsFilter : IFilter
{
public void OnExecuting(ActionExecutingContext ctx)
{
// 读这一下才触发物化;不读的过滤器全程零成本
foreach (var pair in ctx.BoundArguments)
Console.WriteLine($"{pair.Key} = {pair.Value}");
}
public void OnExecuted(ActionExecutedContext ctx) { }
}
于是鉴权、计时、日志这类「不关心参数」的过滤器在直达管线上依然零装箱;只有真去读参数的过滤器付那一次成本。过滤器仍然可以改写 BoundArguments 影响实参,语义与完整管线一致------不存在第二条管线语义。
DispatchResult<TResult> 是这条管线的结果载体,公开 Value、StatusCode、Error、Exception、IsSuccess 与 ThrowIfFailed()。失败时不必自己判状态码,ThrowIfFailed() 会抛 DispatchException。
最后把零装箱的边界说死:它是编译期已知 TArgs 的进程内属性。跨进程时字节边界必然装箱一次,这不是实现遗漏,是物理约束。远程实现保住的是「强类型接口契约 + 全部可观测行为」,不是装箱数。
22.6 本地与远程语义统一:默认接口方法
这一节解释一个曾经很隐蔽的问题。
早期本地引擎与远程引擎各自 实现了 Send<TService,TResult> 与 Dispatch<TResult>。两份实现看起来一样,细节却分叉了。
| 环节 | 本地旧实现 | 远程旧实现 |
|---|---|---|
| 服务名解析 | 转小写、去 I 前缀、去 Controller / Service 后缀 |
不转小写、不拆前缀 |
| 异常映射 | 本地一份转换 | 远程另一份转换 |
之所以长期没出事,是因为服务端路由表查表大小写不敏感,把命名差异意外兜住了。这属于「碰巧能跑」,不是设计保证。一旦哪天路由表改成大小写敏感,或者客户端先按小写名做了缓存,两端就会分叉。
修法是把这两个方法提升为 IDispatchEngine 的默认接口方法:接口自己带实现,所有实现者共用一份。
csharp
// 接口里的实现(示意,签名以附录 A 为准)
TResult Dispatch<TResult>(string serviceName, string operationName, object requestArgs = null)
{
var request = DispatchRequest.Create(serviceName, operationName, requestArgs);
request.ResultType = typeof(TResult);
var response = this.Dispatch(request); // 虚分发,落到具体引擎
if (!response.IsSuccess) throw DispatchException.FromResponse(response);
return response.Result is null ? default! : (TResult)response.Result;
}
四个统一点。
- 命名:服务名解析只有一个入口,本地与远程逐字一致。
- 请求构造 :统一走
DispatchRequest.Create。 - 分发 :
this.Dispatch(request)是虚调用,落到本地或远程各自的实现,不损失多态。 - 异常 :失败统一由内部的
DispatchException.FromResponse收口;DispatchException携带的状态码原样跨进程保真,不会降级成 500。
Send 也走同一套,但保留自己的实参语义:它直接把 argument 作为 Payload,走 __value 单值约定或字典展开;Dispatch<TResult> 走 Create 的具名对象属性展开。这是刻意分工 ,不是割裂------两者都统一委托 this.Dispatch 与同一个异常收口。
于是形成一组刻意的不对称。
| 方法 | 实现方式 | 理由 |
|---|---|---|
Send / Dispatch<TResult> |
接口默认方法,隐式统一 | 语义完全可由通用分发表达,无需两端各写一份 |
Invoke / CreateProxy |
各引擎显式重写 | 本地要零装箱、远程要序列化,实现本就不同 |
顺带回答一个常被问到的问题:为什么不把 object 参数改成泛型? 因为装箱并不发生在调用点。Send 的 object argument 只是入口,真正让值类型装箱的是参数字典与 object 结果这套松散分发模型;就算把签名改成 Send<TService,TArgs,TResult>,值写进字典、经 object 结果返回时照样各装一次,还多出一组并存的重载让用户分不清该用哪个。真要零装箱,走 Invoke<TArgs,TResult> 才是正解------弱类型入口图方便,强类型入口图性能,两者互补。
22.7 小结与练习
这一章的结论压成一张检查表。
| 你要做什么 | 先确认什么 |
|---|---|
| 用生成代理提速 | 调用点所在工程接了生成器;没传 dispatchName;契约是纯同步 |
| 用生成端点提速 | 没注册自定义值提供器;方法没有重载歧义;没有 VDGEN002 |
| 要零装箱 | 用 Invoke<TArgs,TResult>,且调用发生在进程内 |
| 跨进程强类型调用 | 用 Invoke 的远程实现或 Dispatch<TResult>;接受一次序列化装箱 |
| 排查「测试全绿但没提速」 | 多半是生成器没接线,登记表为空,静默回退了 |
练习:
- 建一个只含同步方法的接口,接好生成器,用
CreateProxy<T>()不传组名调用一次;再给接口加一个Task<int>方法,重新编译,观察VDGEN001是否出现。 - 给同一个控制器加两个重载方法、分别带
[Route]别名,观察生成端点是否回退到通用绑定管线,并说明为什么回退是安全的。 - 写一个不读
BoundArguments的过滤器和一个读它的过滤器,分别挂在Invoke路径上,比较两者的分配差异。 - 用字典实参分别调
Send<TService,int>与Dispatch<int>,再说清二者的实参约定差在哪。
附录
附录 A 公开 API 速查
A.1 构建器
csharp
resolver.BuildDispatchEngine() // ServiceResolver 重载
provider.BuildDispatchEngine() // IServiceProvider 重载
IDispatchBuilder 方法 |
作用 |
|---|---|
MapDispatchable<TService>() |
显式注册控制器 |
MapDispatchablesFromAssembly(Assembly) |
扫描程序集内全部 [Route] 类型 |
AddModelBinder(IModelBinder) |
插入自定义绑定器,链首优先 |
AddInputFormatter(IInputFormatter) |
注册输入格式化器 |
AddOutputFormatter(IOutputFormatter) |
注册输出格式化器 |
AddGlobalFilter(IFilter) |
注册全局同步过滤器 |
AddGlobalAsyncFilter(IAsyncFilter) |
注册全局异步过滤器 |
AddGlobalExceptionFilter(IExceptionFilter) |
注册同步异常过滤器 |
AddGlobalExceptionFilter(IAsyncExceptionFilter) |
注册异步异常过滤器 |
AddValueProvider(Func<DispatchRequest, IValueProvider>) |
插入自定义值来源工厂 |
Handle(string, Delegate) |
注册自由委托端点 |
UseContextAccessor(IDispatchContextAccessor) |
接入当前上下文访问器 |
Build() |
生成 IDispatchEngine |
| 扩展方法 | 作用 |
|---|---|
UseValidation() |
一键启用 DataAnnotations 校验 |
Map(pattern, handler) |
注册端点,含 0 至 4 参数泛型重载 |
MapGet / MapPost |
约定别名,内部转到 Handle |
A.2 引擎
| 成员 | 作用 |
|---|---|
Dispatch(DispatchRequest) |
完整同步管线 |
DispatchAsync(DispatchRequest, CancellationToken) |
异步管线 |
Dispatch<TResult>(service, operation, args) |
强类型裸分发(接口默认方法),异常透传 |
Send<TService, TResult>(action, argument) |
按控制器类型定位(接口默认方法),单值 / 字典实参 |
Invoke<TArgs, TResult>(route, in args) |
强类型直达;默认抛 NotSupportedException,由具体引擎重写 |
CreateProxy<T>(dispatchName) |
接口代理,优先生成代理、否则回退运行时拦截器 |
Services |
底层 ServiceResolver |
GetActionDescriptors(Type) |
扁平动作元数据 |
默认接口方法意味着 Dispatch<TResult> 与 Send 在 IDispatchEngine 上只有一份实现,本地引擎与远程引擎不会各写一套;服务名解析统一走 DispatchNames,失败异常统一由内部的 DispatchException.FromResponse 收口。
A.3 标注
[Route]、[NoAutoBind]、[FromBody]、[FromRoute]、[FromHeader]、[FromQuery]、[FromServices]、ActionFilterAttribute、AsyncActionFilterAttribute。
A.4 扩展接口
IFilter、IAsyncFilter、IExceptionFilter、IAsyncExceptionFilter、IModelBinder、IValueProvider、IInputFormatter、IOutputFormatter、IDispatchContextAccessor。
A.5 上下文与值类型
ActionExecutingContext、ActionExecutedContext、ModelBindingContext、ParameterBindingInfo、ModelState、BindingError、DispatchContext、DispatchContextAccessor、RouteValueDictionary、ProblemDetails、DispatchException、FormatterSelector、OutputFormatterSelection、MediaTypes、DispatchStatusCodes、BindingSourceKind、IActionDescriptor。
A.6 格式化器实现
JsonInputFormatter、JsonOutputFormatter、RawBytesFormatter、FlatBinaryFormatter、ValidationFilter。
A.7 生成期与结果载体
| 成员 | 作用 |
|---|---|
DispatchResult<TResult> |
强类型直达的结果载体;公开 Value、StatusCode、Error、Exception、IsSuccess、ThrowIfFailed() |
ActionExecutingContext.BoundArguments |
已绑定实参,首次读取时才物化 |
GeneratedProxyRegistry |
生成代理登记表,模块初始化时写入(供生成代码调用) |
GeneratedEndpointRegistry |
生成端点调用器登记表(供生成代码调用) |
GeneratedInvokeBridge |
生成代理在远程引擎上的调用桥(供生成代码调用) |
注意 DispatchException.FromResponse 与 DispatchResult<TResult>.FromResponse 都是内部实现,不属于公开 API;库外只需要知道失败会抛 DispatchException。
附录 B 术语表
| 术语 | 含义 |
|---|---|
| 分发 | 把一条路由与一组输入映射到某个 CLR 方法并收口结果的过程 |
| 组合根 | 应用中一次性装配引擎的位置 |
| 服务名 / 组名 | 类级 [Route] 决定的一级路由键 |
| 操作名 | 方法名或方法级 [Route] 决定的二级路由键 |
| 模型绑定 | 把请求里的各种来源数据变成方法实参 |
| 值提供器 | 提供字符串键值对的可插拔来源 |
| 绑定器 | 决定某个参数由谁、怎样构造 |
| 格式化器 | 在字节与 CLR 对象之间做转换 |
| 内容协商 | 按 Content-Type / Accept 选择格式化器 |
| 过滤器管线 | 操作执行前后的横切回调集合 |
三态 OnExecuted |
成功、异常、取消三种状态下都会执行的执行后回调 |
ProblemDetails |
RFC 7807 风格的结构化错误体 |
| 快路径 | 全同步、无 Task、框架侧尽量零分配的执行路径 |
| 双形态结果 | Result 供进程内读,Body 供跨进程读 |
| 契约路由 | 组名/操作名 两级精确查表 |
| 自由端点 | 直接注册委托的 Minimal API 风格入口 |
| 默认接口方法(DIM) | 接口自带实现、所有实现者共用的成员;本书用来统一本地与远程语义 |
| 生成代理 | 编译期生成的强类型接口代理,直调直达管线,无运行期反射 |
| 生成端点 | 编译期生成的控制器方法调用器,跳过表达式树编译 |
| 强类型直达 | Invoke<TArgs,TResult> 走的旁路:不做绑定与协商,值类型不装箱 |
| 惰性物化 | 只有真去读 BoundArguments 时才把实参展开成字典并装箱 |
附录 C 仓库示例索引
配套示例是一个单一的可运行控制台工程,运行方式如下。
bash
dotnet run --project examples/Vane.Dispatch.Samples -- list
dotnet run --project examples/Vane.Dispatch.Samples -- D01
dotnet run --project examples/Vane.Dispatch.Samples --no-build -- D05
| 示例 | 主题 | 对应章节 |
|---|---|---|
| D01 | 最小分发、强类型调用、AOT 字典重载 | 第 2 章、第 7 章 |
| D02 | 请求响应对象、404、ProblemDetails |
第 3 章、第 4 章 |
| D03 | 路由三形态、路径模板、JSON 正文 | 第 5 章、第 6 章 |
| D04 | 绑定源、属性级混合、自定义绑定器 | 第 8 章、第 10 章 |
| D05 | 同步 / 异步 / 异常过滤器、校验 | 第 11 章、第 12 章 |
| D06 | 内容协商、415 / 406、零拷贝 Body |
第 15 章 |
| D07 | 异步取消、499、deadline 与 trace | 第 17 章 |
| D08 | 结果双形态与五种调用外观 | 第 4 章、第 7 章 |
另有三个平铺示例,Vane.Dispatch.Demo、Vane.Dispatch.HttpExample、Vane.Dispatch.ValueProviderExample,分别对应综合用法、HTTP 宿主、自定义值提供器。
附录 D 练习要点
| 章节 | 练习核心检查点 |
|---|---|
| 1 至 2 | 能说清五种职责;三种调用方式结果一致 |
| 3 至 4 | 失败响应的五个字段;ProblemDetails 成功为 null |
| 5 至 7 | 别名枚举;CreateProxy<T> 命名坑 |
| 8 至 10 | 六种来源同时绑定;CanBind 写窄 |
| 11 至 14 | 三态 OnExecuted;ExceptionHandled 翻转 500;访问器作用域 |
| 15 至 17 | 415 与 406 复现;宿主映射;499 与 504 区分 |
| 18 至 21 | 容器生命周期;AOT 标注清单;按管线分段排错 |
| 22 | 生成器接线;生成代理与回退;强类型直达的装箱边界;本地与远程语义统一 |
结语
一条分发管线从「找到方法」开始,走到「把结果交给调用方」结束。Vane.Dispatch 把中间每一步都做成可替换的零件,同时把容器与传输层留在宿主手里。这种设计带来了极大的组装自由,也把框架级便利变成了宿主的责任。
读到这里,你已经能独立搭起引擎、注册控制器与端点、写绑定器与过滤器、接自定义协议、挂 HTTP 宿主、处理异步与错误,也知道怎么接编译期生成器、什么时候该用强类型直达。下一步值得做的是把第 16 章的骨架换成你真实项目里的协议,然后按第 20 章的分段方法排查第一个 400。遇到说明与实际行为对不上的地方,写个最小复现跑一遍,通常就够了。