本文介绍 Mud.HttpUtils 的设计思路、能力边界,以及它和 Refit / RestEase 这类成熟库的差异。文中代码均可在 .NET 6+ 下运行,配置片段来自项目内置的选项绑定约定。
背景:HTTP 客户端调用的真实成本
只要项目需要对接外部 HTTP 服务,你就绕不开这一套重复劳动:拼 URL、序列化请求体、反序列化响应、处理非 2xx 状态码、配置 HttpClient 生命周期。一个两个接口手写还能接受,当接口涨到几十个、对接的第三方服务有好几家时,这些样板代码会迅速膨胀,而且每一处都可能是不一致的来源(有人用 Newtonsoft.Json,有人用 System.Text.Json;有人忘了 using,有人忘了 EnsureSuccessStatusCode)。
Refit 和 RestEase 用「接口 + 特性」的声明式方式解决了样板代码的问题,这是个被广泛验证的好思路。但它们普遍止步于「把接口变成一次 HTTP 调用」。一旦进入生产环境,下面这些事情仍然要你自己拼:
- 网络抖动时的重试、单次调用的超时、故障时的熔断;
- Access Token 的获取、缓存、刷新,以及 401 后的自动恢复重试;
- 请求体 / 响应体的字段级加密;
- 多租户场景下,每个租户的令牌和弹性策略彼此隔离;
- 出问题时在链路追踪里看到每一次调用的耗时、重试次数、熔断状态。
这些东西单独看都不难,难的是「每个 API 客户端都要来一遍」,而且很容易写得不一致。Mud.HttpUtils 的思路是:把上面这些能力做进框架,让它们成为声明式接口上的特性或注册时的一行配置,而不是每次重新拼装的乐高。
需要提前说明的立场:Mud.HttpUtils 不是要取代 Refit,它在生态成熟度、社区规模上远不如后者。本文会在后面专门讨论它的局限和适用边界,方便你判断要不要引入。
它是什么
Mud.HttpUtils 由两部分组成:
- 编译时(Roslyn 增量源生成器) :扫描标记了
[HttpClientApi]的接口,为每个接口生成一份强类型实现(落在.Internal命名空间),并在注册阶段生成 DI 代码。 - 运行时(装饰器栈) :生成的实现调用
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] |
默认参数推断是减少噪音的关键。没标任何特性的参数,生成器按类型推断:
| 参数类型 | 推断结果 |
|---|---|
简单类型及数组/可空(string、int、Guid、DateTime...) |
[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 |
遗留兼容 |
HttpClient 与 TokenManage 互斥,同时设置时 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 恢复与弹性重试是两条相互独立的链路:前者由 TokenRecoveryExecutor 在 RecoveryMaxRetries 次数内强制刷新令牌并重试,后者由 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> / IAppContextHolder(AsyncLocalAppContextSwitcher)提供这套隔离:
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 / IHttpResponseInterceptor 按 Order 升序执行。内置 CacheResponseInterceptor(Order = 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 | 同时指定 HttpClient 和 TokenManage |
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 替代 lock、IMemoryCache 替代裸 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。