HttpUtils:一个编译时生成的声明式 HTTP 客户端框架

本文介绍 Mud.HttpUtils 的设计思路、能力边界,以及它和 Refit / RestEase 这类成熟库的差异。文中代码均可在 .NET 6+ 下运行,配置片段来自项目内置的选项绑定约定。

背景:HTTP 客户端调用的真实成本

只要项目需要对接外部 HTTP 服务,你就绕不开这一套重复劳动:拼 URL、序列化请求体、反序列化响应、处理非 2xx 状态码、配置 HttpClient 生命周期。一个两个接口手写还能接受,当接口涨到几十个、对接的第三方服务有好几家时,这些样板代码会迅速膨胀,而且每一处都可能是不一致的来源(有人用 Newtonsoft.Json,有人用 System.Text.Json;有人忘了 using,有人忘了 EnsureSuccessStatusCode)。

RefitRestEase 用「接口 + 特性」的声明式方式解决了样板代码的问题,这是个被广泛验证的好思路。但它们普遍止步于「把接口变成一次 HTTP 调用」。一旦进入生产环境,下面这些事情仍然要你自己拼:

  • 网络抖动时的重试、单次调用的超时、故障时的熔断;
  • Access Token 的获取、缓存、刷新,以及 401 后的自动恢复重试;
  • 请求体 / 响应体的字段级加密;
  • 多租户场景下,每个租户的令牌和弹性策略彼此隔离;
  • 出问题时在链路追踪里看到每一次调用的耗时、重试次数、熔断状态。

这些东西单独看都不难,难的是「每个 API 客户端都要来一遍」,而且很容易写得不一致。Mud.HttpUtils 的思路是:把上面这些能力做进框架,让它们成为声明式接口上的特性或注册时的一行配置,而不是每次重新拼装的乐高。

需要提前说明的立场:Mud.HttpUtils 不是要取代 Refit,它在生态成熟度、社区规模上远不如后者。本文会在后面专门讨论它的局限和适用边界,方便你判断要不要引入。

它是什么

Mud.HttpUtils 由两部分组成:

  1. 编译时(Roslyn 增量源生成器) :扫描标记了 [HttpClientApi] 的接口,为每个接口生成一份强类型实现(落在 .Internal 命名空间),并在注册阶段生成 DI 代码。
  2. 运行时(装饰器栈) :生成的实现调用 IHttpRequestExecutor,运行时外层依次套上弹性策略(ResilientHttpClient)、令牌恢复、追踪 DelegatingHandler,最内层是 EnhancedHttpClient

整个框架拆成 7 个 NuGet 包,可以只引用需要的子集:

组件 作用 何时单独引用
Mud.HttpUtils 元包,聚合 Abstractions + Attributes + Client + Resilience,提供 AddMudHttpUtils 大多数项目
Mud.HttpUtils.Abstractions 纯接口与契约,几乎无第三方依赖 只定义接口、不依赖实现的类库
Mud.HttpUtils.Attributes 特性定义 类库只想暴露特性给上游
Mud.HttpUtils.Client 客户端实现、加密、令牌管理、DI 注册 需要细粒度控制注册的场合
Mud.HttpUtils.Resilience Polly 弹性装饰器 只想加弹性不想引全套
Mud.HttpUtils.Generator 源生成器(编译期,不进运行时) 必须随接口一起引用
Mud.HttpUtils.OpenTelemetry 可观测性适配 需要导出 Tracing / Metrics 时

