摘要:很多人把这两个层面的东西混在一起答"GET 和 POST 有什么区别"。一边是 HTTP协议层的语义契约(GET/POST/PUT/DELETE),一边是 .NET客户端 API 的调用方式(GetAsync/PostAsync/PutAsync/DeleteAsync/SendAsync)。前者是交通法规,后者是方向盘和挡位。本文把两层彻底切开,并给出一张"五个便捷方法到底差在哪"的对称表——你会发现它们其实并不对称。
一、先分清你在说哪一层
层次 | 主角 | 回答的问题 | 谁在乎 |
|---|---|---|---|
协议层 |
| 这个请求想对资源做什么 | 服务端、网关、CDN、浏览器、代理 |
API 层 |
| 我在 C# 里怎么写这段代码 | 只有你和编译器 |
一句话点破:GetAsync之所以叫 Get,只是因为它往请求行里写了GET这个单词。 它不负责"保证幂等""保证可缓存"——那些是协议层的事,由服务端和中间设备决定。
打个比方:
- 协议层 = 寄快递时选的"寄件 / 退货 / 代收货款"。快递公司看到类型才知道能不能保价、能不能撤回、要不要记账。
- API 层 = 你在 App 上点哪个按钮。按钮长得不一样,但填完信息最终都走同一个下单接口。
在 .NET 里,那个"同一个下单接口"就是SendAsync。
二、第一层:HTTP 协议层的 GET / POST / PUT / DELETE
2.1 四个动词的语义契约
方法 | 语义 | 安全 Safe | 幂等 | 有 Body | 可缓存 | 常用成功码 |
|---|---|---|---|---|---|---|
GET | 获取资源表示 | ✅ | ✅ | 规范上无 | ✅ | 200 |
POST | 创建资源 / 执行动作 | ❌ | ❌ | ✅ | ❌ | 201 / 200 |
PUT | 整体替换资源 | ❌ | ✅ | ✅ | ❌ | 200 / 204 |
DELETE | 删除资源 | ❌ | ✅ | 一般无 | ❌ | 204 / 200 |
PATCH | 局部更新 | ❌ | 视实现 | ✅ | ❌ | 200 / 204 |
两个定义值得咬文嚼字:
- 安全(Safe):不产生服务端副作用,只"读"。不代表没日志、没计费,指的是不改变资源状态。
- 幂等(Idempotent):发 1 次和发 N 次,资源最终状态一致。
PUT /products/1提交同一对象 3 次,结果和第 1 次一样 → 幂等DELETE /products/1第一次返回 204,第二次返回 404 ——这也是幂等的,因为"商品不存在"这个状态没变。幂等 ≠ 返回码相同POST /orders发两次就是两张订单 → 非幂等
2.2 为什么这套约定真的重要?
因为中间设备会当真。你的请求要穿过浏览器、企业代理、CDN、API 网关、负载均衡:
- 只有 GET 会被浏览器 / CDN缓存
- 只有幂等方法(GET / PUT / DELETE)在网络抖动后能被自动重试
- 网关的 WAF、限流、审计规则通常按方法区分策略
- 只有非安全方法才会触发 CORS预检(Preflight)
所以"用 POST 干查询"能跑通,代价是:丢掉缓存能力、丢掉自动重试、语义只能靠文档解释。
三、第二层:HttpClient API 层
3.1 真相:所有便捷方法最后都汇聚到 SendAsync
GetAsync / GetStringAsync / GetByteArrayAsync / GetStreamAsync PostAsync / PutAsync / PatchAsync / DeleteAsync │ ▼ 全部是语法糖:内部构造 HttpRequestMessage HttpClient.SendAsync(HttpRequestMessage, ...) │ ▼ HttpMessageHandler 管道(DelegatingHandler 链 → SocketsHttpHandler) │ ▼ 网络GetAsync(url)在语义上几乎等价于:
using var req = new HttpRequestMessage(HttpMethod.Get, url); using var resp = await http.SendAsync(req);微软官方文档也是这么归类的——SendAsync那一栏写的是"USER SPECIFIED"(任意 HttpMethod),其他方法各自对应一个固定动词。
3.2 逐个看:五个方法各自的签名与限制
①GetAsync—— 只有它支持HttpCompletionOption
GetAsync(string requestUri) GetAsync(Uri requestUri) GetAsync(string requestUri, HttpCompletionOption completionOption) // ✅ 独有 GetAsync(string requestUri, CancellationToken ct) GetAsync(Uri requestUri, HttpCompletionOption completionOption, CancellationToken ct) // ... 共 8 个重载,覆盖 string/Uri × 有无 completionOption × 有无 ct注意:GetAsync没有接受HttpContent的重载——这是故意的,协议层就说 GET 不该有 body。
GET 专属的快捷通道(System.Net.Http内置):
var text = await http.GetStringAsync("api/products/1"); // → string var bytes = await http.GetByteArrayAsync("api/products/1/img"); // → byte[] await using var s = await http.GetStreamAsync("api/report.csv");// → Stream var dto = await http.GetFromJsonAsync<ProductDto>("api/products/1"); // ← System.Net.Http.Json 扩展②PostAsync/ ③PutAsync—— 签名几乎一模一样
PostAsync(string requestUri, HttpContent content) PostAsync(string requestUri, HttpContent content, CancellationToken ct) PutAsync (string requestUri, HttpContent content) PutAsync (string requestUri, HttpContent content, CancellationToken ct)它们都只有 4 个重载(string/Uri × 有无 ct),没有HttpCompletionOption版本。 这意味着:POST/PUT 想要流式读取响应,只能走SendAsync。
HttpContent是抽象类,常见实现:
var json = new StringContent(payload, Encoding.UTF8, "application/json"); var json2 = JsonContent.Create(new { name = "显示器", price = 1299 }); // 推荐,自动带 Content-Type var form = new FormUrlEncodedContent(new[] { new KeyValuePair<string,string>("k","v") }); var file = new StreamContent(fileStream); var multi = new MultipartFormDataContent(); // 上传不想手动包装?用System.Net.Http.Json的扩展(.NET 7 补齐了 PATCH):
await http.PostAsJsonAsync("api/products", dto); await http.PutAsJsonAsync("api/products/1", dto); await http.PatchAsJsonAsync("api/products/1", new { price = 459 });⚠️
PostAsJsonAsync这类方法不在HttpClient类上,是System.Net.Http.Json命名空间下的扩展方法。找不到方法时先检查using。
④DeleteAsync—— 最"残缺"的那个
DeleteAsync(string requestUri) DeleteAsync(Uri requestUri) DeleteAsync(string requestUri, CancellationToken ct) DeleteAsync(Uri requestUri, CancellationToken ct)没有任何接受HttpContent的重载。 微软文档的官方说法是:
因为 HTTP DELETE 请求通常不含请求体,
DeleteAsync方法不提供接受HttpContent实例的重载。
想给 DELETE 带 body(比如批量删除)?只能绕道SendAsync:
using var req = new HttpRequestMessage(HttpMethod.Delete, "api/products/bulk") { Content = JsonContent.Create(new { ids = new[] { 1, 2, 3 }, reason = "过期清理" }) }; using var resp = await http.SendAsync(req);顺带提醒:部分服务器(Nginx、IIS 某些版本)会直接丢弃 DELETE 的 body,这是服务器行为,不是 C# 的锅。批量删除更稳妥的设计是
POST /api/products/bulk-delete。
⑤PatchAsync—— 后来才补上的
PatchAsync(string requestUri, HttpContent content) PatchAsync(string requestUri, HttpContent content, CancellationToken ct)从.NET Core 2.1 / .NET Standard 2.1 开始才有。在此之前大家只能手写new HttpRequestMessage(HttpMethod.Patch, url)。它的PatchAsJsonAsync扩展更要等到.NET 7。
⑥SendAsync—— 唯一的完整出口
SendAsync(HttpRequestMessage request) SendAsync(HttpRequestMessage request, CancellationToken ct) SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption) SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken ct)3.3 关键:五个方法其实不对称
这张表是全文最值钱的部分——很多人以为"五个方法只是名字不同",其实能力差别很大:
方法 | 能带 Body | 能指定 | 能设逐请求 Header | 能自定义方法/版本/Options |
|---|---|---|---|---|
| ❌ 无重载 | ✅唯一有 | ❌ | ❌ |
| ✅ | ❌ | ❌ | ❌ |
| ✅ | ❌ | ❌ | ❌ |
| ✅ | ❌ | ❌ | ❌ |
| ❌无重载 | ❌ | ❌ | ❌ |
| ✅ | ✅ | ✅ | ✅ |
由此得出三条实战结论:
- 想让 POST/PUT/PATCH 的响应流式读取 → 必须用
SendAsync(它们没有HttpCompletionOption重载) - 想给 DELETE 带 body → 必须用
SendAsync - 想给任何请求加逐请求 Header(Authorization、TraceId、签名)→ 必须用
SendAsync
3.4SendAsync独占的六种能力
能力 1:任意 HTTP 方法(含自定义)
using var req = new HttpRequestMessage(new HttpMethod("REPORT"), url); // WebDAV.NET 10 起还多了一等公民HttpMethod.Query(RFC 10008 定义的"带 body 的安全查询方法"):
using var q = new HttpRequestMessage(HttpMethod.Query, "api/products/search") { Content = JsonContent.Create(new ProductFilter(Skus, MaxPrice)) }; using var r = await http.SendAsync(q);能力 2:逐请求 Header(而非全局 Header)
// ❌ 并发下互相污染,且 token 无法中途刷新 http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); // ✅ using var req = new HttpRequestMessage(HttpMethod.Get, "api/me"); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); req.Headers.Add("X-Request-Id", requestId);规则:所有请求都一样的(User-Agent、Accept-Language)放DefaultRequestHeaders;会变的(Authorization、TraceId、Tenant-Id)必须放request.Headers。
能力 3:HttpCompletionOption—— 决定"什么时候返回"
// 默认 ResponseContentRead:整个响应体下载进内存后 await 才返回 using var resp = await http.GetAsync(url); // ResponseHeadersRead:收到响应头就返回,响应体还在网络上流着 using var resp2 = await http.SendAsync( new HttpRequestMessage(HttpMethod.Get, url), HttpCompletionOption.ResponseHeadersRead); if (resp2.Content.Headers.ContentLength > 100 * 1024 * 1024) throw new InvalidOperationException("响应过大,拒绝下载"); await using var stream = await resp2.Content.ReadAsStreamAsync(); await stream.CopyToAsync(fileStream); // 大文件直接落盘,不进内存差异的本质:默认的ResponseContentRead会先把整个响应缓冲进一个MemoryStream,好处是连接能立刻归还连接池,坏处是大 payload 时内存陡增;ResponseHeadersRead跳过这层缓冲,直接拿到 socket 上的流。
⚠️ .NET 10 起浏览器环境(WASM / Blazor)默认开启响应流式,需要
HttpCompletionOption.ResponseHeadersRead语义的行为成了默认,ReadAsStreamAsync()返回的不再是MemoryStream—— Blazor 代码里的同步流操作升级后会报错。
能力 4:HttpRequestMessage.Options—— 给管道传私有参数
static readonly HttpRequestOptionsKey<bool> SkipAudit = new("SkipAuditLog"); using var req = new HttpRequestMessage(HttpMethod.Post, "api/users/export"); req.Options.Set(SkipAudit, true); // DelegatingHandler 里能读到能力 5:控制 HTTP 版本与策略
using var req = new HttpRequestMessage(HttpMethod.Get, url) { Version = new Version(2, 0), VersionPolicy = HttpVersionPolicy.RequestVersionOrLower, };能力 6:让DelegatingHandler管道统一拦截
public class TimingHandler : DelegatingHandler { protected override async Task<HttpResponseMessage> SendAsync( HttpRequestMessage request, CancellationToken ct) { var sw = Stopwatch.StartNew(); try { return await base.SendAsync(request, ct); } finally { Log($"{request.Method} {request.RequestUri} {sw.ElapsedMilliseconds}ms"); } } }DelegatingHandler只能重写SendAsync——你的自定义中间件(认证刷新、重试、审计、签名、Mock)天然围绕HttpRequestMessage工作。业务代码统一走SendAsync,中间件才能一视同仁地拦截所有请求。
四、两层对照速查表
维度 | 协议层 GET/POST/PUT/DELETE | API 层 GetAsync/PostAsync/PutAsync/DeleteAsync/SendAsync |
|---|---|---|
本质 | 请求行里的动词字符串 | C# 的方法调用 |
约束对象 | 服务端 + 所有中间设备 | 只有你的编译期 |
幂等性 | 由协议定义 | API不关心,也不检查 |
谁能看到 | 抓包、网关日志都能看到 | 只有源码里有 |
能否自定义 | 可以,任意合法 token | 只有 |
性能差异 | —— | 没有,便捷方法内部就是 |
五、六个高发误解
❌ 误解 1:用SendAsync就能"绕开" GET 的语义
不能。你写HttpMethod.Get,线上报文里就是GET,服务端和 CDN 该怎么对待还是怎么对待。换 API 不改变协议层语义,就像换个牌子的方向盘不改变交通法规。
❌ 误解 2:GetAsync不能带 Body,那就SendAsync+ GET Body
技术上SendAsync确实能往 GET 里塞Content,但 RFC 9110 明确说GET 的 body 没有通用语义,很多代理会静默丢弃,还可能被当成请求走私攻击拦截。正确做法是POST /search或(.NET 10 起)QUERY,而不是给 GET 塞 body。
❌ 误解 3:PostAsync非幂等是 HttpClient 决定的
恰恰相反:PostAsync只是往报文里写了POST。非幂等是HTTP 规范对 POST 的定义。你完全可以用PostAsync打一个幂等接口(服务端做了去重),也可以用SendAsync(HttpMethod.Get, ...)打一个改数据的接口(那是你的锅)。
❌ 误解 4:HttpRequestMessage可以复用
不能。 第二次发送会抛:
InvalidOperationException: The request message was already sent. Cannot send the same request message multiple times.源码层面是HttpClient.CheckRequestMessage调用了request.MarkAsSent(),已发送过的会被拦下。
有趣的反直觉点:为什么 Polly /Microsoft.Extensions.Http.Resilience的重试能重发同一个消息?因为校验只发生在HttpClient.SendAsync这一层,管道内部的DelegatingHandler调base.SendAsync不再校验。所以你自己写重试循环必须新建HttpRequestMessage:
// ❌ 会炸 var req = BuildRequest(); for (int i = 0; i < 3; i++) await http.SendAsync(req); // ✅ 每次新建 for (int i = 0; i < 3; i++) { using var req = BuildRequest(); // 工厂方法,每次全新 var resp = await http.SendAsync(req); if (resp.IsSuccessStatusCode) return resp; }同理:配了
AddStandardResilienceHandler()的重试策略时,请求体优先用StringContent/ByteArrayContent/JsonContent,不要用StreamContent —— 流只能读一次,重试第二次拿不到内容。
❌ 误解 5:SendAsync比GetAsync快
完全一样。GetAsync内部就是构造HttpRequestMessage再调SendAsync,多出来的只有一次对象分配,可忽略。SendAsync的价值是控制粒度,不是性能。真正影响性能的是:复用HttpClient(或IHttpClientFactory)、ResponseHeadersRead流式读取、SocketsHttpHandler连接池配置。
❌ 误解 6:便捷方法会自动抛异常
不会。GetAsync/PostAsync等在收到 4xx / 5xx 时返回正常的HttpResponseMessage,不会抛异常。HTTP 状态码不是"错误",是"结果"。要抛异常得显式调EnsureSuccessStatusCode():
using var resp = await http.GetAsync(url); resp.EnsureSuccessStatusCode(); // 非 2xx 时抛 HttpRequestException var dto = await resp.Content.ReadFromJsonAsync<ProductDto>();六、选型决策:到底用哪个?
要发请求 │ ├─ 标准动词 + 响应体不大 + 无需特殊 header │ └─► 便捷方法:GetFromJsonAsync / PostAsJsonAsync / PutAsJsonAsync / DeleteAsync │ (代码最短,意图最清晰) │ ├─ 满足以下任一 → 必须 SendAsync │ · 方法非标准(QUERY / REPORT / 自定义) │ · 逐请求 header(Authorization、TraceId、签名) │ · POST/PUT/PATCH 也要 ResponseHeadersRead 流式读取 │ · DELETE 要带 body │ · 指定 HTTP 版本、设置 Options │ · 需要统一出口让 DelegatingHandler 拦截所有请求 │ └─ 无论哪个: HttpClient 必须复用(单例 或 IHttpClientFactory) HttpRequestMessage 每次新建,绝不复用 记得 EnsureSuccessStatusCode(),它不会自动抛七、一页纸总结
协议层(GET/POST/PUT/DELETE)是给机器看的契约:
- 查用 GET(安全+幂等+可缓存),增用 POST(非幂等),全改用 PUT(幂等),部改用 PATCH,删用 DELETE(幂等),动作型用 POST
- 用对动词,你免费获得缓存、自动重试、可发现性三样能力
API 层(GetAsync/PostAsync/PutAsync/DeleteAsync/SendAsync)是给人和编译器看的语法糖:
- 五个便捷方法全部是
SendAsync的包装,方法名只决定报文里那个动词字符串 - 它们并不对称:只有
GetAsync有HttpCompletionOption重载;DeleteAsync没有任何带 body 的重载;五个方法都无法设置逐请求 header SendAsync是唯一完整出口:任意方法、逐请求 header、流式读取、HTTP 版本、Options、与DelegatingHandler管道无缝协作HttpRequestMessage一次性、不可复用;HttpClient必须复用
记住这两句就不会再混:
- 协议层决定"这个请求该不该被缓存、被重试"——你说了不算。
- API 层决定"这段代码好不好读、好不好扩展"——这个你说了算。