jk's notes
  • 网络 - 笔记

网络 - 笔记

主要命名空间是 System.Net.* 和 System.IO.* 主要内容:

  • HttpClient 来发送 HTTP 请求
  • HttpListener 来实现 HTTP 服务器
  • SmtpClient 来处理邮件
  • Dns 来转换域名与地址
  • TcpClient, UdpClient, TcpListener, 和 Socket 来直接访问转换层与传输层.

处理 FTP 推荐使用 Nuget 包 FluentFTP.

网络架构

下图是基本的网络架构 (细节略)

image-20250331101406943

然后是一个网络术语(缩写术语)表

简写含义备注
DNSDomain Name Service在域名与 IP 地址之间转换.
FTPFile Transfer Protocol网络上收发文件的协议.
HTTPHypertext Transfer Protocol呈现 Web 页面, 运行 web 服务.
IISInternet Information Services
IPInternet Protocol网络层协议, 在 TCP, UDP 之下.
LANLocal Area Network
POPPost Office Protocol
REST
SMTP
TCP
UDP
UNC
URI
URL

地址与端口

介绍 IPv4 和 IPv6 的概念. 重点是怎么表示 EndPoint.

System.Net 命名空间下的 IPAddress 类专门用于描述地址. 结合 IPEndPoint 来, 使用端口后, 可以表示完整的主机地址信息.

var a = IPAddress.Parse("192.168.1.123");
var ep = new IPEndPoint(a, 8080);

URIs

介绍 URI 的结构与基本用法

image-20250331144155590

常用的方式:

  • 构建安全的 URL, 处理路径合并等信息
  • 解析 URL, 可以使用属性来访问各个部分

HttpClient

比较经典的类, 用于完成基本的网络处理, 主要用于替换老的 WebClient, WebRequest, 以及 WebResponse.

简单说就是比较上层的请求处理类, 方便使用, 支持并发, 适用于单元测试与简单的请求处理. 但是不支持请求过程中的进度控制.

该类提供丰富 Get* 系列方法, 可以完成诸多请求:

var res = await new HttpClient().GetStringAsync("http://...");

还有很多系统方法, 其中方法都是异步的.

image-20250331145107702

不同于老的 Web... 类, HttpClient 的实例可以被复用, 允许同时请求, 它是异步的.

HttpClient 的属性比较少, 常用的就 BaseAddress, Timeout 等. 其他的属性配置需要借助于另一个类: HttpClientHandler.

HttpClient 有点类似于剥壳, 其仅提供基础功能, 它联系了实际上工作的对象.

var handler = new HttpClientHandler() { UseProxy = false };
var client = new HttpClient(handler);

对于代理, cookie, 重定向, 鉴权等.

GetAsync 和 响应消息

GetStringAsync(), GetByteArrayAsync(), GetStreamAsync() 都是对 GetAsync() 的封装. 底层为 GetAsync() 方法. 该方法会返回一个响应消息.

var client = new HttpClient();
HttpResponseMessage response = await client.GetAsync("http://...");
response.EnsureSuccessStatusCode();
string html = await response.Content.ReadAsStringAsync();

使用 HttpResponseMessage 可以获得响应状态码与响应头等信息. 默认情况下, 未成功的响应不会引发异常, 除非主动调用 response.EnsureSuccessStatusCode() 方法.

有一个 CopyToAsync() 方法, 可以方便的将响应体写入流中.

using var stream = File.Create("text.html");
await response.Content.CopyToAsync(stream);

除了 GetAsync() 之外, 还有 PostAsync(), PutAsync(), 以及 DeleteAsync().

SendAsync 和 请求消息

比起 GetAsync() 更底层的方法是 SendAsync(). 要使用它, 需要使用 HttpRequestMessage 类.

var client = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Get, "http://...");
var response = await client.SendAsync(request);
...

使用该方式可以自定义更多请求的细节.

上传数据与 HttpContent

有了 HttpRequestMesage 后, 可以为 Content 属性赋值来上传数据. Content 的类型是 HttpContent, 它是一个抽象类. 可以使用的内置类型有 (支持自定义):

  • ByteArrayContent
  • StringContent
  • FormUrlEncodedContent (用于表单提交数据)
  • StreamContent

例如:

...
request.Content = new StringContent("This is a test");
...

HttpMessageHandler

