使用Webrtc和React Js在网络上共享跨平台的点对点文件
我的动机
我们的目标是制作一个精简易用的点对点文件共享网络应用程序,将更多的精力投入到用户体验与简单地办事上。这个网络应用程序不只是针对特定的个人群体服务的,而是针对整个社区服务。
既然有这么多文件共享网站,为什么我们还要做这些呢?
当然,我也思考过这个问题,但所有的这些网站都没有真正地说明过这些文件在哪里共享或存储。这可能是一种隐私威胁,因为在当前疫情的情况下,许多人或许经常使用这些服务来共享文件甚至机密文件。使用安全的点对点连接和它的数据通道可以传输大量的文件,却不需要存储在任何服务器上,这使得它真正地结实与私有,因为只有连接的客户端/对等端直接与中间服务器通信,不需要中间服务器进行传输。
WebRTC使对等连接和数据通道成为可能。WebRTC基本上是一种相互通信与传送数据的全球网络方式,类似于蓝牙、NFC和WIFI数据共享。我们可以使用WebRTC实现跨平台支持,因为它是基于网络的。
让我们更深入地研究WebRTC。
WebRTC
“WebRTC是一个免费的开放项目,通过简单的APIs为浏览器与移动应用程序提供实时通信(RTC)功能。WebRTC组件已经进行了优化,以更好地满足这一目的。”
webrtc.org
好吧,假设,一个“点对点”关联考虑两部设备之间发送的直接信息,而不需要服务器保存这些信息。听起来这对我们的情况很理想对吧?不幸的是,这不是WebRTC工作的方式!
image图为使用WebRTC进行数据传输
尽管WebRTC实现了点对点连接,但它确实需要一个称为信令服务器的服务器,该服务器用于共享有关预期将其相互连接的设备的数据。这些微妙之处可以通过任何传统的信息共享技术来共享。WebSockets在这里受到青睐,因为它减少了在一个庞大的建立关联的系统中共享这些额外数据的惰性。
简而言之,信令服务器帮助建立连接,然而,当连接建立后,服务器将不再涉及相关设备之间共享的信息。
一年前,当我开始我的第一个WebRTC项目时,很难找到一个在“production”级别下工作得像样的模型。后来我在网上找到了这个Youtube频道编码。开发人员给出了关于可用于生产的WebRTC应用程序的一些很好的例子。
WebRTC如何创建一个连接(技术)
好吧,没有简单的方法来解释这一点,但我的看法是,在网络上所有数量可观的设备中,无论如何都必须有一个设备通过产生信号来启动连接,并将其发送到信令服务器上。这个对等点被称为启动器,在simple-peer(此项目中使用的模块)中,当创建一个启动器对等点时,{initiator:true}会被传递给制作者/构造函数。
image如图:信号服务器在运行
当我们得到对等点的信号信息时,这些信息应该通过某种方式通过信令服务器发送到不同的集线器。不同的集线器获取此信息并尝试与发起程序建立关联。在这个过程中,这些对等体同样产生它们的信号信息并被发送给发起方。发起方获取此信息并尝试与其余对等方建立连接。
瞧!这些设备现在已经连接起来,现在有一个数据通道,可以在没有中间服务器的情况下共享信息。
尽量不要过分强调你无法理解WebRTC的上述工作方式以及简单对等点如何把它抽象化。当我一开始摆弄WebRTC时,它吓了我一大跳。接下来的部分将对这一点进行更简单和细致的解释。
与WebRTC共享文件(使用simple-peer)
const express = require("express");
const http = require("http");
const app = express();
const server = http.createServer(app);
const socket = require("socket.io");
const io = socket(server);
const users = {};
const socketToRoom = {};
io.on('connection', socket => {
socket.on("join room", roomID => {
if (users[roomID]) {
const length = users[roomID].length;
if (length === 2) {
socket.emit("room full");
return;
}
users[roomID].push(socket.id);
} else {
users[roomID] = [socket.id];
}
socketToRoom[socket.id] = roomID;
const usersInThisRoom = users[roomID].filter(id => id !== socket.id);
socket.emit("all users", usersInThisRoom);
});
socket.on("sending signal", payload => {
io.to(payload.userToSignal).emit('user joined', { signal: payload.signal, callerID: payload.callerID });
});
socket.on("returning signal", payload => {
io.to(payload.callerID).emit('receiving returned signal', { signal: payload.signal, id: socket.id });
});
socket.on('disconnect', () => {
const roomID = socketToRoom[socket.id];
let room = users[roomID];
if (room) {
room = room.filter(id => id !== socket.id);
users[roomID] = room;
socket.broadcast.emit('user left', socket.id);
}
});
});
server.listen(process.env.PORT || 8000, () => console.log('server is running on port 8000'));
Websocket服务器JscodeReact前端编码器
import React, { useEffect, useRef, useState } from "react";
import io from "socket.io-client";
import Peer from "simple-peer";
import styled from "styled-components";
import streamSaver from "streamsaver";
const Container = styled.div`
padding: 20px;
display: flex;
height: 100vh;
width: 90%;
margin: auto;
flex-wrap: wrap;
`;
const worker = new Worker("../worker.js");
const Room = (props) => {
const [connectionEstablished, setConnection] = useState(false);
const [file, setFile] = useState();
const [gotFile, setGotFile] = useState(false);
const chunksRef = useRef([]);
const socketRef = useRef();
const peersRef = useRef([]);
const peerRef = useRef();
const fileNameRef = useRef("");
const roomID = props.match.params.roomID;
useEffect(() => {
socketRef.current = io.connect("/");
socketRef.current.emit("join room", roomID);
socketRef.current.on("all users", users => {
peerRef.current = createPeer(users[0], socketRef.current.id);
});
socketRef.current.on("user joined", payload => {
peerRef.current = addPeer(payload.signal, payload.callerID);
});
socketRef.current.on("receiving returned signal", payload => {
peerRef.current.signal(payload.signal);
setConnection(true);
});
socketRef.current.on("room full", () => {
alert("room is full");
})
}, []);
function createPeer(userToSignal, callerID) {
const peer = new Peer({
initiator: true,
trickle: false,
});
peer.on("signal", signal => {
socketRef.current.emit("sending signal", { userToSignal, callerID, signal });
});
peer.on("data", handleReceivingData);
return peer;
}
function addPeer(incomingSignal, callerID) {
const peer = new Peer({
initiator: false,
trickle: false,
});
peer.on("signal", signal => {
socketRef.current.emit("returning signal", { signal, callerID });
});
peer.on("data", handleReceivingData);
peer.signal(incomingSignal);
setConnection(true);
return peer;
}
function handleReceivingData(data) {
if (data.toString().includes("done")) {
setGotFile(true);
const parsed = JSON.parse(data);
fileNameRef.current = parsed.fileName;
} else {
worker.postMessage(data);
}
}
function download() {
setGotFile(false);
worker.postMessage("download");
worker.addEventListener("message", event => {
const stream = event.data.stream();
const fileStream = streamSaver.createWriteStream(fileNameRef.current);
stream.pipeTo(fileStream);
})
}
function selectFile(e) {
setFile(e.target.files[0]);
}
function sendFile() {
const peer = peerRef.current;
const stream = file.stream();
const reader = stream.getReader();
reader.read().then(obj => {
handlereading(obj.done, obj.value);
});
function handlereading(done, value) {
if (done) {
peer.write(JSON.stringify({ done: true, fileName: file.name }));
return;
}
peer.write(value);
reader.read().then(obj => {
handlereading(obj.done, obj.value);
})
}
}
let body;
if (connectionEstablished) {
body = (
<div>
<input onChange={selectFile} type="file" />
<button onClick={sendFile}>Send file</button>
</div>
);
} else {
body = (
<h1>Once you have a peer connection, you will be able to share files</h1>
);
}
let downloadPrompt;
if (gotFile) {
downloadPrompt = (
<div>
<span>You have received a file. Would you like to download the file?</span>
<button onClick={download}>Yes</button>
</div>
);
}
return (
<Container>
{body}
{downloadPrompt}
</Container>
);
};
export default Room;
在此Repo上找到整个代码。如果你在浏览器中尝试应用上述代码并选择一些图片文件(最好小于100KB),它会立即下载这些图片文件。这是因为这个对等点位于一个类似的浏览器中,而发送方处于提示状态。
传送和获取的信息的大小是相等的。这表明我们可以选择一次性移动整个记录!
为什么使用数据缓冲区而不是blob?
在我们过去的代码中,如果我们选择了一个巨大的文件(大于100KB),那么文档很可能不会被发送,这是WebRTC通道的某些约束的直接结果。
image如图:数组缓冲区漫画插图(mozilla.org)
每个数组缓冲区一次只能有16KB的限制。简而言之,这意味着我们必须将文档划分成小数组缓冲区。
小文件可以通过WebRTC一次性发处,然而,对于大文档,明智的做法是将文件隔离到较小的数组缓冲区中,并同样发送每个部分。ArrayBuffer和Blob对象都有削减容量,这使得此过程更加简单。为此,如果你仔细查看代码,你会发现我们使用了一个名为stream saver的模块,它可以将数组缓冲区转换回blob。
笔记
let array = [];
self.addEventListener("message", event => {
if (event.data === "download") {
const blob = new Blob(array);
self.postMessage(blob);
array = [];
} else if (event.data === "abort") {
array = [];
} else {
array.push(event.data);
}
})
因为javascript是单线程的。处理大量数组缓冲区可能导致漂亮的UI无法响应。为了解决这个问题,我们将使用服务工作人员。一个服务工作人员是浏览器在后台运行的脚本,是与Web页面分离的,这为不需要Web页面或用户交互的特性打开大门。
在服务工作程序中处理数组缓冲区
将文件划分为数组缓冲区的优点
虽然它可能会感觉分隔文件只是一些额外的代码,并且会让东西相互纠缠,但我们得到以下好处,并且可以帮助改进我们的文档共享应用程序。
image跨平台支持(由mozilla.org提供说明)
- 支持几乎所有的浏览器
- 支持庞大的文档大小——正如前面提到的,这是我们为什么要实现它的基本解释。
- 一个更好的方法来破译所发送信息的度量——通过在缓冲区中发送一个记录,我们现在可以显示信息,例如,发送的文档的级别,发送记录的速度等等。
- 识别未完成发送的文件——在无法完全发送文件的情况下,现在能够以不同的方式获取和处理文件。
结论
由于我们有一个使用WebRTC的文档直接共享程序,而且它还利用了ArrayBuffer,我们现在应该开始考虑为应用程序的生产做准备的东西了。这些细节需要更多的探索,而不仅仅是遵循一个直接的教程。
可以补充的更多内容:
- 信令服务器(STUN和TURN服务器)。
- 使多个对等连接可拓展。
- 当WebRTC不能工作时才用的一种混合共享方式。
- 提高传输效率和速度。
我希望我已经提供了足够的信息让你们开始使用WebRTC应用程序。