.Net 5 网络改进

起因

.Net 5在2020年11月发布,在网络协议栈这一块进行改进和优化,主要包括Http/Socket及网络相关的安全性, 本文主要是根据: .NET 5 Networking Improvements 

在.Net 5 网络这一块进行了改进,主要包括Http/socket/及网络相关的安全性

HTTP更好的错误处理

在.Net Core 3.1中,对Http这一块进行很多改进,收到很多请求要求对HttpClient增加区分 请求超时和取消Http请求 功能.最初需要自定义CancellationToken才可以实现.
class Program
{
    private static readonly HttpClient _client = new HttpClient()
    {
        Timeout = TimeSpan.FromSeconds(10)
    };

    static async void Main(string[] args)
    {
        var cts = new CancellationTokenSource();
        try
        {
            using var response = await _client.GetAsync("http://localhost:5000/sleepFor?secons=100", cts.Token);
        }
        catch (TaskCanceledException e) when (cts.IsCancellationRequested)
        {
            Console.WriteLine("取消请求:" + e.Message);
        }
        catch (TaskCanceledException e)
        {
            Console.WriteLine("请求超时:" + e.Message);
        }
    }
}

在这次调整改进,为了兼容性,可以通过TaskCanceledException过滤内部异常,来判断是否是超时.

class Program
{
    private static readonly HttpClient _client = new HttpClient()
    {
        Timeout = TimeSpan.FromSeconds(10)
    };

    static async void Main(string[] args)
    {
        var cts = new CancellationTokenSource();
        try
        {
            using var response = await _client.GetAsync("http://localhost:5000/sleepFor?secons=100", cts.Token);
        }
        catch (TaskCanceledException e) when (e.InnerException is TimeoutException)
        {
            Console.WriteLine("请求超时:" + e.Message);
        }
        catch (TaskCanceledException e)
        {
            Console.WriteLine("取消请求:" + e.Message);
               
        }
    }
}

另一个改进就是在HttpRequestException添加HttpStatusCode,在请求响应时调用EnsureSuccessStatusCode,在异常时,可以通过HttpStatusCode进行判断和处理.

class Program
{
    private static readonly HttpClient _client = new HttpClient();

    static async void Main(string[] args)
    {
        try
        {
            using var response = await _client.GetAsync("http://localhost:5000/doesNotExists");
            response.EnsureSuccessStatusCode();

        }
        catch (HttpRequestException e) when (e.StatusCode == HttpStatusCode.NotFound)
        {
            Console.WriteLine("找不到doesNotExists:" + e.Message);
        }
    }
}

在HttpClient的辅助方法,如GetStringAsync/GetByteArrayAsync/GetStreamAsync没有返回HttpResponseMessage,而是在内部调用EnsureSuccessStatusCode,所以这几个辅助方法,不用再次调用EnsureSuccessStatusCode.

class Program
{
    private static readonly HttpClient _client = new HttpClient();

    static async void Main(string[] args)
    {
        try
        {
            //GetStringAsync/GetByteArrayAsync/GetStreamAsync不需要调用EnsureSuccessStatusCode
            //这几个方法内部都已经调用过了EnsureSuccessStatusCode
            using var stream = await _client.GetStreamAsync("http://localhost:5000/doesNotExists");
        }
        catch (HttpRequestException e) when (e.StatusCode == HttpStatusCode.NotFound)
        {
            Console.WriteLine("找不到doesNotExists:" + e.Message);
        }
    }
}

HttpRequestException增加新的外部可以使用的构造函数,可以手动创建带有StatusCode的异常.

class Program
{
    private static readonly HttpClient _client = new HttpClient();

    static async void Main(string[] args)
    {
        try
        {
            using var response = await _client.GetAsync("http://localhost:5000/doesNotExists");
            if (response.StatusCode >= HttpStatusCode.BadRequest)
            {
                //主动将大于400的状态码,进行抛出异常
                throw new HttpRequestException("请求异常", inner: null, response.StatusCode);
            }
        }
        catch (HttpRequestException e) when (e.StatusCode == HttpStatusCode.NotFound)
        {
            Console.WriteLine("找不到doesNotExists:" + e.Message);
        }
    }
}

上边这些改进,都是社区进行改进的.

统一的Http跨平台实现

在.Net Core早期Http实现是要依赖WinHttpHandler(Windows)和CurlHandler(Linux/Mac).
  1. WinHttpHandler 是基于Windows的WinHttp
  2. CurlHandler 是基于curl库,curl库可以在Linux/Unix/Mac运行的.
