【译】.NET 6 网络改进
阅读原文时间:2022年03月17日阅读:1

原文 | Máňa Píchová

翻译 | 郑子铭

对于 .NET 的每个新版本,我们都希望发布一篇博客文章,重点介绍网络的一些变化和改进。在这篇文章中,我很高兴谈论 .NET 6 中的变化。

这篇文章的上一个版本是 .NET 5 网络改进

HTTP/2 窗口缩放

随着 HTTP/2 和 gRPC 的兴起,我们的客户发现 SocketsHttpHandler 的 HTTP/2 下载速度在连接到具有显着网络延迟的地理位置较远的服务器时无法与其他实现相提并论。在具有高带宽延迟产品的链路上,与其他能够利用链路物理带宽的实现相比,一些用户报告了 5 到 10 倍的差异。举个例子:在我们的一个基准测试中,curl 能够达到特定跨大西洋链路的最大 10 Mbit/s 速率,而 SocketsHttpHandler 的速度最高为 2.5 Mbit/s。除其他外,这严重影响了 gRPC 流式处理方案。

问题的根本原因是固定大小的 HTTP/2 接收窗口,当以高延迟接收 WINDOW_UPDATE 帧时,它的 64KB 大小太小而无法保持网络繁忙,这意味着 HTTP/2 自己的流量控制机制正在停止网络链接。