下面是整体结构。注意「使用者代码」和「运行时核心」之间通过生成代码解耦------你的业务只依赖接口,不依赖具体实现。
#mermaid-svg-kzOCbrqbmAvjXXNu{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kzOCbrqbmAvjXXNu .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kzOCbrqbmAvjXXNu .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kzOCbrqbmAvjXXNu .error-icon{fill:#552222;}#mermaid-svg-kzOCbrqbmAvjXXNu .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kzOCbrqbmAvjXXNu .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kzOCbrqbmAvjXXNu .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kzOCbrqbmAvjXXNu .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kzOCbrqbmAvjXXNu .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kzOCbrqbmAvjXXNu .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kzOCbrqbmAvjXXNu .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kzOCbrqbmAvjXXNu .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kzOCbrqbmAvjXXNu .marker.cross{stroke:#333333;}#mermaid-svg-kzOCbrqbmAvjXXNu svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kzOCbrqbmAvjXXNu p{margin:0;}#mermaid-svg-kzOCbrqbmAvjXXNu .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-kzOCbrqbmAvjXXNu .cluster-label text{fill:#333;}#mermaid-svg-kzOCbrqbmAvjXXNu .cluster-label span{color:#333;}#mermaid-svg-kzOCbrqbmAvjXXNu .cluster-label span p{background-color:transparent;}#mermaid-svg-kzOCbrqbmAvjXXNu .label text,#mermaid-svg-kzOCbrqbmAvjXXNu span{fill:#333;color:#333;}#mermaid-svg-kzOCbrqbmAvjXXNu .node rect,#mermaid-svg-kzOCbrqbmAvjXXNu .node circle,#mermaid-svg-kzOCbrqbmAvjXXNu .node ellipse,#mermaid-svg-kzOCbrqbmAvjXXNu .node polygon,#mermaid-svg-kzOCbrqbmAvjXXNu .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kzOCbrqbmAvjXXNu .rough-node .label text,#mermaid-svg-kzOCbrqbmAvjXXNu .node .label text,#mermaid-svg-kzOCbrqbmAvjXXNu .image-shape .label,#mermaid-svg-kzOCbrqbmAvjXXNu .icon-shape .label{text-anchor:middle;}#mermaid-svg-kzOCbrqbmAvjXXNu .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-kzOCbrqbmAvjXXNu .rough-node .label,#mermaid-svg-kzOCbrqbmAvjXXNu .node .label,#mermaid-svg-kzOCbrqbmAvjXXNu .image-shape .label,#mermaid-svg-kzOCbrqbmAvjXXNu .icon-shape .label{text-align:center;}#mermaid-svg-kzOCbrqbmAvjXXNu .node.clickable{cursor:pointer;}#mermaid-svg-kzOCbrqbmAvjXXNu .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-kzOCbrqbmAvjXXNu .arrowheadPath{fill:#333333;}#mermaid-svg-kzOCbrqbmAvjXXNu .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-kzOCbrqbmAvjXXNu .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-kzOCbrqbmAvjXXNu .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kzOCbrqbmAvjXXNu .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-kzOCbrqbmAvjXXNu .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kzOCbrqbmAvjXXNu .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-kzOCbrqbmAvjXXNu .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kzOCbrqbmAvjXXNu .cluster text{fill:#333;}#mermaid-svg-kzOCbrqbmAvjXXNu .cluster span{color:#333;}#mermaid-svg-kzOCbrqbmAvjXXNu div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-kzOCbrqbmAvjXXNu .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kzOCbrqbmAvjXXNu rect.text{fill:none;stroke-width:0;}#mermaid-svg-kzOCbrqbmAvjXXNu .icon-shape,#mermaid-svg-kzOCbrqbmAvjXXNu .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kzOCbrqbmAvjXXNu .icon-shape p,#mermaid-svg-kzOCbrqbmAvjXXNu .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-kzOCbrqbmAvjXXNu .icon-shape .label rect,#mermaid-svg-kzOCbrqbmAvjXXNu .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kzOCbrqbmAvjXXNu .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kzOCbrqbmAvjXXNu .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kzOCbrqbmAvjXXNu :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 运行时核心
编译时 Mud.HttpUtils.Generator
使用者代码
扫描特性
生成 Internal.UserApi
聚合
装饰
采集
声明式接口 IUserApi
业务服务注入 IUserApi
HttpInvokeClassSourceGenerator

RegistrationGenerator
Mud.HttpUtils.Abstractions

接口与契约
Mud.HttpUtils.Attributes

特性
Mud.HttpUtils.Client

客户端/令牌/加密
Mud.HttpUtils.Resilience

Polly 装饰器
Mud.HttpUtils.OpenTelemetry

追踪与指标
Mud.HttpUtils 元包

AddMudHttpUtils

快速开始

bash 复制代码
dotnet add package Mud.HttpUtils
dotnet add package Mud.HttpUtils.Generator
csharp 复制代码
using Mud.HttpUtils.Attributes;

[HttpClientApi(HttpClient = "IEnhancedHttpClient")]
public interface IUserApi
{
    [Get("/users/{id}")]
    Task<UserInfo> GetUserAsync([Path] int id);

    [Post("/users")]
    Task<UserInfo> CreateUserAsync([Body] CreateUserRequest request);

    [Get("/users")]
    Task<List<UserInfo>> GetUsersAsync(
        [Query] string? name = null,
        [Query] int page = 1,
        [Query] int pageSize = 20);
}

注册时把弹性策略一并配好:

