本文正在参加「金石计划」
前言
1.技术背景
如今对于网络进行实时通讯的要求越来越高,相关于网络进行实时通讯技术应运而生,如WebRTC,QUIC,HTTP3,WebTransport,WebAssembly,WebCodecs等。
2.QUIC相关概念
QUIC相关文章请看:blog.csdn.net/aa252887798…
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的后台服务进行通信:www.w3.org/TR/webtrans…
javascript复制代码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());
3.WebTransport在.NET 7中的使用
3.1 创建项目
新建一个.NET Core 的空项目,修改 csproj 文件
javascript复制代码<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>
3.2 侦听HTTP/3端口
要设置 WebTransport 连接,首先需要配置 Web 主机并通过 HTTP/3 侦听端口:
javascript复制代码// 配置端口 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; }); });
3.3 获取WebTransport会话
javascript复制代码app.Run(async (context) => { var feature = context.Features.GetRequiredFeature<IHttpWebTransportFeature>(); if (!feature.IsWebTransportRequest) { return; } var session = await feature.AcceptAsync(CancellationToken.None); }); await app.RunAsync();
3.4 监听WebTransport请求
javascript复制代码var stream = await session.AcceptStreamAsync(CancellationToken.None); var inputPipe = stream.Transport.Input; var outputPipe = stream.Transport.Output;
4.WebTransport在JavaScript中使用
4.1 创建 WebTransport 实例
传入服务地址并创建 WebTransport 实例, transport.ready 完成,此时连接就可以使用了。
javascript复制代码const url = ''https://localhost:5002''; const transport = new WebTransport(url); await transport.ready;
4.2 通信测试
javascript复制代码// 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}`); }
二、WebTransport通信完整源码
1.服务端
javascript复制代码// 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; }
2.客户端
javascript复制代码<!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 >Communication Log</h2> <table > <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 += ''<div>'' + `<input type="radio" name = "streams" value = "${i}" id = "${i}" >` + `<label for="${i}">${i} - ${openStreams[i].type}</label>` + ''</div >''; } 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 += ''<tr>'' + `<td>${getTimestamp()}</td>` + `<td>${from}</td>` + `<td>${to}</td>` + `<td>${data}</td>` ''</tr>''; } 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>