前言
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());
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>
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;
});
});
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();
3.4 监听WebTransport请求
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 完成,此时连接就可以使用了。
const url = 'https://localhost:5002';
const transport = new WebTransport(url);
await transport.ready;
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}`);
}
二、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;
}
2.客户端
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>WebTransport Test Page</title>
</head>
<body>
<script src="certificate.js"></script>
<div id="panel">
WebTransport Test Page
<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 += '<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>