csharp 复制代码
services.AddMudHttpUtils("userApi", "https://api.example.com", options =>
{
    options.Retry.MaxRetryAttempts = 3;
    options.Retry.UseExponentialBackoff = true;
    options.Timeout.TimeoutSeconds = 30;
});
services.AddWebApiHttpClient();

之后像调用本地方法一样使用,构造函数注入的是接口而非具体类:

csharp 复制代码
public class UserService
{
    private readonly IUserApi _userApi;
    public UserService(IUserApi userApi) => _userApi = userApi;

    public Task<UserInfo> GetUserByIdAsync(int id) => _userApi.GetUserAsync(id);
}

所有弹性、序列化、错误处理都已经在框架内部完成。下面分模块看这些能力具体怎么用。

核心能力详解

1. 声明式接口与参数映射

支持的 HTTP 方法与参数特性如下(星号表示支持参数/方法/接口多级):

特性 说明 示例
[Path] URL 路径参数 [Get("/users/{id}")] + [Path] int id
[Query] URL 查询参数 [Query] string? name
[QueryMap] 对象/字典展开为查询参数 [QueryMap] SearchCriteria criteria
[ArrayQuery] 数组查询参数 [ArrayQuery] int[] ids
[RawQueryString] 原始查询字符串 [RawQueryString] string qs
[Header]* 请求头 [Header("X-API-Key")] string k
[Body] 请求体(支持原始串/加密) [Body] CreateUserRequest req
[Form] application/x-www-form-urlencoded 字段 [Form("username")] string user
[FormContent] 表单数据 [FormContent] IFormContent formData
[MultipartForm] / [Upload] 多部件表单 / 文件上传 [Upload] IFormFile file
[FilePath] 文件下载落盘 [FilePath] string savePath
[Token]* Token 注入 [Token(TokenTypes.UserAccessToken)]
[Retry] / [Timeout] / [CircuitBreaker] 方法级弹性策略 [Retry(MaxRetries = 3)]
[HeaderMerge] 同名头部合并策略 [HeaderMerge(HeaderMergeMode.Replace)]
[SerializationMethod] 序列化方式(接口/方法级) [SerializationMethod(SerializationMethod.Xml)]
[InterfacePath] / [InterfaceQuery] 接口级固定路径/查询参数 [InterfaceQuery("version", "2.0")]
[AllowAnyStatusCode] 不抛异常、允许任意状态码 [AllowAnyStatusCode]

默认参数推断是减少噪音的关键。没标任何特性的参数,生成器按类型推断:

参数类型 推断结果
简单类型及数组/可空(stringintGuidDateTime...) [Query]
复杂类型(自定义对象、List<T>Dictionary<K,V> [Body](JSON)
CancellationToken / IProgress<T> 不参与推断,原样透传
csharp 复制代码
[HttpClientApi(HttpClient = "IEnhancedHttpClient")]
public interface IUserApi
{
    // keyword → [Query],user → [Body]
    [Post("users/advanced-search")]
    Task<List<User>> AdvancedSearchAsync(string keyword, SearchCriteria criteria, CancellationToken ct = default);
}

接口级动态属性让你把「所有方法都带」的参数提升为接口属性,而不是每个方法重复写:

csharp 复制代码
[HttpClientApi(HttpClient = "IEnhancedHttpClient")]
[BasePath("{tenantId}/api/v1")]
public interface ITenantApi
{
    [Path("tenantId")]  string TenantId { get; set; }
    [Query("apiKey")]   string ApiKey   { get; set; }

    [Get("users")]
    Task<List<User>> GetUsersAsync();
}

// 使用
var api = provider.GetRequiredService<ITenantApi>();
api.TenantId = "tenant-123";
api.ApiKey   = "my-key";
await api.GetUsersAsync();   // → /tenant-123/api/v1/users?apiKey=my-key

[BasePath] 定义统一前缀;方法 URL 以 / 开头则忽略前缀。[QueryMap] 还支持嵌套属性分隔符、是否包含 null 值、JSON 还是 ToString 序列化等细粒度控制。

2. 三种运行模式

同一个接口生成出来的构造函数依赖,由 [HttpClientApi] 的配置决定:

模式 配置 构造函数依赖 适用场景
HttpClient(推荐) HttpClient = "IEnhancedHttpClient" IOptions<JsonSerializerOptions> + IEnhancedHttpClient 通用,配合 AddMudHttpUtils
TokenManager TokenManage = "IFeishuAppManager" IOptions<JsonSerializerOptions> + 令牌管理器 飞书/钉钉等需令牌管理
默认 不设置 IOptions<JsonSerializerOptions> + IMudAppContext 遗留兼容

HttpClientTokenManage 互斥,同时设置时 HttpClient 优先(编译期报 HTTPCLIENT007)。

3. 弹性策略

弹性基于 Polly,以装饰器形式套在 IEnhancedHttpClient 外层。默认配置:重试开启(3 次,指数退避)、超时开启(30s)、熔断关闭(可手动开启)。策略组合顺序固定为 重试(外层)→ 熔断(中间)→ 超时(内层)------超时只约束单次尝试,每次重试独立计时。

全局配置支持代码与 appsettings.json 两种方式:

json 复制代码
{
  "MudHttpResilience": {
    "Retry":         { "Enabled": true, "MaxRetryAttempts": 3, "UseExponentialBackoff": true },
    "Timeout":       { "Enabled": true, "TimeoutSeconds": 30 },
    "CircuitBreaker":{ "Enabled": true, "FailureThreshold": 5, "BreakDurationSeconds": 30 }
  }
}

也可以在方法上用特性覆盖全局策略:

csharp 复制代码
[Get("/reports/export")]
[Retry(MaxRetries = 5)]
[Timeout(120000)]
[CircuitBreaker(FailureThreshold = 10, BreakDurationSeconds = 60)]
Task<byte[]> ExportReportAsync();

单次请求在装饰器内的执行流程如下,重试时通过 HttpRequestMessageCloner 克隆请求体,因此请求体超过 MaxCloneContentSize(默认 10MB)时会自动跳过重试,避免大文件上传时克隆开销:
#mermaid-svg-lnhwIopUxr0R1Spc{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-lnhwIopUxr0R1Spc .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lnhwIopUxr0R1Spc .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lnhwIopUxr0R1Spc .error-icon{fill:#552222;}#mermaid-svg-lnhwIopUxr0R1Spc .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lnhwIopUxr0R1Spc .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lnhwIopUxr0R1Spc .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lnhwIopUxr0R1Spc .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lnhwIopUxr0R1Spc .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lnhwIopUxr0R1Spc .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lnhwIopUxr0R1Spc .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lnhwIopUxr0R1Spc .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lnhwIopUxr0R1Spc .marker.cross{stroke:#333333;}#mermaid-svg-lnhwIopUxr0R1Spc svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lnhwIopUxr0R1Spc p{margin:0;}#mermaid-svg-lnhwIopUxr0R1Spc .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-lnhwIopUxr0R1Spc .cluster-label text{fill:#333;}#mermaid-svg-lnhwIopUxr0R1Spc .cluster-label span{color:#333;}#mermaid-svg-lnhwIopUxr0R1Spc .cluster-label span p{background-color:transparent;}#mermaid-svg-lnhwIopUxr0R1Spc .label text,#mermaid-svg-lnhwIopUxr0R1Spc span{fill:#333;color:#333;}#mermaid-svg-lnhwIopUxr0R1Spc .node rect,#mermaid-svg-lnhwIopUxr0R1Spc .node circle,#mermaid-svg-lnhwIopUxr0R1Spc .node ellipse,#mermaid-svg-lnhwIopUxr0R1Spc .node polygon,#mermaid-svg-lnhwIopUxr0R1Spc .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-lnhwIopUxr0R1Spc .rough-node .label text,#mermaid-svg-lnhwIopUxr0R1Spc .node .label text,#mermaid-svg-lnhwIopUxr0R1Spc .image-shape .label,#mermaid-svg-lnhwIopUxr0R1Spc .icon-shape .label{text-anchor:middle;}#mermaid-svg-lnhwIopUxr0R1Spc .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-lnhwIopUxr0R1Spc .rough-node .label,#mermaid-svg-lnhwIopUxr0R1Spc .node .label,#mermaid-svg-lnhwIopUxr0R1Spc .image-shape .label,#mermaid-svg-lnhwIopUxr0R1Spc .icon-shape .label{text-align:center;}#mermaid-svg-lnhwIopUxr0R1Spc .node.clickable{cursor:pointer;}#mermaid-svg-lnhwIopUxr0R1Spc .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-lnhwIopUxr0R1Spc .arrowheadPath{fill:#333333;}#mermaid-svg-lnhwIopUxr0R1Spc .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-lnhwIopUxr0R1Spc .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-lnhwIopUxr0R1Spc .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lnhwIopUxr0R1Spc .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-lnhwIopUxr0R1Spc .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lnhwIopUxr0R1Spc .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-lnhwIopUxr0R1Spc .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-lnhwIopUxr0R1Spc .cluster text{fill:#333;}#mermaid-svg-lnhwIopUxr0R1Spc .cluster span{color:#333;}#mermaid-svg-lnhwIopUxr0R1Spc div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-lnhwIopUxr0R1Spc .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-lnhwIopUxr0R1Spc rect.text{fill:none;stroke-width:0;}#mermaid-svg-lnhwIopUxr0R1Spc .icon-shape,#mermaid-svg-lnhwIopUxr0R1Spc .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lnhwIopUxr0R1Spc .icon-shape p,#mermaid-svg-lnhwIopUxr0R1Spc .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-lnhwIopUxr0R1Spc .icon-shape .label rect,#mermaid-svg-lnhwIopUxr0R1Spc .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lnhwIopUxr0R1Spc .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-lnhwIopUxr0R1Spc .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-lnhwIopUxr0R1Spc :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是

Open
Closed/HalfOpen

否(命中 RetryStatusCodes)


IHttpRequestExecutor 发起请求
请求体 > MaxCloneContentSize?
记录警告,跳过重试
PolicyWrap:重试 → 熔断 → 超时
单次尝试
熔断状态?
快速失败
超时(单次计时)
内层 IEnhancedHttpClient 发送
成功?
返回响应
重试次数 < Max?
CloneAsync 克隆请求体
抛出最终异常

4. 令牌管理

这部分是 Mud.HttpUtils 和 Refit 差异最大的地方。框架内置:

  • TokenManagerBase / UserTokenManagerBase:并发安全的令牌缓存与刷新抽象基类,用 SemaphoreSlim(1,1) 保证同一时刻只有一个线程刷新;
  • StandardOAuth2TokenManager:覆盖 Authorization Code / Client Credentials / ROPC / Refresh Token 流程;
  • TokenRecoveryDelegatingHandler:401 时自动刷新令牌并重试;
  • TokenRefreshHostedService(.NET 6+)/ TokenRefreshBackgroundService(netstandard2.0):后台主动刷新,避免临界过期。

自定义一个令牌管理器只需要实现两个方法:

csharp 复制代码
public class MyTokenManager : TokenManagerBase
{
    protected override Task<TokenInfo?> GetCachedTokenAsync(string tokenType, CancellationToken ct)
        => Task.FromResult<TokenInfo?>(_cache.Get(tokenType));

    protected override async Task<TokenInfo> RefreshTokenCoreAsync(string tokenType, CancellationToken ct)
    {
        var resp = await FetchTokenAsync(ct);
        return new TokenInfo(resp.AccessToken, resp.ExpireTime);
    }
}

Token 注入有多种模式,支持 Header / Query / Path / ApiKey / HmacSignature / BasicAuth / Cookie:

csharp 复制代码
[Token(TokenTypes.TenantAccessToken)]
public interface IApi { }

// 用户级令牌:自动注入 ICurrentUserContext
[HttpClientApi(TokenManage = "IFeishuAppManager")]
[Token(TokenType = "UserAccessToken", RequiresUserId = true)]
public interface IFeishuUserApi { }

// 用 TokenManagerKey 解耦业务概念和技术查找键
[Token(TokenType = "UserAccessToken", TokenManagerKey = "FeishuUser")]
public interface IFeishuContactApi { }

DefaultTokenProvider 的关键设计是不持有 IMudAppContext,而是通过每次调用的 TokenRequest(含 UserId)接收上下文 。这意味着 UseApp() / UseDefaultApp() 的上下文切换不会因为共享状态而串味------这是手写 DelegatingHandler 时很容易踩的坑。

401 恢复与弹性重试是两条相互独立的链路:前者由 TokenRecoveryExecutorRecoveryMaxRetries 次数内强制刷新令牌并重试,后者由 Polly 装饰器负责网络层面的重试,互不干扰。
HttpClient TokenRecoveryDelegatingHandler TokenManagerBase IMudAppContext DefaultTokenProvider DefaultHttpRequestExecutor 业务调用 HttpClient TokenRecoveryDelegatingHandler TokenManagerBase IMudAppContext DefaultTokenProvider DefaultHttpRequestExecutor 业务调用 #mermaid-svg-DMtJO0DgI2cvm8X5{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DMtJO0DgI2cvm8X5 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DMtJO0DgI2cvm8X5 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DMtJO0DgI2cvm8X5 .error-icon{fill:#552222;}#mermaid-svg-DMtJO0DgI2cvm8X5 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DMtJO0DgI2cvm8X5 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DMtJO0DgI2cvm8X5 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DMtJO0DgI2cvm8X5 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DMtJO0DgI2cvm8X5 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DMtJO0DgI2cvm8X5 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DMtJO0DgI2cvm8X5 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DMtJO0DgI2cvm8X5 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DMtJO0DgI2cvm8X5 .marker.cross{stroke:#333333;}#mermaid-svg-DMtJO0DgI2cvm8X5 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DMtJO0DgI2cvm8X5 p{margin:0;}#mermaid-svg-DMtJO0DgI2cvm8X5 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-DMtJO0DgI2cvm8X5 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-DMtJO0DgI2cvm8X5 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-DMtJO0DgI2cvm8X5 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-DMtJO0DgI2cvm8X5 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-DMtJO0DgI2cvm8X5 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-DMtJO0DgI2cvm8X5 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-DMtJO0DgI2cvm8X5 .sequenceNumber{fill:white;}#mermaid-svg-DMtJO0DgI2cvm8X5 #sequencenumber{fill:#333;}#mermaid-svg-DMtJO0DgI2cvm8X5 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-DMtJO0DgI2cvm8X5 .messageText{fill:#333;stroke:none;}#mermaid-svg-DMtJO0DgI2cvm8X5 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-DMtJO0DgI2cvm8X5 .labelText,#mermaid-svg-DMtJO0DgI2cvm8X5 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-DMtJO0DgI2cvm8X5 .loopText,#mermaid-svg-DMtJO0DgI2cvm8X5 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-DMtJO0DgI2cvm8X5 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-DMtJO0DgI2cvm8X5 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-DMtJO0DgI2cvm8X5 .noteText,#mermaid-svg-DMtJO0DgI2cvm8X5 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-DMtJO0DgI2cvm8X5 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-DMtJO0DgI2cvm8X5 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-DMtJO0DgI2cvm8X5 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-DMtJO0DgI2cvm8X5 .actorPopupMenu{position:absolute;}#mermaid-svg-DMtJO0DgI2cvm8X5 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-DMtJO0DgI2cvm8X5 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-DMtJO0DgI2cvm8X5 .actor-man circle,#mermaid-svg-DMtJO0DgI2cvm8X5 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-DMtJO0DgI2cvm8X5 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 调用(含 UserId?) GetTokenAsync(TokenRequest) 取当前 App / 用户上下文 TokenManager GetOrRefreshTokenAsync() SemaphoreSlim 加锁 + 缓存检查 AccessToken 携带 Token 发送 401 强制刷新(≤ RecoveryMaxRetries) 新令牌 重发请求 成功响应

5. 安全:加密与 SSRF 防护

请求体 / 字段级加密用 IEncryptionProvider,内置 DefaultAesEncryptionProvider(AES-CBC,v2.0.0 起 IV 自动随机生成,无需配置):

csharp 复制代码
services.AddMudHttpClient("myApi", encryption =>
{
    encryption.Key = Convert.FromBase64String("your-base64-key");
}, client =>
{
    client.BaseAddress = new Uri("https://api.example.com");
});
csharp 复制代码
[Post("/api/secure")]
Task<Response> PostSecureAsync(
    [Body(EnableEncrypt = true, EncryptPropertyName = "data")] Request request);

UrlValidator 提供 SSRF 防护:域名白名单、HTTPS 强制、私有 IP 与内网域名检测。可从配置绑定,也可运行时增删:

json 复制代码
{
  "MudHttpClients": {
    "AllowedDomains": [ "api.example.com", "cdn.example.com" ],
    "Clients": {
      "ExternalApi": { "BaseAddress": "https://external.api.com", "AllowCustomBaseUrls": true }
    }
  }
}

AllowCustomBaseUrls = true 会放宽域名限制,但仍阻止私有 IP 和内网域名。

6. 多租户与 App 上下文

对接多个第三方应用(比如一个服务同时对接多个飞书租户)时,需要每个 App 有独立的令牌、配置和弹性策略。IMudAppContext / IAppManager<T> / IAppContextHolderAsyncLocalAppContextSwitcher)提供这套隔离:

csharp 复制代码
services.AddSingleton<IAppManager<FeishuContext>, DefaultAppManager<FeishuContext>>();

// 切换上下文后发出的请求会使用对应 App 的令牌与弹性策略
using (appContext.UseApp("tenant-A"))
{
    await contactApi.GetUsersAsync();
}

DefaultAppManager<T> 还暴露 ConfigurationChanged 事件,支持配置热更新。IResiliencePolicyResolver(如 AppResiliencePolicyResolver)可按 App 维护独立策略。

7. 文件、流式与 Response<T>

文件上传用 [Upload] / [MultipartForm],下载用 [FilePath] 落盘或返回 byte[]

csharp 复制代码
[Post("/upload")]
Task<UploadResult> UploadAsync([Upload(FieldName = "doc")] IFormFile file);

[Get("/files/{fileId}")]
Task DownloadFileAsync([Path] string fileId, [FilePath(BufferSize = 81920)] string savePath);

大文件上传通过 ProgressableStreamContent 报告进度,不会整文件载入内存。.NET 6+ 还支持 IAsyncEnumerable 流式响应:

csharp 复制代码
await foreach (var message in _httpClient.SendAsAsyncEnumerable<ChatMessage>(request, ct))
    yield return message;

Response<T> 在返回业务数据的同时附带状态码与响应头:

