Skip to content
Go back

谁拥有 next:Pipeline、职责链与中间件

三个名字经常被互换:Pipeline、Chain of Responsibility、ASP.NET Core middleware。它们确实都像「一串有序组件」——但这正是混淆的来源。Nick Cosentino 在 Pipeline vs Chain of Responsibility vs ASP.NET Core Middleware 里给出的区分标准很干脆:看谁拥有 continuation,以及每个组件被期望做什么。

本文的三段示例按 net10.0、C# 14 与可空引用类型编写,我已在 .NET 10.0.301 SDK 下编译运行验证;中间件行为与 ASP.NET Core 10 官方文档核对一致。

一表看清三种模型

问题PipelineChain of ResponsibilityASP.NET Core 中间件
主要目的分阶段转换或处理选出有能力的处理器拦截并组合 HTTP 请求/响应行为
正常参与方式每个必需阶段按序运行一个或多个候选处理器可能拒绝每个被到达的中间件决定是否调用 next
Continuation 所有者通常是外部 runner通常是当前处理器当前中间件
输入/输出形态常随阶段变化通常同一个请求保持同型共享 HttpContext,异步 Task 返回
短路含义到达终止结果而停止处理器接受了请求而停止中间件未调用 next 而停止
逆向回卷非固有非固有内建:await next 之后的代码
顺序的意义转换依赖候选优先级请求顺序 + 响应逆序

表中描述的是每个模型的「重心」。真实系统可以混合特征:pipeline 可以拒绝一项;职责链的处理器可以在转发前丰富请求;中间件可以改造 HttpContext 里的值。关键问题是,哪个行为定义了架构

Pipeline:分阶段转换

微软的 Pipes and Filters 模式指南把流水线描述为「由管道连接的独立过滤器,每个过滤器消费输入、产出输出」。在进程内的 C# Pipeline 里,这些边界可能就是方法调用与类型值。

重要的不变量是:阶段属于计划内的转换。 校验不是「可能处理订单的候选者」,定价也不是「同一请求的另一个候选」。校验的输出成为定价的输入:

namespace PatternComparison.PipelineExample;

public sealed record RawOrder(
    string CustomerId,
    IReadOnlyList<RawOrderLine> Lines);

public sealed record RawOrderLine(
    string Sku,
    int Quantity,
    decimal UnitPrice);

public sealed record ValidatedOrder(
    string CustomerId,
    IReadOnlyList<RawOrderLine> Lines);

public sealed record PricedOrder(
    string CustomerId,
    decimal Total);

public static class OrderPipeline
{
    public static PricedOrder Execute(RawOrder input)
    {
        ValidatedOrder validated = Validate(input);
        PricedOrder priced = CalculatePrice(validated);

        return priced;
    }

    private static ValidatedOrder Validate(RawOrder input)
    {
        if (string.IsNullOrWhiteSpace(input.CustomerId))
        {
            throw new ArgumentException(
                "A customer identifier is required.",
                nameof(input));
        }

        if (input.Lines.Count == 0)
        {
            throw new ArgumentException(
                "At least one order line is required.",
                nameof(input));
        }

        return new ValidatedOrder(
            input.CustomerId.Trim(),
            input.Lines);
    }

    private static PricedOrder CalculatePrice(
        ValidatedOrder input)
    {
        decimal total = input.Lines.Sum(
            line => line.Quantity * line.UnitPrice);

        return new PricedOrder(input.CustomerId, total);
    }
}

Execute 拥有 continuation。每个转换不接收 next 委托,也不去寻找有能力的后继者。序列是显式的:RawOrder 变成 ValidatedOrder,再变成 PricedOrder。即使你用可复用 runner 统一执行同一契约的阶段,架构要点不变:runner 决定下一个阶段是谁。

Chain of Responsibility:候选处理器选择

GoF 风格的职责链意图是:给多个对象处理请求的机会,沿链传递直到某个对象处理它——发送者不需要知道最终接收者。

