Vane.Dispatch 服务分发器:从零到实践

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 说起

假设你在做一个订单系统,调用方传来一个字符串操作名和一组参数。最直觉的写法是这样。

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("未知操作");
    }
}

这段代码能跑,问题会随功能增长逐个冒出来。

  1. switch 越来越长,新增操作要改调度代码。
  2. 类型转换散落在各处,(int)args["id"] 在传字符串时才报错。
  3. 日志、鉴权、校验、异常包装要复制进每个 case。
  4. 想从进程内调用改成网络调用,返回值是 object,序列化边界模糊。
  5. 参数来自路径、请求头、正文时,取值逻辑与业务代码混在一起。

把这几件事拆开看,调度代码其实承担了五种彼此独立的职责。

职责 手写 switch 的做法 成熟管线的做法
找到该执行哪个方法 字符串分支 构建期生成路由表,运行期查表
把外部数据变成方法实参 手工强转 模型绑定与类型转换
执行前与执行后的横切逻辑 复制粘贴 过滤器管线
表达成功与失败 抛异常或返回 null 统一响应对象与状态码
把结果交给调用方 直接返回 object 进程内对象与跨进程字节双形态

服务分发器就是把这五件事收进一条可复用的管线,让业务代码只剩方法本身。

1.2 Vane.Dispatch 的定位

它是一个微内核。给它一条路由字符串和一组输入,它找到对应的 CLR 方法,绑定参数,跑过滤器,调用方法,把结果收进统一的响应对象。它不监听端口,不解析 HTTP 报文,不管理数据库连接,也不强制你用某个 DI 容器。

它唯一要求的外部协作是一个委托。

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 包。

dotnet new console -n FirstDispatch -f net10.0
cd FirstDispatch
dotnet add package Vane.Dispatch

若你在仓库内学习,改用项目引用更方便。

<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 放在文件末尾。

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;
}

预期输出:

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 下会有警告。改用字典即可。

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 三个骨架类型

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 拆出的只读派生属性

还有一个静态工厂,适合构造跨进程信封。

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

公开工厂与失败出口。

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 风格。

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] 一次表达三件事

[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] 是操作名与别名

[Route("orders")]
[Route("order-api")]
public sealed class OrderController
{
    [Route("fetch")]
    [Route("get-by-id")]
    public string Get(int id) => $"order #{id}";
}

方法级首个 [Route] 模板成为操作名,其余模板成为别名。类级多个 [Route] 同理,首个别名之外的模板成为组别名。于是下面四条路由指向同一个方法。

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]

[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 批量扫描

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 直接获得强类型参数。

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,再参与参数绑定。

var response = engine.Dispatch(new DispatchRequest
{
    Route = "mini/hello/Alice",
    ResultType = typeof(string),
});
// response.Result == "hello, Alice!"

占位符值无需在 Payload 里再放一份。宿主若已在上游解析过路径,也可以直接填 RouteValues。

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 完整管线入口

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>

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>

int b = engine.Send<CalcController, int>("Square", 9);

组名由控制器类型解析,省掉手写字符串。argument 就是「本次调用的唯一参数」:字典直接按键展开;非字典(原始值或单对象)经 __value 约定映射到操作参数,数组则按参数索引做位置映射。Send 内部仍走完整分发管线,因此过滤器、绑定、错误模型都生效。

注意它与 Dispatch<TResult> 的刻意分工:Send 面向「单值 / 字典」,Dispatch<TResult> 面向「具名对象的属性展开」。二者同为接口默认方法,统一委托 this.Dispatch 与同一个异常收口,本地与远程语义一致,详见第 22 章。

7.4 接口代理 CreateProxy<T>

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>

DispatchResult<int> r = engine.Invoke<int, int>("calc/Square", 9);
int value = r.Value;

TArgs 按值进入,TResult 原样返回,全程不经过 object[] 与 object 结果,值类型不装箱,也不分配 DispatchResponse。代价是它不做模型绑定、不做内容协商:参数由调用方直接给出,结果也不写 Body。

它适合进程内热路径。跨进程时字节边界必然带来一次序列化装箱,「零装箱」这个保证无法越界保真——这是物理约束,不是实现遗漏。远程引擎提供了同一个方法的实现:把参数按名展开后走通用分发,保住强类型接口契约与状态码、异常映射等全部可观测行为。

多参数用 ValueTuple 或具名载体。

var sum = engine.Invoke<(int, int), int>("calc/Add", (2, 3));

位置元组只在进程内按位映射;跨进程服务端按名绑定,应改用字段名与形参一致的具名载体,否则元组项名 Item1 / Item2 匹配不上形参。

Invoke 是接口里唯一「默认抛、由具体引擎重写」的方法:本地引擎给出零装箱实现,远程引擎给出按名展开加通用分发的实现,第三方引擎没重写时会得到 NotSupportedException。第 22 章展开生成代理如何自动走这条路径。

7.6 异步入口 DispatchAsync

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] 关掉推断,逐项显式声明。

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";
}

