数据通道
webRTC的数据通道
RTCDataChannel
是专门用来传输除音视频数据之外的任何数据的,如短消息、实时文字聊天、文件传输、远程桌面、游戏控制、P2P加速等;当然像是文本聊天、文件传输也可以通过服务器中转
的方式实现,但webRTC则是优先使用P2P
方案,即两端之间直接传输数据,可以减小服务端压力,当然webRTC也是可以采用中继的方案;
数据通道可以配置在不同模式中,一种是使用重传机制的可靠传输模式(默认模式),可以确保数据成功传输到对等端;另一种是不可靠传输模式,该模式下可以通过设置
maxRetransmits
指定最大传输次数,或通过maxPacketLife设置传输间隔时间实现;这两种配置项是互斥的,不可同时设置,当同为null时使用可靠传输模式,有一个值不为null时开启不可靠传输模式
创建数据通道
function newDataChannel() {
log("*** Create Data Channel.");
dcFile = pc.createDataChannel(peerID, { protocol: "file", id: channelId++ });
dcFile.binaryType = "arraybuffer";
dcFile.bufferedAmountLowThreshold = 65536;
log(
"new data channel , id: " +
dcFile.id +
",binaryType: " +
dcFile.binaryType +
", protocol: " +
dcFile.protocol
);
setupDataChannelEvent(dcFile);
}
- 数据通道相关说明
- 多个数据通道的对应关系
- 如果发起端创建了多个数据通道,接收端会为每个数据通道触发一次dataChannel事件,接收端为了区分数据通道与发送端一一对应,需要利用
Protocol
进行区分,protocol
表示自协议的名称,可以是任意字符串,这样接收端在每个dataChannel事件中获取到发起端的protocol后就可以与发起端一一对应了 - binaryType可以指定
BloB类型
或ArrayBuffer类型
,可以使用数据通道传输二进制数据或其他字符串数据,可以利用传输字符串来在传输文件前将文件信息等元数据发送给接收端 - bufferedAmountLowThreshold属性,该属性对缓存区设置了一条“水位线”,解决了
数据通道不支持设置缓存大小的问题
,当缓冲区从高水位降到设置的水位线时会触发bufferedamountlow
事件,当降低到设置值以下时再次调用send()
方法发送数据
- 如果发起端创建了多个数据通道,接收端会为每个数据通道触发一次dataChannel事件,接收端为了区分数据通道与发送端一一对应,需要利用
- 多个数据通道的对应关系
数据传输中的断点续传
在进行文件数据传输的过程中,需要支持的数据类型较多,包括string、Blob、ArrayBuffer、ArrayBufferView等;在传输的过程中由于网络和设备等原因会导致出现传输失败的问题,此时就可以通过
断点续传
的方式来解决这个问题;
具体的思想是:在传输文件时会将文件分成多个块,在接收端每收到一个文件就告知发送端该模块已经接收完毕,这样当在传输过程中中断时就可以在中断的地方继续重传了;
RTCDataChannel
RTCDataChannel是webRTC专门用来传输
非音视频数据
的类,其设计是参考了WebSocket的实现,其支持的数据类型也非常多,包括字符串、BloB、ArrayBuffer和ArrayBufferView等;
WebSocket是基于
TCP传输
的,可以保证数据安全有序的到达,另外WebSocket需要服务器中转,依靠ICE Servers来穿透NAT,有些场景下可能会多一层TURN服务器的转发
构建WebSocket需要一个URL,与服务器建立连接,创建一个唯一的SocketSessionId。dataChannel的连接依赖于一个RTCPeerConnection对象,当RTCPeerConnection建立起来之后,可以包含一个或多个RTCDataChannel
RTCDataChannel使用的协议是
SCTP
(Stream Control Transport Protocol)(是一种与TCP、UDP同级的传输协议),可以直接在IP协议之上运行,但在WebRTC的情况下,SCTP通过安全的DTLS隧道进行隧道传输,该隧道本身在UDP之上运行,与传统的协议区别如下:
- 使用的模式 - 有序可靠/不可靠无序
- 有序可靠模式(TCP模式)
- 该模式下消息可以有序到达,但消息传输会比较慢
- 不可靠无序模式(UDP模式)
- 不保证消息可达和有序,没有其他开销,速度快
- 部分可靠模式(SCDP模式)
- 消息的有序性和可达性是可以配置的
- 有序可靠模式(TCP模式)
创建与配置RTCDataChannel
RTCDataChannel对象是由RTCPeerConnection对象生成的,有了RTCPeerConnection对象后调用createDataChannel方法就可以创建RTCDataChannel了
var pc = new RTCPeerConnection(); // 创建 RTCPeerConnection 对象
var dc = pc.createDataChannel("dc", options); // 创建RTCDataChannel 对象
- 参数
- 标签(字符串),即
createDataChannel
的名字 - options:配置项
- ordered(Boolean):消息的传递是否有序
- maxPacketLifeTime:重传消息失败的最长时间。也就是说超过这个时间后,即使消息重传失败了也不再进行重传了,与maxRetransmits互斥
- maxRetransmits:重传消息失败的最大次数,与maxPacketLifeTime互斥
- protocol:用户自定义的子协议,也就是说可以根据用户自己的业务需求而定义的私有协议,默认为空
- negotiated:如果为 true,则会删除另一方数据通道的自动设置。这也意味着你可以通过自己的方式在另一侧创建具有相同 ID 的数据通道-带内协商还是带外协商
- id:当 negotiated 为 true 时,允许你提供自己的 ID 与 channel 进行绑定
- readyState:数据通道的状态,类型是RTCDataChannelState枚举类型
- RTCDataChannelState包含的枚举值有
- connecting:正在尝试建立数据传输通道,是RTCDataChannel对象的初识状态
- open:数据传输状态已建立,可以正常通行
- closing:正在关闭数据传输通道
- closed:数据传输通道已关闭或建立失败
- RTCDataChannelState包含的枚举值有
- 标签(字符串),即
createDataChannel对象的创建协商方式
- In-band协商方式(默认)
- 假设通信双方中的一方调用createDataChannel 创建 RTCDataChannel 对象时,将 options 参数中的 negotiated字段设置为 false,则通信的另一方就可以通过它的 RTCPeerConnection 对象的ondatachannel 事件来得到与对方通信的 RTCDataChannel 对象了,这种方式就是 In-band 协商方式
- A 端调用 createDataChannel 创建 RTCDataChannel 对象
- A 端与 B 端交换 SDP,即进行媒体协商(offer/answer)
- 媒体协商完成后双方就可以互相发送消息了
- 当A端向B端发送数据时会触发B端的ondatachannel 事件,从而获取到A端的RTCDataChannel对象,然后双方就可以通过RTCDataChannel通信了
- 优势是:RTCDataChannel 对象可以在需要时自动创建,不需要应用程序做额外的逻辑处理。
- Out-of-band 协商方式
- 这种方式不再是一端调用createDataChannel,另一端监听 ondatachannel 事件,从而实现双方的数据通信;而是两端都调用 createDataChannel 方法创建 RTCDataChannel 对象,再通过 ID 绑定来实现双方的数据通信;
- A 端调用 createDataChannel({negotiated: true, id: 0}) 方法
- B 也调用 createDataChannel({negotiated: true, id: 0}) 方法
- 双方交换 SDP, 即进行媒体协商( offer/answer)
- 媒体协商成功后,数据通道可以被立即使用,它们是通过 ID 进行匹配的(这里的ID 就是上面 options 中指定的 ID,ID 号必须一致)
- 上述的ID是从0开始计数的,每创建一个RTCDataChannel ID就加1,即这些 ID 只能与 WebRTC 实现协商的 SCTP 流数量一样,如果你使用的 ID 太大了,而又没有那么多的SCTP 流的话,那么你的数据通道就不能正常工作了
- 这种方式不再是一端调用createDataChannel,另一端监听 ondatachannel 事件,从而实现双方的数据通信;而是两端都调用 createDataChannel 方法创建 RTCDataChannel 对象,再通过 ID 绑定来实现双方的数据通信;
- 每个RTCPeerConnection连接都关联了一个基础的
SCTP协议
传输通道,即属性sctp,其类型是RTCSctpTransport- RTCSctpTransport包含的各属性有
- Transport:只读,DTLS层的传输通道
- state:只读,类型是RTCSctpTransportState枚举没写,SCTP的传输状态
- RTCSctpTransportState定义了SCTP的传输状态,各状态的含义如下
- connecting:RTCSctpTransport正在协商建立连接,是SCTP传输通道的初始状态
- connected:协商完成,建立SCTP传输通道
- closed:SCTP传输通道关闭
- SCTP传输通道的状态变化,会触发statechange,该事件对应事件句柄onstatechange
- SCTP是运行在UDP上的,本质上是对UDP的封装,在应用层实现了有序性与可靠性的配置,自行实现了TCP的相关功能
- maxMessageSize:只读,单次调用send()方法可以发送的最大字节数
- maxChannels:只读,可以同时打开的最大通道数
- RTCSctpTransport包含的各属性有
createDataChannel事件
createDataChannel()方法会创建一个新的数据通道,用于传输图片、文件、文本、数据包等任意数据,该方法会触发negotiationneeded事件;
- 基本语法:
const dataChannel = RTCPeerConnection.createDataChannel(label,[ options])
- 参数:label,指定通道的名称,长度不可超过65535个字节
- 参数:options,可选参数,数据通道的配置选项
- 返回值:返回类型为RTCDataChannel的数据通道对象
- 异常:会抛出如下异常值
createDataChannel事件与WebSocket的事件处理非常类似,createDataChannel在打开、关闭、接收到消息和出错的时候都会接受的事件
var pc = new RTCPeerConnection(); // 创建 RTCPeerConnection 对象
var dc = pc.createDataChannel("dc", options); // 创建RTCDataChannel 对象
dc.onerror = (error)=>{} // 当通道发生错误时触发该事件 可能是由于连接问题或其他原因导致的
dc.onopen = (event) => {}
dc.onclose = (event) => {} // 当通道关闭时触发此事件 这可能是由于连接中断或者调用`close()`方法导致的
dc.onmessage = (event) => {}
dc.onbufferedamountlow = (event) => {} // 当发送缓冲区的大小低于其缓冲区阈值时触发此事件,这是一个提示 告诉您可以安全的发送更多的数据
- 示例
const pc = new RTCPeerConnection(options);
const channel = pc.createDataChannel("chat");
channel.onopen = (event) => {
channel.send('Hi you!');
}
channel.onmessage = (event) => {
console.log(event.data);
}
- 接收端的ondatachannel事件句柄
- 发起端调用createDataChannel()方法创建数据通道时,应答端会触发dataChannel事件,对应事件句柄是ondatachannel
const pc = new RTCPeerConnection(options);
pc.ondatachannel = (event) => {
const channel = event.channel;
channel.onopen = (event) => {
channel.send("Hi back!");
};
channel.onmessage = (event) => {
console.log(event.data);
};
};
-
close()方法
- 用于关闭数据传输通道,每一个对等方都可以调用该方法关闭数据通道,关闭连接是异步进行的,可以通过监听close事件获取关闭完成的通知
- 调用语法为:
RTCDataChannel.close()
- 调用后将触发以下过程操作
- RTCDataChannel.readyState设置为closing
- close()方法调用返回,同时启动后台任务继续执行下面的任务
- 传输层对未完成发送的数据进行处理,协议层决定继续发送还是丢弃
- 关闭底层传输通道
- RTCDataChannel.readyState变为closed
- 如果底层传输通道关闭失败,触发NetworkError事件
- 如果成功,则触发close事件
- 示例
const pc = new RTCPeerConnection(); const dc = pc.createDataChannel("my channel"); dc.onmessage = (event) => { console.log("received: " + event.data); // 收到第一条消息后就调用close()方法关闭通道 关闭成功会触发close事件 在事件句柄onclose中获得通知 dc.close(); }; dc.onopen = () => { console.log("datachannel open"); }; dc.onclose = () => { // onclose事件句柄 检测通道是否关闭成功 console.log("datachannel close"); }
-
send(data)方法
- 该方法通过数据通道将数据发送到对等端
- 语法:
RTCDataChannel.send(data)
- 参数:data-要发送的数据,类型可以是字符串、BloB、ArrayBuffer或ArrayBufferView,发送的数据大小受
RTCSctpTransport.maxMessageSize
的限制 - 调用失败后返回的异常值
- InvalidStateError:当前数据通道不是open状态
- TypeError:发送数据大小超过了
maxMessageSize
限制 - OperationError:当前缓存队列满了
- 示例
const pc = new RTCPeerConnection(options); const channel = pc.createDataChannel("chat"); channel.onopen = (event) => { let obj = { message: msg, timestamp: new Date(), }; channel.send(JSON.stringify(obj)); };
-
message()方法
- 当从对等端收到消息时触发message事件,对应事件句柄是onmessage,也用于WebSocket消息事件中
- 事件句柄语法:``dc.onmessage => event => {}`
- event属性说明
- data:接收到的任意数据
- origin:描述消息发送源
- lastEventId:当前事件的ID
- source:消息发送源对象
- ports:发送消息使用的端口
dc.onmessage = (ev) => {
let newParagraph = document.createElement("p");
let textNode = document.createTextNode(event.data);
newParagraph.appendChild(textNode);
document.body.appendChild(newParagraph);
};
- other方法
- close():数据通道被关闭时触发close事件,对应事件句柄onclose
- error():数据通道出错时触发,对应事件句柄onerror
- open():用于收发数据的底层传输通道被打开且可用时,触发open事件,对应事件句柄onopen
- 建立数据通道的方式
- 带内协商(默认):发起端调用createDataChannel()方法,接收端监听dataChannel事件,通道建立完成后两端都触发open事件,从而两端都可以获取到数据通道的对象
- 带外协商:在两端都调用createDataChannel()方法,任何一端都无需监听dataChannel事件
- 对等方A调用createDataChannel()方法,指定可选参数,negotiated为true表示带外协商,同时指定ID为0
dataChannel = pc.createDataChannel(label,{negotiated: true, id: 0})
- 通过信令的方式或其他带外方式将ID值传递给对等方B
- 对等方以同样的方式调用createDataChannel()方法,传入相同的ID值
- 开始SDP协商
- 两端都触发open事件
- 对等方A调用createDataChannel()方法,指定可选参数,negotiated为true表示带外协商,同时指定ID为0
et dataChannel = pc.createDataChannel("MyApp Channel", {
negotiated: true
});
dataChannel.addEventListener("open", (event) => {
beginTransmission(dataChannel);
});
requestRemoteChannel(dataChannel.id);
数据通道 - 发送文本消息
使用RTCPeerConnection的createDataChannel()方法创建一个可以发送任意数据的数据通道,创建时需要传递一个字符串为通道ID;
具体实现
- 发送端与接收端建立连接后,发送端可以通过
send
发送消息 - 接收端通过监听
ondatachannel
事件,可以获得远端数据通道
- 远端数据通道监听
onmessage
事件就可以接收
文本了
部分过程分析
- 需要创建的对象与通道
- 本地连接对象:localConnection
- 远端连接对象:remoteConnection
- 发送通道:sendChannel
- 接收通道:receiveChannel
- 实例化本地发送数据通道,指定通道ID,添加onopen和onclose事件
let sendChannel = localConnection.createDataChannel("webrtc-datachannel") //指定通道ID为’webrtc-dataChannel‘
sendChannel.onopen = {}
sendChannel.onclose = {}
- 远端连接里添加ondatachannel事件,对应事件里的回参event.channel对象即为接收端数据通道,然后在该对象上添加onmessage事件,用于接受发送端发送过来的文本消息
remoteConnection.ondatachannel = (event) => {
receiveChannel = event.channel;
//接收消息事件监听
receiveChannel.onmessage = (event) => {
//消息event.data
};
receiveChannel.onopen = {
// ...
};
receiveChannel.onclose = {
// ...
};
};
数据通道 - 发送文件
实施步骤
-
RTCDataChannel对象的创建
- 在传输文件时需要保证文件内容的有序性和完整性
- 需要带options参数进行配置化RTCDataChannel对象
- options - ordered:保证数据有序到达
- options - maxRetransmits:丢包后的重传次数
// 创建 RTCDataChannel 对象的选项 var options = { ordered: true, maxRetransmits: 30, }; // 创建 RTCPeerConnection 对象 var pc = new RTCPeerConnection(); // 创建 RTCDataChannel 对象 var dc = pc.createDataChannel("dc", options);
- 通过RTCDataChannel对象接收数据
- 创建好RTCDataChannel对象后,需要进行对应事件的回调函数设置
var pc = new RTCPeerConnection(); // 创建 RTCPeerConnection 对象 var dc = pc.createDataChannel("dc", options); // 创建RTCDataChannel 对象 dc.onerror = (error)=>{} dc.onopen = () => {} dc.onclose = () => {} dc.onmessage = (event) => {}//数据到达时触发该事件
- 实现
onmessage
事件
var receiveBuffer = []; // 存放数据的数组 var receiveSize = 0; // 数据大小 onmessage = (event) => { // 每次事件被触发时,说明有数据来了,将收到的数据放到数组中 receiveBuffer.push(event.data); // 更新已经收到的数据的长度 receivedSize += event.data.byteLength; // 如果接收到的字节数与文件大小相同,则创建文件 if (receivedSize === fileSize) { //fileSize 是通过信令传过来的 // 创建文件 var received = new Blob(receiveBuffer, { type: "application/octet-stream", }); // 将 buffer 和 size 清空,为下一次传文件做准备 receiveBuffer = []; receiveSize = 0; // 生成下载地址 downloadAnchor.href = URL.createObjectURL(received); downloadAnchor.download = fileName; downloadAnchor.textContent = `Click to download '${fileName}' (${fileSize} bytes)`; downloadAnchor.style.display = "block"; } };
-
文件的读取与发送
- 注意点1:sendData函数的执行是在fileSlice(0)开始的
- 注意点2:fileReader对象的onload事件是在有数据被读入到FileReader的缓冲区之后再触发的
function sendData() { var offset = 0; // 偏移量 var chunkSize = 16384; // 每次传输的块大小 var file = fileInput.files[0]; // 要传输的文件,它是通过 HTML 中的 file 获取的 // 创建 fileReader 来读取文件 fileReader = new FileReader(); fileReader.onload = (e) => { // 当数据被加载时触发该事件 dc.send(e.target.result); // 发送数据 offset += e.target.result.byteLength; // 更改已读数据的偏移量 if (offset < file.size) { // 如果文件没有被读完 readSlice(offset); // 读取数据 } }; var readSlice = (o) => { const slice = file.slice(offset, o + chunkSize); // 计算数据位置 fileReader.readAsArrayBuffer(slice); // 读取 16K 数据 //数据读取到缓冲区后就会触发fileReader的onload事件,从而执行onload的回调函数 }; readSlice(0); // 开始读取数据 }
-
通过信令传递文件的基本信息
- 文件在发送前,需要通知接收端知道所要接收的文件大小、类型和文件名,这些信息就需要通过信令服务器进行传递信息给接收端
- 发送端通过信令服务器(socket.io)发送信息到接收端
// 获取文件相关的信息 fileName = file.name; fileSize = file.size; fileType = file.type; lastModifyTime = file.lastModified; // 向信令服务器发送消息 sendMessage(roomid, { // 将文件信息以 JSON 格式发磅 type: "fileinfo", name: file.name, size: file.size, filetype: file.type, lastmodify: file.lastModified, }); //dcFile.send(JSON.stringify({ // method: 'file', // name: file.name, // size: file.size //}));
- 接收端进行信令接收,接收到信息后只需要等待文件传输完成后再根据这些信息生成对应的文件即可
socket.on("message", (roomid, data) => { // 如果是 fileinfo 类型的消息 if (data.hasOwnProperty("type") && data.type === "fileinfo") { // 读出文件的基本信息 fileName = data.name; fileType = data.filetype; fileSize = data.size; lastModifyTime = data.lastModify; } });
- 拓展-数据分片传输
- 语法:
const newBlob = blob.slice(start, end, contentType)
- start:newBlob的起始字段
- end:newBlob的结束字段
- contentType:内容种类,默认为空值
- 由于File继承自Blob,因此File文件对象也可以调用slice()方法分段读取文件
- 获取用户对象的文件对象File
- 调用File的slice()方法,该方法分段读取File对象数据,并返回一个BloB对象
- 对文件进行分段读取时,需要权衡分段的大小,避免分段过小导致的CPU开销过大问题和过大导致的数据传输丢失的问题
- 调用Blob对象的ArrayBuffer()方法,异步读取ArrayBuffer数据
- ArrayBuffer()方法从Blob对象中读取数据,返回一个Promise,成功后会得到一个ArrayBuffer类型的二进制数据,其语法如下
const aPromise = blob.arrayBuffer(); blob.arrayBuffer().then(buffer => /* process the ArrayBuffer */); const buffer = await blob.arrayBuffer();
- ArrayBuffer()方法从Blob对象中读取数据,返回一个Promise,成功后会得到一个ArrayBuffer类型的二进制数据,其语法如下
- 如果数据通道当前剩余缓存大于bufferedAmountLowThreshold,则等待bufferedamountlow事件,降下来之后再进行数据发送
async function readFileData(file) { let offset = 0; let buffer = null; const chunkSize = pc.sctp.maxMessageSize; while(offset < file.size) { const slice = file.slice(offset, offset + chunkSize); buffer = await slice.arrayBuffer(); if (dcFile.bufferedAmount > 65535) { // 等待缓存队列降到阈值之下 await new Promise(resolve => { dcFile.onbufferedamountlow = (ev) => { log("bufferedamountlow event! bufferedAmount: " + dcFile.bufferedAmount); resolve(0); } }); } // 可以发送数据了 dcFile.send(buffer); offset += buffer.byteLength; sendProgress.value = offset; // 更新发送速率 const interval = (new Date()).getTime() - lastReadTime; bitrateSpan.textContent = `${Math.round(chunkSize * 8 /interval)}kbps`; lastReadTime = (new Date()).getTime(); } }
- 发送ArrayBuffer数据
- 接收端接收的数据有
- 元数据:字符串格式的文件描述信息数据
- 文件内容数据:将文件内容数据追加的接收缓存中,当接收的总数据与文件大小一致时表示数据接收完毕
- 接收端使用typeof()方法对文件类型进行判断,如果是字符串再对methods进行判断,当method为File时表示为元数据,不是字符串时表示为文件内容数据
class PeerFile { constructor() {} reset() { this.name = ""; this.size = 0; this.buffer = []; this.receivedSize = 0; this.time = new Date().getTime(); } } const receiveFile = new PeerFile(); function handleDataMessage(channel, data) { log(`Receive data channel message ,type: ${typeof data}`); if (typeof data === "string") { // 字符串 log(`Receive string data from '${channel.protocol}', data: ${data}`); const mess = JSON.parse(data); if (mess.method === "file") { // 文件元数据 receiveFile.reset(); receiveFile.name = mess.name; receiveFile.size = mess.size; receiveProgress.max = mess.size; } else if (mess.method === "message") { // 聊天消息 handleReceivedMessage(mess); } return; } // 文件内容数据 log( `Receive binary data from '${channel.protocol}', size: ${data.byteLength}` ); receiveFile.buffer.push(data); receiveFile.receivedSize += data.byteLength; // 更新进度条 receiveProgress.value = receiveFile.receivedSize; // 更新接收速率 const interval = new Date().getTime() - receiveFile.time; bitrateSpan.textContent = ` ${Math.round( (data.byteLength * 8) / interval )}kbps`; receiveFile.time = new Date().getTime(); if (receiveFile.receivedSize === receiveFile.size) { // 文件接收完毕,开始下载 downloadFile(receiveFile); } }
- 语法:
数据通道 - 白板共享
白板操作相对简单一些,不需要视频通话,因此不需要进行媒体流的相应处理,只需要借助canvas画板作为一路媒体流来建立连接,这样对方就可以看到自己的画布了,主要是利用captureStreamAPI进行;
- 语法:
this.localstream = this.$refs['canvas'].captureStream();
数据通道创造性用途
数据通道不仅仅包含即时消息,通常数据通道让人熟知的功能是:支持语音、视频和数据,数据部分通常放在文字聊天和“超越”的内涵中;
文件共享
最明显的是可以在两个浏览器之间共享文件,不需要依赖服务端,但需要让浏览器在两端都运行,因此没有异步的特性,其减少了所需的宽带,且有了权限访问的限制,增加了数据传递的隐私性
CDN扩充
当多个浏览器访问同一个数据资源时,可以使用数据通道让他们在对等端发送该资源的片段,从而减少CDN的负载;
此种方式也称“点辅助交付”或“P2P CDN”
网络上的比特流
采用上述的1和2的结合,可以在网络上运行bit torrent - 可以在网络上与任何人共享来自一个来源的文件
低延迟网络
由于可以让两个浏览器之间直接访问,因此可以使用数据通道创建一个低延迟的网络,在该网络中数据直接共享,而无需在途中通过服务器;
无服务器的网络
如何使用数据通道在浏览器之间创建Web服务器,这样可以减少“真实”Web服务器运行服务的需求,并将这些服务器仅用作临时创建的动态网络的访问点