我们考虑了“廉价”选项来解决这个问题,例如定义一个固定大小的大窗口——这可能会导致不必要的高内存占用——或者要求用户根据经验观察手动配置接收窗口。这些似乎都不令人满意,因此我们决定实现一种类似于 TCP 或 QUIC 中的自动窗口大小调整算法 (dotnet/runtime#54755)。

结果证明效果很好,将下载速度提升到接近其理论最大值。但是,由于 HTTP/2 PING 帧用于确定 HTTP/2 连接的往返时间,因此我们必须非常小心,以免触发服务器的 PING 泛洪保护机制。我们实现了一个算法,该算法应该可以很好地与 gRPC 和现有的 HTTP 服务器一起工作,但我们想确保我们有一个逃生路径,以防出现问题。可以通过将 System.Net.SocketsHttpHandler.Http2FlowControl.DisableDynamicWindowSizing AppContext 开关设置为 true 来关闭动态窗口大小以及相应的 PING 帧。如果这变得有必要,仍然可以通过为 SocketsHttpHandler.InitialHttp2StreamWindowSize 分配更高的值来解决吞吐量问题。

HTTP/3 和 QUIC

在 .NET 5 中,我们发布了 QUIC 和 HTTP/3 的实验性实现。它仅限于 Windows 的 Insider 版本,并且有相当多的仪式让它工作。

在 .NET 6 中,我们大大简化了设置。

  • 在 Windows 上,我们将 MsQuic 库作为运行时的一部分提供,因此无需下载或引用任何外部内容。唯一的限制是需要 Windows 11 或 Windows Server 2022。这是因为 TLS 1.3 对 SChannel 中的 QUIC 的支持在早期的 Windows 版本中不可用。
  • 在 Linux 上,我们将 MsQuic 作为标准 Linux 包 libmsquic(deb 和 rpm)发布在 Microsoft Package Repository 中。在 Linux 上不将 MsQuic 与 runtime 捆绑在一起的原因是,我们将 libmsquic 与 QuicTLS 一起发布,QuicTLS 是 OpenSSL 的一个分支,提供了必要的 TLS API。由于我们将 QuicTLS 与 MsQuic 捆绑在一起,我们需要能够在正常的 .NET 发布计划之外进行安全补丁。

我们还大大提高了稳定性并实现了许多缺失的功能,在 .NET 6 里程碑中解决了大约 90 个问题

HTTP/3 使用 QUIC 而不是 TCP 作为其传输层。我们的 QUIC 协议的 .NET 实现是在 System.Net.Quic 库中的 MsQuic 之上构建的托管层。 QUIC 是一种通用协议,可用于多种场景,不仅仅是 HTTP/3,而且是新的,最近才在 RFC 9000 中获得批准。我们没有足够的信心认为当前的 API 形式能够经受住时间,并且适合其他协议使用,因此我们决定在此版本中将其保密。因此,.NET 6 包含 QUIC 协议实现,但没有公开它。它仅在内部用于 HttpClient 和 Kestrel 服务器中的 HTTP/3。

尽管在此版本中为消除错误付出了很多努力,但我们仍然认为 HTTP/3 的质量还没有完全为生产做好准备。由于任何 HTTP 请求都可能通过 Alt-Svc 标头无意中升级到 HTTP/3 并开始失败,因此我们选择在此版本中默认禁用 HTTP/3 功能。在 HttpClient 中,它隐藏在 System.Net.SocketsHttpHandler.Http3Support AppContext 开关后面。

我们之前的文章中已经描述了如何设置所有内容的所有细节:HttpClientKestrel。在 Linux 上,获取 libmsquic 包,在 Windows 上,确保操作系统版本至少为 10.0.20145.1000。然后,您只需要启用 HTTP/3 支持并将 HttpClient 设置为使用 HTTP/3:

using System.Net;

// Set this switch programmatically or in csproj:
// <RuntimeHostConfigurationOption Include="System.Net.SocketsHttpHandler.Http3Support" Value="true" />
AppContext.SetSwitch("System.Net.SocketsHttpHandler.Http3Support", true);

// Set up the client to request HTTP/3.
var client = new HttpClient()
{
    DefaultRequestVersion = HttpVersion.Version30,
    DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher,
};
var resp = await client.GetAsync("https://<http3 endpoint>");

// Print the response version.
Console.WriteLine($"status: {resp.StatusCode}, version: {resp.Version}");

我们鼓励您尝试 HTTP/3!如果您遇到任何问题,请在 dotnet/runtime 中提出问题。

HTTP 重试逻辑

.NET 6 将 HTTP 请求重试逻辑更改为基于固定重试计数限制(请参阅 dotnet/runtime#48758)。

以前,.NET 5 不允许在“新”连接(未用于先前请求的连接)上发生连接失败时请求重试。我们这样做主要是为了确保重试逻辑不会陷入无限循环。这对于 HTTP/2 连接来说不是最理想的并且特别有问题(请参阅 dotnet/runtime#44669)。另一方面,.NET 5 对在许多情况下允许重试过于宽松,这并不完全符合 RFC 2616。例如,我们正在重试任意异常,例如在 IO 超时时,即使用户明确设置了此超时,并且可能希望在超过超时时使请求失败(而不是重试)。

无论请求是否是连接上的第一个请求,.NET 6 重试逻辑都将起作用。它引入了当前设置为 5 的重试限制。将来,如果需要,我们可能会考虑对其进行调整或使其可配置。

为了更好地遵守 RFC,请求现在只有在我们认为服务器正试图优雅地断开连接时才可重试——也就是说,当我们在 HTTP/1.1 的任何其他响应数据之前收到 EOF 或收到 HTTP/2 的 GOAWAY。

.NET 6 更保守的重试行为的缺点是,以前被宽松重试策略掩盖的失败将开始对用户可见。例如,如果服务器以非优雅的方式(通过发送 TCP RST 数据包)断开空闲连接,则由于 RST 失败的请求将不会自动重试。这在关于迁移到 .NET 6 的 AAD 文章中简要提及。解决方法是将客户端的空闲超时 (SocketsHttpHandler.PooledConnectionIdleTimeout) 设置为服务器空闲超时的 50-75%(如果已知)。这样一来,请求永远不会在服务器以空闲状态关闭连接的竞争中被捕获——HttpClient 会更快地清除它。另一种方法是在 HttpClient 之外实现自定义重试策略。这也将允许调整重试策略和启发式方法,例如,如果可以根据特定服务器的逻辑和实现重试一些通常非幂等的请求。

SOCKS 代理支持是一个长期存在的问题 (dotnet/runtime#17740),最终由社区贡献者 @huoyaoyuan 实现。我们已经在 .NET 6 Preview 5 博客文章中介绍了这一新增功能。该更改增加了对 SOCKS4、SOCKS4a 和 SOCKS5 代理的支持。

SOCKS 代理是一个非常通用的工具。例如,它可以提供与 VPN 类似的功能。最值得注意的是 SOCKS 代理用于访问 Tor 网络。

配置HttpClient使用SOCKS代理,只需要在定义proxy1时使用socks方案即可:

var client = new HttpClient(new SocketsHttpHandler()
{
    // Specify the whole Uri (schema, host and port) as one string or use Uri directly.
    Proxy = new WebProxy("socks5://127.0.0.1:9050")
});

var content = await client.GetStringAsync("https://check.torproject.org/");
Console.WriteLine(content);

此示例假设您正在计算机上运行 tor 实例。如果请求成功,您应该能够找到“恭喜。此浏览器配置为使用 Tor。”在响应内容中。

1.在原博文中,我们犯了一个错误,使用了错误的WebProxy 构造函数重载。它只需要第一个参数中的主机名,并且不能与 HTTP 以外的任何其他代理类型一起使用。我们还为 .NET 7 (dotnet/runtime#62338) 修复了这种特殊的构造函数行为不一致问题。

WinHTTP

WinHttpHandler 是 WinHTTP 的包装器,因此功能集取决于 WinHTTP 中的功能。在此版本中,有一些新增功能可以公开或启用 HTTP/2 的 WinHttp 功能。它们是使用户能够在 .NET Framework 上使用 gRPC .NET 的更大努力 (dotnet/core#5713) 的一部分。目标是实现从 WCF 到 .NET Framework 上的 gRPC 以及再到 .NET Core / .NET 5+ 上的 gRPC 的更平滑过渡。

  • 尾随标头 (dotnet/runtime#44778)。

    • 对于 .NET Core 3.1 / .NET 5 及更高版本,尾随标头在 HttpResponseMessage.TrailingHeaders 中公开。
    • 对于 .NET Framework,它们在 HttpRequestMessage.Properties["__ResponseTrailers"] 中公开,因为 .NET Framework 上没有 TrailingHeaders 这样的属性。
  • 双向流 (dotnet/runtime#44784)。此更改是完全无缝的,WinHttpHandler 将在适当时自动允许双向流式传输,即当请求内容没有已知长度并且底层 WinHTTP 支持它时。

  • TCP 保持活动配置。 TCP keep-alive 用于保持空闲连接打开,并防止中间节点(如代理和防火墙)比客户端预期的更快断开连接。在 .NET 6 中,我们为 WinHttpHandler 添加了 3 个新属性来配置它:

    public class WinHttpHandler
    {
    // Controls whether TCP keep-alive is getting send or not.
    public bool TcpKeepAliveEnabled { get; set; }
    // Delay to the first keep-alive packet during inactivity.
    public TimeSpan TcpKeepAliveTime { get; set; }
    // Interval for subsequent keep-alive packets during inactivity.
    public TimeSpan TcpKeepAliveInterval { get; set; }
    }

这些属性对应于 WinHTTP tcp_keepalive 结构。

将 TLS 1.3 与 WinHttpHandler 一起使用 (dotnet/runtime#58590)。此功能对用户是透明的,唯一需要的是 Windows 支持。

其他 HTTP 更改

.NET 6 中的许多 HTTP 更改已经在 Stephen Toub 关于性能的大量文章中进行了讨论,但其中很少有值得重复的。

  • 在 SocketsHttpHandler (runtime/dotnet#44818) 中重构了连接池。新方法允许我们始终处理首先可用的连接上的请求,无论是新建立的连接还是同时准备好处理请求的连接。之前,在请求到来时所有连接都忙的情况下,我们将开始打开一个新连接并让请求等待它。此更改适用于 HTTP/1.1 以及启用了 EnableMultipleHttp2Connections 的 HTTP/2。
  • 添加了未经验证的 HTTP 标头枚举 (runtime/dotnet#35126)。更改将新的 API HttpHeaders.NonValidated
  • 添加到标头集合中。它允许在收到标头时检查标头(无需进行清理),它还跳过所有解析和验证逻辑,不仅节省了 CPU 周期,还节省了分配。
  • 优化 HPack Huffman 解码 (dotnet/runtime#43603)。 HPack 是 HTTP/2 RFC 7541 的标头(解)压缩格式。从我们的微基准测试来看,这种优化将解码所需的时间减少到原始解码时间的 0.35 左右(dotnet/runtime#1506)。
  • 引入 ZLibStream。最初,我们没想到 zlib 信封在 deflate 压缩内容数据 (dotnet/runtime#38022) 中,RFC 2616 将其定义为带 deflate 压缩的 zlib 格式。一旦我们解决了这个问题,就会出现另一个问题,因为并非所有服务器都将 zlib 信封放置到位。所以我们引入了一种机制来检测格式并使用适当类型的流(dotnet/runtime#57862)。
  • 添加了 cookie 枚举。在 .NET 6 之前,无法枚举 CookieContainer 中的所有 cookie。您需要知道他们的域名才能获得它们。此外,没有办法获取有任何 cookie 的域列表。人们使用丑陋的伎俩来访问 cookie (dotnet/runtime#44094)。因此我们引入了一个新的 API CookieContainer.GetAllCookies 来列出容器中的所有 cookie (dotnet/runtime#44094)。

通过在 Windows 上使用自动重用端口范围来处理端口耗尽

在大规模打开并发 HTTP/1.1 连接时,您可能会注意到新连接尝试在一段时间后开始失败。在 Windows 上,这通常发生在大约 16K 并发连接左右,其中套接字错误 10055 (WSAENOBUFS) 作为内部 SocketException 消息。通常,网络堆栈会选择一个尚未绑定到另一个套接字的端口,这意味着同时打开的最大连接数受动态端口范围的限制。这是一个可配置的范围,通常默认为 49152-65535,理论上限制为 216=65536 个端口,因为端口是 16 位数字。

为了解决远程端点 IP 地址和/或端口不同的情况下的这个问题,Windows 早在 Windows 8.1 时代就引入了一种称为自动重用端口范围的功能。 .NET 框架通过可选属性 ServicePointManager.ReusePort 公开了相关的套接字选项 SO_REUSE_UNICASTPORT,但此属性在 .NET Core / .NET 5+ 上成为无操作 API。相反,在 dotnet/runtime#48219 中,我们为 .NET 6+ 上的所有传出异步 Socket 连接启用了 SO_REUSE_UNICASTPORT,允许在连接之间重用端口,只要:

  • 连接的完整 4 元组(本地端口、本地地址、远程端口、远程地址)是唯一的。
  • 自动重用端口范围在机器上配置。

您可以使用以下 PowerShell cmdlet 设置自动重用端口范围:

Set-NetTCPSetting -SettingName InternetCustom `
                  -AutoReusePortRangeStartPort <start-port> `
                  -AutoReusePortRangeNumberOfPorts <number-of-ports>

设置需要重启才能生效。

来自 Windows 功能的作者:

由于粘性向后兼容性问题,自动重用端口范围必须专门用于使用此特殊逻辑的出站连接。这意味着如果自动重用端口范围配置为与众所周知的侦听端口(例如端口 80)重叠,则尝试将侦听套接字绑定到该端口将失败。此外,如果自动重用端口范围完全覆盖常规临时端口范围,则正常的通配符绑定将失败。通常,选择作为默认临时端口范围的严格子集的自动重用范围将避免问题。但是管理员仍然必须小心,因为一些应用程序使用临时端口范围内的大端口号作为“知名”端口号。

全局禁用 IPv6 的选项

从 .NET 5 开始,我们在 SocketsHttpHandler 中使用 DualMode 套接字。这使我们能够处理来自 IPv6 套接字的 IPv4 流量,并且被 RFC 1933 认为是一种有利的做法。另一方面,我们收到了一些用户在通过不支持 IPv6 和/或双通道的 VPN 隧道连接时遇到问题的报告- 正确堆叠套接字。为了缓解 IPv6 的这些问题和其他潜在问题,dotnet/runtime#55012 实施了一个开关,以在整个 .NET 6 进程中全局禁用 IPv6。

如果您遇到类似问题并决定通过禁用 IPv6 来解决这些问题,您现在可以将环境变量 DOTNET_SYSTEM_NET_DISABLEIPV6 设置为 1 或 System.Net.DisableIPv6 运行时配置设置为 true。

System.Net.Sockets 中新的基于跨度和任务的重载

在社区的帮助下,我们设法使 Socket 和相关类型在 Span、Task 和取消支持方面接近 API-complete。完整的 API-diff 太长了,无法包含在这篇博文中,你可以在这个 dotnet/core 文档中找到它。我们要感谢@gfoidl@ovebastiansen@PJB3005 的贡献!

在 .NET 6 中,我们在网络安全领域做了两个值得一提的小改动。

延迟的客户端协商

这是一个服务器端的 SslStream 函数。当服务器决定需要为已建立的连接重新协商加密时使用它。例如,当客户端访问需要初始未提供的客户端证书的资源时。

新的 SslStream 方法如下所示:

public virtual Task NegotiateClientCertificateAsync(CancellationToken cancellationToken = default);

该实现使用两种不同的 TLS 功能,具体取决于 TLS 版本。对于最高 1.2 的 TLS,使用 TLS 重新协商 (RFC 5746)。对于 TLS 1.3,使用握手后身份验证扩展 (RFC 8446)。这两个特性在 SChannel AcceptSecurityContext 函数中被抽象出来。因此,Windows 完全支持延迟客户端协商。不幸的是,OpenSSL 的情况有所不同,因此支持仅限于 TLS 重新协商,即 Linux 上的 TLS 最高 1.2。此外,MacOS 根本不受支持,因为它的安全层不提供其中任何一个。我们全力以赴缩小 .NET 7 中的这一平台差距。

请注意,HTTP/2 (RFC 8740) 不允许 TLS 重新协商和握手后身份验证扩展,因为它通过一个连接多路复用多个请求。

模仿改进

这是 Windows 独有的功能,其中单个进程可以通过 WindowsIdentity.RunImpersonatedAsync 在不同用户下运行线程。我们在 .NET 6 中修复的两种情况下表现不佳。第一种情况是在进行异步名称解析时 (dotnet/runtime#47435)。另一个是在发送 HTTP 请求时,我们不会尊重模拟用户 (dotnet/runtime#58033)。

我们收到了很多关于 HttpClientActivity 创建 (dotnet/runtime#41072) 和自动跟踪标头注入 (dotnet/runtime#35337) 方面的默认行为的问题、投诉和错误报告。这些问题在自动创建 Activity 的 ASP.NET Core 项目中更加明显,无意中打开了作为 HttpClient 处理程序链的一部分的 DiagnosticsHandler。此外,DiagnosticsHandler 是一个内部类,没有通过 HttpClient 公开的任何配置,因此迫使用户想出一些变通办法来控制行为(dotnet/runtime#31862)或只是将其完全关闭(dotnet/runtime#35337-comment)。

所有这些问题都在 .NET 6 (dotnet/runtime#55392) 中得到解决。现在可以使用 DistributedContextPropagator 控制标头注入。它可以通过 DistributedContextPropagator.Current 在全局范围内完成,也可以通过 HttpClient/SocketsHttpHandler 和 SocketsHttpHandler.ActivityHeadersPropagator 来完成。我们还准备了一些最需要的实现:

  • NoOutputPropagator 抑制跟踪标头注入。
  • PassThroughPropagator 使用来自根 Activity 的值注入跟踪标头,即透明地执行并发送与应用程序接收到的相同标头值。

为了更精细地控制标头注入,可以提供自定义 DistributedContextPropagator。例如,一个用于完全跳过 DiagnosticsHandler 发出的一层(归功于@MihaZupan):

public sealed class SkipHttpClientActivityPropagator : DistributedContextPropagator
{
    private readonly DistributedContextPropagator _originalPropagator = Current;

    public override IReadOnlyCollection<string> Fields => _originalPropagator.Fields;

    public override void Inject(Activity? activity, object? carrier, PropagatorSetterCallback? setter)
    {
        if (activity?.OperationName == "System.Net.Http.HttpRequestOut")
        {
            activity = activity.Parent;
        }

        _originalPropagator.Inject(activity, carrier, setter);
    }

    public override void ExtractTraceIdAndState(object? carrier, PropagatorGetterCallback? getter, out string? traceId, out string? traceState) =>
        _originalPropagator.ExtractTraceIdAndState(carrier, getter, out traceId, out traceState);

    public override IEnumerable<KeyValuePair<string, string?>>? ExtractBaggage(object? carrier, PropagatorGetterCallback? getter) =>
        _originalPropagator.ExtractBaggage(carrier, getter);
}

最后,为了将这一切整合在一起,设置 ActivityHeadersPropagator:

// Set up headers propagator for this client.
var client = new HttpClient(new SocketsHttpHandler() {
    // -> Turns off activity creation as well as header injection
    // ActivityHeadersPropagator = null

    // -> Activity gets created but no trace header is injected
    // ActivityHeadersPropagator = DistributedContextPropagator.CreateNoOutputPropagator()

    // -> Activity gets created, trace header gets injected and contains "root" activity id
    // ActivityHeadersPropagator = DistributedContextPropagator.CreatePassThroughPropagator()

    // -> Activity gets created, trace header gets injected and contains "parent" activity id
    // ActivityHeadersPropagator = new SkipHttpClientActivityPropagator()

    // -> Activity gets created, trace header gets injected and contains "System.Net.Http.HttpRequestOut" activity id
    // Same as not setting ActivityHeadersPropagator at all.
    // ActivityHeadersPropagator = DistributedContextPropagator.CreateDefaultPropagator()
});

// If you want the see the order of activities created, add ActivityListener.
ActivitySource.AddActivityListener(new ActivityListener()
{
    ShouldListenTo = (activitySource) => true,
    ActivityStarted = activity => Console.WriteLine($"Start {activity.DisplayName}{activity.Id}"),
    ActivityStopped = activity => Console.WriteLine($"Stop {activity.DisplayName}{activity.Id}")
});

// Set up activities, at least two layers to show all the differences.
using Activity root = new Activity("root");
// Header format can be overridden, default is W3C, see https://www.w3.org/TR/trace-context/).
// root.SetIdFormat(ActivityIdFormat.Hierarchical);
root.Start();
using Activity parent = new Activity("parent");
// parent.SetIdFormat(ActivityIdFormat.Hierarchical);
parent.Start();

var request = new HttpRequestMessage(HttpMethod.Get, "https://www.microsoft.com");

using var response = await client.SendAsync(request);
Console.WriteLine($"Request: {request}"); // Print the request to see the injected header.

HttpClient 使用 System.Uri,它根据 RFC 3986 进行验证和规范化,并以可能破坏其最终客户的方式修改一些 URI。例如,较大的服务或 SDK 可能需要将 URI 从其源(例如 Kestrel)透明地传递给 HttpClient,这在 .NET 5 中是不可能的(请参阅 dotnet/runtime#52628dotnet/runtime#58057)。

.NET 6 引入了一个新的 API 标志 UriCreationOptions.DangerousDisablePathAndQueryCanonicalization(请参阅 dotnet/runtime#59274),这将允许用户禁用 URI 上的任何规范化并“按原样”使用它。

设置 DangerousDisablePathAndQueryCanonicalization 意味着没有验证和输入的转换不会超过权限。作为副作用,使用此选项创建的 Uri 实例不支持 Uri.Fragments - 它始终为空。此外,Uri.GetComponents(UriComponents, UriFormat) 不能用于 UriComponents.Path 或 UriComponents.Query,并且会抛出 InvalidOperationException。

请注意,禁用规范化还意味着保留字符不会被转义(例如,空格字符不会更改为 %20),这可能会破坏 HTTP 请求并使应用程序受到请求偷渡的影响。仅当您确保 URI 字符串已被清理时才设置此选项。

var uriString = "http://localhost/path%4A?query%4A#/foo";

var options = new UriCreationOptions { DangerousDisablePathAndQueryCanonicalization = true };
var uri = new Uri(uriString, options);
Console.WriteLine(uri); // outputs "http://localhost/path%4A?query%4A#/foo"
Console.WriteLine(uri.AbsolutePath); // outputs "/path%4A"
Console.WriteLine(uri.Query); // outputs "?query%4A#/foo"
Console.WriteLine(uri.PathAndQuery); // outputs "/path%4A?query%4A#/foo"
Console.WriteLine(uri.Fragment); // outputs an empty string

var canonicalUri = new Uri(uriString);
Console.WriteLine(canonicalUri.PathAndQuery); // outputs "/pathJ?queryJ"
Console.WriteLine(canonicalUri.Fragment); // outputs "#/foo"

请注意,该 API 是我们为 .NET 7 设计的更大 API 表面的一部分(请参阅 dotnet/runtime#59099)。

这并不是 .NET 6 中发生的所有网络更改的详尽列表。我们尝试选择最有趣或影响最大的更改。如果您在网络堆栈中发现任何错误,请随时与我们联系。你可以在 GitHub 上找到我们。

另外,我要感谢我的合著者:

.NET 6 Networking Improvements

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

欢迎转载、使用、重新发布,但务必保留文章署名 郑子铭 (包含链接: http://www.cnblogs.com/MingsonZheng/ ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。

如有任何疑问,请与我联系 (MingsonZheng@outlook.com) 。

手机扫一扫

移动阅读更方便

阿里云服务器
腾讯云服务器
七牛云服务器