调用侧分别填对应字段。

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。

[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。

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 演示了这一点。

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}";
}

请求侧的键要带模型名前缀。

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 默认绑定器链

默认装配顺序如下。

BodyModelBinder → ServicesModelBinder → ValueProviderModelBinder → DefaultModelBinder

CompositeModelBinder 按注册顺序挑首个 CanBind(info) 返回 true 的绑定器。AddModelBinder 用 Insert(0, binder) 把自定义绑定器插到链首,因此它优先于全部内置绑定器。

前三个绑定器各管一个明确来源。DefaultModelBinder 是兜底,同时承担 MVC 里复杂对象绑定器、集合绑定器、字典绑定器的职责。

9.2 点号前缀与递归绑定

复杂对象按 模型名.属性名 拼接键,嵌套继续加点号。

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));
}
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

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 只有两个方法。

public interface IValueProvider
{
    bool ContainsPrefix(string prefix);
    bool TryGetValue(string name, out string value);
}

ContainsPrefix 用于探测某个子路径是否存在,TryGetValue 返回字符串值,类型转换交给 ValueConverter。下面把请求 Context 里的用户声明接进绑定链。

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 插到内置来源之前。

[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 定义两个方法。

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。

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 四个接口与两个特性基类

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);
}

想在方法上用特性标注,继承两个抽象类即可。

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 两个上下文

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 路径的顺序如下。

参数绑定
  → 全局同步 OnExecuting
  → 操作级同步 OnExecuting
  → 调用操作
  → 全局同步 OnExecuted
  → 操作级同步 OnExecuted
  → 同步异常过滤器 OnException(仅存在未处理异常时)
  → 判定状态码

异步 DispatchAsync 路径多出两段:执行后异步过滤器与异步收口。

参数绑定
  → 全局同步 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 全局与操作级

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())。

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] 等标准特性,也支持模型级自定义规则。

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 与异常过滤器后再决定响应。