之前描述过, HttpClient 只是一个壳, 具体的配置在 HttpClientHandler 中, 而 HttpClientHandler 又是 HttpMessageHandler 的子类. 它也是一个抽象类:

public abstract class HttpMessageHandler: IDisposable {
  protected internal abstract Task<HttpResponseMessage> SendAsync(
    HttpRequestMessage request, 
    CancellationToken cancellationToken);
  public void Dispose();
  protected virtual void Dispose (bool disposing);
}

而 HttpClient 中调用的 SendAsync, 就是这个方法.

由于 HttpMessageHandler 足够简单, 它更容易进行扩展, 完成既定任务.

单元测试与模拟数据

可以对 HttpMessageHandler 进行派生来 mocking 数据, 来辅助单元测试

class MockHandler: HttpMessageHandler {
  Func<HttpRequestMessage, HttpResponseMessage> _responseGenerator;
  public MockHandler(
    Func<HttpRequestMessage, HttpResponseMessage> responseGenerator
  ) {
    _responseGenerator = responseGenerator;
  }
  protected override Task<HttpResponseMessage> SendAsync(
  	HttpRequestMessage request,
    CancellationToken cancellationToken
  ) {
    cancellationToken.ThrowIfCancellationRequested();
    var response = _responseGenerator(request);
    response.RequestMessage = request;
    return Task.FromResult(response);
  }
}

这个方法定义了如何请求与响应, 用这个方法可以预定义响应结果, 然后基于该结果来进行断言, 完成单元测试. 因为如何响应是在构造函数中定义的, 由使用者定义, 这样可以应用的场景更多.

var mocker = new MockHandler(request => new HttpResponseMessage (HttpStatusCode.OK) {
  Content = new StringContent("...")
});
var client = new HttpClient(mocker);
...

使用 DelegatingHandler 来处理链式处理

可以派生 DelegatingHandler, 然后在消息处理中调用另一个处理, 形成一个链式处理. 使用这个方法可以在内部实现自定义鉴权, 压缩, 以及自定义加密协议等. 下面是一个日志处理实例:

class LoggingHandler: DelegatingHandler {
  public LoggingHandler(HttpRequestHandler nextHandler) {
    InnerHandler = nextHandler;
  }
  public async override Task<HttpResponseMessage> SendAsync(
    HttpRequestMessage request,
    CancellationToken cancellationToken
  ) {
    Console.WriteLine("请求 " + request.RequestUri);
    var response = await base.SendAsync(request, cancellationToken);
    Console.WriteLine("获得响应 " + response.StatusCode);
    return response;
  }
}

这里重写后又使用 async 修饰是合法的.

这里还有一个更好的办法, 就是不去写死 Console, 而是利用构造器传入 Action<T> 来进行日志的输出, 做到可定制输出方式.

代理 (Proxy)

首先作者简要介绍了一下代理服务器的含义与作用.

  • 它是一个中间服务
  • HTTP 请求会被路由到这里

常常在企业中会指定部分人员可访问某特定环境, 可以通过代理服务器来完成.

计算网络还是需要弄一下.

基本用法:

  • 创建 WebProxy 实例, 必要时配置鉴权.

  • 创建 HttpClientHandler, 配置 proxy.

  • 创建 HttpClient 使用对应 HttpClientHandler 即可.

var proxy = new WebProxy("192.168.10.49", 808);
proxy.Credentials = new NetworkCredential("username", "password", "domain");

var handler = new HttpClientHandler() { Proxy = proxy };
var client = new HttpClient(handler);
...

如果需要禁用代理, 可以设置 handler.UseProxy = false, 不需要清空 Proxy 属性.

如果在 NetworkCredential 中设置了域, 那么会使用基于 win 的鉴权方案. 要使用当前用户, 则为代理的 Credentials 属性设置静态值 CredentialCache.DefaultNetworkCredentials.

为了避免反复设置 Proxy 属性, 有一个全局配置方法:

HttpClient.DefaultWebProxy = myWebProxy;

鉴权

可以按照下面方法为 HttpClient 提供用户名与密码:

string username = "myuser";
string password = "mypassword";

var handler = new HttpClientHandler();
handler.Credentials = new NetworkCredential(username, password);
var client = new HttpClient(handler);
...

这部分逻辑暂略, 不太熟悉关于这部分背后密码控制的流程

CredentialCache

略

使用请求头处理鉴权

直接在请求头中设置:

