全功能WebAPI客户端-AxiosNet
在 .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。
一、怎么用
1.1 安装
dotnet add package AxiosNet # 核心客户端 + 声明式 API 客户端(同一包)
dotnet add package AxiosNet.DependencyInjection # 可选:DI 注册扩展
dotnet tool install -g AxiosNet.Cli # 可选:从 OpenAPI 生成客户端(设计期工具,不进运行时)
核心包零第三方运行时依赖,只用 BCL。DI 扩展依赖 Microsoft.Extensions.DependencyInjection.Abstractions 一个抽象包;CLI 是设计期工具,用 YamlDotNet 解析 OpenAPI 文档,不进运行时。目标框架 net6.0 / net8.0 双目标,.NET 6 ~ 11 都能引用。
1.2 三种写法,同一个内核
原始请求:返回类型由泛型决定
// 原文:不解析
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。
声明式:模型或接口,调用只剩一句
端点多起来之后,URL 和参数散落在调用处是最难维护的。AxiosNet 给两种声明方式,由同一个 ApiClient 承载:
[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 同一路径):
public interface IUserApi
{
[Get("users/{id}")]
Task<User> GetAsync(int id, CancellationToken ct = default);
}
有 OpenAPI 文档时,连接口都不用写:
axiosnet-gen ./openapi.json -o ./Generated -n MyApp.Api --style interface
这一点是它和 Refit 最直接的同台竞争:Refit 需要 Refitter 之类的第三方工具才能从 OpenAPI 生成,AxiosNet 把生成器做进了同一个仓库、同一个包族。
流式:请求体边算边发,响应体边收边解析
// 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 增量输出、行情推送、分页列表都能直接用)。
1.3 治理配置:一个对象写完
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 统一,尝试级事件 |
二、功能
不逐条列 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 难得多。
熔断有两条打开规则。 只按连续失败熔断,低流量的"半死不活"下游永远熔不断——每夹一次成功就清零,而它恰恰最该被熔断;只按窗口失败率熔断,小流量又填不满样本。所以两条并存:样本够看比率,不够看连续失败。
和主流组件比功能
选了四个对手,加上自己:
| 实现 | 版本 | 形态 |
|---|---|---|
HttpClient |
BCL(.NET 8) | 基线,功能最少但吞吐天花板 |
| Refit | 16.4.0 | 声明式接口 + 源生成(只测 ForGenerated,不含反射回退) |
| RestSharp | 113.0.0 | 请求对象模型,老项目存量大 |
| Flurl | Flurl.Http 4.0.2 | 字符串即请求的链式写法 |
| AxiosNet | 仓库源码(net8.0) | 默认 AxiosClient,不做任何调优 |
功能对比是定性判断,依据是各库公开 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,前四家都够用,甚至更省。
易用性:同一件事写五遍
场景:GET /users?page=2&tags=dev&tags=ops,带 Bearer 令牌,10 秒超时,返回 List<User>。
// 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 |
三、性能
口径
这一节的所有数字都来自仓库内 test/AxiosNet.Comparison,不是引用。口径必须写清楚,否则数字没有意义:
| 项 | 值 |
|---|---|
| 环境 | 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 轮的中位数 |
四家第三方实现与 AxiosNet 都注入了与 AxiosNet 默认连接池逐项对齐的 SocketsHttpHandler(Cookies / Proxy / 连接数 / 超时 / 解压一致),否则测到的是默认值差异,不是实现差异。
表里最后一行 RawSocket 不是客户端:它是本仓库基准里裸 TCP 发预编码请求的实现,作用是服务端上限参照——加上它是为了知道"这个窗口里有多少成本其实是服务端和回环造成的"。
这套数据不覆盖什么,也一次说清: 只测 HTTP/1.1 明文回环 GET + 51 B JSON;TLS、HTTP/2、HTTP/3、上传、大正文、流式响应都不在范围内。服务端与客户端同进程,分配指标必须减基线,CPU 指标含服务端。每个实现按各自文档的惯用写法,不做"为跑分而调优"。Refit 只测源生成模式,反射回退是另一档性能。大正文与流式的分项成本在另一个 BenchmarkDotNet 工程里,两套数字不可互换。
结果
| 实现 | RPS | p50 (µs) | p99 (µs) | 净分配/请求 | CPU | vs HttpClient |
|---|---|---|---|---|---|---|
| HttpClient | 84,389 | 84 | 218 | 3.6 KB | 49% | — |
| Refit | 78,353 | 88 | 232 | 4.5 KB | 48% | 92.8% |
| AxiosNet | 77,183 | 87 | 251 | 5.0 KB | 49% | 91.5% |
| Flurl | 75,553 | 90 | 262 | 5.9 KB | 49% | 89.5% |
| RestSharp | 49,014 | 116 | 1,551 | 31.4 KB | 44% | 58.1% |
| RawSocket(服务端上限) | 106,945 | 66 | 149 | 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)。剩下的差距在尾部和每请求固定开销上。
四、几个值得单独说的实现细节
功能表谁都能列,难的是这些能力凑在一起之后不互相打架。挑四个点。
4.1 一次尝试 = 一条中间件链,顺序是契约
「一次尝试」被抽象成可组合的中间件链,默认顺序:
重试 → 备用端点 → 熔断 → 对冲 → 并发闸门 → 审计 → 发送
这个顺序不是随手排的,每一条都对应一个语义:
- 重试在最外层 → 每次尝试都会重新过一遍备用端点、熔断、对冲与闸门;
- 备用端点在熔断之外 → 每个端点各自过熔断,一个镜像挂了不影响另一个;
- 熔断在对冲之外 → 一整组对冲分支只记一笔失败,不会因为发了 3 条就把失败数乘以 3;
- 熔断在闸门之前 → 被熔断拒绝的请求不占并发槽位。
中间件恒在链上,是否干活由它自己看 ctx.Config 决定——没配重试策略时重试中间件就是纯直通。所以链可以在构造期编译一次,每次尝试零分配。
并发槽位有个容易漏的坑:中间件如果自己给了响应、或者自己抛了异常,就不会调用 next,槽位没人归还。所以闸门自己兜底归还;唯一的例外是流式响应——槽位随正文交给 StreamOwner,由流的 Dispose 释放,正文下载期间这条请求一直占着额度。这个"所有权转移"用一个 SlotTransferred 标志告诉闸门"别还"。
4.2 熔断的两条打开规则
前面提过:连续失败管低流量,窗口失败率管高流量。还有两个细节:
- "没发出"不算下游故障。 排队到
TotalTimeout耗尽而抛的异常,下游一个字节都没收到,记成失败就等于"越拥堵越容易熔断健康的下游",把局部排队放大成链路不可用。 - 卡死的探测不会永久占名额。 半开态的探测 30 秒没回报就整轮作废、重新打开,迟到的结果按代次丢弃——否则一次挂住的探测会让该维度永远停在半开。
4.3 响应正文:能不解码就不解码,能不复制就不复制
正文读取有三种形态:单块数组、池化数组、多段(chunked 或超过 8 MB 时按段累积)。默认实现要先把多段装配成一整块连续数组,才能交给 Deserialize(ReadOnlyMemory<byte>)。但"装配"本身就是一次整份正文的复制——32 MB 的响应就是多复制 32 MB。
所以加了 ISequenceContentSerializer:实现这个接口,就能直接拿 ReadOnlySequence<byte> 解析,省掉那一份复制。内置的 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"时,才和响应共用同一块内存。
这类优化的共同点是:判据不是"看起来能不能省",而是"能不能证明安全"。 证明不了就复制,这是可以接受的成本;证明了就省,这是白赚的。
4.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 仍然全跑。
小结
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。

浙公网安备 33010602011771号