[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 异常过滤器

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 结构化错误体

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。

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 接入与使用

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 实例。

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 两个接口

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 写法

默认装配如下。

输入: 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。

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 接入二进制协议

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,引擎跳过自序列化。

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 一个最小骨架

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 按需异步

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 形参被视为注入,不参与绑定,由调用器在编译期识别。自由端点与控制器一致。

[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 父跨度标识
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 两种接入方式

最小路径是直接给委托。

ServiceResolver resolver = type =>
    type.GetConstructor(Type.EmptyTypes)?.Invoke(null)
    ?? throw new MissingMethodException(type.FullName);

var engine = resolver.BuildDispatchEngine()
    .MapDispatchable<OrderController>()
    .Build();

已有 IServiceProvider 时用另一个重载。

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 构造器注入

控制器由解析器构造,因此构造器参数可以由容器注入。

[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 友好写法清单

// 用字典构造请求,避开匿名对象反射
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 迁移建议

  1. 先把 HTTP 语义留在宿主层,引擎只看 组名/操作名。
  2. 把路由当成字符串查表键,重载改成不同名字或别名。
  3. 过滤器只有同步与异步两套 action 级钩子,在 OnExecuted 里读 Exception 与 Canceled。
  4. 校验需要显式 UseValidation()。
  5. 容器经 ServiceResolver 接入,生命周期归容器。
  6. 纯 CPU 用 Dispatch,有 I/O 与取消需求用 DispatchAsync,取消结果 499。
  7. 进程内调用优先 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>() 写在哪个工程里,那个工程就得显式引用生成器。框架工程、测试工程、业务工程各算各的。

<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>() 的分流很简单。

不传 dispatchName 且登记表里有 T  →  用生成代理
其余情况                          →  回退运行时拦截器

「不传 dispatchName」不是随手加的条件:生成代码里的路由在编译期就写死了,没法承载运行时传进来的自定义组名。所以一旦写 CreateProxy<ICalc>("calc"),就必然走回退路径。想用生成代理,得让接口名和类级 [Route] 自然对得上。

两条路径在调用侧的行为对比。

生成代理 运行时拦截器
本地引擎 直调 Invoke<TArgs,TResult>,无 object[]、无反射分发 调用装进 object[],再走通用分发
远程引擎 经生成调用桥:参数字典、ResultType、取消令牌、失败异常映射 同一套拦截器逻辑
Native AOT 可用 标注 RequiresDynamicCode,不可用

注意远程分支并不「退化」:它仍然保住强类型接口契约、状态码保真与异常映射,只是把参数按名装进字典——跨进程本来就要序列化。生成代理在本地拿零分配,在远程拿统一语义,分工是刻意的。

22.4 生成端点:MapDispatchable<T> 的直达调用器

另一条生成器管线盯的是 MapDispatchable<T>() 的调用点,为控制器方法生成调用器,登记键是(控制器类型, 方法名)。这个键不参与路由归一化,所以命名规则调整不会让生成表和路由表错位。

登记表里有两种形态。

形态 何时用 特点
静态直达调用器 端点没有过滤器 零参数载体分配
调用器工厂 端点带过滤器 每个请求新建一个载体实例,不能跨请求复用

有过滤器时为什么不能复用?参数承载在载体实例的字段上,多个并发请求共用一个实例会互相踩。工厂形态保证一请求一实例。

三种情况会回退到通用绑定管线。

  1. 同一个(类型, 方法名)被登记多次——通常是重载方法各自带 [Route] 别名。登记表把它标成歧义,该键一律回退,避免用错参数形态的调用器。
  2. 注册了自定义值提供器。生成代码按默认顺序直读参数,与自定义提供器的顺序可能不一致,此时引擎干脆不查生成表。
  3. 方法形态不可直读:泛型方法、ref 返回、ref struct 参数或返回值、参数超过 8 个、ref / out 参数等。此时报 VDGEN002。

排查性能问题时,顺序应该是:先确认生成路径有没有命中,再看诊断,最后才谈别的。回退太安静了,不主动查根本发现不了。

22.5 强类型直达与 BoundArguments 惰性物化

7.5 已经见过 Invoke<TArgs,TResult> 的用法。这里讲它和过滤器怎么共存——这是设计上最巧的一处。

直达管线本来不做模型绑定,参数由调用方直接给出,值类型不装箱。可一旦挂了过滤器,过滤器可能要读 ActionExecutingContext.BoundArguments 看看实参是什么,甚至改写它。如果为了这个「可能」在每次调用前都把实参展开成 IDictionary<string, object>,零装箱就白做了。

做法是惰性物化:引擎不预先展开,只登记一个展开器(或生成调用器)作为来源;过滤器第一次真正读 BoundArguments 时,才物化字典并装箱。

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 的默认接口方法:接口自己带实现,所有实现者共用一份。

// 接口里的实现(示意,签名以附录 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;
}

四个统一点。

  1. 命名:服务名解析只有一个入口,本地与远程逐字一致。
  2. 请求构造:统一走 DispatchRequest.Create。
  3. 分发:this.Dispatch(request) 是虚调用,落到本地或远程各自的实现,不损失多态。
  4. 异常:失败统一由内部的 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>;接受一次序列化装箱
排查「测试全绿但没提速」 多半是生成器没接线,登记表为空,静默回退了

练习:

  1. 建一个只含同步方法的接口,接好生成器,用 CreateProxy<T>() 不传组名调用一次;再给接口加一个 Task<int> 方法,重新编译,观察 VDGEN001 是否出现。
  2. 给同一个控制器加两个重载方法、分别带 [Route] 别名,观察生成端点是否回退到通用绑定管线,并说明为什么回退是安全的。
  3. 写一个不读 BoundArguments 的过滤器和一个读它的过滤器,分别挂在 Invoke 路径上,比较两者的分配差异。
  4. 用字典实参分别调 Send<TService,int> 与 Dispatch<int>,再说清二者的实参约定差在哪。

附录

附录 A 公开 API 速查

A.1 构建器

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 仓库示例索引

配套示例是一个单一的可运行控制台工程,运行方式如下。

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。遇到说明与实际行为对不上的地方,写个最小复现跑一遍,通常就够了。

posted @ 2026-11-13 23:58  风云  阅读(0)  评论(0)    收藏  举报