从 axios 写法到服务端治理:AxiosNet 与 HttpClient / Refit / RestSharp / Flurl 的横向对比
本文的数据来自仓库内的可复现基准,不是引用别人的跑分。功能对比是对各库公开 API 的定性判断,性能对比给了完整口径和"这套数据不覆盖什么"。看完再决定要不要往下读源码。
一、先把问题说清楚
在 .NET 里发一个 HTTP 请求只需要三行:
csharp
using var http = new HttpClient();
var json = await http.GetStringAsync("https://api.example.com/users/1");
但只要这段代码进了生产环境,清单就会变成这样:
- 超时到底是一次尝试的,还是整条请求的?
- 失败了重试几次?退避多久?非幂等的 POST 能不能重试?
- 下游整体挂了,还要不要继续打?熔断了谁来恢复?
- 同一时刻最多发多少条?排队排多久算超时?
- 这个接口能不能缓存 30 秒?并发同一 URL 能不能只发一次?
- 出问题怎么定位:卡在连接池、卡在重试等待、还是卡在服务端?
BCL 只负责传输层。剩下的部分,行业里的标准答案是把 HttpClient + Polly + 某个缓存库 + 几个自写 DelegatingHandler 拼起来。这套方案没问题,它的问题是配置分散 :重试在 Polly 的策略里,超时在 HttpClient.Timeout 和 Polly 的 TimeoutPolicy 里,缓存键在缓存库的配置里,观测点散在每一条 Handler 上。线上出了事,第一件事是搞清楚"这次失败到底被哪一层改写过"。
AxiosNet 的出发点很朴素:把这些东西收进一个配置对象,并且让写法对齐前端同学熟悉的 axios。
csharp
using AxiosNet;
using AxiosNet.Retry;
using AxiosNet.Circuit;
var http = Axios.Create(c =>
{
c.BaseAddress = "https://api.example.com/";
c.Timeout = TimeSpan.FromSeconds(10); // 单次尝试
c.TotalTimeout = TimeSpan.FromSeconds(30); // 整条请求(含重试与排队)
c.Retry.Policy = RetryPolicy.Exponential(3, TimeSpan.FromMilliseconds(200));
c.Circuit.Policy = new CircuitBreaker { FailureThreshold = 5 };
});
var user = (await http.GetAsync<User>("users/1")).Data;
同一个配置对象里,超时、重试、熔断、缓存、并发闸门、观测全部就位。这是它和"再包一层 HttpClient"最本质的区别:不是语法糖,是把治理管线的所有权收回来了。
配置的归属对比更直观:
| 治理能力 | 传统拼装落在哪 | AxiosNet 落在哪 |
|---|---|---|
| 单次尝试超时 | HttpClient.Timeout(重试后含义会变) |
Config.Timeout |
| 整条请求预算 | Polly 的 TimeoutPolicy(还要自己算剩余) |
Config.TotalTimeout |
| 重试 | Polly RetryPolicy + Handler 注册 |
Config.Retry.Policy |
| 熔断 | Polly CircuitBreaker + Handler 注册 |
Config.Circuit.Policy |
| 响应缓存 | 另一个缓存库的配置 | Config.Cache |
| 并发上限 | 自写 SemaphoreSlim 或 RateLimiter |
Config.Limits.MaxConcurrentRequests |
| 观测 | 每条 Handler 各加一次埋点 | Telemetry 统一,尝试级事件 |
二、它是什么
| 项 | 值 |
|---|---|
| 包 | AxiosNet(核心 + 声明式客户端同包)、AxiosNet.DependencyInjection(可选)、AxiosNet.Cli(可选,设计期 dotnet tool) |
| 目标框架 | net6.0 / net8.0 双目标,.NET 6 ~ 11 都能引用 |
| 运行时依赖 | 核心包零第三方,只用 BCL |
| 风格 | 全异步,Task + CancellationToken |
| 观测 | ActivitySource + Meter + DiagnosticListener,默认开启,无人订阅时零产出 |
| AOT | 两个目标都 IsTrimmable,net8.0 另开 IsAotCompatible,包内自带源生成器 |
安装:
bash
dotnet add package AxiosNet
dotnet add package AxiosNet.DependencyInjection # 可选
dotnet tool install -g AxiosNet.Cli # 可选:OpenAPI 生成客户端
关于"零依赖"要说准确:核心包确实只用 BCL;DI 扩展依赖 Microsoft.Extensions.DependencyInjection.Abstractions 一个抽象包;CLI 是设计期工具,用 YamlDotNet 解析 OpenAPI 文档,不进运行时。
三、三种写法,同一个内核
3.1 原始请求:返回类型由泛型决定
csharp
// 原文:不解析
var text = await Axios.GetAsync<string>("https://api.example.com/ping");
// dynamic:不定义 DTO,按字段取值
var dyn = await Axios.GetAsync<dynamic>("https://api.example.com/users/1");
Console.WriteLine($"{dyn.Data.name}, {dyn.Data.address.city}");
// 强类型
var response = await Axios.GetAsync<User>("https://api.example.com/users/1");
四种形态(string / AxiosDynamic / T / 非泛型 JsonElement)都在同一个方法族里,不用换 API。
3.2 声明式:模型或接口,调用只剩一句
端点多起来之后,URL 和参数散落在调用处是最难维护的。AxiosNet 给两种声明方式,由同一个 ApiClient 承载:
csharp
[Get("users/{id}")]
public sealed class GetUserRequest : IApiRequest<User>
{
[Path] public int Id { get; set; }
[Query("verbose")] public string? Verbose { get; set; }
}
var client = new ApiClient("https://api.example.com/");
var user = (await client.RequestAsync(new GetUserRequest { Id = 5 })).Data;
也可以直接写接口,由包内源生成器在编译期烘焙真实代理(JIT 与 Native AOT 同一路径):
csharp
public interface IUserApi
{
[Get("users/{id}")]
Task<User> GetAsync(int id, CancellationToken ct = default);
}
有 OpenAPI 文档时,连接口都不用写:
bash
dotnet tool install -g AxiosNet.Cli
axiosnet-gen ./openapi.json -o ./Generated -n MyApp.Api --style interface
这一点是它和 Refit 最直接的同台竞争:Refit 需要 Refitter 之类的第三方工具才能从 OpenAPI 生成,AxiosNet 把生成器做进了同一个仓库、同一个包族。
3.3 流式:请求体边算边发,响应体边收边解析
csharp
// http 是 AxiosClient 实例:请求体给异步序列,库不缓冲
await http.PostAsync<UploadResult>("ingest", ReadChunks(file), c =>
{
c.ContentType = "text/csv";
});
// 声明式客户端把 SSE / NDJSON / JSON 数组解析成 IAsyncEnumerable<T>
var stream = await api.RequestStreamAsync(
new ChatStreamRequest { Prompt = "hi" },
AxiosStreamFormat.ServerSentEvents);
await foreach (var chunk in stream.Data!)
{
Console.Write(chunk.Text);
}
Server-Sent Events、NDJSON、JSON 数组三种分帧格式内置,不用自己写 StreamReader 循环;每收完一帧就交付,不等整个响应下完(LLM 增量输出、行情推送、分页列表都能直接用)。
四、能力盘点
不逐条列 API,按"服务端真正会用到"分三组。
请求与响应
| 能力 | 入口 |
|---|---|
| 查询参数对象化 | c.Params = new { page = 2, tags = new[] { "dev", "ops" } } |
| 表单 / 文件上传 | PostFormAsync / FormFile |
| 流式请求体 | data = IAsyncEnumerable<byte[]> 或内存块 |
| 流式响应分帧 | RequestStreamAsync<T>(SSE / NDJSON / JSON 数组) |
| 进度、限速、体积上限 | Callbacks.OnDownloadProgress / Limits.MaxRate / 大小上限 |
| 请求体压缩 | c.RequestCompression = AxiosCompression.Gzip |
| 令牌缓存与刷新 | AccessTokenManager(提前刷新、并发刷新单飞、401 重放) |
| 只拼 URL 不发请求 | client.GetUri(...) |
流量治理
| 能力 | 入口 |
|---|---|
重试(退避 / 抖动 / Retry-After / 幂等门禁) |
c.Retry.Policy = RetryPolicy.Exponential(...) |
| 全局重试预算 | RetryBudgetPolicy(令牌桶,只拦重试不拦首发) |
| 熔断 | CircuitBreaker(滚动窗口失败率 + 连续失败两条规则,半开慢启动) |
| 并发对冲 | c.Hedging.MaxHedges = 1 |
| 备用端点 + 端点健康记忆 | c.Failover.Endpoints = ... |
| 幂等键 | c.Idempotency.Enabled = true |
| 在途并发闸门 | c.Limits.MaxConcurrentRequests + 排队上限 |
| 响应缓存 + 单飞 + 条件复用 | client.Defaults.Cache = new AxiosCache(...) |
| 短路响应(mock / 回放) | c.ShortCircuit = new AxiosShortCircuit { ... } |
连接与观测
| 能力 | 入口 |
|---|---|
| 按 origin 自建连接池 | 默认;HttpConnectionPool.GetOrCreate("key") 可命名共享 |
| 多出口(代理 / 凭据 / 证书 / Cookie 分档) | ConnectionPoolOptions.AddTransport("egress-a", ...) |
| HTTP/2 默认、HTTP/3 可选 | c.Connection.HttpVersion |
| 尝试级中间件链 | AxiosAttemptPipeline.Default.Append(...) |
| 三路遥测 | AddSource("AxiosNet") / AddMeter("AxiosNet") |
| 统一错误模型 | AxiosException + 稳定 Code,如 ERR_CIRCUIT_OPEN、ERR_QUEUE_TIMEOUT |
这些能力不是"接口都摆在那里",而是每条都有明确的语义边界。举两个例子:
超时分两个层级。 Timeout 是一次尝试的预算(3 次重试 × 10 秒,最坏 40 秒);TotalTimeout 是整条请求的预算,排队等待、退避等待、重试全部计入。当 TotalTimeout 在请求发出前就耗尽时,库内三处逻辑必须同时认这件事------熔断不计失败、重试不重试、换端点也不换------因为它一个字节都没发出去,记成下游故障或者再换一个镜像重试,都是在放大问题。这类"边界一致"比多写一个 API 难得多。
熔断有两条打开规则。 只按连续失败熔断,低流量的"半死不活"下游永远熔不断------每夹一次成功就清零,而它恰恰最该被熔断;只按窗口失败率熔断,小流量又填不满样本。所以两条并存:样本够看比率,不够看连续失败。
五、和主流组件比
5.1 对比对象与方法
选了四个对手,加上自己:
| 实现 | 版本 | 形态 |
|---|---|---|
HttpClient |
BCL(.NET 8) | 基线,功能最少但吞吐天花板 |
| Refit | 16.4.0 | 声明式接口 + 源生成(只测 ForGenerated,不含反射回退) |
| RestSharp | 113.0.0 | 请求对象模型,老项目存量大 |
| Flurl | Flurl.Http 4.0.2 | 字符串即请求的链式写法 |
| AxiosNet | 仓库源码(net8.0) | 默认 AxiosClient,不做任何调优 |
HttpClient / Refit / RestSharp / Flurl 四家都注入了与 AxiosNet 默认连接池逐项对齐 的 SocketsHttpHandler(Cookies / Proxy / 连接数 / 超时 / 解压一致),否则测到的是默认值差异,不是实现差异。
没有纳入 WebApiClientCore、RestEase 等:不是它们不重要,而是没有同口径实测数据,写进来就只能凭印象,那不如不写。
5.2 功能对比
功能对比是定性判断,依据是各库公开 API。表里"需 Polly"指需要额外引入第三方弹性库并把它装进 DelegatingHandler 链------这本身是可行方案,但它是另一个包 + 另一套配置模型,和"同一次配置调用里就能写"不是一回事。
请求能力
| 能力 | HttpClient | Refit | RestSharp | Flurl | AxiosNet |
|---|---|---|---|---|---|
| 强类型反序列化 | ✅ | ✅ | ✅ | ✅ | ✅ |
原文 / dynamic / JsonElement |
需自己处理 | 需自己处理 | 需自己处理 | 需自己处理 | 四种形态内置 |
| 声明式端点 | --- | 接口 + 特性 | --- | --- | 模型式 + 接口式 |
| OpenAPI 生成客户端 | --- | 需第三方 | --- | --- | 内置 axiosnet-gen |
| 流式响应分帧(SSE / NDJSON / JSON 数组) | 需自己读流 | 返回 Stream |
返回流 | 返回流 | 内置三种格式 |
| 流式请求体(边算边发) | 需自定义 HttpContent |
有限 | 有限 | 有限 | 异步序列 / 内存块 |
| 上传下载进度 | 需自定义 | --- | 部分支持 | --- | 回调内置 |
| 字节限速 / 体积上限 | --- | --- | --- | --- | 内置 |
| 请求体压缩 | 手写 | --- | --- | --- | 内置 |
| 令牌缓存 / 提前刷新 / 401 重放 | --- | 部分 | 部分 | --- | AccessTokenManager |
弹性与治理(差距最大的一块)
| 能力 | HttpClient | Refit | RestSharp | Flurl | AxiosNet |
|---|---|---|---|---|---|
重试(退避 / 抖动 / Retry-After) |
需 Polly | 需 Polly | --- | 需 Polly | 内置策略 |
| 全局重试预算 | 需自写 | 需自写 | --- | --- | RetryBudgetPolicy |
| 熔断 | 需 Polly | 需 Polly | --- | 需 Polly | 内置状态机 |
| 并发对冲(Hedging) | --- | --- | --- | --- | 内置 |
| 备用端点 / 端点健康记忆 | 自写 | 自写 | 自写 | 自写 | 内置 |
| 幂等键 | 自写 | 自写 | 自写 | 自写 | 内置 |
| 在途并发闸门 + 排队上限 | 自写 | 自写 | 自写 | 自写 | 内置 |
| 响应缓存 + 单飞 + 条件复用 | 需缓存库 | 需缓存库 | 需缓存库 | 需缓存库 | 内置 |
| 单次预算 vs 整条请求预算 | 只有 Timeout |
继承 | 有超时 | 有超时 | Timeout + TotalTimeout |
工程化
| 能力 | HttpClient | Refit | RestSharp | Flurl | AxiosNet |
|---|---|---|---|---|---|
| DI 注册 | IHttpClientFactory |
Refit.HttpClientFactory |
手写 | 手写 | AddAxiosClient / AddApiClient |
| 遥测 | 内置事件 | 继承 | 继承 | 继承 | 三路 + 尝试级事件 |
| Native AOT / 裁剪 | 原生支持 | 源生成支持 | 反射较重 | 反射较重 | IsAotCompatible + 生成器烘焙 |
| 第三方运行时依赖 | 0(BCL) | 1 个包 | 1 个包 | 2 个包 | 0 |
结论很清楚:功能维度上,AxiosNet 的定位不是"更好的 HttpClient",而是"把 HttpClient 周边那套拼装收进一个包"。 如果你只需要发 GET,前四家都够用,甚至更省。
5.3 性能对比
口径
这一节的所有数字都来自仓库内 test/AxiosNet.Comparison,不是引用。口径必须写清楚,否则数字没有意义:
| 项 | 值 |
|---|---|
| 采集时间 | 2026-11-01 10:13:55 +08:00(本轮窗口 CPU 份额 44%~49%,在项目自定的 42% 判定门槛之上) |
| 环境 | Windows 10.0.19045;18 核;.NET 8.0.31;Server GC=False;Release |
| 负载 | 每实现 8 并发常驻 × 1 秒窗口 × 5 轮,轮间整体轮转实现顺序,预热 3 秒 + 冷却 2 秒 |
| 请求 | HTTP/1.1 明文回环 GET,正文 51 B JSON,反序列化为同一个 User |
| 延迟 | Stopwatch 包住「发请求 + 读正文 + 反序列化」,含服务端处理与回环 RTT |
| 分配 | 进程级 GC.GetTotalAllocatedBytes ÷ 完成请求数,净分配 已减掉 RawSocket 基线 |
| 汇总 | 每列取 5 轮的中位数 |
为什么不用"写文章这一刻"的数字:本文定稿时(2026-11-02 上午)本机正在跑 Visual Studio、Defender 扫描与兼容性遥测,两次复跑的 CPU 份额只有 11%~45%,轮间抖动超过 40%(同一实现相邻两轮能差一倍),按项目自己的口径(CPU 份额低于 42% 时只打印数字、不做结论)属于无效采样,因此没有采用。上面这组是达标窗口里的一次完整对照,复现命令在文末。
表里最后一行 RawSocket 不是客户端:它是本仓库基准里裸 TCP 发预编码请求的实现,作用是服务端上限参照。加上它是为了知道"这个窗口里有多少成本其实是服务端和回环造成的"。
结果
| 实现 | RPS | p50 (µs) | p99 (µs) | 分配/请求 | 净分配/请求 | CPU | vs HttpClient |
|---|---|---|---|---|---|---|---|
| HttpClient | 84,389 | 84 | 218 | 3.7 KB | 3.6 KB | 49% | --- |
| Refit | 78,353 | 88 | 232 | 4.6 KB | 4.5 KB | 48% | 92.8% |
| AxiosNet | 77,183 | 87 | 251 | 5.2 KB | 5.0 KB | 49% | 91.5% |
| Flurl | 75,553 | 90 | 262 | 6.0 KB | 5.9 KB | 49% | 89.5% |
| RestSharp | 49,014 | 116 | 1,551 | 31.5 KB | 31.4 KB | 44% | 58.1% |
| RawSocket(服务端上限) | 106,945 | 66 | 149 | 131 B | 0 B | 45% | 126.7% |
每轮原始 RPS 也放出来,避免只信一个漂亮数字:
| 实现 | 第 1 轮 | 第 2 轮 | 第 3 轮 | 第 4 轮 | 第 5 轮 |
|---|---|---|---|---|---|
| HttpClient | 94,717 | 82,191 | 84,408 | 80,174 | 84,389 |
| Refit | 84,992 | 77,327 | 79,008 | 78,353 | 77,919 |
| AxiosNet | 79,078 | 77,183 | 76,708 | 76,026 | 78,199 |
| Flurl | 78,292 | 75,553 | 75,685 | 74,609 | 74,110 |
| RestSharp | 51,606 | 48,710 | 48,609 | 49,014 | 49,421 |
| RawSocket | 107,612 | 104,077 | 106,945 | 106,334 | 111,036 |
除 HttpClient 的第 1 轮偏高(首轮余温,+12%)外,其余轮次相对中位的波动都在 ±9% 以内;AxiosNet 最稳,+2.5% / -1.5%。对比复跑时"同一实现相邻两轮差一倍"的场面,就知道为什么要挑窗口。
怎么读这组数字
第一,8.5% 的吞吐差,换的是一整个治理栈。
AxiosNet 与裸 HttpClient 不是同一档,但差距是 8.5%,不是数量级。同一窗口里两者的 CPU 份额都是 49%,p50 只差 3 µs------说明差的不是传输层,而是每次请求上多走的配置合并、拦截器/转换链、统一错误模型与遥测判定。
把这 8.5% 换个说法:它替代的是 Polly + 缓存库 + 自写 Handler 链。那套拼装如果在同样的 8 并发下测,成本不会比 8.5% 更低------只是从来没人把它算进"HTTP 客户端"的账上。
第二,Refit 和 AxiosNet 同档(92.8% vs 91.5%)。
两者都是"源生成/轻封装 + 少量每请求分配"的代表。Refit 分配更低(4.5 KB vs 5.0 KB),因为它不承担治理管线;AxiosNet 多出来的约 0.5 KB 就是这些管线的固定成本。这个差值是可以解释的、也是有上限的,不是"每加一个特性就多一份分配"。
第三,RestSharp 的尾延迟和它的分配是同一件事。
p99 1,551 µs,是其它实现的 6~7 倍;净分配 31.4 KB/请求,是 HttpClient 的 8.7 倍。每请求新建 RestRequest + 自身的序列化路径带来更高的 Gen0 压力,窗口内的 GC 暂停直接体现在尾延迟上。这不是服务端或适配器带进来的------用不经过 socket 的伪 Handler 单独复测,ExecuteAsync<User> 单次约 29.4 KB,同量级。
第四,RawSocket 说明瓶颈已经开始向服务端转移。
HttpClient 已经跑到服务端上限的 79%,各客户端的 RPS 都被一定程度上限。所以这组数字适合比较相对关系,不适合拿来宣称"某库能跑 8 万 RPS"。
第五,最新提交把比值推到了 97.0%。
上面主表是一次完整横向对照。之后仓库里做了一轮响应正文的读取优化(多段正文与池化段,不再无条件装配成连续数组),同机配对复测(HttpClient vs AxiosNet,9 轮中位):
| 实现 | RPS | p50 (µs) | p99 (µs) | 净分配/请求 | CPU | vs HttpClient |
|---|---|---|---|---|---|---|
| HttpClient | 51,598 | 141 | 324 | 3.6 KB | 48% | --- |
| AxiosNet | 50,071 | 140 | 320 | 4.7 KB | 52% | 97.0% |
绝对 RPS 比主表低(机器状态不同,同一台机器两次运行差一倍很正常),但比值是同窗口配对量,可以比:91.5% → 97.0%,每请求净分配 5.0 KB → 4.7 KB。p50 已经追平(140 vs 141 µs),剩下的差距在尾部和每请求固定开销上。
这套数据不覆盖什么
- 只测 HTTP/1.1 明文回环 GET + 51 B JSON。TLS、HTTP/2、HTTP/3、上传、大正文、流式响应都不在范围内。
- 服务端与客户端同进程,分配指标必须减基线,CPU 指标含服务端。
- 每个实现按各自文档的惯用写法,不做"为跑分而调优"(RestSharp 每请求新建
RestRequest就是它的正常用法)。 - Refit 只测源生成模式,反射回退是另一档性能。
- 大正文与流式的分项成本在另一个 BenchmarkDotNet 工程里,两套数字不可互换。
5.4 易用性:同一件事写五遍
场景:GET /users?page=2&tags=dev&tags=ops,带 Bearer 令牌,10 秒超时,返回 List<User>。
csharp
// HttpClient:请求消息、鉴权头、超时 CTS、读正文、反序列化,五步都自己写
var url = "https://api.example.com/users?page=2&tags=dev&tags=ops";
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
using var res = await http.SendAsync(req, cts.Token);
res.EnsureSuccessStatusCode();
var users = await res.Content.ReadFromJsonAsync<List<User>>(cts.Token);
// Refit:接口声明一次,调用一行;查询数组、鉴权头、超时靠特性/HttpClient 配置表达
public interface IUserApi
{
[Get("/users")]
Task<List<User>> SearchAsync(
[Query] int page,
[Query] string[] tags,
[Header("Authorization")] string authorization,
CancellationToken ct = default);
}
// RestSharp:请求对象上逐个加参数与头
var req = new RestRequest("users")
.AddQueryParameter("page", 2)
.AddQueryParameter("tags", "dev")
.AddQueryParameter("tags", "ops")
.AddHeader("Authorization", $"Bearer {token}");
// Flurl:URL + 查询 + 鉴权 + 超时 + 反序列化,全部链在一行
var users = await "https://api.example.com/users"
.SetQueryParam("page", 2)
.SetQueryParam("tags", new[] { "dev", "ops" })
.WithOAuthBearerToken(token)
.WithTimeout(TimeSpan.FromSeconds(10))
.GetJsonAsync<List<User>>(cancellationToken: ct);
// AxiosNet:配置对象集中表达
var response = await http.GetAsync<List<User>>("users", c =>
{
c.Params = new { page = 2, tags = new[] { "dev", "ops" } };
c.Headers.Set("Authorization", $"Bearer {token}");
}, ct);
单次调用最省代码的是 Flurl,这个没有争议。AxiosNet 的优势不在"少写几个字符",而在多请求之间的一致性:超时、重试、缓存、鉴权都在实例默认值里,调用点只写差异部分。
同一件事加上"失败重试 3 次 + 熔断 + 缓存":
| 实现 | 需要做什么 |
|---|---|
| HttpClient | 引入 Polly(或 Microsoft.Extensions.Http.Resilience),组 DelegatingHandler 链,再单独接缓存库 |
| Refit | 同上,Polly 挂在传给 RestService.For 的 HttpClient 上 |
| RestSharp | 自己在循环里重试,熔断与缓存全自写 |
| Flurl | 同上;事件钩子能做记录,但不等于弹性策略 |
| AxiosNet | 同一个配置对象里写 c.Retry.Policy、c.Circuit.Policy、client.Defaults.Cache |
六、几个值得单独说的实现细节
功能表谁都能列,难的是这些能力凑在一起之后不互相打架。挑四个点。
6.1 一次尝试 = 一条中间件链,顺序是契约
「一次尝试」被抽象成可组合的中间件链,默认顺序:
重试 → 备用端点 → 熔断 → 对冲 → 并发闸门 → 审计 → 发送
这个顺序不是随手排的,每一条都对应一个语义:
- 重试在最外层 → 每次尝试都会重新过一遍备用端点、熔断、对冲与闸门;
- 备用端点在熔断之外 → 每个端点各自过熔断,一个镜像挂了不影响另一个;
- 熔断在对冲之外 → 一整组对冲分支只记一笔失败,不会因为发了 3 条就把失败数乘以 3;
- 熔断在闸门之前 → 被熔断拒绝的请求不占并发槽位。
中间件恒在链上,是否干活由它自己看 ctx.Config 决定------没配重试策略时重试中间件就是纯直通。所以链可以在构造期编译一次,每次尝试零分配。
并发槽位有个容易漏的坑:中间件如果自己给了响应、或者自己抛了异常,就不会调用 next,槽位没人归还。所以闸门自己兜底归还;唯一的例外是流式响应------槽位随正文交给 StreamOwner,由流的 Dispose 释放,正文下载期间这条请求一直占着额度。这个"所有权转移"用一个 SlotTransferred 标志告诉闸门"别还"。
6.2 熔断的两条打开规则
前面提过:连续失败管低流量,窗口失败率管高流量。还有两个细节:
- "没发出"不算下游故障。 排队到
TotalTimeout耗尽而抛的异常,下游一个字节都没收到,记成失败就等于"越拥堵越容易熔断健康的下游",把局部排队放大成链路不可用。 - 卡死的探测不会永久占名额。 半开态的探测 30 秒没回报就整轮作废、重新打开,迟到的结果按代次丢弃------否则一次挂住的探测会让该维度永远停在半开。
6.3 响应正文:能不解码就不解码,能不复制就不复制
这一条是最近一轮优化的重点,也是 97.0% 那个数字的来源。
正文读取有三种形态:单块数组、池化数组、多段(chunked 或超过 8 MB 时按段累积)。默认实现要先把多段装配成一整块连续数组,才能交给 Deserialize(ReadOnlyMemory<byte>)。但"装配"本身就是一次整份正文的复制------32 MB 的响应就是多复制 32 MB。
所以加了 ISequenceContentSerializer:实现这个接口,就能直接拿 ReadOnlySequence<byte> 解析,省掉那一份复制。内置的 JSON 实现走的就是这条路------Utf8JsonReader 本身能在多段 buffer 上工作,先把段装配成整块再解析,纯属浪费。
同一轮还改了缓存的正文所有权。缓存条目要不要克隆正文,取决于能不能证明调用方拿不到这块数组:
csharp
private static bool CanShareBody(AxiosResponse response, byte[] body)
=> !ReferenceEquals(response.Data, body)
&& response.Config?.TransformResponseSnapshot is not { Length: > 0 };
byte[] / ArrayBuffer 形态的 Data 就是原始字节本身,调用方一次改写就会污染后续命中,必须克隆;配了 transformResponse 时,它可能把数组包进任意对象,无法证明没被拿走,按最坏情况克隆。只有"JSON / 文本形态且没有 transform"时,才和响应共用同一块内存。
这类优化的共同点是:判据不是"看起来能不能省",而是"能不能证明安全"。 证明不了就复制,这是可以接受的成本;证明了就省,这是白赚的。
6.4 AOT 不是"打个标记",是把反射路径整段切掉
库声明了 IsAotCompatible 不等于 AOT 下能用。AxiosNet 的做法是:
- 包内自带源生成器(
analyzers/dotnet/cs,引用即生效),编译期为每个带路由特性的模型烘焙元数据,为接口声明生成真实代理类,在模块初始化器里注册; - 运行时不再反射模型 / 接口,成员取值是直接 getter 委托;
- JIT 专用的反射实现整段写在
RuntimeFeature.IsDynamicCodeSupported为 true 的分支里 ,ILC 把它折成常量false后,整段不可达,连同类型一起被裁掉; - AOT 下未命中注册表会抛
AxiosAotMetadataException并带修复指引,不会静默丢字段。
结果:示例在 win-x64 上发布 8.3 MiB、linux-x64 10.2 MiB,CI 把 IL2026 / IL3050 / 以及 DAM 那组警告直接当错误------"发布成功"等于"分析器 0 警告"。
七、什么情况下别用它
这部分必须写,否则前面的对比没有可信度。
| 场景 | 更合适的选择 |
|---|---|
| 只发几个 GET,没有治理需求 | HttpClient + System.Net.Http.Json,少一层抽象 |
| 追求极致吞吐,每 1% 都要 | HttpClient(84k vs 77k,差 8.5%) |
| 团队已经深度使用 Polly,策略是跨协议共用的 | 继续用 Polly;AxiosNet 的治理只针对 HTTP 请求 |
| 需要 .NET Framework / .NET Core 3.1 | 不支持,目标只有 net6.0 / net8.0 |
| 只想要链式语法、不关心治理 | Flurl 更轻,代码更短 |
| 端点多、要类型安全、不需要内置治理 | Refit 更成熟,生态资料更多 |
还有一个客观短板:生态。 HttpClient / Refit / RestSharp 有十年的问答、博客、StackOverflow 积累;AxiosNet 目前的主要资料就是仓库 README 本身。这不是靠功能表能弥补的,只能靠时间。
八、工程现状
判断一个库能不能进生产,看这几项比看功能表有用:
| 项 | 现状 |
|---|---|
| 用例数 | 2167(net8.0)/ 2160(net6.0),两个 TFM 各跑一遍,共 4253 次执行 |
| 行 / 分支覆盖率 | 91.7% / 87.3%(net8.0,Debug 口径,排除性能与分配类用例) |
| 测试方式 | 自研 TcpListener HTTP/1.1 Keep-Alive 服务器跑真实 TCP 往返,不是 mock Handler |
| 运行时矩阵 | 编译目标 net6.0/net8.0 两份资产;运行时在 .NET 6 与 .NET 10 上各跑一遍 |
| AOT | win-x64 / linux-x64 矩阵,裁剪与 AOT 分析器 0 警告,包消费 smoke 脚本 |
| 示例 | 三套:核心 + 声明式(50+ 项交互菜单)、DI 六场景、Native AOT |
覆盖率 91.7% 这个数字要说清口径:是 Debug 下的行覆盖率,Release 下序列点更少、分母会明显变小,两种口径不能混用。排除的用例有四类(性能比值与分配护栏在插桩下都会失真),普通 dotnet test 仍然全跑。
写测试时踩过的坑也有价值:时序敏感的用例(断言上限耗时)与按分配字节数断言的用例必须各自独占运行,否则并行跑测试时互相干扰,会出现"本地绿、CI 红"的随机失败。
九、小结
AxiosNet 不是一个"更快的 HttpClient"。它在吞吐上比裸 HttpClient 慢 8.5%,每请求多约 1.4 KB 分配,这是它背着整个治理栈的代价,也是它存在的理由。
它真正想解决的问题是:服务端调用外部 HTTP 时,那套"重试 + 熔断 + 限流 + 缓存 + 观测"的拼装,能不能只写一遍、只在一个地方配、出错时只查一个地方。
如果这个问题的答案是"能,而且我愿意为此付 8.5% 的吞吐",那它值得一试;如果答案是"我只要发个 GET",那用 HttpClient 就好------这也是它自己在文档里给出的选型建议。
bash
dotnet add package AxiosNet
仓库(Gitee 主仓):https://gitee.com/itsm/axios-net
GitHub 只读镜像:https://github.com/sunyuliang/AxiosNet
复现本文的性能数据:
bash
cd test/AxiosNet.Comparison
dotnet run -c Release -- --rounds 5 --duration 1 --tag my-run
# 当回归门禁用:吞吐不低于 HttpClient 的 85%,净分配不超过其 2 倍
dotnet run -c Release -- --min-ratio 0.85 --max-alloc-ratio 2
完整的对比报告(含功能矩阵与选型建议)在 docs/http-client-comparison.md。