var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = 
  new AuthorizationHeaderValue(
    "Basic", 
    Convert.ToBase64(Encoding.UTF8.GetBytes("username:password"))
  );
...

该策略对基于请求头的鉴权均有效, 例如 OAuth 等.

var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = 
  new AuthorizationHeaderValue("Bearer", token);
...

Header

作者简要介绍了一下 Header 的结构, 它就是键值对, 以及提示了 .NET 提供了标准的 Header 集合可使用. 该集合提供强类型的支持, 也可以使用自定义头.

只需要通过 DefaultRequestHeaders 属性即可访问.

var client = new HttpClient();

client.DefaultRequestHeaders.UserAgent.Add(
  new ProductInfoHeaderValue("VS", "2022")
);

client.DefaultRequestHeaders.Add("CustomHeader", "VS22");

HttpClient 的 DefaultRequestHeaders 属性会作用域所有的请求; 如果需要单个请求控制 Header, 可以使用 HttpRequestMessage 的 Headers 属性.

QueryString

重点是对参数值进行序列化. 主要是 Uri 提供的静态方法 EscapeDataString(), 对于 URL 依旧是字符串拼接.

还有一个 EscapeUriString 方法.

上传数据

主要是模拟表单请求. 涉及到的类 FormUrlEncodedContent, 该类将键值对封装成表单数据.

...
var dic = new Dictionary<string, string> { ... };
var values = new FormUrlEncodedContent(dic);
var response = await client.PostAsync(uri, values);
...

Cookies

简要介绍了 Cookie 的结构, 即键值对. 不过 每一个 Cookie 值也是带有属性的, 涉及到可访问性, 过期时间等.

然后介绍了 Cookie 的作用, 就是放服务器知道是同一个客户端访问了.

默认 HttpClient 会忽略从服务器返回的 Cookie 数据. 要使用 Cookie, 需要创建 CookieContainer 对象.

var cc = new CookieContainer();
var handler = new HttpClientHandler();
handler.CookieContainer = cc;
var client = new HttpClient(handler);

手动往 Cookie 中添加数据:

var c = new Cookie("Key", "value", ...);
cc.Add(c);

编写 Http 服务

基于 .NET 6 编写 HTTP 服务, 使用 Top-Level 的最佳方法是使用 minimal API.

var app = WebApplication.CreateBuilder().Build();
app.MapGet("/", () => "Hello World");
app.Run();

也可以使用 HttpListener 来实现. 这是一个底层方法. 下面是一个案例, 监听 51111 端口, 监听单个固定地址, 请求后响应一行消息:

using var server = new SimpleHttpServer();
// 请求
Console.WriteLine(
  await new HttpClient()
    .GetStringAsync("http://localhost:51111/MyApp/Request.txt")
);

class SimpleHttpServer {
  readonly HttpListener listener = new HttpListener();
  public SimpleHttpServer() => ListenAsync();
  
  async void ListenAsync() {
    listener.Prefixes.Add("http://localhost:51111/MyApp/");
    listener.Start();
    
    // 等待客户端请求
    HttpListenerContext context = await listener.GetContextAsync();
    
    // 响应请求
    string msg = "你请求的是 " + content.Request.RawUrl;
    context.Response.ContentLength64 = Encoding.UTF8.GetByteCount(msg);
    content.Response.StatusCode = (int)HttpStatusCode.OK;
    
    using Stream s = context.Response.OutputStream;
    using StreamWriter writer = new StreamWriter(s);
    await writer.WriteAsync(msg);
  }
  
  public void Dispose() => listener.Close();
}

一次性请求.

在 Win 中 HttpListener 内部没有使用 Socket 对象, 而是使用的 Windows HTTP Server API (系统调用). 这一特点允许同一个 IP, 同一个端口, 监听不同的地址. 对特殊的企业防火墙的配置有一定优势 (不用单独开启端口).

然后作者对代码进行了简要解释 (监听, 阻塞, 请求, 响应等), 并对请求处理过程做了介绍.

要处理完整的 HTTP 请求, 至少要写入 Content-Length 和 StatusCode.

下面是一个简单的 web page server, 采用异步的方式:

using System;
using System.IO;
using System.Net;
using System.Text;
using System.Threading.Tasks;

class WebServer {
  HttpListener _listener;
  string _baseFolder; // web 页面所在目录
  
