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



    前言

    1.技术背景

    如今对于网络进行实时通讯的要求越来越高,相关于网络进行实时通讯技术应运而生,如WebRTC,QUIC,HTTP3,WebTransport,WebAssembly,WebCodecs等。

    2.QUIC相关概念

    QUIC相关文章请看:https://blog.csdn.net/aa2528877987/article/details/127744186

    3.HTTP/3.0

    • 基于QUIC协议:天然复用QUIC的各种优势;
    • 新的HTTP头压缩机制:QPACK,是对HTTP/2中使用的HPACK的增强。在QPACK下,HTTP头可以在不同的QUIC流中不按顺序到达。

    一、WebTransport

    1.WebTransport概念

    WebTransport 是一个协议框架,该协议使客户端与远程服务器在安全模型下通信,并且采用安全多路复用传输技术。最新版的WebTransport草案中,该协议是基于HTTP3的,即WebTransport可天然复用QUIC和HTTP3的特性。

    WebTransport 是一个新的 Web API,使用 HTTP/3 协议来支持双向传输。它用于 Web 客户端和 HTTP/3 服务器之间的双向通信。它支持通过 不可靠的 Datagrams API 发送数据,也支持可靠的 Stream API 发送数据。

    WebTransport 支持三种不同类型的流量:数据报(datagrams) 以及单向流和双向流。

    WebTransport 的设计基于现代 Web 平台基本类型(比如 Streams API)。它在很大程度上依赖于 promise,并且可以很好地与 async 和 await 配合使用。

    2.WebTransport在js中的使用

    W3C的文档关于支持WebTransport的后台服务进行通信:https://www.w3.org/TR/webtransport/
    在这里插入图片描述

    let transport = new WebTransport("https://x.x.x.x");
    await transport.ready;
    let stream = await transport.createBidirectionalStream();
    let encoder = new TextEncoder();
    let writer = stream.writable.getWriter();
    await writer.write(encoder.encode("Hello, world! What's your name?"))
    writer.close();
    console.log(await new Response(stream.readable).text());
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    3.WebTransport在.NET 7中的使用

    3.1 创建项目

    新建一个.NET Core 的空项目,修改 csproj 文件

    <Project Sdk="Microsoft.NET.Sdk.Web">
    
      <PropertyGroup>
        <TargetFramework>net7.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
        <AspNetCoreHostingModel>OutOfProcess</AspNetCoreHostingModel>
        <!-- 1.Turn on preview features -->
        <EnablePreviewFeatures>True</EnablePreviewFeatures>
      </PropertyGroup>
    
      <ItemGroup>
        <!-- 2.Turn on the WebTransport AppContext switch -->
        <RuntimeHostConfigurationOption Include="Microsoft.AspNetCore.Server.Kestrel.Experimental.WebTransportAndH3Datagrams" Value="true" />
      </ItemGroup>
    
    </Project>
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    3.2 侦听HTTP/3端口

    要设置 WebTransport 连接,首先需要配置 Web 主机并通过 HTTP/3 侦听端口:

    // 配置端口
    builder.WebHost.ConfigureKestrel((context, options) =>
    {
        // 配置web站点端口
        options.Listen(IPAddress.Any, 5551, listenOptions =>
        {
            listenOptions.UseHttps();
            listenOptions.Protocols = HttpProtocols.Http1AndHttp2;
        });
        // 配置webtransport端口
        options.Listen(IPAddress.Any, 5552, listenOptions =>
        {
            listenOptions.UseHttps(certificate);
            listenOptions.UseConnectionLogging();
            listenOptions.Protocols = HttpProtocols.Http1AndHttp2AndHttp3;
        });
    });
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    3.3 获取WebTransport会话

    app.Run(async (context) =>
    {
        var feature = context.Features.GetRequiredFeature<IHttpWebTransportFeature>();
        if (!feature.IsWebTransportRequest)
        {
            return;
        }
        var session = await feature.AcceptAsync(CancellationToken.None); 
    });
    
    await app.RunAsync();
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    3.4 监听WebTransport请求

    var stream = await session.AcceptStreamAsync(CancellationToken.None);
    
    var inputPipe = stream.Transport.Input;
    var outputPipe = stream.Transport.Output;
    
    • 1
    • 2
    • 3
    • 4

    4.WebTransport在JavaScript中使用

    4.1 创建 WebTransport 实例

    传入服务地址并创建 WebTransport 实例, transport.ready 完成,此时连接就可以使用了。

    const url = 'https://localhost:5002';
    const transport = new WebTransport(url);
     
    await transport.ready;
    
    • 1
    • 2
    • 3
    • 4

    4.2 通信测试

    // Send two Uint8Arrays to the server.
    const stream = await transport.createSendStream();
    const writer = stream.writable.getWriter();
    const data1 = new Uint8Array([65, 66, 67]);
    const data2 = new Uint8Array([68, 69, 70]);
    writer.write(data1);
    writer.write(data2);
    try {
      await writer.close();
      console.log('All data has been sent.');
    } catch (error) {
      console.error(`An error occurred: ${error}`);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    二、WebTransport通信完整源码

    1.服务端

    // Licensed to the .NET Foundation under one or more agreements.
    // The .NET Foundation licenses this file to you under the MIT license.
    #nullable enable
    
    using System.Net;
    using System.Security.Cryptography;
    using System.Security.Cryptography.X509Certificates;
    using System.Text;
    using Microsoft.AspNetCore.Connections;
    using Microsoft.AspNetCore.Connections.Features;
    using Microsoft.AspNetCore.Http.Features;
    using Microsoft.AspNetCore.Server.Kestrel.Core;
    
    var builder = WebApplication.CreateBuilder(args);
    
    // 生成密钥
    var certificate = GenerateManualCertificate();
    var hash = SHA256.HashData(certificate.RawData);
    var certStr = Convert.ToBase64String(hash);
    
    // 配置端口
    builder.WebHost.ConfigureKestrel((context, options) =>
    {
        // 配置web站点端口
        options.Listen(IPAddress.Any, 5551, listenOptions =>
        {
            listenOptions.UseHttps();
            listenOptions.Protocols = HttpProtocols.Http1AndHttp2;
        });
        // 配置webtransport端口
        options.Listen(IPAddress.Any, 5552, listenOptions =>
        {
            listenOptions.UseHttps(certificate);
            listenOptions.UseConnectionLogging();
            listenOptions.Protocols = HttpProtocols.Http1AndHttp2AndHttp3;
        });
    });
    
    var app = builder.Build();
    
    // 使可index.html访问
    app.UseFileServer();
    
    app.Use(async (context, next) =>
    {
        // 配置/certificate.js以注入证书哈希
        if (context.Request.Path.Value?.Equals("/certificate.js") ?? false)
        {
            context.Response.ContentType = "application/javascript";
            await context.Response.WriteAsync($"var CERTIFICATE = '{certStr}';");
        }
    
        // 配置服务器端应用程序
        else
        {
            //判断是否是WebTransport请求
            var feature = context.Features.GetRequiredFeature<IHttpWebTransportFeature>();
    
            if (!feature.IsWebTransportRequest)
            {
                await next(context);
            }
            //获取判断是否是WebTransport请求会话
            var session = await feature.AcceptAsync(CancellationToken.None);
    
            if (session is null)
            {
                return;
            }
    
            while (true)
            {
                ConnectionContext? stream = null;
                IStreamDirectionFeature? direction = null;
                // 等待消息
                stream = await session.AcceptStreamAsync(CancellationToken.None);
                if (stream is not null)
                {
                    direction = stream.Features.GetRequiredFeature<IStreamDirectionFeature>();
                    if (direction.CanRead && direction.CanWrite)
                    {
                        //单向流
                        _ = handleBidirectionalStream(session, stream);
                    }
                    else
                    {
                        //双向流
                        _ = handleUnidirectionalStream(session, stream);
                    }
                }
            }
        }
    });
    
    await app.RunAsync();
    
    static async Task handleUnidirectionalStream(IWebTransportSession session, ConnectionContext stream)
    {
        var inputPipe = stream.Transport.Input;
    
        // 将流中的一些数据读入内存
        var memory = new Memory<byte>(new byte[4096]);
        while (!stream.ConnectionClosed.IsCancellationRequested)
        {
            var length = await inputPipe.AsStream().ReadAsync(memory);
    
            var message = Encoding.Default.GetString(memory[..length].ToArray());
    
            await ApplySpecialCommands(session, message);
    
            Console.WriteLine("RECEIVED FROM CLIENT:");
            Console.WriteLine(message);
    
        }
    }
    
    static async Task handleBidirectionalStream(IWebTransportSession session, ConnectionContext stream)
    {
        var inputPipe = stream.Transport.Input;
        var outputPipe = stream.Transport.Output;
    
        // 将流中的一些数据读入内存
        var memory = new Memory<byte>(new byte[4096]);
        while (!stream.ConnectionClosed.IsCancellationRequested)
        {
            var length = await inputPipe.AsStream().ReadAsync(memory);
    
            // 切片以仅保留内存的相关部分
            var outputMemory = memory[..length];
    
            // 处理特殊命令
            await ApplySpecialCommands(session, Encoding.Default.GetString(outputMemory.ToArray()));
    
            // 对数据内容进行一些操作
            outputMemory.Span.Reverse();
    
            // 将数据写回流
            await outputPipe.WriteAsync(outputMemory);
    
            memory.Span.Fill(0);
        }
    }
    
    static async Task ApplySpecialCommands(IWebTransportSession session, string message)
    {
        switch (message)
        {
            case "Initiate Stream":
                var stream = await session.OpenUnidirectionalStreamAsync();
                if (stream is not null)
                {
                    await stream.Transport.Output.WriteAsync(new("Created a new stream from the client and sent this message then closing the stream."u8.ToArray()));
                }
                break;
            case "Abort":
                session.Abort(256 /*No error*/);
                break;
            default:
                break; // in all other cases the string is not a special command
        }
    }
    
    // Adapted from: https://github.com/wegylexy/webtransport
    // We will need to eventually merge this with existing Kestrel certificate generation
    // tracked in issue #41762
    static X509Certificate2 GenerateManualCertificate()
    {
        X509Certificate2 cert;
        var store = new X509Store("KestrelSampleWebTransportCertificates", 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)
            {
                store.Close();
                return cert;
            }
        }
        // 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();
        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
    • 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
    • 160
    • 161
    • 162
    • 163
    • 164
    • 165
    • 166
    • 167
    • 168
    • 169
    • 170
    • 171
    • 172
    • 173
    • 174
    • 175
    • 176
    • 177
    • 178
    • 179
    • 180
    • 181
    • 182
    • 183
    • 184
    • 185
    • 186
    • 187
    • 188
    • 189
    • 190
    • 191
    • 192
    • 193
    • 194
    • 195
    • 196
    • 197
    • 198
    • 199
    • 200
    • 201
    • 202
    • 203
    • 204
    • 205

    2.客户端

    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="utf-8" />
        <title>WebTransport Test Page</title>
    </head>
    <body>
        <script src="certificate.js"></script>
    
        <div id="panel">
            <h1 id="title">WebTransport Test Page</h1>
            <h3 id="stateLabel">Ready to connect...</h3>
            <div>
                <label for="connectionUrl">WebTransport Server URL:</label>
                <input id="connectionUrl" value="https://127.0.0.1:5552" disabled />
                <p>Due to the need to synchronize certificates, you cannot modify the url at this time.</p>
                <button id="connectButton" type="submit" onclick="connect()">Connect</button>
            </div>
    
            <div id="communicationPanel" hidden>
                <div>
                    <button id="closeStream" type="submit" onclick="closeActiveStream()">Close active stream</button>
                    <button id="closeConnection" type="submit" onclick="closeConnection()">Close connection</button>
                    <button id="createBidirectionalStream" type="submit" onclick="createStream('bidirectional')">Create bidirectional stream</button>
                    <button id="createUnidirectionalStream" type="submit" onclick="createStream('output unidirectional')">Create unidirectional stream</button>
                </div>
                <h2>Open Streams</h2>
                <div id="streamsList">
                    <p id="noStreams">Empty</p>
                </div>
    
                <div>
                    <h2>Send Message</h2>
                    <textarea id="messageInput" name="text" rows="12" cols="50"></textarea>
                    <button id="sendMessageButton" type="submit" onclick="sendMessage()">Send</button>
                </div>
            </div>
        </div>
    
        <div id="communicationLogPanel">
            <h2 style="text-align: center;">Communication Log</h2>
            <table style="overflow: auto; max-height: 1000px;">
                <thead>
                    <tr>
                        <td>Timestamp</td>
                        <td>From</td>
                        <td>To</td>
                        <td>Data</td>
                    </tr>
                </thead>
                <tbody id="commsLog">
                </tbody>
            </table>
        </div>
    
        <script>
            let connectionUrl = document.getElementById("connectionUrl");
            let messageInput = document.getElementById("messageInput");
            let stateLabel = document.getElementById("stateLabel");
            let commsLog = document.getElementById("commsLog");
            let streamsList = document.getElementById("streamsList");
            let communicationPanel = document.getElementById("communicationPanel");
            let noStreamsText = document.getElementById("noStreams");
    
            let session;
            let connected = false;
            let openStreams = [];
    
            async function connect() {
                if (connected) {
                    alert("Already connected!");
                    return;
                }
    
                communicationPanel.hidden = true;
                stateLabel.innerHTML = "Ready to connect...";
    
                session = new WebTransport(connectionUrl.value, {
                    serverCertificateHashes: [
                        {
                            algorithm: "sha-256",
                            value: Uint8Array.from(atob(CERTIFICATE), c => c.charCodeAt(0))
                        }
                    ]
                });
                stateLabel.innerHTML = "Connecting...";
    
                await session.ready;
    
                startListeningForIncomingStreams();
    
                setConnection(true);
            }
    
            async function closeConnection() {
                if (!connected) {
                    alert("Not connected!");
                    return;
                }
    
                for (let i = 0; i < openStreams.length; i++) {
                    await closeStream(i);
                }
    
                await session.close();
    
                setConnection(false);
    
                openStreams = [];
                updateOpenStreamsList();
            }
    
            function setConnection(state) {
                connected = state;
                communicationPanel.hidden = !state;
    
                let msg = state ? "Connected!" : "Disconnected!";
                stateLabel.innerHTML = msg;
                addToCommsLog("Client", "Server", msg);
            }
    
            function updateOpenStreamsList() {
                streamsList.innerHTML = "";
                for (let i = 0; i < openStreams.length; i++) {
                    streamsList.innerHTML += '
    ' + `${i}" id = "${i}" >` + `">${i} - ${openStreams[i].type}` + '
    '
    ; } noStreamsText.hidden = openStreams.length > 0; } async function closeActiveStream() { let streamIndex = getActiveStreamIndex(); await closeStream(streamIndex); } async function closeStream(index) { let stream = openStreams[index].stream; if (!stream) { return; } // close the writable part of a stream if it exists if (stream.writable) { await stream.writable.close(); } // close the readable part of a stream if it exists if (stream.cancelReaderAndClose) { await stream.cancelReaderAndClose(); } // close the stream if it can be closed manually if (stream.close) { await stream.close(); } // remove from the list of open streams openStreams.splice(index, 1); updateOpenStreamsList(); } async function startListeningForIncomingStreams() { try { let streamReader = session.incomingUnidirectionalStreams.getReader(); let stream = await streamReader.read(); if (stream.value && confirm("New incoming stream. Would you like to accept it?")) { startListeningForIncomingData(stream.value, openStreams.length, "input unidirectional"); // we don't add to the stream list here as the only stream type that we can receive is // input. As we can't send data over these streams, there is no point in showing them to the user } } catch { alert("Failed to accept incoming stream"); } } async function startListeningForIncomingData(stream, streamId, type) { let reader = isBidirectional(type) ? stream.readable.getReader() : stream.getReader(); // define a function that we can use to abort the reading on this stream var closed = false; stream.cancelReaderAndClose = () => { console.log(reader); reader.cancel(); reader.releaseLock(); closed = true; } // read loop for the stream try { while (true) { let data = await reader.read(); let msgOut = ""; data.value.forEach(x => msgOut += String.fromCharCode(x)); addToCommsLog("Server", "Client", `RECEIVED FROM STREAM ${streamId}: ${msgOut}`); } } catch { alert(`Stream ${streamId} ${closed ? "was closed" : "failed to read"}. Ending reading from it.`); } } async function createStream(type) { if (!connected) { return; } let stream; switch (type) { case 'output unidirectional': stream = await session.createUnidirectionalStream(); break; case 'bidirectional': stream = await session.createBidirectionalStream(); startListeningForIncomingData(stream, openStreams.length, "bidirectional"); break; default: alert("Unknown stream type"); return; } addStream(stream, type); } function addStream(stream, type) { openStreams.push({ stream: stream, type: type }); updateOpenStreamsList(); addToCommsLog("Client", "Server", `CREATING ${type} STREAM WITH ID ${openStreams.length}`); } async function sendMessage() { if (!connected) { return; } let activeStreamIndex = getActiveStreamIndex(); if (activeStreamIndex == -1) { alert((openStreams.length > 0) ? "Please select a stream first" : "Please create a stream first"); } let activeStreamObj = openStreams[activeStreamIndex]; let activeStream = activeStreamObj.stream; let activeStreamType = activeStreamObj.type; let writer = isBidirectional(activeStreamType) ? activeStream.writable.getWriter() : activeStream.getWriter(); let msg = messageInput.value.split("").map(x => (x).charCodeAt(0)); await writer.write(new Uint8Array(msg)); writer.releaseLock(); addToCommsLog("Client", "Server", `SENDING OVER STREAM ${activeStreamIndex}: ${messageInput.value}`); } function isBidirectional(type) { return type === "bidirectional"; } function getActiveStream() { let index = getActiveStreamIndex(); return (index === -1) ? null : openStreams[index].stream; } function getActiveStreamIndex() { let allStreams = document.getElementsByName("streams"); for (let i = 0; i < allStreams.length; i++) { if (allStreams[i].checked) { return i; } } return -1; } function addToCommsLog(from, to, data) { commsLog.innerHTML += '' + `${getTimestamp()}` + `${from}` + `${to}` + `${data}` ''; } function getTimestamp() { let now = new Date(); return `${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}.${now.getMilliseconds()}`; } </script> </body> </html> <style> html, body { background-color: #459eda; padding: 0; margin: 0; height: 100%; } body { display: flex; flex-direction: row; } #panel { background-color: white; padding: 12px; margin: 0; border-top-right-radius: 12px; border-bottom-right-radius: 12px; border-right: #0d6cad 5px solid; max-width: 400px; min-width: 200px; flex: 1; min-height: 1200px; overflow: auto; } #panel > * { text-align: center; } #communicationLogPanel { padding: 24px; flex: 1; } #communicationLogPanel > * { color: white; width: 100%; } #connectButton { background-color: #2e64a7; } #messageInput { max-width: 100%; } #streamsList { max-height: 400px; overflow: auto; } input { padding: 6px; margin-bottom: 8px; margin-right: 0; } button { background-color: #459eda; border-radius: 5px; border: 0; text-align: center; color: white; padding: 8px; margin: 4px; width: 100%; } </style>
    • 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
    • 160
    • 161
    • 162
    • 163
    • 164
    • 165
    • 166
    • 167
    • 168
    • 169
    • 170
    • 171
    • 172
    • 173
    • 174
    • 175
    • 176
    • 177
    • 178
    • 179
    • 180
    • 181
    • 182
    • 183
    • 184
    • 185
    • 186
    • 187
    • 188
    • 189
    • 190
    • 191
    • 192
    • 193
    • 194
    • 195
    • 196
    • 197
    • 198
    • 199
    • 200
    • 201
    • 202
    • 203
    • 204
    • 205
    • 206
    • 207
    • 208
    • 209
    • 210
    • 211
    • 212
    • 213
    • 214
    • 215
    • 216
    • 217
    • 218
    • 219
    • 220
    • 221
    • 222
    • 223
    • 224
    • 225
    • 226
    • 227
    • 228
    • 229
    • 230
    • 231
    • 232
    • 233
    • 234
    • 235
    • 236
    • 237
    • 238
    • 239
    • 240
    • 241
    • 242
    • 243
    • 244
    • 245
    • 246
    • 247
    • 248
    • 249
    • 250
    • 251
    • 252
    • 253
    • 254
    • 255
    • 256
    • 257
    • 258
    • 259
    • 260
    • 261
    • 262
    • 263
    • 264
    • 265
    • 266
    • 267
    • 268
    • 269
    • 270
    • 271
    • 272
    • 273
    • 274
    • 275
    • 276
    • 277
    • 278
    • 279
    • 280
    • 281
    • 282
    • 283
    • 284
    • 285
    • 286
    • 287
    • 288
    • 289
    • 290
    • 291
    • 292
    • 293
    • 294
    • 295
    • 296
    • 297
    • 298
    • 299
    • 300
    • 301
    • 302
    • 303
    • 304
    • 305
    • 306
    • 307
    • 308
    • 309
    • 310
    • 311
    • 312
    • 313
    • 314
    • 315
    • 316
    • 317
    • 318
    • 319
    • 320
    • 321
    • 322
    • 323
    • 324
    • 325
    • 326
    • 327
    • 328
    • 329
    • 330
    • 331
    • 332
    • 333
    • 334
    • 335
    • 336
    • 337
    • 338
    • 339
    • 340
    • 341
    • 342
    • 343
    • 344
    • 345
    • 346
    • 347
    • 348
    • 349
    • 350
    • 351
    • 352
    • 353
    • 354
    • 355
    • 356
    • 357
    • 358
    • 359
    • 360
    • 361
    • 362
    • 363
    • 364
    • 365
    • 366
    • 367

    3.效果

    在这里插入图片描述

    在这里插入图片描述

  • 相关阅读:
    10:00面试,10:08就出来了,问的问题有点变态。。。
    万应案例精选|以“数”战“疫”,万应低代码敏捷开发筑牢抗“疫”堡垒
    进程地址空间
    含文档+PPT+源码等]精品基于SSM的公交线路查询系统[包运行成功]Java毕业设计SSM项目源码论文
    机械硬盘HDD
    TortoiseSVN小乌龟的使用
    小程序的wxss和css区别?
    nginx —— win下搭建nginx - hls服务,使用FFmpeg进行rtmp/http/m3u8推拉流
    Swing有几种常用的事件处理方式?如何监听事件?
    Mysql JSON 类型 索引&查询 操作
  • 原文地址:https://blog.csdn.net/aa2528877987/article/details/128046202