由于这两个组件都存在依赖系统的特性,很难实现跨平台的统一的.为此我们在.Net Core 2.1加入了SocketHttpHandler(托管的,纯c#的实现),随着SocketHttpHandler可靠性也越来越好,在.Net 5中我们决定在System.Net.Http.dll移除依赖系统的实现, WinHttpHandler改为独立的Nuget包,可以在项目中单独添加使用.

#引入WinHttpHandler库
dotnet add package system.net.http.winhttphandler
class Program
{
    //WinHttpHandler 只支持Windows,如非必要的还是尽量不要使用WinHttpHandler
    //CurlHandler 代码被移除,没有单独的nuget包
    private static readonly HttpClient _client = new HttpClient(new WinHttpHandler());

    static async void Main(string[] args)
    {
        using var response = await _client.GetAsync("http://localhost:5000/doesNotExists");
    }
}

为SocketHttpHandler增加扩展性

HttpClient是一个高级的API,使用比较方便简单,但在个别情况下,不够灵活,也没法更细的力度的控制,所以我们为SocketHttpHandler增加两个扩展地方:

ConnectCallBack 允许创建新的自定义连接,每次打开新的TCP连接都会调用,可用于进程内通信,DNS解析,设置Socket或者特定平台的配置,或者打开新连接时进行通知.

  1. ConnectCallBack传递DnsEndPonit来确定Remote EndPoint,通过HttpMessageRequest创建和初始化连接对象.
  2. SocketHttpHandler提供连接池,创建的连接对象可以处理多个请求.
  3. 返回一个新的Stream(流)
  4. ConnectCallBack不应该创建TLS,TLS主要在SocketHttpHandler处理
  5. 没有设置ConnectCallBack,会调用默认的实现

private static async ValueTask<Stream> DefaultConnectAsync(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
{
    // The following socket constructor will create a dual-mode socket on systems where IPV6 is available.
    Socket socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
    // Turn off Nagle's algorithm since it degrades performance in most HttpClient scenarios.
    socket.NoDelay = true;

    try
    {
        await socket.ConnectAsync(context.DnsEndPoint, cancellationToken).ConfigureAwait(false);
        // The stream should take the ownership of the underlying socket,
        // closing it when it's disposed.
        return new NetworkStream(socket, ownsSocket: true);
    }
    catch
    {
        socket.Dispose();
        throw;
    }
}

PlantextStreamFilter 允许在新打开的连接对象定制处理.该回调是连接对象创建完成之后(包括TLS握手),是在任何Http请求之前调用.使用注意事项:

  1. 传递一个Stream(流),写
  2. 相同的Stream(流)也将被后续的请求使用
  3. 使用Stream作为返回值,该Stream可以被修改,也可以把Stream自定义
  4. 自定义处理Stream


socketsHandler.PlaintextStreamFilter = (context, token) =>
{
    Console.WriteLine($"Request {context.InitialRequestMessage} --> negotiated version {context.NegotiatedHttpVersion}");
    return ValueTask.FromResult(context.PlaintextStream);
};

通过SocketHttpHandler 2个扩展创建连接的过程:

  1. 通过ConnectCallBack打开TCP连接
  2. SocketHttpHandler内部在有需要TLS,会创建TLS
  3. PlantextStreamFilter 如果没有注册,不进行调用

HttpClient同步方法 Send

一般我们建议使用异步网络API,以获得更好的性能和可伸缩性,在个别情况HttpClient需要同步阻塞等待.所以同步API也是必要的. SendAsync存在可伸缩性问题的,是因为多个线程完成单个操作,如UI线程死锁.为了避免这个问题,我们加入SendAsync同步版本Send, Send使用注意事项:
  1. Send仅支持Http/1.1,是因为在Http/2连接对象是多路复用的.
  2. Send不能与ConnectCallBack一起使用.
  3. 如果使用SocketHttpHandler不是默认的实现,则必须要重写SocketHttpHandler中的Send函数,否则默认的HttpMessageHandler就会抛出异常.
  4. 如果对HttpContent自定义,这需要重写HttpContent中的SerializerToStream.
尽可能使用异步API

Http 2 版本选择

这里主要说的Http 2使用TLS和不使用TLS(h2c),主要是Http 2默认是使用TLS,请求和响应是加密的,不是明文的.在.Net 5中加入HttpVersionPolicy,通过设置HttpVersionPolicy来决定要不要使用TLS.

对于GetAsync/SendAsync/PostAsync/DeleteAsync通过HttpRequstMessage.VersionPolicy来设置. HttpClient的话则通过HttpVersionPolicy来设置.
class Program
{
    private static readonly HttpClient _client = new HttpClient()
    {
        // Allow only HTTP/2, no downgrades or upgrades.
        DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact,
        DefaultRequestVersion = HttpVersion.Version20
    };

    static async Task Main()
    {
        try
        {
            // Request clear-text http, no https.
            // The call will internally create a new request corresponding to:
            //   new HttpRequestMessage(HttpMethod.Get, "http://localhost:5001/h2c")
            //   {
            //     Version = HttpVersion.Version20,
            //     VersionPolicy = HttpVersionPolicy.RequestVersionExact
            //   }
            using var response = await _client.GetAsync("http://localhost:5001/h2c");
        }
        catch (HttpRequestException ex)
        {
            // Handle errors, including when h2c connection cannot be established.
            Console.WriteLine("Error: " + ex.Message);
        }
    }
}

Http2 多连接

Http2 支持多个请求在单个TCP连接(connection)进行复用,在Http2规范中,只有一个TCP连接打开到服务器中,这个很适合浏览器,解决了在Http1.1中,多个请求多个连接的问题.
可以将最大并发请求数量减少到设置的数量,设置的数量最小为100,对于服务之间的通信,如一个客户端向服务器发生非常多的请求并保持长连接的话,这会影响服务的吞吐量和性能,为了解决该问题,加入了打开单个端点(single endpoint)http/2多连接.

Http2 默认配置不支持单端点多连接,要启用的话,对SocketsHttpHandler的EnableMultipleHttp2Connections=true.
class Program
{
    private static readonly HttpClient _client = new HttpClient(new SocketsHttpHandler()
    {
        // Enable multiple HTTP/2 connections.
        EnableMultipleHttp2Connections = true,

        // Log each newly created connection and create the connection the same way as it would be without the callback.
        ConnectCallback = async (context, token) =>
        {
            Console.WriteLine($"New connection to {context.DnsEndPoint} with request:{Environment.NewLine}{context.InitialRequestMessage}");

            var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
            await socket.ConnectAsync(context.DnsEndPoint, token).ConfigureAwait(false);
            return new NetworkStream(socket, ownsSocket: true);
        },
    })
    {
        // Allow only HTTP/2, no downgrades or upgrades.
        DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact,
        DefaultRequestVersion = HttpVersion.Version20
    };

    static async Task Main()
    {
        // Burst send 2000 requests in parallel.
        var tasks = new Task[2000];
        for (int i = 0; i < tasks.Length; ++i)
        {
            tasks[i] = _client.GetAsync("http://localhost:5001/");
        }
        await Task.WhenAll(tasks);
    }
}

EnableMultipleHttp2Connections这行如果注释掉或者改为false,在创建新连接的时候只调用一次ConnectionCallBack,在控制台上打印输出的信息.

可配置Ping

HTTP/2规范定义了PING帧,这是一种确保空闲连接保持活动的机制. 这个特性对于长时间运行的空闲连接非常有用,否则这些连接将被丢弃. 在流和长远程过程调用等gRPC场景中可以找到这样的连接. 到目前为止,我们只是回复PING请求,但从未发送它们。  
 
在.Net 5,实现了发送PING帧(#31198)的可配置时间间隔、超时,以及是否总是发送或只发送活动请求. 使用默认值的完整配置是: 
public class SocketsHttpHandler
{
    //省略部分代码

    // The client will send PING frames to the server if it hasn't receive any frame on the connection for this period of time.
    public TimeSpan KeepAlivePingDelay { get; set; } = Timeout.InfiniteTimeSpan;

    // The client will close the connection if it doesn't receive PING ACK frame within the timeout.
    public TimeSpan KeepAlivePingTimeout { get; set; } = TimeSpan.FromSeconds(20);

    // Whether the client will send PING frames only if there are any active streams on the connection or even if it's idle.
    public HttpKeepAlivePingPolicy KeepAlivePingPolicy { get; set; } = HttpKeepAlivePingPolicy.Always;

    //省略部分代码
}
public enum HttpKeepAlivePingPolicy
{
    // PING frames are sent only if there are active streams on the connection.
    WithActiveRequests,

    // PING frames are sent regardless if there are any active streams or not.
    Always
}

KeepAlivePingDelay(Timeout.InfinitieTimeSpan)的默认值表示此功能通常已关闭,PING帧不会自动发送到服务器。客户端仍将回复接收到的PING帧,无法关闭。要启用自动ping,必须将KeepAlivePingDelay更改为1分钟:

class Program
{
    private static readonly HttpClient _client = new HttpClient(new SocketsHttpHandler()
    {
        KeepAlivePingDelay = TimeSpan.FromSeconds(60)  //改为1分钟
    });
}

只有当与服务器没有活跃的通信时,才会发送PING帧. 每一个从服务器收到的帧都会重置延迟,只有在没有收到KeepAlivePingDelay帧后,才会发送一个PING帧.服务器得到KeepAlivePingTimeout来应答.如果没有,就会被认为是丢失和断开的.该算法会定期检查延迟和超时.但最多每秒钟检查一次.将KeepAlivePingDelay或KeepAlivePingTimeout设置为较小的值将导致异常.


秋风 2021-03-14