这是选择模型。支持请求可以由账单、技术支持或兜底处理。每个处理器是本质上同一个请求的候选:

namespace PatternComparison.ChainExample;

public sealed record SupportRequest(
    string Category,
    string Description);

public interface ISupportHandler
{
    ISupportHandler SetNext(ISupportHandler next);

    string Handle(SupportRequest request);
}

public abstract class SupportHandler : ISupportHandler
{
    private ISupportHandler? _next;

    public ISupportHandler SetNext(ISupportHandler next)
    {
        _next = next;
        return next;
    }

    public virtual string Handle(SupportRequest request)
    {
        return _next?.Handle(request)
            ?? "No handler accepted the request.";
    }
}

public sealed class BillingHandler : SupportHandler
{
    public override string Handle(SupportRequest request)
    {
        return request.Category.Equals(
            "billing",
            StringComparison.OrdinalIgnoreCase)
            ? $"Billing accepted: {request.Description}"
            : base.Handle(request);
    }
}

public sealed class TechnicalHandler : SupportHandler
{
    public override string Handle(SupportRequest request)
    {
        return request.Category.Equals(
            "technical",
            StringComparison.OrdinalIgnoreCase)
            ? $"Technical support accepted: {request.Description}"
            : base.Handle(request);
    }
}

public static class SupportChain
{
    public static ISupportHandler Create()
    {
        BillingHandler billing = new();
        TechnicalHandler technical = new();

        billing.SetNext(technical);

        return billing;
    }
}

请求不会在每一跳变成不同的类型:账单处理器检查它,要么处理要么委托;只有账单拒绝,技术支持才接到同一个概念上的请求。这里决定架构的边界是候选选择,而不是基类语法。

ASP.NET Core 中间件:自己拥有 continuation

中间件的续延契约比典型 Pipeline 更强。官方文档写明:每个中间件选择是否把请求传给下一个组件,并可以在该调用前后执行工作。

RequestDelegate 接收 HttpContext、返回 Task;用 Use 注册的组件拿到 next 委托;Run 注册终点委托,那个分支不再继续到后续中间件:

using Microsoft.AspNetCore.Http;

WebApplicationBuilder builder =
    WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();

app.Use(async (HttpContext context, RequestDelegate next) =>
{
    Console.WriteLine("A: request");

    await next(context);

    Console.WriteLine("A: response");
});

app.Use(async (HttpContext context, RequestDelegate next) =>
{
    Console.WriteLine("B: request");

    if (context.Request.Path == "/blocked")
    {
        context.Response.StatusCode =
            StatusCodes.Status403Forbidden;
        await context.Response.WriteAsync("Blocked.");
        return;
    }

    await next(context);

    Console.WriteLine("B: response");
});

app.Run(async context =>
{
    Console.WriteLine("Endpoint");
    await context.Response.WriteAsync("Hello.");
});

await app.RunAsync();

正常请求按注册顺序进入、按逆序返回(我本地实测输出与此完全一致):

A: request
B: request
Endpoint
B: response
A: response

访问 /blocked 时,中间件 B 不调用 next,剩余请求管道被短路:endpoint 不执行,B 的 after-next 代码不出现在该分支,控制权回到 A——A 已经调用了 B 并在回卷,所以它的响应侧代码照常运行(实测 A: request → B: request → A: response,HTTP 403)。

这个嵌套调用形状是关键:中间件不是向前迭代,而是每个组件包住它调用的下游委托。

只有中间件会逆序回卷

假设注册 A、B、C:请求侧执行 A、B、C;如果每个组件都 await next,完成顺序从 C 回到 B 回到 A。于是产生两种顺序:

普通分阶段 Pipeline 没有自动反向回卷:三个转换 A、B、C 运行完,结果直接离开 C——除非 runner 显式添加清理、补偿或其他反向操作。职责链也没有固有的响应回卷:处理器可以调用后继再执行后续工作,但那让它更像中间件或 Decorator,不属于 GoF 选择意图的要求。

