使用.net实现axios
AxiosNet开发初衷
.NET 原生的 HTTP 调用要拼的东西太多了,特别是form提交时,基本要深入http协议,自行拼接参数,涉及文件的还得自行拼接内容分隔,
而其它如api的请求,需要考虑连接复用,网页格式编码,返回内容的格式化、解决了连接复用又得考虑dns过期、上传下载进度监控等等等,特别麻烦。
而 JS 生态的 axios 用起来很方便,于是照着它模拟了一份、也加入了.net专属的一些特性,整体用法与axios基本一致、组件库为vibecoding+人工复核而来。
组件库
零第三方运行时依赖,单目标 net6.0,覆盖 .NET 6 及以上全部运行时。
仓库:https://gitee.com/itsm/axios-net(主仓) / https://github.com/sunyuliang/AxiosNet(镜像)
一、起因:写一个生产可用的请求,要多少样板代码
在 .NET 里发一个最简单的 GET 请求,确实只要三行:
var http = new HttpClient();
var json = await http.GetStringAsync("https://api.example.com/users/1");
但只要这个请求要进生产环境,它很快就会变成这样:
public async Task<Order?> GetOrderAsync(long id, CancellationToken ct)
{
// 1. 拼 URL —— 参数要编码,数组要用对方约定的格式
var url = $"https://api.example.com/orders/{id}"
+ $"?expand=items&tag={Uri.EscapeDataString("季度 报表")}&ids[]=1&ids[]=2";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
// 2. 认证头 —— 通常还得异步取 token
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", await _tokenProvider.GetAsync(ct));
// 3. 超时 —— HttpClient.Timeout 是实例级的,且会取消整个请求,只能自己套一层 linked CTS
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(10));
HttpResponseMessage response;
try
{
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeoutCts.Token);
}
catch (TaskCanceledException ex)
{
// 4. 到底是"我取消了"还是"超时了"?只能靠这个判断
if (ct.IsCancellationRequested) throw;
throw new TimeoutException("上游超时", ex);
}
using (response)
{
// 5. 错误处理 —— EnsureSuccessStatusCode() 会把响应体丢掉,排查时啥也看不到
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(ct);
throw new HttpRequestException($"{(int)response.StatusCode}: {body}");
}
// 6. 反序列化 —— 还得先想清楚编码
await using var stream = await response.Content.ReadAsStreamAsync(ct);
return await JsonSerializer.DeserializeAsync<Order>(stream, _jsonOptions, ct);
}
}
这还只是"一个 GET"。真到业务里,你会发现自己在反复写同一类代码:
- 连接怎么复用:
new HttpClient()每个请求一个,短时间高并发直接把 socket 打满(TIME_WAIT堆积、端口耗尽);改成全局单例,又踩到 DNS 缓存——服务端切了 IP,客户端还连着旧地址。 - 并发怎么控:某个上游扛不住 200 并发,你想限制到 20,就得去设
SocketsHttpHandler.MaxConnectionsPerServer,而 Handler 的生命周期、什么时候该重建,又是一套学问。 - URL 怎么拼:数组参数是
ids[]=1&ids[]=2还是ids=1,2?嵌套对象是filter[kind]=book还是filter.kind=book?中文和空格有没有漏编码?每个后端都可能不一样。 - 请求体怎么编码:一个
Dictionary<string, object>,到底该发 JSON 还是 form?同一个字典类型,在这个接口是 JSON,在那个接口是表单,靠类型根本猜不出来。 - multipart 怎么拼:手写
MultipartFormDataContent,要处理文件名、Content-Type、边界、流什么时候释放——写完还得担心大文件把内存吃了。 - 响应是什么编码:老系统返回 GBK,
Content-Type里还不写 charset;返回的 JSON 前面带个 UTF-8 BOM;或者 charset 写错了。ReadAsStringAsync拿到一串乱码,只能靠猜。 - 错误怎么统一:
HttpRequestException、TaskCanceledException、JsonException、IOException混在一起往上抛,上层根本没法分类处理,日志里也看不出是 4xx 还是 5xx、是超时还是网络断了。 - 上传下载怎么做:大文件要流式,不能整包进内存;要进度条就得自己包一层
Stream;要限速就更别说了,BCL 没有现成能力。
单个问题都不算难,难的是每个项目、每个团队都要把这些重新拼一遍,而且拼得对不对全靠经验。我自己在不同项目里写过至少三版类似的封装,每次都有新的坑。
于是就想着,干脆照着 axios 的用法,在 .NET 上模拟一个:不重新发明 API,而是把它那套设计搬过来——配置项命名、方法签名、错误码都尽量对齐,用过 axios 的人基本零学习成本;底层则换成 .NET 自己的 SocketsHttpHandler 和强类型体系。
二、我想要的其实只有三件事
第一,调用要顺手。 业务代码里绝大多数的请求,应该一眼能看懂在干什么,而不是被 HttpRequestMessage、Content、Headers 淹没。
第二,网络层的默认值要正确。 连接复用、DNS 刷新、并发上限、编码识别这些东西,不应该要求每个业务开发都懂。默认跑起来是对的,需要调的时候有开关。
第三,别把 .NET 的能力丢掉。 强类型、CancellationToken、Span/Stream、依赖注入、自定义 HttpMessageHandler——这些是 .NET 的优势,不能被一个"语法糖"包没了。
三条合在一起,形态就定下来了:用法上像 axios,实现上是纯 .NET。
三、快速上手
dotnet add package AxiosNet
using AxiosNet;
// 静态门面:等同 axios.get('/users/1')
var res = await Axios.GetAsync<User>("https://api.example.com/users/1");
Console.WriteLine($"{res.Status} {res.Data!.Name}");
// 实例:等同 axios.create({ baseURL, timeout, headers })
using var http = Axios.Create(c =>
{
c.BaseAddress = "https://api.example.com/";
c.Timeout = TimeSpan.FromSeconds(10);
c.Headers.Set("Authorization", "Bearer xxx");
});
var created = await http.PostAsync<User>("users", new { name = "bob", age = 30 });
返回结构也和老朋友一样:Data / Status / StatusText / Headers / Config / Request。
string url = http.GetUri(c =>
{
c.Url = "users";
c.Params = new { page = 2, ids = new[] { 1, 2 } };
});
// https://api.example.com/users?page=2&ids[]=1&ids[]=2
GetUri 只拼 URL 不发请求,调试、打日志、生成预签名地址的时候特别省事——这是 axios 里我最喜欢的小功能之一。
四、它到底做了什么
4.1 配置写一次,请求到处用
实例默认值 + 单次覆盖,合并规则跟 axios 一致:
var api = Axios.Create(c =>
{
c.BaseAddress = "https://api.example.com/";
c.Timeout = TimeSpan.FromSeconds(10);
c.Headers.Set("X-Client", "order-service");
c.JsonSerializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
NumberHandling = JsonNumberHandling.AllowReadingFromString
};
});
// 单次覆盖:这次要 60 秒,其他不变
await api.GetAsync<Report>("reports/monthly", c => c.Timeout = TimeSpan.FromSeconds(60));
// 显式关掉实例默认的超时也没问题
await api.GetAsync("slow-job", c => c.Timeout = TimeSpan.Zero);
这里有个实现细节值得一提:配置项的 setter 会打一个 *Set 标记,合并时用"是否显式设置过"而不是"是否等于默认值"来判断。否则 Timeout = TimeSpan.Zero(表示不超时)就会被实例默认的 10 秒覆盖——这种"设了没生效"的 bug 最难查。同理,MaxContentLength = null 能清掉实例默认的上限。
4.2 URL 与查询参数
await http.GetAsync("search", c => c.Params = new
{
q = "a b", // q=a%20b
ids = new[] { 1, 2 }, // ids[]=1&ids[]=2
filter = new { kind = "book" } // filter[kind]=book
});
默认序列化规则对齐 axios:数组用 a[]=v、嵌套对象用 o[k]=v、null 跳过、中文和空格正确转义。后端要别的格式(逗号分隔、dot 风格),用 ParamsSerializer 一次性换掉:
c.ParamsSerializer = p =>
{
var q = (ItemQuery)p!;
return $"ids={string.Join(",", q.Ids)}&q={Uri.EscapeDataString(q.Q)}";
};
4.3 请求体:只认 ContentType,不猜类型
这是我和不少 .NET 封装思路不一样的地方。
大部分库会按 data 的运行时类型推断:字典 → 表单,对象 → JSON,字符串 → 原样。听起来聪明,实际很坑——同一个 Dictionary<string, object>,在这个接口该发 JSON,在那个接口该发表单,类型本身不携带这个信息。一旦猜错,服务端收到的是一坨格式不对的东西,报错还很难看懂。
AxiosNet 的规则只有一条:编码方式由 ContentType 决定,与数据类型无关。
ContentType |
对象 / 字典 / 集合 | string / byte[] / Stream / HttpContent |
|---|---|---|
application/json(默认) |
JSON 文本 | 原样发送 |
application/x-www-form-urlencoded |
键值对表单(a[b]=v、a[]=v) |
原样发送 |
multipart/form-data |
字段 + 文件部件 | 原样发送 |
其他(text/plain、application/xml…) |
仍是 JSON 文本,只是头不同 | 原样发送 |
// 默认 JSON
await http.PostAsync("users", new { name = "bob" });
// 要表单就显式声明,不用换数据类型
await http.PostAsync("login", new Dictionary<string, object?>
{
["username"] = "alice",
["tags"] = new[] { "a", "b" } // tags[]=a&tags[]=b
}, c => c.ContentType = AxiosConfig.FormUrlEncodedContentType);
// multipart:对象里直接放 FormFile,库负责拼部件、算边界、释放流
await http.PostFormAsync("upload", new
{
title = "季度报告",
files = new[]
{
FormFile.FromPath("a.pdf"), // 库打开/释放
new FormFile("b.zip", File.OpenRead("b.zip"), "application/zip")
}
});
PostFormAsync / PutFormAsync / PatchFormAsync 对应 axios 的 postForm,默认就是 multipart——少写一行 ContentType,也少一次"忘了设"。
4.4 响应:强类型、dynamic、标量、流,都行
var typed = await http.GetAsync<User>("users/1"); // 强类型
var raw = await http.GetAsync("users/1"); // JsonElement
var text = await http.GetAsync("ping", c => c.ResponseType = ResponseType.Text);
var bytes = await http.GetAsync<byte[]>("files/icon.png"); // 自动按 ArrayBuffer
var stream = await http.GetAsync<Stream>("files/big.zip"); // 自动按 Stream
// 不想定义 DTO 的时候,像 JS 一样访问
var dynRes = await http.GetAsync<dynamic>("users/1");
dynamic data = dynRes.Data!;
Console.WriteLine($"{data.name}, {data.address.city}, {data.roles[0]}");
GetAsync<string> 返回的是原始文本,不会把 JSON 字符串再解析一遍;GetAsync<int>、GetAsync<DateTime> 这类标量也能直接拿。实现上是"先读原始字节 → 再解码成文本 → 最后按目标类型转换",顺序固定,所以不会出现"想拿原文却被 JSON 解析器吃掉引号"这种事。
4.5 拦截器与转换链
// 请求拦截器:与 axios 一致,按注册逆序执行
http.Interceptors.Request.Use(async cfg =>
{
cfg.Headers.Set("Authorization", $"Bearer {await tokens.GetAsync()}");
return cfg;
});
// 响应拦截器:rejected 分支可以吞错、返回兜底数据,也可以刷新 token 后重放
var id = http.Interceptors.Response.Use(
onFulfilled: res => res,
onRejected: ex =>
{
var err = (AxiosException)ex;
if (err.Code == AxiosException.ErrBadRequest && err.Status == 401)
{
// 刷新 token、重放请求,或返回缓存
}
throw ex;
});
http.Interceptors.Response.Eject(id);
配合 transformRequest / transformResponse,可以统一脱掉服务端 { code, data } 的外壳,或者在发送前给字典型 body 打时间戳。转换链是线程安全的(内部走快照枚举),运行期增删不会炸。
4.6 错误模型:一个 catch 搞定
try
{
await http.GetAsync("orders/404");
}
catch (AxiosException ex)
{
Console.WriteLine(ex.Code); // ERR_BAD_REQUEST
Console.WriteLine(ex.Status); // 404
Console.WriteLine(ex.Message); // Request failed with status code 404
Console.WriteLine(ex.Cause); // 底层异常(DNS/连接失败/JSON 解析)
logger.LogError("{@Error}", ex.ToJson()); // 结构化日志直接丢
}
| Code | 触发场景 |
|---|---|
ERR_BAD_REQUEST |
4xx;请求体超过 MaxBodyLength |
ERR_BAD_RESPONSE |
5xx;反序列化失败;响应体超过 MaxContentLength |
ECONNABORTED |
超时(消息可用 TimeoutMessage 覆盖) |
ERR_NETWORK |
连接被拒 / DNS 失败 / 连接中断 / 响应被截断 |
ERR_CANCELED |
外部 CancellationToken 取消 |
ERR_INVALID_URL |
URL 非法(相对地址又没给 BaseAddress) |
ERR_BAD_OPTION_VALUE |
配置项用法错误 |
关键点:底层异常不会被吞掉,统一挂在 Cause 上。排查问题时既能按业务语义分类,又能拿到原始的 HttpRequestException / JsonException。
ex.ToJson() 输出与 axios AxiosError.toJSON() 同形的字典(name / message / code / status / method / url / isAxiosError / stack),对结构化日志友好。
4.7 超时与取消
// 按请求设超时
await http.GetAsync("slow", c => c.Timeout = TimeSpan.FromMilliseconds(500));
// 外部取消
using var cts = new CancellationTokenSource();
var task = http.GetAsync("slow", cancellationToken: cts.Token);
cts.CancelAfter(TimeSpan.FromSeconds(1));
这两件事在原生 API 里对外是同一个异常(TaskCanceledException;.NET 5+ 内层可能带一个 TimeoutException,但外层类型一样,判断仍要落到 ct.IsCancellationRequested 上),要区分就得自己建 linked CTS 再判断。库内部把它们拆成了两个 error code:超时 ECONNABORTED,取消 ERR_CANCELED。Axios.IsCancel(ex) 也能直接用。
流式响应的超时覆盖到正文读取结束——不是"响应头回来就算完",否则一个慢速下载能永久挂在那里。
4.8 上传下载:进度、限速、上限
await http.PostAsync("files/raw", File.OpenRead(path), c =>
{
c.ContentType = "application/octet-stream";
c.MaxBodyLength = 200 * 1024 * 1024; // 发送前就拦住
c.MaxRate = 8 * 1024 * 1024; // 限速 8 MB/s
c.OnUploadProgress = e =>
Console.WriteLine($"{e.Progress:P0} {e.Rate / 1024:F0} KB/s 剩余 {e.Estimated}");
});
进度事件带 Loaded / Total / Progress / Rate / Estimated,按 50ms 节流,结束时保证有且仅有一次 100%。限速是按"已传字节数反算应耗时、超前就 Delay"实现的,允许小段突发,限的是平均速率。
下载同理,大文件走流式:
var res = await http.GetAsync<Stream>("files/report.zip");
await using var source = res.Data!;
await using var target = File.Create(@"D:\downloads\report.zip");
await source.CopyToAsync(target);
ResponseType.Stream 用 HttpCompletionOption.ResponseHeadersRead,内存占用恒定。这里有个容易忽略的生命周期问题:流必须释放,连接才会回到池里;只读一半就丢掉,那条 TCP 会被直接废弃。
4.9 文本编码:GBK 不再靠猜
响应解码按三级回退:显式 ResponseEncoding → Content-Type 的 charset → BOM → UTF-8。未知 charset 不会抛异常,而是静默往下走。
// 老接口返回 GBK,头里还不写 charset
var res = await http.GetAsync("legacy", c =>
c.ResponseEncoding = Encoding.GetEncoding("gbk"));
// 请求也按 GBK 发
await http.PostAsync("legacy", "中文", c =>
c.RequestEncoding = Encoding.GetEncoding("gbk"));
显式设置 RequestEncoding 时,会同步写进 Content-Type 的 charset——头部和实际字节必须一致,不然服务端解出来还是乱码。
提醒:.NET Core 上用 GBK 需要先注册
CodePagesEncodingProvider.Instance,否则Encoding.GetEncoding("gbk")直接抛。这是平台限制,库不替你做全局注册。
4.10 连接池与 DNS:这部分才是重点
前面那些都是"写得爽不爽",连接池是"跑得稳不稳"。
.NET 的 SocketsHttpHandler 本身会复用 TCP 连接,问题在于谁来管 Handler 的生命周期:
- 每次
new HttpClient()→ 每个实例一套连接池,连接建立后不复用,高并发下 socket 迅速耗尽; - 全局一个
HttpClient→ 连接复用了,但PooledConnectionLifetime默认是无限:只要有持续流量,连接就一直活着。服务端 DNS 变了(扩容、切机房、K8s Service IP 变化),客户端可能几小时都还连着旧 IP。
.NET 官方给的答案是 SocketsHttpHandler.PooledConnectionLifetime 加 ConnectCallback。道理不复杂,但要每个项目都正确配一遍,不现实。
AxiosNet 在这之上做了一层按 origin(scheme://host:port)隔离的池:
using var pool = new HttpConnectionPool(new ConnectionPoolOptions
{
MaxConnectionsPerServer = 64, // 单站点最大 TCP 连接
PooledConnectionLifetime = TimeSpan.FromMinutes(10), // 连接最长存活,到期换新(会重新解析 DNS)
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2), // 空闲连接关闭
ConnectTimeout = TimeSpan.FromSeconds(15),
IdleHandlerLifetime = TimeSpan.FromMinutes(5), // 整个 origin 空闲后驱逐
AutomaticDecompression = true // gzip/deflate/br
});
// 多个业务模块各自的 client,共享同一个池
using var orderClient = new AxiosClient(pool: pool);
using var payClient = new AxiosClient(pool: pool);
var stats = pool.GetStatistics();
// EndpointCount / TotalLeases / Endpoints[*].ActiveLeases
每个 origin 一个调优过的 SocketsHttpHandler,所有请求共享它——连接复用、MaxConnectionsPerServer 限流、空闲驱逐、可观测统计都在这一层。默认值已经能跑:单站点 100 连接、连接 10 分钟轮换、空闲 2 分钟回收。
关于 DNS,有个必须说清楚的真相:池里存的是 Handler,不是 IP;IP 绑在 Handler 内部的 TCP 连接上。所以 DNS 切换后的收敛窗口,等于"连接还能复用多久"。
PooledConnectionLifetime = 10min意味着最坏情况下,切 DNS 后 10 分钟内还有请求打到旧地址;- 快速切流场景可以调到 30–60 秒,代价是连接重建更频繁;
- 连接到期是在下一次取用时换新,不会打断在途请求;
- 需要服务发现(Consul/Nacos/SRV/权重)时,用
HandlerFactory拿到默认 Handler 后设置ConnectCallback,它按"每条新 TCP 连接"调用一次,天然跟着生命周期刷新。
我做示例的时候专门写了两个本地服务器模拟同一域名的旧/新地址,跑起来能直观看到"切 DNS 后仍然命中旧地址 → 生命周期到期后落到新地址",以及 pool.Clear() 的立即失效(会打断在途请求,只能运维手动用)。
连接复用是实测过的:示例与测试里,5 个顺序请求只建立 1 条 TCP;6 个并发慢请求在 MaxConnectionsPerServer = 2 下,峰值并发恰好是 2。
4.11 JSON 可插拔
默认 System.Text.Json,三层覆盖:全局 AxiosJson.Options → 实例 → 单次请求。默认是 Web 风格(输出 camelCase、输入大小写不敏感),跟 JS 生态习惯一致。
要整体换引擎(比如团队统一用 Newtonsoft.Json,或者上源生成序列化),实现 IAxiosJsonSerializer 就行:
public sealed class NewtonsoftJsonSerializer : IAxiosJsonSerializer
{
public string Serialize(object? value, Type type, AxiosConfig config)
=> JsonConvert.SerializeObject(value, type, Settings);
public object? Deserialize(string json, Type type, AxiosConfig config)
=> JsonConvert.DeserializeObject(json, type, Settings);
public object? ParseDynamic(string json, AxiosConfig config)
=> JsonConvert.DeserializeObject<JToken>(json, Settings);
public object ParseDocument(string json, AxiosConfig config)
=> JToken.Parse(json);
}
AxiosJson.Serializer = new NewtonsoftJsonSerializer(); // 全局生效
四条路径(请求体、强类型响应、dynamic、非泛型文档)一起换,不会出现"请求走 STJ、响应走 Newtonsoft"的割裂。
五、和原生 HttpClient 的对比
5.1 同一个需求,两份代码
需求:带 Bearer token、两个查询参数、10 秒超时、区分取消与超时、4xx/5xx 保留响应体、反序列化成强类型。
原生:
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(10));
var url = $"https://api.example.com/orders?page=2&ids[]=1&ids[]=2";
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
HttpResponseMessage resp;
try
{
resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cts.Token);
}
catch (TaskCanceledException ex)
{
if (ct.IsCancellationRequested) throw;
throw new TimeoutException("请求超时", ex);
}
using (resp)
{
if (!resp.IsSuccessStatusCode)
{
var body = await resp.Content.ReadAsStringAsync(CancellationToken.None);
throw new HttpRequestException($"HTTP {(int)resp.StatusCode}: {body}");
}
await using var s = await resp.Content.ReadAsStreamAsync(ct);
return await JsonSerializer.DeserializeAsync<OrderList>(s, _options, ct);
}
AxiosNet:
var res = await http.GetAsync<OrderList>("orders", c =>
{
c.Params = new { page = 2, ids = new[] { 1, 2 } };
c.Timeout = TimeSpan.FromSeconds(10);
}, cancellationToken: ct);
OrderList list = res.Data!;
差别不在行数,在于这些行为是不是每次都由人重新想一遍。上面那段原生代码里,ids[] 的方括号有没有编码对、超时到底该不该用 CancellationToken.None 读错误体、流释放顺序对不对——每一处都可能写错,而且写错了不一定立刻暴露。
5.2 能力对照
| 维度 | 原生 HttpClient | AxiosNet |
|---|---|---|
| 连接复用 | SocketsHttpHandler 自动,但 Handler 生命周期要自己管 |
按 origin 池化,实例/跨实例共享,默认参数可用 |
| 并发上限 | 手设 MaxConnectionsPerServer |
池选项,超限在连接层排队 |
| DNS 变更 | 自己配 PooledConnectionLifetime / ConnectCallback |
默认 10 分钟轮换,可调;HandlerFactory 接服务发现 |
| 空闲回收 | 无 origin 级概念 | 后台定时驱逐空闲 origin,GetStatistics() 可观测 |
| query 序列化 | Uri.EscapeDataString 手拼 |
数组/嵌套/null 跳过,可自定义 |
| 请求体编码 | 自己选 StringContent/FormUrlEncodedContent/MultipartFormDataContent |
由 ContentType 决定,multipart 自动拼文件 |
| 响应解码 | 依赖 Content-Type charset |
charset → BOM → UTF-8,可强制覆盖 |
| 反序列化 | 自己调 JsonSerializer |
泛型 / JsonElement / dynamic / 标量 |
| 错误语义 | 异常类型混杂,EnsureSuccessStatusCode 丢 body |
统一 AxiosException + Code + Response + Cause |
| 超时 vs 取消 | 同一个 TaskCanceledException |
ECONNABORTED / ERR_CANCELED 分开 |
| 上传下载进度 | 无原生支持 | 回调 + 平均速率 + 预计剩余 |
| 限速 | 无 | MaxRate,上传下载各自受限 |
| 大小限制 | 自己数 | MaxContentLength 边读边计数,超限立即断开 |
| 拦截器 | DelegatingHandler(同步管道,改 body 麻烦) |
请求/响应拦截器,支持异步、可恢复、可 Eject |
| 测试替身 | 自建 Handler / 起服务器 | new AxiosClient(handler: stub) 直接注入 |
表格里的"原生"一列不是"做不到",而是"要自己写、而且每次都得写对"。这两件事的成本完全不同。
需要说明的是:AxiosNet 不是替代 HttpClient,而是在它上面加了一层。最终请求还是走 SocketsHttpHandler,没有自研 socket 栈。额外开销只有每请求一次配置克隆,以及启用进度/限速时的一层流包装——不启用就不包。
5.3 什么时候别用它
坦白讲,有几种情况直接用 HttpClient 更合适:
- 整个项目只有一个接口调用,几行代码的事,引入一个库反而多一层认知成本;
- 需要极致的传输层控制:HTTP/3、QUIC、自定义
ConnectCallback做复杂路由、mTLS 细节调优——这些应该在 Handler 层做,库只提供HandlerFactory这个接入点; - 强 AOT / trimming 场景,且用到了
dynamic路径(动态绑定器对裁剪不友好),这时应该只用强类型 API 或者干脆别用; - 团队已有成熟的基础设施封装,再叠一层只会让排查链路更长。
工具是拿来解决问题的,不是拿来统一信仰的。
六、几个实现上的取舍
写这个库的过程中,有几个决定花了不少时间,记下来给同样想造轮子的人参考。
1. 单目标 net6.0,不做多目标
.NET 只保证"高版本运行时能加载低版本程序集",而库用到的 API 在 6.0 全部存在。所以一份 lib/net6.0 就能跑在 6/7/8/9/10/11 上,多目标只会让构建变慢、包变大,没有任何收益。代价是源码里不能出现任何 7/8 专属 API(ObjectDisposedException.ThrowIf、ArgumentException.ThrowIfNullOrWhiteSpace、JsonNamingPolicy.SnakeCaseLower 都不能用),这条线必须自己守住。
不支持 netstandard2.0 的原因很直接:连接池建立在 SocketsHttpHandler 上,而 netstandard2.0 的契约里根本没有这个类型。
2. 不做运行时类型推断
前面说过,data 是字典还是对象,不决定它该被编码成什么。硬猜的结果是"大部分时候对,偶尔错得莫名其妙"。宁可让调用方显式写一次 ContentType,也不要一个"聪明但不可预测"的默认行为。
3. 用 *Set 标记而不是哨兵值
Timeout = 0、MaxContentLength = null 都是有意义的显式配置,不能和"没设置"混为一谈。用哨兵值(比如 -1 表示未设置)会把这类语义堵死,setter 打标记是更干净的解法。
4. 流式响应的租约要持有到流释放
连接池的租约如果只持有到"响应头返回",那么一个 30 分钟的大文件下载期间,该 origin 可能被认为空闲而被驱逐,或者租约统计完全失真。所以 ResponseType.Stream 的租约跟着流走,流 Dispose 才归还。
反过来,非 2xx 的流式响应必须立刻丢弃正文并归还连接——否则一个没人读的错误响应流会长期占住 TCP 连接,这种泄漏在高并发下非常致命。
5. 错误统一包装,但保留 Cause
统一异常类型是为了让上层能写 catch (AxiosException),但如果把底层异常吃掉,排查时就成了黑盒。所以每个包装点都保留 InnerException,并在 ToJson() 里输出结构化字段。
6. 测试用自研 TCP 服务器,而不是 mock
连接复用、并发上限、流式租约这类行为,用 mock 是测不出来的——必须能数清楚"到底建立了几条 TCP"。所以测试项目里写了一个基于 TcpListener 的轻量 HTTP/1.1 Keep-Alive 服务器(零 NuGet 依赖),直接观测连接建立次数和峰值并发。目前 168 个用例,跑在 net6.0 + net8.0 两个目标上,共 336 次执行。
七、现状
- 包名:
AxiosNet,NuGet 可直接安装 - 依赖:零第三方运行时依赖,只用 BCL
- 框架:单目标
net6.0,覆盖 .NET 6 及以上 - 测试:168 个 xUnit 用例 × 2 个运行时 = 336 次执行,全绿
- 示例:
samples/AxiosNet.Simple内置离线演示服务器,覆盖 20 类调用(含 multipart 精确解析、裸流上传、DNS 切换、Newtonsoft.Json 替换) - 仓库:Gitee 主仓,GitHub 只读镜像
跑一遍示例就能看到全部能力,不需要联网、不需要额外服务:
git clone https://gitee.com/itsm/axios-net.git
dotnet run --project samples/AxiosNet.Simple
八、最后
这个库解决的问题不"高级",甚至有点琐碎:拼 URL、设超时、判异常、释放流。但正是这些琐碎的事,在每个项目里重复了几十上百遍,而且每次都可能出一两个隐蔽的 bug。
axios 的价值从来不是"能发请求"——浏览器自带 fetch 也能发。它的价值是把 HTTP 客户端该有的默认行为收进一个稳定的契约里,让业务代码只关心业务。.NET 这边一直缺一个同等定位的东西:HttpClient 是很好的传输层,但它不是产品级的客户端。
AxiosNet 就是照着这个思路做的。如果你也在项目里写过第三版 HTTP 封装,不妨看看它是不是你想要的那个形态。

浙公网安备 33010602011771号