一、业务背景
系统在 VX.XX.X
版本提供了 webshell
的能力,但为了进一步防止恶意操作,所以我们需要实现“录制”用户的操作的能力。
咱可以提前通过一张 GIF 动图来看看最终实现的效果。
在系统设置模块中,打开终端视频回放模块,在这里存储着所有用户对于 webshell
的操作界面记录,并且我们可以发现,录制所呈现出来的效果(清晰度,用户输入,以及鼠标运行轨迹)都是非常不错的,具体实现方案,且看下文。
二、方案选型标准
- 能够完整录制用户操作行为
- 跨平台使用
- 用户对“录制”操作无感知
- 录制可回放,方便存储
三、方案选型
根据上述的方案选型标准,从众多的技术选型中,选择了两个较为合适的技术框架进行调研,分别为 webRTC
和 rrweb
。
前者为 Google
开源的,该技术适用于所有现代浏览器以及所有主要平台的原生客户端。WebRTC
背后的技术是作为开放式网络标准实现的,并且可用作所有主要浏览器中的常规 JavaScript API
。
后者为 Yuyz0112
在 github
上的开源项目,目前已有 10.9k 的 star
, 是一个广受用户好评的成熟项目。
使用 WebRTC
WebRTC (Web Real-Time Communications) 是一项实时通讯技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流和(或)音频流或者其他任意数据的传输。WebRTC 包含的这些标准使用户在无需安装任何插件或者第三方的软件的情况下,创建点对点(Peer-to-Peer)的数据分享和电话会议成为可能。
使用方式
- 在页面上放置几个按钮。
<body>
<button id="start">屏幕分享</button>
<button id="record">开始录制</button>
<button id="stop">结束录制</button>
<button id="download">下载视频</button>
<video autoplay playsinline id="player"></video>
<script src="./index.js"></script>
</body>
- 点击按钮之后执行对应方法。
// 核心代码如下,具体示例可见下方 github
let buffer = [], // 存储录制视频的data数据段
mediaRecorder, // new MediaRecorder 的实例对象
allStream; // navigator.mediaDevices.getDisplayMedia 返回的stream
class WebRTCAction {
start() {
// ...
navigator.mediaDevices
.getDisplayMedia({
video: true,
audio: false,
})
.then(stream => {
allStream = stream;
document.querySelector('#player').srcObject = stream;
})
.catch(err => {
console.error(err);
});
}
record() {
const options = {
mimeType: 'video/webm;codecs=vp8',
};
// 判断是否是支持的mimeType格式
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
console.error('不支持的视频格式');
return;
}
try {
mediaRecorder = new MediaRecorder(allStream, options);
// 处理采集到的事件
mediaRecorder.ondataavailable = function(e) {
if (e && e.data && e.data.size > 0) {
// 存储到数组中
buffer.push(e.data);
}
};
// 开始录制
mediaRecorder.start(10);
} catch (e) {
console.error(e);
}
}
download() {
mediaRecorder.stop();
// ...
}
stop() {
//...
}
}
const webAction = new WebRTCAction();
实现效果预览
整体代码见 github: webRTC-demo
简析原理
webRTC 点对点通信流程图
- PeerA 与 PeerB 通过信令服务器进行媒体协商,如双方使用的音视频编码格式。双方交换的媒体数据由 SDP 协议描述。
- PeerA 与 PeerB 通过 STUN 服务器获取到各自自己的网络信息,如 IP 和端口。然后通过信令服务器转发互相交换各种的网络信息。这样双方就知道对方的 IP 和端口了,即 P2P 打洞成功建立直连。这个过程涉及到 NAT 及 ICE 协议,具体后面会详细描述。
- PeerA 与 PeerB 如果没有建立起直连,则通过 TURN 中转服务器转发音视频数据,最终完成音视频通话。
优缺点
优点
- 使用简单,API 较为简单,可将录屏获得的数据转为视频资源,进行 OSS 直传。
- 跨平台(Web、Windows、MacOS、Linux、iOS、Android)
不足之处
- 用户可操作性太大,且是由用户进行录屏窗口的选择,无法做到用户无感知录制,录制之后为视频资源,占据比较大的存储空间。
使用 rrweb
rrweb 是 'record and replay the web' 的简写,旨在利用现代浏览器所提供的强大 API 录制并回放任意 web 界面中的用户操作。
使用方式
- 通过
yarn
下载rrweb
。
yarn add rrweb -S
- 通过
yarn
下载rrweb-player
。
yarn add rrweb-player -S
下面是官网上提供的最简单实践方式。
let events = [];
rrweb.record({
emit(event) {
// 将 event 存入 events 数组中
events.push(event);
},
});
// save 函数用于将 events 发送至后端存入,并重置 events 数组
function save() {
const body = JSON.stringify({ events });
events = [];
fetch('http://YOUR_BACKEND_API', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body,
});
}
// 每 10 秒调用一次 save 方法,避免请求过多
setInterval(save, 10 * 1000);
对于 webshell
的录屏方式,我们需要配置 rrweb
使其支持 canvas
的录制,并且为了使其更易用,我们需要在录制时做到将数据进行压缩并且进行分片上传。
let timer: NodeJS.Timeout | null = null;
const recodeEvents = useRef([]);
import { zip } from 'js-zip';
/**
* 开始录屏,并存储 events 数据,开启 canvas 录制、允许下载 canvas 中的字体文件
*
*/
const startRecord = () => {
stopFn = rrweb.record({
emit(event) {
recodeEvents.current.push(event);
},
recordCanvas: true,
collectFonts: true,
});
timer = setInterval(saveRecord, 3 * 1000);
};
/**
* 压缩 events 数据,并上传至后端
*
*/
const saveRecord = async () => {
if (timer) {
let events = recodeEvents.current;
recodeEvents.current = [];
let video_data = zip(JSON.stringify({ events }));
video_data = video_data.toString();
const param = {
id: currentVideoId,
video_data,
};
await YOURUPLOADFUNCTION(param);
}
};
实现效果预览
整体代码见 github: rrweb-demo
简析原理
rrweb 录制视频流程图
录制部分
当用户打开 webshell
界面之后,前端逻辑代码会自动开始“录制”当前界面,用户是无感知的,并执行轮询上传数据的接口,为了保证数据的实时性,程序中采用的是轮询的方式,如果采用当关闭 webshell
之后,一次性上传所有的录制数据,会导致上传数据量过大,如果该接口请求失败了,那么整段数据将不复存在。并且还采用了 gzip
算法进行数据的压缩,保证不存在大数据量的 http
传输。
回放部分
回放相对来说简单一些,由于我们上传的录制数据是经过 gzip
算法压缩过的,所以数据量会特别的小,我们采用一次性请求全部的 JSON
数据段,再进行 gzip
的解压缩,再将播放器界面渲染出来即可完成播放的一系列操作。
优缺点
优点: 是业界方案,github star 数量较多,值得信赖,能够实现用户无感知录屏,并且可以很方便的实现用户操作的复现。
不足之处: 对于特殊场景的支持不够友好,比如对于存在 pdf 的场景下,就会表现的十分卡顿。
最终实践方案
标准 | webRTC | rrweb |
---|---|---|
完整录制屏幕 | √ | √ |
跨平台使用 | √ | √ |
用户对“录制”操作无感知 | × | √ |
录制可回放,方便存储 | × | √ |
综上所述,鉴于 rrweb
的优秀录制视频还原能力,以及实现用户无感知录制能力,且不存在录制 pdf
的场景,避免了目前使用 rrweb
会造成卡顿的场景 ,最终实现方案选择了 rrweb
。
四、rrweb 原理详解
技术流程图
rrweb
主要由三部分组成 rrweb
、rrweb-snapshot
、rrweb-player
。
录制原理
rrweb
在录制时会首先进行首屏 DOM
快照,遍历整个页面的 DOM Tree
并通过 nodeType
映射转换为 JSON
结构数据,同时针对增量改变的数据同步转换为 JSON
数据进行存储。整个录制的过程会生成 unique id
来确定增量数据所对应的 DOM
节点,通过 timestamp
保证回放顺序。
对于首屏快照后的增量数据更新,则是通过 mutationObserver
获取 DOM
增量变化,通过全局事件监听、事件(属性)代理的方式进行方法(属性)劫持,并将劫持到的增量变化数据存入 JSON
数据中。
回放原理
首先针对首屏 DOM
快照进行重建,在遍历 JSON
产物的同时通过自定义 type
映射到不同的节点构建方法,重建首屏的 DOM
结构。
DOM 快照
实际上, ⻚⾯中的视图状态可以通过 DOM
树的形式描述,所以当我们尝试录制⼀个⻚⾯时,我们可以记录 DOM
树在各个时间点上的状态。 记录每一时刻页面的 DOM
状态,回放的时候根据时间点显示即可。
简单实现一个记录当前状态。
// 克隆当前的 document 元素
const docEl = document.documentElement.cloneNode(true);
// 替换
document.replaceChild(docEl, document.documentElement);
我们通过上述方式获取到的是当前 DOM
的状态。
但是,每一时刻都记录全量数据会导致数据量过于大,不便于存储。
于是,rrweb
采用的方式是通过 DOM
快照。
记录初始页面的 DOM
状态,或者特定某个时刻的 DOM
状态, 后续收集的是不同时间点的操作指令 或者 某个时刻 某个 DOM
的变化作为一个增量快照, 在原先快照的基础上,不断加入根据行为解析的 DOM
数据,构建了后续的快照,减少大量数据的存储或传输。
我们获取到的 DOM
快照是一个 DOM
节点数据,并不是可序列化的,我们不能将其转化为可方便传输的数据,也就无法上传到服务器,无法实现远程录制的功能。
rrweb-snapshot
在 rrweb
中,是通过 rrweb-snapshot
来实现上述功能的。
rrweb-snapshot
包含 两部分
- snapshot: 将
DOM
及其状态转化为可序列化的数据结构并添加唯一标识 - rebuild: 将
snapshot
记录的数据结构重建为对应的DOM
。
snapshot 实现细节
-
构建页面
DOM
树,为每一个Node
节点都绑定了一个唯一id
, 这个映射只要是为了方便后续的增量快照操作。 -
将
href
,src
,CSS
中的相对路径设为绝对路径 将一些脚本,样式,图片等引用的相对路径改为绝对路径。 -
将页面引用的样式变为内联样式,以确保可以使用本地样式 将页面引用的样式读取变为内联样式。
-
将一些
DOM
状态内联到HTML
属性中,例如HTMLInputElement
的值 记录没有反映在HTML
中的视图状态。例如 输⼊后的值不会反映在其HTML
中,我们需要读取其value
值并加以记录。 -
将
script
标记转换为noscript
标记,以避免脚本被执行 在播放录制页面时,页面的脚本是不能够被执行的,需要禁掉。
rebuild 实现细节
- 通过创建
DOM
, 设置属性,将snapshot
生成的数据再转化成对应的DOM
插入文档中。
上面我们将需要生成的数据已经处理好了,那么接下来就要处理录制的问题了。
rrweb
我们上面有说过是通过增量快照的形式来进行记录数据的一个变化的。在 rrweb
中 是通过 rrweb
仓库来实现的,包含两部分 record
, 和 replay
。
- record: 用于记录
DOM
中的所有变更(mutation) - replay: 则是将记录的变更按照对应的时间一一重放
record 实现细节
- record 会监听用户的行为来记录相关的数据。
- DOM 变动
- 节点创建、销毁
- 节点属性变化
- 文本变化
- 鼠标移动
- 鼠标交互
- mouse up、mouse down
- click、double click、context menu
- focus、blur
- touch start、touch move、touch end
- 页面或元素滚动
- 视窗大小改变
- 输入
MutationObserver
其中监听 DOM 变化使用的 API 为 MutationObserver
。
MutationObserver
接口提供了监视对 DOM 树所做更改的能力。它被设计为旧的 Mutation Events 功能的替代品,该功能是 DOM3 Events 规范的一部分。
当监视的 DOM
发生变动时, MutationObserver
将收到通知并触发预先设定好的回调参数,与 addEventListener
方法 比较相似。
当我们尝试改变页面 DOM
的属性,或者新增 DOM
节点的时候,都会对应生成一条 mutationObserver record
, record
记录了一些变动信息。 在 rrweb
中, 对每一条 mutation record
做了以下处理。
private processMutation = (m: mutationRecord) => {
// 首先判断是否为忽略的 DOM 节点
if (isIgnored(m.target)) {
return;
}
// 判断节点类型
switch (m.type) {
// ...
case 'attributes': {
const target = m.target as HTMLElement;
let value = (m.target as HTMLElement).getAttribute(m.attributeName!);
// 对 input 标签中的 value 属性进行处理
if (m.attributeName === 'value') {
value = maskInputValue({
maskInputOptions: this.maskInputOptions,
tagName: (m.target as HTMLElement).tagName,
type: (m.target as HTMLElement).getAttribute('type'),
value,
maskInputFn: this.maskInputFn,
});
}
// 判断是否为不需要监听变化的 DOM 节点
if (isBlocked(m.target, this.blockClass) || value === m.oldValue) {
return;
}
// ...
break;
}
// ...
}
};
public processMutations = (mutations: mutationRecord[]) => {
mutations.forEach(this.processMutation);
this.emit();
};
针对不同的类型进行处理, characterData
是节点内容或节点文本变动,attributes
是节点属性的变动,childList
是子节点的变动,包括新增子节点,移除子节点,移动子节点等。
addEventListeners
其中监听 鼠标移动,鼠标交互,页面滚动,视窗大小 使用的 API 为 事件绑定
// 实现 addEventListeners 逻辑的核心代码
export function on(
type: string,
fn: EventListenerOrEventListenerObject,
target: Document | IWindow = document,
): listenerHandler {
const options = { capture: true, passive: true };
target.addEventListener(type, fn, options);
return () => target.removeEventListener(type, fn, options);
}
// 监听 页面滚动 的核心代码
export function initScrollObserver(
cb: scrollCallback,
doc: Document,
mirror: Mirror,
blockClass: blockClass,
sampling: SamplingStrategy,
): listenerHandler {
const updatePosition = throttle<UIEvent>((evt) => {
const target = getEventTarget(evt);
if (!target || isBlocked(target as Node, blockClass)) {
return;
}
const id = mirror.getId(target as INode);
// ...
}, sampling.scroll || 100);
return on('scroll', updatePosition, doc);
}
replay 实现细节
- 解析收集到的
events
集合。 - 当事件类型为
FullSnapshot
时,会调用rebuild
, 根据快照数据生成页面的DOM
, 当事件类型为IncrementalSnapshot
时,则说明是增量快照,即收集的数据只是DOM
的变化数据或者对应的用户行为数据,根据不同的数据类型做对应的节点插入,删除,节点属性的更改等。
const firstFullsnapshot = this.service.state.context.events.find(
(e) => e.type === EventType.FullSnapshot,
);
if (firstFullsnapshot) {
setTimeout(() => {
// 判断是否为 FullSnapshot
if (this.firstFullSnapshot) {
return;
}
this.firstFullSnapshot = firstFullsnapshot;
this.rebuildFullSnapshot(
firstFullsnapshot as fullSnapshotEvent & { timestamp: number },
);
this.iframe.contentWindow!.scrollTo(
(firstFullsnapshot as fullSnapshotEvent).data.initialOffset,
);
}, 1);
}
private getCastFn(event: eventWithTime, isSync = false) {
// 判断 type 类型 是否为 IncrementalSnapshot
case EventType.IncrementalSnapshot:
castFn = () => {
// 调用 applyIncremental 函数
this.applyIncremental(event, isSync);
if (isSync) {
return;
}
};
break;
default:
//...
}
// applyIncremental 函数具体实现
// 其中 incrementalSnapshotEvent 代表增量数据,其具体增量类型可以通过 `event.data.source` 字段进行判断:
private applyIncremental(
e: incrementalSnapshotEvent & { timestamp: number; delay?: number },
isSync: boolean,
) {
const { data: d } = e;
// 判断增量类型
switch (d.source) {
// ...
case IncrementalSource.Mutation: {
if (isSync) {
d.adds.forEach((m) => this.treeIndex.add(m));
d.texts.forEach((m) => this.treeIndex.text(m));
d.attributes.forEach((m) => this.treeIndex.attribute(m));
d.removes.forEach((m) => this.treeIndex.remove(m, this.mirror));
}
try {
this.applyMutation(d, isSync);
} catch (error) {
this.warn(`Exception in mutation ${error.message || error}`, d);
}
break;
}
// ...
default:
}
}
五、总结
其实,前端实现录制能力可以实现很多比较实用的功能。
比如: 当你在处理线上问题的时候,由于环境的影响、用户数据和浏览器版本等等原因而不能快速复现时,那么使用 rrweb
来实现一套前端监控系统就显得十分的有必要了。目前业内已经有比较合适的方案了,开源的有Sentry
, 商业化的有fundebug
。
比如: 当你需要将用户的操作进行回溯时,那么使用rrweb
等方案实现前端录制就是一个很好的选择。
...
系统使用 rrweb
来实现前端录屏操作还只是众多可应用方面的一个实践而已,所以,掌握好 WebRTC
或 rrweb
这种类似的技术还是十分有必要的。