简介:这份PDF资料面向具备一定C#基础的开发者,聚焦HTTP请求中POST方式的数据发送与接收这一常见但易踩坑的环节,帮助读者理清客户端提交表单、调用Web API以及服务端解析请求体的完整实现思路。资源包共1个PDF文件,约36KB,内容以代码示例与要点讲解为主,涵盖HttpClient发送POST请求、StringContent封装键值对数据、服务端通过Request.InputStream读取字节流并解码,以及将参数拆分为键值对集合等关键技巧。目前已有1144人学习下载,说明其在同类网络编程资料中具备一定参考价值。读者可从中获得可直接套用的收发数据代码模板、POST与GET差异的对比理解,以及参数解析与异常处理的排错思路,适合需要快速上手C#网络请求或完善接口调用逻辑的开发人员查阅。
1. 用 C# 的 HttpClient 发 POST,为什么有人三行跑通、有人卡在 415
同一个HttpClient,有人三行代码就把 JSON 推上去了,有人折腾一下午卡在 415 Unsupported Media Type,还有人明明请求发出去了,服务端收到的却是空 body。问题几乎从来不在“POST 会不会写”,而在几个默认行为上:Content-Type没设、body 被序列化成了application/x-www-form-urlencoded却按 JSON 解析、HttpClient被using包着反复 new 导致端口耗尽。这篇笔记就围绕 C# 使用 POST 发送和接收数据这条主线,把HttpClient的请求构造、内容类型、序列化、响应读取、异常排查和连接复用讲透。适合正在对接第三方接口、写内部服务调用、或者被“请求发出去了但对方说没收到”折磨过的 .NET 开发者。下面所有代码基于 .NET 6+ 的System.Net.Http,不依赖任何第三方库也能跑,需要 JSON 序列化时用内置的System.Text.Json。
2. 先把 POST 的请求骨架搭对:HttpClient、HttpRequestMessage 与内容类型
POST 和 GET 最大的区别是它带 body,而 body 的“身份”由Content-Type声明。服务端框架(不管是 ASP.NET Core、Spring 还是 Nginx 后面的业务网关)通常先看这个头,再决定用哪个解析器。头不对,body 再正确也是 415 或 400。所以第一步不是急着写业务字段,而是把请求骨架搭对。
2.1 用 HttpClient 发一个最小可用的 POST
先看一个不依赖任何封装、能直接跑的最小例子。它向一个假想的接口提交 JSON,并读回响应文本。
using System; using System.Net.Http; using System.Text; using System.Text.Json; using System.Threading.Tasks; class Program { // 静态复用:HttpClient 设计为长生命周期对象,不要每次请求都 new private static readonly HttpClient client = new HttpClient { Timeout = TimeSpan.FromSeconds(15) // 默认 100 秒,业务接口一般不需要这么久 }; static async Task Main() { var url = "https://example.com/api/order/create"; // 1. 构造业务对象 var payload = new { orderId = "A20240501", amount = 199.00, remark = "测试下单" }; // 2. 序列化为 JSON 字符串 var json = JsonSerializer.Serialize(payload); // 3. 包装成 StringContent,并显式指定 Content-Type var content = new StringContent(json, Encoding.UTF8, "application/json"); // 4. 发送 POST HttpResponseMessage resp = await client.PostAsync(url, content); // 5. 读取状态码和响应体 Console.WriteLine($"StatusCode: {(int)resp.StatusCode}"); string body = await resp.Content.ReadAsStringAsync(); Console.WriteLine($"Body: {body}"); } }这段代码里真正决定成败的是第 3 步。StringContent的第三个参数就是Content-Type,写成application/json服务端才会按 JSON 解析。很多人图省事写成new StringContent(json),默认类型是text/plain,接口直接返回 415。第 4 步的PostAsync是语法糖,内部等价于构造HttpRequestMessage再SendAsync,简单场景够用。第 5 步先看状态码再看 body,是因为 4xx/5xx 时 body 往往是错误详情,不看会漏掉关键信息。
2.2 需要自定义请求头时改用 HttpRequestMessage
PostAsync只能设 URL 和 content,一旦要加Authorization、X-Request-Id、自定义Accept,就得换成HttpRequestMessage。这是更通用的写法,建议直接养成习惯。
var request = new HttpRequestMessage(HttpMethod.Post, url); // 请求级头:只作用于这一次请求 request.Headers.Add("Authorization", "Bearer <your-token>"); request.Headers.Add("X-Request-Id", Guid.NewGuid().ToString("N")); request.Headers.Accept.ParseAdd("application/json"); // 内容级头:Content-Type 属于 content,不属于 request request.Content = new StringContent(json, Encoding.UTF8, "application/json"); HttpResponseMessage resp = await client.SendAsync(request);这里有个高频翻车点:Content-Type是内容头,必须设在request.Content.Headers上,不能直接request.Headers.Add("Content-Type", ...),否则运行时会抛InvalidOperationException,提示该头必须由内容设置。Authorization这类请求头则相反,设在request.Headers上。分不清这两类头,是新手最常见的报错来源之一。
2.3 表单、JSON、纯文本三种 Content 的选型
不是所有接口都吃 JSON。老系统、支付回调、部分第三方平台仍然要求application/x-www-form-urlencoded。选错类型,服务端要么解析出空字段,要么直接拒绝。
| 场景 | Content 类型 | 构造方式 |
|---|---|---|
| 现代 REST 接口 | application/json | new StringContent(json, Encoding.UTF8, "application/json") |
| 传统表单提交 | application/x-www-form-urlencoded | new FormUrlEncodedContent(dict) |
| 纯文本/XML | text/plain/application/xml | new StringContent(text, Encoding.UTF8, "text/plain") |
| 文件上传 | multipart/form-data | new MultipartFormDataContent() |
表单场景用FormUrlEncodedContent最省事,它自动处理 URL 编码,避免中文和特殊字符出问题:
var form = new Dictionary<string, string> { ["username"] = "test_user", ["password"] = "p@ss word&123" // 含空格和 & ,交给 FormUrlEncodedContent 编码 }; var content = new FormUrlEncodedContent(form);如果手动拼"username=test&password=..."字符串,密码里的&会被服务端当成参数分隔符,字段直接截断。这类问题在联调时特别隐蔽,因为请求确实发出去了,只是服务端收到的值不对。
3. 接收数据不只是 ReadAsStringAsync:状态码、反序列化与流式读取
请求发出去只是上半场,响应怎么读、怎么判、怎么反序列化,决定了这段代码在生产环境里稳不稳。很多人只写一句ReadAsStringAsync就完事,结果遇到 500 时拿到一坨 HTML 错误页,反序列化直接抛异常,日志里什么都看不出来。
3.1 先判 IsSuccessStatusCode,再决定怎么读
HttpResponseMessage不会因为 4xx/5xx 抛异常,它只是把状态码放在那里。必须自己判断,否则会把错误响应当正常数据解析。
HttpResponseMessage resp = await client.SendAsync(request); if (!resp.IsSuccessStatusCode) { // 错误分支:把状态码和原始 body 一起记下来,方便排查 string errBody = await resp.Content.ReadAsStringAsync(); Console.WriteLine($"请求失败 {(int)resp.StatusCode} {resp.ReasonPhrase}"); Console.WriteLine($"错误详情: {errBody}"); return; } // 成功分支:按接口约定的格式反序列化 string okBody = await resp.Content.ReadAsStringAsync(); var result = JsonSerializer.Deserialize<ApiResult>(okBody, new JsonSerializerOptions { PropertyNameCaseInsensitive = true // 服务端字段大小写和 C# 属性不一致时兜底 });PropertyNameCaseInsensitive = true这个选项值得默认加上。服务端返回{"Code":0}而 C# 属性叫code时,不加这个选项反序列化出来就是默认值 0 或 null,看起来“成功”了其实数据全丢,属于典型的玄学 bug。
3.2 用泛型封装一个可复用的 PostJson 方法
业务代码里到处写StringContent和ReadAsStringAsync很啰嗦,封装成泛型方法更清爽。下面这个版本覆盖了序列化、发送、状态判断和反序列化。
public static async Task<T?> PostJsonAsync<T>( HttpClient client, string url, object payload, CancellationToken ct = default) { var json = JsonSerializer.Serialize(payload); using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = content }; using HttpResponseMessage resp = await client.SendAsync(request, ct); string body = await resp.Content.ReadAsStringAsync(ct); if (!resp.IsSuccessStatusCode) { // 抛出带状态码和 body 的异常,调用方一眼能看出问题 throw new HttpRequestException( $"POST {url} 失败: {(int)resp.StatusCode}, body={body}"); } return JsonSerializer.Deserialize<T>(body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); }参数说明:client由外部传入并复用,方法内不负责创建和释放;payload是任意可序列化对象;ct是取消令牌,配合Timeout一起用能在超时或用户取消时及时释放连接。注意request和content都用了using,但client没有——这是刻意的,HttpClient不该在这里释放。
3.3 大响应体用流式读取,别一次性读进内存
如果接口返回的是文件流、大列表或导出数据,ReadAsStringAsync会把整个响应塞进内存,几百 MB 的响应很容易把进程撑爆。这时应该读流。
using HttpResponseMessage resp = await client.SendAsync( request, HttpCompletionOption.ResponseHeadersRead); // 关键:头一到就返回,不等 body resp.EnsureSuccessStatusCode(); await using Stream stream = await resp.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); char[] buffer = new char[8192]; int read; while ((read = await reader.ReadAsync(buffer, 0, buffer.Length)) > 0) { // 分块处理,比如写入本地文件或逐行解析 Console.Write(new string(buffer, 0, read)); }HttpCompletionOption.ResponseHeadersRead是这里的核心参数。默认值是ResponseContentRead,会等整个 body 下载完才返回,大文件场景下表现为“卡住很久然后一次性出结果”。改成ResponseHeadersRead后,响应头一到就能开始处理流,内存占用从“整个响应体”降到“一个缓冲区”。
4. 避坑与排查:POST 联调里最常见的 5 个翻车现场
这一章按“现象 → 原因 → 解决”整理,都是实际联调里反复出现的。遇到问题先对照这里,能省掉大量抓包时间。
4.1 现象:服务端返回 415 Unsupported Media Type
原因:Content-Type没设或设错。new StringContent(json)默认是text/plain,而接口只接受application/json。另一种情况是手动往request.Headers里加Content-Type,运行时直接抛异常。
解决:统一用new StringContent(json, Encoding.UTF8, "application/json"),或者request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json")。记住内容头归 content,请求头归 request。
4.2 现象:服务端收到的字段全是 null 或空
原因:序列化格式和接口预期不一致。常见的是把 JSON 字符串当成表单发,或者属性名大小写、命名策略不匹配(C# 默认 PascalCase,接口要 camelCase)。
解决:确认Content-Type和实际 body 格式一致;序列化时用JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }统一转小写驼峰;反序列化时加PropertyNameCaseInsensitive = true。
4.3 现象:请求偶尔超时,高并发下大量 SocketException
原因:HttpClient被using包着每次 new。每个实例会占用连接,短时间大量创建导致端口耗尽(TIME_WAIT 堆积),表现为“连接被强制关闭”或“无法连接到远程服务器”。
解决:HttpClient设计为长生命周期,注册为单例或静态字段复用。在 ASP.NET Core 里用IHttpClientFactory管理,它能处理 DNS 刷新和连接池。控制台程序里用一个静态HttpClient即可。
4.4 现象:中文提交后服务端收到乱码
原因:编码不一致。StringContent不指定编码时默认可能是 UTF-8,但某些老接口按 GBK 解析;或者请求头里charset和实际编码不符。
解决:显式写Encoding.UTF8,并在Content-Type里带上charset=utf-8。如果对方确实要 GBK,需要先Encoding.GetEncoding("GBK")拿到字节数组,再用ByteArrayContent发送,同时头里声明charset=gbk。
4.5 现象:加了 Authorization 头却仍然返回 401
原因:token 格式不对,或者头加错了位置。有的接口要求Bearer <token>,有的要求直接放 token;有的框架对头名大小写敏感。
解决:先确认接口文档要求的完整格式,Bearer和 token 之间是一个空格;用request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token)这种强类型写法,比手拼字符串更不容易错。抓包确认头确实发出去了,再怀疑 token 本身是否过期。
5. 进阶:连接复用、超时控制与一个可复用的请求封装
把前面的点串起来,落到一个能在项目里直接用的封装上。核心是三件事:连接复用、超时可控、异常可读。
5.1 用 IHttpClientFactory 管理生命周期
在 ASP.NET Core 或通用主机里,推荐用IHttpClientFactory。它内部维护连接池,定期轮换底层 handler,既避免端口耗尽,又能拿到 DNS 更新。
// Program.cs 里注册 builder.Services.AddHttpClient("api", c => { c.BaseAddress = new Uri("https://example.com/"); c.Timeout = TimeSpan.FromSeconds(10); c.DefaultRequestHeaders.Add("Accept", "application/json"); }); // 业务类里注入 public class OrderService { private readonly IHttpClientFactory _factory; public OrderService(IHttpClientFactory factory) => _factory = factory; public async Task<ApiResult?> CreateAsync(object payload) { var client = _factory.CreateClient("api"); // 每次拿到的都是受管理的实例 return await PostJsonAsync<ApiResult>(client, "api/order/create", payload); } }CreateClient返回的HttpClient可以放心用完就丢,底层 handler 由工厂池化,不会造成端口耗尽。这是它和手动new HttpClient最本质的区别。
5.2 超时和取消要分两层设
HttpClient.Timeout是整体超时,覆盖连接、发送、接收全过程。如果还想对单次请求做更细的控制,用CancellationTokenSource。
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); try { var result = await PostJsonAsync<ApiResult>(client, url, payload, cts.Token); } catch (OperationCanceledException) { // 区分是超时取消还是外部取消,日志里记清楚 Console.WriteLine("请求在 5 秒内未完成,已取消"); }注意HttpClient.Timeout触发时抛的是TaskCanceledException(继承自OperationCanceledException),和手动取消的异常类型一样,排查时要结合日志时间判断到底是哪一层触发的。
5.3 一个带重试的 POST 封装
网络抖动导致的偶发失败,重试比直接报错更友好。但 POST 不是幂等的,重试前要确认接口支持幂等(比如带唯一请求号),否则可能重复下单。
public static async Task<T?> PostWithRetryAsync<T>( HttpClient client, string url, object payload, int maxRetry = 3, CancellationToken ct = default) { for (int i = 0; i < maxRetry; i++) { try { return await PostJsonAsync<T>(client, url, payload, ct); } catch (HttpRequestException ex) when (i < maxRetry - 1) { // 只对网络类异常重试,业务 4xx 不重试 Console.WriteLine($"第 {i + 1} 次失败: {ex.Message},准备重试"); await Task.Delay(TimeSpan.FromMilliseconds(300 * (i + 1)), ct); // 退避 } } throw new HttpRequestException($"POST {url} 重试 {maxRetry} 次仍失败"); }退避时间按300ms * (i+1)递增,避免失败时集中重试把服务端压垮。when (i < maxRetry - 1)这个异常过滤器保证最后一次失败直接抛出,不再吞掉。如果接口本身不幂等,把重试去掉,改成记录失败请求人工补偿更稳妥。
我自己的习惯是:任何对外 POST 都先确认三件事——Content-Type 对不对、HttpClient 是不是复用的、超时和重试有没有边界。这三条守住,联调里八成的“玄学问题”都不会出现。希望帮到你。
本文还有配套的精品资源,点击获取