csharp 复制代码
var r = await api.GetUserAsync(1);
var user = r.Data;          // 业务数据
var code = r.StatusCode;    // HttpStatusCode
var headers = r.Headers;    // 响应头

注意:不要把 Response<T>[Cache] 组合使用------缓存会连同状态码和响应头一起存,可能导致后续请求返回过期的状态码。生成器会对此发出 HTTPCLIENT011 编译警告。

8. 缓存与拦截器

IHttpRequestInterceptor / IHttpResponseInterceptorOrder 升序执行。内置 CacheResponseInterceptorOrder = 100)配合 MemoryHttpResponseCache 实现内存响应缓存:

csharp 复制代码
services.AddMemoryCache();
services.AddSingleton<IHttpResponseCache, MemoryHttpResponseCache>();
services.AddSingleton<IHttpResponseInterceptor, CacheResponseInterceptor>();

你也可以写自己的拦截器,比如统一给响应做脱敏日志:

csharp 复制代码
public class LoggingInterceptor : IHttpResponseInterceptor
{
    public int Order => 50;
    public Task OnResponseAsync(HttpResponseContext context, CancellationToken ct)
    {
        // context.Response / context.Exception 可读
        return Task.CompletedTask;
    }
}

9. 可观测性

Mud.HttpUtils.OpenTelemetry 一键开启分布式追踪与指标:

csharp 复制代码
builder.Services.AddMudHttpOpenTelemetry(options =>
{
    options.OtlpEndpoint = new Uri("http://otel-collector:4317");
});

自动采集的指标包括:HTTP 请求计数、请求耗时、缓存命中、令牌刷新、重试次数、熔断器状态、下载字节数与耗时。TracingDelegatingHandler 会为每个请求创建 Activity,记录方法、URL、状态码、耗时,可直接接入 OTLP 收集器与主流 APM。

10. 编译期诊断

生成器会在编译阶段对不合理的接口定义报错或告警,把问题挡在运行前。常用的几个:

Diagnostic 级别 触发条件
HTTPCLIENT007 Error 同时指定 HttpClientTokenManage
HTTPCLIENT011 Warning [Cache]Response<T> 组合
HTTPCLIENT012 Error 泛型接口不支持生成
HTTPCLIENT013 Error URL 模板占位符与 [Path] 参数不匹配
HTTPCLIENT005 Error URL 模板格式无效

完整的诊断码列表见主仓库 README 的「编译警告参考」章节。

与 Refit / RestEase 的对比

先放一张对照表,标注含义:内置 = 框架直接提供;需集成 = 要自己接 Polly / 自定义 Handler 等;不支持 = 当前无对应能力。

维度 Mud.HttpUtils Refit RestEase
代码生成方式 编译时增量源生成 反射,或 6+ 的可选源生成 运行时反射
核心路径零反射 内置 源生成模式可接近 不支持
方法级弹性策略(重试/超时/熔断) 内置 需集成 需集成
并发安全令牌管理 + 401 自愈 内置 不支持 不支持
请求/响应加解密 内置 不支持 不支持
多租户 App 上下文隔离 内置 不支持 不支持
SSRF / URL 白名单 内置 不支持 不支持
OpenTelemetry 可观测 内置 需集成 需集成
流式响应 / 文件上传下载 内置 内置 有限
Response<T> 元数据包装 内置 ApiResponse<T> 有限
接口级动态属性 内置 不支持 有限
netstandard2.0 内置 有限 内置
编译期诊断 内置 有限 不支持