「中间件像 Decorator 又像职责链」正是这个包裹质量:它把候选续延与嵌套包裹结合,而经典 Pipeline 通常强调前向转换。

短路并不总是同一个意思

「short-circuit」三个设计都有,含义却跟着模型走:

三种情况可能产生相似的控制流,但表达的契约不同。把三者都叫「带提前退出的管道」,就丢掉了它们各自存在的理由。

顺序回答不同的问题

这就是注册顺序后果不同的原因:把校验移到定价之后,可能违反数据依赖;把通用处理器放到特定处理器之前,特定处理器可能永远收不到匹配请求;移动授权或响应压缩中间件,会改变哪些 HTTP 行为包裹哪些下游组件。顺序不只是列表位置,它表达依赖、优先级或嵌套关系。

同一需求,三个模型各司其职

考虑这个需求:「拒绝无效订单、记录诊断、返回 HTTP 响应」。一句话可能同时涉及三个模型,但不会让它们等价:

最后形成的调用路径可以是:中间件建立 HTTP 上下文并调用 next;endpoint 代码请职责链选出负责的用例;选出的处理器执行类型化的业务 Pipeline;控制权沿中间件响应路径返回。每一层都有独立的存在理由。 去掉名字、只看「一串组件」,这些职责就被抹掉了。

混合设计:重心变了要及时改名

一个设计可以从某个模型开始,逐步采用另一个模型的定义性行为。这不自动是错的,但在调用者形成错误预期之前,契约应该改名或记录。

衡量的方法是看行为契约,而不是类名:

类名(Processor、Handler、Filter、Middleware)是线索,不是证据。按行为契约分类,也给了审查者一个共同的词汇去质疑顺序、所有权与短路的假设。

保持 HTTP 生命周期边界可见

ASP.NET Core 中间件绑定 HttpContext、HTTP 请求生命周期与框架启动时的组合。官方文档说明:约定式中间件按应用生命周期构造一次;scoped 依赖应放入 InvokeAsync;工厂激活的 IMiddleware 支持按请求激活。

这提醒我们 middleware 不是与传输无关的阶段抽象——它的生命周期与契约都是为 ASP.NET Core 请求设计的。如果同一个业务转换必须从 HTTP endpoint、队列消费者和命令行工具运行,把它放进普通服务或 Pipeline 阶段,让中间件处理 HTTP 特有的事情,并在合适的边界调用那个服务。

另外提一句(原文的谨慎立场):Microsoft Agent Framework 的 middleware(agent-run、function-calling、IChatClient 中间件)在续延链意义上可用作类比,但它不是这三个通用模式的权威定义,具体实现请以当前 Microsoft 文档为准。

按续延所有权选择

名字模糊时,按顺序问自己:

  1. 每个组件都必须转换数据供下一个必需组件消费?→ Pipeline。
  2. 多个组件是候选、真实接收者对发送者未知?→ 职责链。
  3. 每个组件收到 next、包裹下游工作、操作 HttpContext?→ ASP.NET Core 中间件。
  4. 逆序响应行为必不可少?→ 中间件直接具备这个形状。
  5. 逻辑必须在 HTTP 之外运行?→ 把业务处理留在中间件之外,即使中间件是发起者。

也可以不把单一标签强加给一个有意组合多个模型的系统:HTTP endpoint 经中间件进入,调用职责链选择处理器,处理器内部执行类型化 Pipeline——只要每层遵守自己的契约,架构依然是可理解的。

落到具体动作:去你的代码库里找一个「叫 Handler 但每层都运行、每层都转换类型」的类,以及一个「里面藏着业务转换」的中间件——前者按续延契约改名或补文档,后者把转换抽到普通服务。区分这三者节省的不是命名功夫,是等 bug 出在「该谁负责」上的排查时间。

Aide Hub 会继续分享 AI 助手、开发工具与软件工程实践中的具体做法。

参考


Tags


Next

别让 AI 选错 NuGet 包:四道仓库护栏