• 【愚公系列】2022年11月 .NET CORE工具案例-.NET 7中的Quic通信



    前言

    QUIC(Quick UDP Internet Connection)是谷歌制定的一种基于UDP的低时延的互联网传输层协议。我们知道,TCP/IP协议族是互联网的基础。其中传输层协议包括TCP和UDP协议。与TCP协议相比,UDP更为轻量,但是错误校验也要少得多。这意味着UDP往往效率更高(不经常跟服务器端通信查看数据包是否送达或者按序),但是可靠性比不上TCP。通常游戏、流媒体以及VoIP等应用均采用UDP,而网页、邮件、远程登录等大部分的应用均采用TCP。

    QUIC很好地解决了当今传输层和应用层面临的各种需求,包括处理更多的连接,安全性,和低延迟。QUIC融合了包括TCP,TLS,HTTP/2等协议的特性,但基于UDP传输。QUIC的一个主要目标就是减少连接延迟,当客户端第一次连接服务器时,QUIC只需要1RTT(Round-Trip Time)的延迟就可以建立可靠安全的连接,相对于TCP+TLS的1-3次RTT要更加快捷。之后客户端可以在本地缓存加密的认证信息,再次与服务器建立连接时可以实现0-RTT的连接建立延迟。QUIC同时复用了HTTP/2协议的多路复用功能(Multiplexing),但由于QUIC基于UDP所以避免了HTTP/2的队头阻塞(Head-of-Line Blocking)问题。因为QUIC基于UDP,运行在用户域而不是系统内核,使得QUIC协议可以快速的更新和部署,从而很好地解决了TCP协议部署及更新的困难。

    以下是TCP和Quic通信过程的示例图:

    在这里插入图片描述

    一、.NET 7中的Quic通信

    1.下载.NET 7预览版

    下载地址:https://dotnet.microsoft.com/zh-cn/download/dotnet/7.0

    在这里插入图片描述

    2.vs2022配置使用预览版SDK

    在这里插入图片描述
    在这里插入图片描述

    3. .NET 中使用 Quic

    下面是 System.Net.Quic 命名空间下,比较重要的几个类。

    • QuicConnection

    表示一个 QUIC 连接,本身不发送也不接收数据,它可以打开或者接收多个QUIC 流。

    • QuicListener

    用来监听入站的 Quic 连接,一个 QuicListener 可以接收多个 Quic 连接。

    • QuicStream

    表示 Quic 流,它可以是单向的 (QuicStreamType.Unidirectional),只允许创建方写入数据,也可以是双向的(QuicStreamType.Bidirectional),它允许两边都可以写入数据。

    4. .NET 中使用 Quic代码解析

    4.1 服务端

    建了一个 QuicListener,监听了本地端口 9999,指定了 ALPN 协议版本。

    // 创建 QuicListener
    var listener = await QuicListener.ListenAsync(new QuicListenerOptions
    { 
        ApplicationProtocols = new List<SslApplicationProtocol> { SslApplicationProtocol.Http3  },
        ListenEndPoint = new IPEndPoint(IPAddress.Loopback,9999), 
        ConnectionOptionsCallback = (connection,ssl, token) => ValueTask.FromResult(new QuicServerConnectionOptions()
        {
            DefaultStreamErrorCode = 0,
            DefaultCloseErrorCode = 0,
            ServerAuthenticationOptions = new SslServerAuthenticationOptions()
            {
                ApplicationProtocols = new List<SslApplicationProtocol>() { SslApplicationProtocol.Http3 },
                ServerCertificate = GenerateManualCertificate()//生成证书
            }
        }) 
    }); 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    在这里插入图片描述

    因为 Quic 需要 TLS 加密,所以要指定一个证书,GenerateManualCertificate 方法可以方便地创建一个本地的测试证书。

    X509Certificate2 GenerateManualCertificate()
    {
        X509Certificate2 cert = null;
        var store = new X509Store("KestrelWebTransportCertificates", StoreLocation.CurrentUser);
        store.Open(OpenFlags.ReadWrite);
        if (store.Certificates.Count > 0)
        {
            cert = store.Certificates[^1];
    
            // rotate key after it expires
            if (DateTime.Parse(cert.GetExpirationDateString(), null) < DateTimeOffset.UtcNow)
            {
                cert = null;
            }
        }
        if (cert == null)
        {
            // generate a new cert
            var now = DateTimeOffset.UtcNow;
            SubjectAlternativeNameBuilder sanBuilder = new();
            sanBuilder.AddDnsName("localhost");
            using var ec = ECDsa.Create(ECCurve.NamedCurves.nistP256);
            CertificateRequest req = new("CN=localhost", ec, HashAlgorithmName.SHA256);
            // Adds purpose
            req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection
            {
                new("1.3.6.1.5.5.7.3.1") // serverAuth
    
            }, false));
            // Adds usage
            req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false));
            // Adds subject alternate names
            req.CertificateExtensions.Add(sanBuilder.Build());
            // Sign
            using var crt = req.CreateSelfSigned(now, now.AddDays(14)); // 14 days is the max duration of a certificate for this
            cert = new(crt.Export(X509ContentType.Pfx));
    
            // Save
            store.Add(cert);
        }
        store.Close();
    
        var hash = SHA256.HashData(cert.RawData);
        var certStr = Convert.ToBase64String(hash);
        //Console.WriteLine($"\n\n\n\n\nCertificate: {certStr}\n\n\n\n"); // <-- you will need to put this output into the JS API call to allow the connection
        return cert;
    } 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47

    在这里插入图片描述

    阻塞线程,直到接收到一个 Quic 连接,一个 QuicListener 可以接收多个连接。并接收一个入站的 Quic 流, 一个 QuicConnection 可以支持多个流。

    var connection = await listener.AcceptConnectionAsync();
    
    Console.WriteLine($"Client [{connection.RemoteEndPoint}]: connected");
    
    var stream = await connection.AcceptInboundStreamAsync();
    
    Console.WriteLine($"Stream [{stream.Id}]: created");
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    使用 System.IO.Pipeline 处理流数据,读取行数据,并回复一个 ack 消息。

    await ProcessLinesAsync(stream);
    
    // 处理流数据
    async Task ProcessLinesAsync(QuicStream stream)
    {
        var reader = PipeReader.Create(stream);  
        var writer = PipeWriter.Create(stream);
    
        while (true)
        {
            ReadResult result = await reader.ReadAsync();
            ReadOnlySequence<byte> buffer = result.Buffer;
    
            while (TryReadLine(ref buffer, out ReadOnlySequence<byte> line))
            {
                // Process the line. 
                ProcessLine(line);
    
                // Ack 
                //await writer.WriteAsync(System.Text.Encoding.UTF8.GetBytes($"ack: {DateTime.Now.ToString("HH:mm:ss")} \n"));
            }
    
            // Tell the PipeReader how much of the buffer has been consumed.
            reader.AdvanceTo(buffer.Start, buffer.End);
    
            // Stop reading if there's no more data coming.
            if (result.IsCompleted)
            {
                break;
            } 
        }
    
        Console.WriteLine($"Stream [{stream.Id}]: completed");
    
        await reader.CompleteAsync();  
        await writer.CompleteAsync();    
    } 
    
    bool TryReadLine(ref ReadOnlySequence<byte> buffer, out ReadOnlySequence<byte> line)
    {
        // Look for a EOL in the buffer.
        SequencePosition? position = buffer.PositionOf((byte)'\n');
    
        if (position == null)
        {
            line = default;
            return false;
        }
    
        // Skip the line + the \n.
        line = buffer.Slice(0, position.Value);
        buffer = buffer.Slice(buffer.GetPosition(1, position.Value));
        return true;
    } 
    
    void ProcessLine(in ReadOnlySequence<byte> buffer)
    {
        foreach (var segment in buffer)
        {
            Console.WriteLine("Recevied -> " + System.Text.Encoding.UTF8.GetString(segment.Span));
        }
    
        Console.WriteLine();
    } 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64

    在这里插入图片描述

    4.2 客户端

    4.2.1 单个流

    直接使用 QuicConnection.ConnectAsync 连接到服务端。

    Console.WriteLine("Quic Client Running...");
    
    await Task.Delay(3000);
    
    // 连接到服务端
    var connection = await QuicConnection.ConnectAsync(new QuicClientConnectionOptions
    {
        DefaultCloseErrorCode = 0,
        DefaultStreamErrorCode = 0,
        RemoteEndPoint = new IPEndPoint(IPAddress.Loopback, 9999),
        ClientAuthenticationOptions = new SslClientAuthenticationOptions
        {
            ApplicationProtocols = new List<SslApplicationProtocol> { SslApplicationProtocol.Http3 },
            RemoteCertificateValidationCallback = (sender, certificate, chain, errors) =>
            {
                return true;
            }
        }
    });  
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    创建一个出站的双向流。

    // 打开一个出站的双向流
    var stream = await connection.OpenOutboundStreamAsync(QuicStreamType.Bidirectional); 
    
    var reader = PipeReader.Create(stream);
    var writer = PipeWriter.Create(stream);  
    
    • 1
    • 2
    • 3
    • 4
    • 5

    后台读取流数据,然后循环写入数据。

    // 后台读取流数据
    _ = ProcessLinesAsync(stream);
    
    Console.WriteLine(); 
    
    // 写入数据
    for (int i = 0; i < 7; i++)
    {
        await Task.Delay(2000);
    
        var message = $"Hello Quic {i} \n";
    
        Console.Write("Send -> " + message);  
    
        await writer.WriteAsync(Encoding.UTF8.GetBytes(message)); 
    }
    
    await writer.CompleteAsync(); 
    
    Console.ReadKey(); 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    ProcessLinesAsync 和服务端一样,使用 System.IO.Pipeline 读取流数据。

    async Task ProcessLinesAsync(QuicStream stream)
    {
        while (true)
        {
            ReadResult result = await reader.ReadAsync();
            ReadOnlySequence<byte> buffer = result.Buffer;
    
            while (TryReadLine(ref buffer, out ReadOnlySequence<byte> line))
            { 
                // 处理行数据
                ProcessLine(line);
            }
         
            reader.AdvanceTo(buffer.Start, buffer.End); 
         
            if (result.IsCompleted)
            {
                break;
            }
        }
    
        await reader.CompleteAsync();
        await writer.CompleteAsync();
    
    } 
    
    bool TryReadLine(ref ReadOnlySequence<byte> buffer, out ReadOnlySequence<byte> line)
    { 
        SequencePosition? position = buffer.PositionOf((byte)'\n');
    
        if (position == null)
        {
            line = default;
            return false;
        }
     
        line = buffer.Slice(0, position.Value);
        buffer = buffer.Slice(buffer.GetPosition(1, position.Value));
        return true;
    }
    
    void ProcessLine(in ReadOnlySequence<byte> buffer)
    {
        foreach (var segment in buffer)
        {
            Console.Write("Recevied -> " + System.Text.Encoding.UTF8.GetString(segment.Span));
            Console.WriteLine();
        }
    
        Console.WriteLine();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51

    到这里,客户端和服务端的代码都完成了,客户端使用 Quic 流发送了一些消息给服务端,服务端收到消息后在控制台输出,并回复一个 Ack 消息,因为我们创建了一个双向流。

    4.2.2 多个流

    我们上面说到了一个 QuicConnection 可以创建多个流,并行传输数据。

    改造一下服务端的代码,支持接收多个 Quic 流。

    var cts = new CancellationTokenSource();
    
    while (!cts.IsCancellationRequested)
    {
        var stream = await connection.AcceptInboundStreamAsync();
    
        Console.WriteLine($"Stream [{stream.Id}]: created");
    
        Console.WriteLine();
    
        _ = ProcessLinesAsync(stream); 
    } 
    
    Console.ReadKey();  
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    对于客户端,我们用多个线程创建多个 Quic 流,并同时发送消息。

    默认情况下,一个 Quic 连接的流的限制是 100,当然你可以设置 QuicConnectionOptions 的 MaxInboundBidirectionalStreams 和 MaxInboundUnidirectionalStreams 参数。

    for (int j = 0; j < 5; j++)
    {
        _ = Task.Run(async () => {
    
            // 创建一个出站的双向流
            var stream = await connection.OpenOutboundStreamAsync(QuicStreamType.Bidirectional); 
          
            var writer = PipeWriter.Create(stream); 
    
            Console.WriteLine();
     
            await Task.Delay(2000);
            
            var message = $"Hello Quic [{stream.Id}] \n";
    
            Console.Write("Send -> " + message);
    
            await writer.WriteAsync(Encoding.UTF8.GetBytes(message));
    
            await writer.CompleteAsync(); 
        });  
    } 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    在这里插入图片描述

    目录

    完整服务端:

    using System;
    using System.Buffers;
    using System.IO.Pipelines;
    using System.IO.Pipes;
    using System.Net;
    using System.Net.Quic;
    using System.Net.Security; 
    using System.Security.Cryptography;
    using System.Security.Cryptography.X509Certificates; 
    
    Console.WriteLine("Quic Server Running...");
    
    // 创建 QuicListener
    var listener = await QuicListener.ListenAsync(new QuicListenerOptions
    { 
        ApplicationProtocols = new List<SslApplicationProtocol> { SslApplicationProtocol.Http3  },
        ListenEndPoint = new IPEndPoint(IPAddress.Loopback,9999), 
        ConnectionOptionsCallback = (connection,ssl, token) => ValueTask.FromResult(new QuicServerConnectionOptions()
        {
            DefaultStreamErrorCode = 0,
            DefaultCloseErrorCode = 0,
            ServerAuthenticationOptions = new SslServerAuthenticationOptions()
            {
                ApplicationProtocols = new List<SslApplicationProtocol>() { SslApplicationProtocol.Http3 },
                ServerCertificate = GenerateManualCertificate()
            }
        }) 
    });
    // 生成证书
    X509Certificate2 GenerateManualCertificate()
    {
        X509Certificate2 cert = null;
        var store = new X509Store("KestrelWebTransportCertificates", StoreLocation.CurrentUser);
        store.Open(OpenFlags.ReadWrite);
        if (store.Certificates.Count > 0)
        {
            cert = store.Certificates[^1];
    
            // rotate key after it expires
            if (DateTime.Parse(cert.GetExpirationDateString(), null) < DateTimeOffset.UtcNow)
            {
                cert = null;
            }
        }
        if (cert == null)
        {
            // generate a new cert
            var now = DateTimeOffset.UtcNow;
            SubjectAlternativeNameBuilder sanBuilder = new();
            sanBuilder.AddDnsName("localhost");
            using var ec = ECDsa.Create(ECCurve.NamedCurves.nistP256);
            CertificateRequest req = new("CN=localhost", ec, HashAlgorithmName.SHA256);
            // Adds purpose
            req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection
            {
                new("1.3.6.1.5.5.7.3.1") // serverAuth
    
            }, false));
            // Adds usage
            req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false));
            // Adds subject alternate names
            req.CertificateExtensions.Add(sanBuilder.Build());
            // Sign
            using var crt = req.CreateSelfSigned(now, now.AddDays(14)); // 14 days is the max duration of a certificate for this
            cert = new(crt.Export(X509ContentType.Pfx));
    
            // Save
            store.Add(cert);
        }
        store.Close();
    
        var hash = SHA256.HashData(cert.RawData);
        var certStr = Convert.ToBase64String(hash);
        //Console.WriteLine($"\n\n\n\n\nCertificate: {certStr}\n\n\n\n"); // <-- you will need to put this output into the JS API call to allow the connection
        return cert;
    }
    
    var connection = await listener.AcceptConnectionAsync();
    
    Console.WriteLine($"Client [{connection.RemoteEndPoint}]: connected");
    
    var cts = new CancellationTokenSource();
    
    while (!cts.IsCancellationRequested)
    {
        var stream = await connection.AcceptInboundStreamAsync();
    
        Console.WriteLine($"Stream [{stream.Id}]: created");
    
        Console.WriteLine();
    
        _ = ProcessLinesAsync(stream); 
    } 
    
    Console.ReadKey();      
    
    // 处理流数据
    async Task ProcessLinesAsync(QuicStream stream)
    {
        var reader = PipeReader.Create(stream);  
        var writer = PipeWriter.Create(stream);
    
        while (true)
        {
            ReadResult result = await reader.ReadAsync();
            ReadOnlySequence<byte> buffer = result.Buffer;
    
            while (TryReadLine(ref buffer, out ReadOnlySequence<byte> line))
            {
                // Process the line. 
                ProcessLine(line);
    
                // Ack 
                //await writer.WriteAsync(System.Text.Encoding.UTF8.GetBytes($"ack: {DateTime.Now.ToString("HH:mm:ss")} \n"));
            }
    
            // Tell the PipeReader how much of the buffer has been consumed.
            reader.AdvanceTo(buffer.Start, buffer.End);
    
            // Stop reading if there's no more data coming.
            if (result.IsCompleted)
            {
                break;
            } 
        }
    
        Console.WriteLine($"Stream [{stream.Id}]: completed");
    
        await reader.CompleteAsync();  
        await writer.CompleteAsync();    
    } 
    
    bool TryReadLine(ref ReadOnlySequence<byte> buffer, out ReadOnlySequence<byte> line)
    {
        // Look for a EOL in the buffer.
        SequencePosition? position = buffer.PositionOf((byte)'\n');
    
        if (position == null)
        {
            line = default;
            return false;
        }
    
        // Skip the line + the \n.
        line = buffer.Slice(0, position.Value);
        buffer = buffer.Slice(buffer.GetPosition(1, position.Value));
        return true;
    } 
    
    void ProcessLine(in ReadOnlySequence<byte> buffer)
    {
        foreach (var segment in buffer)
        {
            Console.WriteLine("Recevied -> " + System.Text.Encoding.UTF8.GetString(segment.Span));
        }
    
        Console.WriteLine();
    } 
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    • 152
    • 153
    • 154
    • 155
    • 156
    • 157
    • 158
    • 159

    完整客户端:

    using System.Buffers;
    using System.IO;
    using System.IO.Pipelines;
    using System.IO.Pipes;
    using System.Net;
    using System.Net.Quic;
    using System.Net.Security;
    using System.Reflection.PortableExecutable;
    using System.Text;
    using System.Xml.Linq;
    
    Console.WriteLine("Quic Client Running...");
    
    await Task.Delay(3000);
    
    // 连接到服务端
    var connection = await QuicConnection.ConnectAsync(new QuicClientConnectionOptions
    {
        DefaultCloseErrorCode = 0,
        DefaultStreamErrorCode = 0,
        RemoteEndPoint = new IPEndPoint(IPAddress.Loopback, 9999),
        ClientAuthenticationOptions = new SslClientAuthenticationOptions
        {
            ApplicationProtocols = new List<SslApplicationProtocol> { SslApplicationProtocol.Http3 },
            RemoteCertificateValidationCallback = (sender, certificate, chain, errors) =>
            {
                return true;
            }
        }
    });
    
    for (int j = 0; j < 5; j++)
    {
        _ = Task.Run(async () => {
    
            // 打开一个出站的双向流
            var stream = await connection.OpenOutboundStreamAsync(QuicStreamType.Bidirectional); 
          
            var writer = PipeWriter.Create(stream); 
    
            Console.WriteLine();
    
            // 写入数据
            await Task.Delay(2000);
    
            var message = $"Hello Quic [{stream.Id}] \n";
    
            Console.Write("Send -> " + message);
    
            await writer.WriteAsync(Encoding.UTF8.GetBytes(message));
    
            await writer.CompleteAsync(); 
        });  
    } 
    
    
    Console.ReadKey();  
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
  • 相关阅读:
    磷脂修饰Fe3O4磁性纳米颗粒|罗丹明标记表面氨基功能化葡聚糖修饰的Fe3O4磁性纳米颗粒
    NLP冻手之路(5)——中文情感分类(以BERT为基础,由Hugging Face库支持,代码实践)
    六十八、vue高级
    ECCV 2022|经典算法老当益壮,谷歌提出基于k-means聚类的视觉Transformer
    单片机控制直流电机(风扇)电路详解
    国产720亿参数开源免费模型来了!对标Llama2 70B,一手实测在此
    MySQL之内存篇
    神经网络参数研究方法,神经网络参数研究方向
    php请求库guzzlehttp/guzzle:~6.0拦截请求异常,自定义处理异常案例
    一文读懂野指针
  • 原文地址:https://blog.csdn.net/aa2528877987/article/details/127744186