  public WebServer(string uriPrefix, string baseFolder) {
    _listener = new HttpListener();
    _listener.Prefixes.Add(uriPrefix);
    _baseFolder = baseFolder;
  }
  
  public async void Start() {
    _listener.Start(); // 开启, 然后进入循环, 开始监听, 阻塞
    while (true) {
      try {
        var context = await _listener.GetContextAsync();
        Task.Run(() => ProcessRequestAsync(context));
      }
      catch(HttpListenerException) { break; }       // 停止监听
      catch(InvalidOperationException) { break; }   // 停止监听
    }
  }
  
  public void Stop() => _listener.Stop();
  
  async void ProcessRequestAsync(HttpListenerContext context) {
    try {
      string fileName = Path.GetFileName(context.Request.RawUrl);
      string path = Path.Combine(_baseFolder, fileName);
      byte[] msg;
      if (!FIle.Exist(path)) {
        Console.WriteLine("资源未找到: " + path);
        context.Response.StatusCode = (int)HttpStatusCode.NotFound;
        msg = Encoding.UTF8.GetBytes("Sorry, that page dose not exist");
      } else {
      	constext.Response.StatusCode = (int)HttpStatusCode.Ok;
        msg = File.ReadAllBytes(path);
      }
      context.Response.ContentLength64 = msg.Length;
      using (Stream s = context.Response.OutputStream) 
        await s.WriteAsync(msg, 0, msg.Length);
    } catch (Exception ex) {
      Console.WriteLine("请求错误: " + ex);
    }
  }
}

Tips

需要注意的是, 如果端口被占用的情况下, HttpListener 无法启动, 除非占用端口的也采用 Windows HTTP Server API.

为了提升性能, 这里使用了异步方法. 如果是在 UI 开发中, 可能会阻塞 UI 线程. 可以考虑: Task.Run(Start). 或者在调用 GetContextAsync() 之后调用 ConfigureAwait(false).

需要注意的是, 虽然使用异步方法, 但是直到 await 才会处理异步.

使用 DNS

静态类 Dns 封装了 DNS 操作, 主要有两个方法:

  • GetHostAddress, 将域名转换为 IP 地址.
  • GetHostEntry, 将 IP (字符串, 或 IPAddress) 转换为 域名.

在使用 WebRequest 或 TcpClient 等类时, 域名是自动转换为 IP 的. 如果一个处理中有大量请求, 可以提前将域名转换为 IP, 这样避免每次的转换, 性能会更好, 特别是处理传输层时 (例如: TcpClient, UdpCLient, 以及 Socket).

Dns 类也提供了 异步版本的方法.

使用 SmtpClient 发送邮件

使用 System.Net.Mail 下的 SmtpClient, 可以通过 SMTP, Simple Mail Transfer Protocol, 简单邮件传输协议来发送邮件. 要发送邮件, 只需要给 SmtpClient 实例的 Host 属性设置 SMTP 服务器的地址, 然后调用 Send 即可.

SmtpClient client = new SmtpClient();
client.Host = "mail.myserver.com";
client.Send("from@adomain.com", "to@adomain.com", "subject", "body");

更复杂的邮件信息可以通过构造 MailMessage 来实现, 包括附件.

...
MailMessage mm = new MailMessage();

mm.Sender = new MailAddress("kay@domain.com", "Kay");
mm.From = new MailAddress("kay@domain.com", "Kay");
mm.To.Add(new MailAddress("bob@domain.com", "Bob"));
mm.CC.Add(new MailAddress("dan@domain.com", Dan));

mm.Subject = "Hello!";
mm.Body = "Hi there. Here's the photo";
mm.IsBodyHtml = false;
mm.Priority = MailPriority.High;

Attachment a = new Attachment(
  "photo.jpg",
  System.Net.Mime.MediaTypeNames.Image.Jpeg
);
mm.Attachments.Add(a);

client.Send(mm);

大多数 SMTP 服务都在互联网环境下, 连接都需要鉴权:

var client = new SmtpClient("smtp.myisp.com", 587) {
  Credentials = new NetworkCredential("username", "password"),
  EnableSsl = true
};
...

通过修改 DeliveryMethod 属性, 可以设置使用 IIS 来发送邮件, 或将邮件内容写入 .eml 文件中. 这在开发中会很有用.

SmtpClient client = new SmtpClient();
client.DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory;
client.PickupDirectoryLocation = @"c:\mail";

使用 TCP

Last Updated:
Contributors: jk