写在前面
前阵子小伙伴微信给我发了一链接,说让我在电脑上打开看看。
“我电脑没装微信”
“内容很丰富”
“等下,我账号貌似登不上网页版微信”
“你去浏览器上输,我给你念”
“。。。”
不知道你们有没有过这种苦恼,于是老夫在干饭之余写了一个web应用,在不安装其它软件的情况下,使用浏览器实现跨端数据的传输。
👉 ox.bruceau.com
使用方法
首先在PC浏览器打开ox.bruceau.com
这是是老夫设计的主页,也是唯一的一个页面 = =。他主要有两个功能:
- 点击左侧的
电脑复制到手机
会弹出一个二维码,用手机扫描二维码就会出现连接提示,当连接成功后会在二维码上出现跳动的提示,这个时候可以在电脑上复制一段文字或图片,再跑到页面中按Control+V
就会将电脑剪切板中的内容发送到手机。 - 点击又侧的
手机复制到电脑
会弹出一个二维码,扫码建立连接过程同上。连接成功后可以在手机的输入框内粘贴或输入任意文字,也可以选择图片发送到电脑,发送成功后会自动将发送的内容塞入电脑剪切板中,然后我们可以在电脑手动上进行粘贴使用这些内容。我还增加了图片识别功能,可以将手机中的图片发送到电脑,然后在电脑上按住左键拖拽选择需要识别的文字。 这俩功能其实从建立连接到数据传输都是一模一样的方式。至于为什么要分成两个功能呢?没错就是为了骗点击量。
第一次进入页面后还可以看到我精心准备的新手引导,或者你还可以看图文指南:
WebRTC在前端
WebRTC连接是文章应用的核心,它是由谷歌公司在2011年发布开源的用来建立P2P实时音视频传输的一套标准,包含了硬件、音视频驱动、各类数据传输协议、STUN/TURN 服务器以及SDP等等。
如今在现代Web浏览器中已经可以轻松通过RTCPeerConnection
,MediaDevice
等API建立WebRTC连接来进行音视频流或其它数据buffer的单点传输。如本文建立P2P传输的主要手段都是由RTCPeerConnection接口提供,下面我会简单介绍一下WebRTC建立连接进行数据传输的思路和原理。
WebRTC与WebSocket
提起Web间的数据通信就会想到WebSocket,稳定又安全。但基于TCP的WebSocket,为了保证传输的可靠性,加了一堆包头不说,发完消息还要对个暗号,对不上就要重发。当然最主要的问题是以老夫目前一个月1500块伙食费的财力,租的服务器带宽小就很慢,可能传几张大图片可能就欠费了。
相比之下用WebRTC建立起一的P2P单点通信,不仅传输速率快,而且p2p建立连接之后是不走服务器流量的,可谓是相当划算。当然,缺点就是在国内的网络环境下能实现NAT穿透成功的概率大概只有百分之50左右,一旦穿透失败webrtc会切换为中继模式,用指定的服务器做流量中转,保证数据传输。
Dsp与信令服务器
【图片来自网络】
首先我们了解一下webRtc建立p2p的过程,如上图所示:
- A设备创建Offer(sdp),并发送给B设备。
- B设备收到Offer,设置为远程remote,同时创建Answer(sdp),并发送给A设备。
- A设备收到Answer,并设置成远端remote。
- 询问stun服务器,获取icecandidate
- NAT穿透建立连接
所以建立WebRTC连接的第一步就是进行sdp的交换,那到底什么是sdp呢?
sdp其实就是一段对设备各种硬件和状态的描述文本,比如网络状况、音视频设备参数、ice候选等等
v=0
↵o=- 6005770948690505604 2 IN IP4 127.0.0.1
↵s=-
...
webRTC需要对比连接双方的sdp,然后根据双方各自的能力协商出最合适的连接方式,所以我们需要用到一个web服务器来交换彼此的戒指哦不彼此的sdp,这个服务器一般叫做信令服务器。
stun/turn服务器与NAT穿透
通信双方确认好彼此的sdp信息后,就可以开始尝试NAT穿透了,WebRTC通过ICE框架来解决网络穿透的问题,这个框架包含了stun/turn
技术。
stun
服务器的主要用途是将收到的UDP数据包返回给客户端返回公共ip和端口,以及双方的连通性检测等等。它实现起来比较简单,所以网络上有很多公开的免费使用的stun地址可供选择。
[图片来自网络]
如果stun
服务器NAT失败,这时候我们就需要turn
服务器来实现间接通信,这个服务器需要自己搭,它与stun相比主要增加了中继服务器,也就是通过turn服务器传输的数据是会跑自己服务器的流量计费的。
一般来说可以在webRTC提供的接口中设置多个stun/turn服务器地址,webRTC尝试所有服务器,并返回相应的icecandidate,再通过信令服务器交换双方的icecandidate,尝试建立连接。
const ICE_config= {
'iceServers': [
{
'url': 'stun:stun.l.google.com:19302'
},
{
'url': 'turn:192.158.29.39:3478?transport=udp',
},
{
'url': 'turn:192.158.29.39:3478?transport=tcp',
}
]
}
const pc = new RTCPeerConnection(ICE_config);
心路历程
上图就是建立连接的流程图,用户在PC端创建二维码之后会在信令服务器生成一个唯一uid,并以uid为key值通过lru算法缓存一个可过期的连接状态对象(stateinfo),并将这个uid返回给PC端。PC端使用uid轮询stateinfo的状态同步到本地,同时当本地有offer或ice产生时更新至stateinfo。
当手机扫码后会得到PC端stateinfo的uid,然后同PC端一样,使用uid去轮询和同步stateinfo状态。而当尝试nat穿透超过一定时间后,我会认定其为连接失败,转换为中继服务器(websocket)进行数据传输。
当然,一台移动设备切换至websocket后会记录当前网络环境与uid,在之后用同一网络环境和同一台pc进行连接时会直接切换为websocket模式,省去不必要的等待时间。
提示:下文中代码片段并非完整代码,可能无法运行或有漏洞,仅供参考
搭建信令服务器
搭建一个信令服务器的方法有很多,可以用轮询的方式在服务器维护双方信令,也可以用webscoket的方式直接同步。
由于文中应用并发并不会很高,所以用node koa简单撸了一个服务器,以http轮询的方式来实现信令交换。
首先需要一个创建stateinfo的接口create
router.post('/create', async (ctx) => {
const uid = createUid()
cache.set(uid, {
offer: '',
answer: '',
status: 1,
candidate1: [],
candidate2: [],
})
ctx.body = {
code: 0,
data: {
uid
}
}
})
优化一下,每台PC只生成一次uid,节约内存,同时提供快速重连的可能。我们加个uid检查的中间件即可:
module.exports = () => async (ctx, next) => {
let uid = ctx.cookies.get(names.CLIENT_UID_NAME)
if (!uid) {
uid = createUid(ctx)
ctx.cookies.set(names.CLIENT_UID_NAME, uid, { httpOnly: true })
}
ctx.state.uid = uid
return next()
}
// create接口改造为
router.post('/create', async (ctx) => {
const uid = ctx.state.uid
...
再提供一个用来轮询状态接口check
router.get('/check', async (ctx) => {
const { uid } = ctx.state
const data = cache.get(uid)
if (!data) {
ctx.body = { code: errorCode.NOT_FIND, message: errorCode.NOT_FIND_MESSAGE }
return
}
ctx.body = { code: 0, data, message: '' }
})
当客户端状态更新时需要同步到服务器stateinfo,所以需要一个更新接口update
router.post('/update', async (ctx) => {
const { body } = ctx.request
const { uid } = ctx.state
const stateinfo = cache.get(uid)
if (body.offer) {
stateinfo.offer = body.offer
stateinfo.status = 2
}
// 切换至websocket
if (body.upgrade) {
stateinfo.status = -1
}
...
})
搭建中继服务器
webrtc在国内nat穿透成功率不到百分之50,所以当穿透失败时需要有一个中继服务来保证数据成功送达。原本它是用turn服务器进行中继过度的,它找不到免费的,所以通常都是用coturn自己部署。但是这东西依赖数据库,要么mysql要么redis啥的,以我仅剩0.5G内存的阿里云ECS只能望而却步,反正都是跑自个儿的流量,直接用webscoket中继一下。
websocket是http的一种升级,Client发送一个upgrade
request,这是一个http请求,server返回101,升级成功,开始建立webscoket连接。所以当我们不想再启动另一个node服务器时,只需要定制http服务器的upgrade函数,就能实现http服务器与websocket服务器共用同一端口。
module.exports = function createSocket(server) {
const socketServer = new webSocket.Server({ noServer: true })
socketServer.on('connection', function connection(ws, uid, type) {
setSocketRoom(uid, type, ws)
ws._room_type = type
ws._room_id = uid
ws.on('message', function incoming(message) {
sendMessage(this._room_id, this._room_type, message)
})
ws.on('close', function onclose() {
closeRoom(this._room_id)
})
})
server.on('upgrade', (request, socket, head) => {
const urlParsed = request.url.match(/^\/ws\/\?uid=(.+)?&type=(.+)$/)
const [_, uid, type] = urlParsed
initSocketRoom(uid)
if (hasConnectedRoom(uid)) {
socket.write('HTTP2.0 401 room is connected\r\n\r\n')
socket.destroy()
return
}
socketServer.handleUpgrade(request, socket, head, function done(ws) {
socketServer.emit('connection', ws, uid, type);
});
})
}
图片分片传输
如何使用webrtc创建dataChannel进行数据传输的相关文章有很多,这里不再赘述。
他支持发送的数据类型有string
,bolb
,arraybuffer
等,并且单次传输数据的大小是有限制的,这个大小限制在不同浏览器中的表现也不太一样,比如chorme/firfox浏览器可以达到256kb,而在ios safari中只有16kb。所以一些比较大的数据比如图片,就需要进行分片传输。
首先我们先将图片转成arraybuffer对象,保险起见以8kb长度将其分割成若干段
public sendFileSlice(file: File) {
if (file.size > 1024 * 1024 * 10) {
message.error('图片太大了,老夫扛不住啊')
return
}
const fragmentSize = 1024 * 8; // 每次send最大只有8kb
const fileReader = new FileReader();
fileReader.onload = () => {
const buffer = fileReader.result as ArrayBuffer;
const size = buffer.byteLength;
const partitionLength = Math.abs(size / fragmentSize);
const fragments = Array<ArrayBuffer>();
for (let i = 0; i < partitionLength; i++) {
fragments.push(buffer.slice(i * fragmentSize, (i + 1) * fragmentSize));
}
this.sendFragmentArrayBuffer(fragments, file.type);
};
fileReader.readAsArrayBuffer(file);
}
我们可以给传输数据定义一些类型枚举,每次发送消息时将这些head带上,方便接收方根据不同的head进行不同的操作
export enum MessageActionHead {
// 系统通信
NATIVE = "[native]",
// 连接成功的应答
CONNECT = "[connect]",
// 发送的文本数据
TEXT = "[text]",
// 图片buffer, 由于send存在大小限制,我采用分片传输
BUFFER = "[buffer]",
// buffer分片收到确认的回信
INFO = "[info]",
// 心跳包
HEART = "[heart]",
// 关闭连接
CLOSE = "[close]",
// 挂起,移动端选择图片时会挂起js,这时候需要停止心跳包的监听
HOLDER = "[holder]",
}
当收信方接收到BUFFER
字符时,则表示需要进行分片数据收集了,并在每收到一个buffer后回信INFO
字符,发信方再开始发送下一个buffer,保证包的时序性。
操作电脑剪切板
在浏览器中拷贝到剪切板有两种方式
第一种是使用input
+ execCommand
将input中的内容选中然后调用execCommand('copy')
复制,但是这种方法只能拷贝文本至剪切板,并且需要用户点击事件授权。
第二种方式就是操作navigator.clipboard
对象直接修改剪切板内容,这种方式是无感知的,同时兼顾文本与图片
const tryCopyClipboard = async (
type: MessageActionHead,
content: string | Blob
) => {
if (!hasClipboard) return 0;
if (type === MessageActionHead.TEXT) {
try {
const copyText = (content as string).replace(/\\n/g, '\n')
await navigator.clipboard.writeText(copyText);
return true;
} catch (e) {
console.log(e);
return false;
}
}
if (type === MessageActionHead.BUFFER) {
try {
const targetType = (content as Blob).type;
let copyTargetBlob = content as Blob;
if (targetType.endsWith("jpg") || targetType.endsWith("jpeg")) {
copyTargetBlob = await jpgBlobToPng(content as Blob);
}
await navigator.clipboard.write([
new window.ClipboardItem({
[copyTargetBlob.type]: copyTargetBlob,
}),
]);
return true;
} catch (e) {
console.log(e);
return false;
}
}
return false;
};
识别图片中的文字
用开源的JavascriptOCR引擎——Tesseract.js可以很方便的实现提取图片中文字的功能,Tesseract.js由ocr引擎和语言包组成,ocr会根据初始化时指定的语言种类去github下载相应的语言包,中文语言包大概有30M左右。
tessearct.js的api很简单,但是问题来了,语言包是放在github上的,国内访问不了,怎么解决呢?
通过tessearct的语言包加载源码可以看到它会先去浏览器的indexDB中找缓存,找不到才会去重新下载,所以我们只需要先将所需的语音包从github下载上传到自己的cdn,然后在客户端初始化Tessearct之前手动将语言包从cdn下载到客户端并缓存到indexDB即可。
// 缓存检查
export async function catchDistrictFile(
processCallback?: (event: ProgressEvent) => void,
successCallback?: () => void
) {
try {
await initDBConnect();
const hasChCache = await findDB(CH_DATA_NAME);
const hasEnCache = await findDB(EN_DATA_NAME);
// 如果
if (hasChCache) {
const buffer = await loadFile(
`${import.meta.env.VITE_CDN_URL}gz/${CH_DATA_NAME}.gz`,
processCallback
);
setBuffer('./' + CH_DATA_NAME, gzip.gunzipSync(buffer));
}
if (hasEnCache) {
const buffer = await loadFile(
`${import.meta.env.VITE_CDN_URL}gz/${EN_DATA_NAME}.gz`,
processCallback
);
setBuffer('./' + EN_DATA_NAME, gzip.gunzipSync(buffer));
}
successCallback && successCallback();
} catch (e) {
console.log(e);
}
}
async function readText () {
await catchDistrictFile()
await worker.load();
await worker.loadLanguage("eng+chi_sim");
await worker.initialize("eng+chi_sim", OEM.TESSERACT_ONLY);
await worker.setParameters({
tessedit_pageseg_mode: PSM.SINGLE_BLOCK,
});
}
最后
虽然webRTC成功建立P2P连接的概率不到百分之五十,但相比直接用服务器进行数据转发,起码能省掉一半带宽和流量费不是,穷人家的程序员越是要精打细算[狗头]。