从 axios 写法到服务端治理:AxiosNet 与 HttpClient / Refit / RestSharp / Flurl 的横向对比

本文的数据来自仓库内的可复现基准,不是引用别人的跑分。功能对比是对各库公开 API 的定性判断,性能对比给了完整口径和"这套数据不覆盖什么"。看完再决定要不要往下读源码。


一、先把问题说清楚

在 .NET 里发一个 HTTP 请求只需要三行:

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。

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("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,包内自带源生成器

安装:

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 原始请求:返回类型由泛型决定

// 原文:不解析
var text = await Axios.GetAsync("https://api.example.com/ping");

// dynamic:不定义 DTO,按字段取值
var dyn = await Axios.GetAsync("https://api.example.com/users/1");
Console.WriteLine($"{dyn.Data.name}, {dyn.Data.address.city}");

// 强类型
var response = await Axios.GetAsync("https://api.example.com/users/1");

四种形态(string / AxiosDynamic / T / 非泛型 JsonElement)都在同一个方法族里,不用换 API。

3.2 声明式:模型或接口,调用只剩一句

端点多起来之后,URL 和参数散落在调用处是最难维护的。AxiosNet 给两种声明方式,由同一个 ApiClient 承载:

[Get("users/{id}")]
public sealed class GetUserRequest : IApiRequest
{
    [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 同一路径):

public interface IUserApi
{
    [Get("users/{id}")]
    Task GetAsync(int id, CancellationToken ct = default);
}

有 OpenAPI 文档时,连接口都不用写:

dotnet tool install -g AxiosNet.Cli
axiosnet-gen ./openapi.json -o ./Generated -n MyApp.Api --style interface

这一点是它和 Refit 最直接的同台竞争:Refit 需要 Refitter 之类的第三方工具才能从 OpenAPI 生成,AxiosNet 把生成器做进了同一个仓库、同一个包族。

3.3 流式:请求体边算边发,响应体边收边解析

// http 是 AxiosClient 实例:请求体给异步序列,库不缓冲
await http.PostAsync("ingest", ReadChunks(file), c =>
{
    c.ContentType = "text/csv";
});

// 声明式客户端把 SSE / NDJSON / JSON 数组解析成 IAsyncEnumerable
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 或内存块
流式响应分帧 RequestStreamAsync(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 单次约 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。

// 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>(cts.Token);

// Refit:接口声明一次,调用一行;查询数组、鉴权头、超时靠特性/HttpClient 配置表达
public interface IUserApi
{
    [Get("/users")]
    Task> 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>(cancellationToken: ct);

// AxiosNet:配置对象集中表达
var response = await http.GetAsync>("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)。但"装配"本身就是一次整份正文的复制——32 MB 的响应就是多复制 32 MB。

所以加了 ISequenceContentSerializer:实现这个接口,就能直接拿 ReadOnlySequence 解析,省掉那一份复制。内置的 JSON 实现走的就是这条路——Utf8JsonReader 本身能在多段 buffer 上工作,先把段装配成整块再解析,纯属浪费。

同一轮还改了缓存的正文所有权。缓存条目要不要克隆正文,取决于能不能证明调用方拿不到这块数组:

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 就好——这也是它自己在文档里给出的选型建议。

dotnet add package AxiosNet

仓库(Gitee 主仓):https://gitee.com/itsm/axios-net
GitHub 只读镜像:https://github.com/sunyuliang/AxiosNet

复现本文的性能数据:

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。


原文地址: https://www.cveoy.top/t/topic/qHRU 著作权归作者所有。请勿转载和采集!

免费AI点我,无需注册和登录