几个客观的补充:

  • Refit 生态成熟、文档完善、使用者众多 ,遇到坑更容易搜到答案;它还支持 HttpClient 拦截、鉴权头、多种内容协商,配合 Microsoft.Extensions.Http.Resilience 也能补上弹性。如果你只需要「少写样板代码」,Refit 完全够用且更稳。
  • RestEase 更轻、API 更朴素,但纯反射实现在 AOT / 极高性能场景下不合适,社区活跃度也偏低。
  • Mud.HttpUtils 的差异化集中在「生产级可靠性开箱即用」:令牌生命周期、加密、多租户、可观测这些它都做成了一等公民。代价是抽象更厚、学习面更宽,而且要接受它是一个相对年轻的库。

如果你的服务对接大量第三方 HTTP API,且每个 API 都要重复搭建重试/熔断/令牌刷新/加密/监控这套脚手架,Mud.HttpUtils 能显著减少这部分重复劳动。如果只是对接一两个简单接口,Refit 更轻、风险更低。

性能

核心路径(JSON 序列化、URL 构建、请求头处理)在生成代码里直接完成,没有运行时反射,可用于 AOT。仍有少量场景保留反射(已做缓存优化):

场景 是否反射 范围
application/x-www-form-urlencoded Body 仅该 Body 模式
[QueryMap] 非字典复杂类型展开 仅 QueryMap
XML 序列化/反序列化 是(XmlSerializer 内部) 仅 XML Content-Type

性能敏感路径建议用 JSON + 简单类型查询参数。框架还用 SemaphoreSlim 替代 lockIMemoryCache 替代裸 ConcurrentDictionary 管理用户令牌缓存,并在大文件上传时走流式进度上报而非整文件入内存。

适用与不适用

适合:

  • 需要对接多个第三方 HTTP 服务,且都要生产级可靠性;
  • 有 Token 生命周期管理、多租户隔离、字段加密诉求;
  • 希望编译期就发现接口定义错误,而非运行期才崩。

不太适合:

  • 只对接一两个简单接口,引入整套框架的复杂度不划算;
  • 团队对 Refit 已有深厚积累,迁移收益有限;
  • 对库的成熟度、社区支持有强要求(Mud.HttpUtils 尚年轻);
  • 需要极致轻量、零额外依赖的客户端(元包会带来 Abstractions/Client/Resilience 一整套引用)。

迁移自 Refit 的注意点

接口定义从 Refit 的 [Get("...")] 等特性迁移到 Mud.HttpUtils 同名特性成本不高,主要差异在:

  • 注册方式从 builder.Services.AddRefitClient<T>() 改为 AddMudHttpUtils + AddWebApiHttpClient
  • 返回类型若用 ApiResponse<T>,对应改为 Response<T>(注意缓存组合限制);
  • 原本手写的 DelegatingHandler(鉴权、重试)可逐步迁移到框架内置的令牌恢复与 Polly 装饰器,先移除重复部分再迁移;
  • 弹性策略从 Microsoft.Extensions.Http.Resilience 配置迁移到 MudHttpResilience 配置节或代码 options

结语

Mud.HttpUtils 把「声明式接口」从「少写样板」推进到了「少搭生产级基础设施」:编译时生成换性能与类型安全,装饰器叠加换能力可组合且不侵入业务,令牌/加密/弹性/多租户/可观测内置换开箱可用性。它不是一个在所有维度都优于 Refit 的替代品,而是在「对接很多外部 API 且都需要可靠性」这个具体问题上,提供了更省心的默认方案。

项目基于 MIT 许可证开源,支持 .NET Standard 2.0 / 6.0 / 8.0 / 10.0。各子包的详细文档见仓库内对应的 README。

相关推荐
luj_17689 小时前
残熵算法实时化三大瓶颈突破
c语言·开发语言·网络·经验分享·算法
吴懿不在9 小时前
某次热身赛re方向wp WORDS 51,500 READ 172 MIN base64,
网络
冰可乐配11 小时前
OpenClaw.NET .NET 原生插件开发完全指南:以 Mempalace 插件为范例
.net
财财源源11 小时前
.NET 程序保护实战系列01-流水线架构与保护引擎总览
架构·.net
牧濑红莉11 小时前
自定义 Visual Studio 的界面外观
.net
小林ixn13 小时前
从“玩具工具”到“跨语言利器”:MCP 协议实战解析
运维·服务器·网络
2603_9547083114 小时前
全维度容错设计,打造微电网安全运行屏障
服务器·网络·数据库·人工智能·分布式·安全
青岛前景互联信息技术有限公司15 小时前
以新标准为底座,前景互联打造高危场景智能接处警新体系
大数据·网络·人工智能
大飞记Python15 小时前
Linux命令速查手册(测试开发4年实战总结,附PDF)
linux·网络·pdf