服务端录制原理分析

简要

功能简要

什么是服务端录制,通俗来讲就是在服务器上把网站录制下来,包括网站的 声音、动做、刷新、跳转 等。并保存成一个视频文件node

原理简要

经过虚拟桌面 xvfb 技术启动 PuppeteerPuppeteer 打开 Chrome,再调用 Chrome Extension API 进行录制生成 Stream,最终经过 H5 API 把 Stream 转换成 webm 格式的视频文件git

难点

如何录制声音、在服务器上、作成自动化github

分析

在前期调研阶段,想到了各类方案,如:web

  1. 使用 Canvas 进行截图、拼凑
  2. ChromeH5 的各个 API

可是通过各个方面的测试,最终肯定下来,使用 Chrome 插件提供的一个 API: chrome.tabCapture.capture ,这个 API 其实在 Chrome 插件文档里的介绍是这样的:chrome

捕获当前活动标签页的可视区域。该方法只能在扩展程序被调用以后在当前活动网页上使用,与 activeTab 的工做方式相似。数据库

捕获当前活动标签页的可视区域 这段话表明了这个 API 的功能,后面的话表明了这个插件的限制,也就是说你不能直接调用。须要一个用户操做才能去调用这个 API(不得不说,Chrome对安全问题是很重视的)api

这个限制就是当时开发遇到的第一个问题,由于整个录制都是在服务器上运行的,是不可能有人工干预的状况。因而翻了下 Chrome 的源码,果真在 tab_capture_api.cc 找到了,核心代码以下:浏览器

// Make sure either we have been granted permission to capture through an
// extension icon click or our extension is whitelisted.
if (!extension()->permissions_data()->HasAPIPermissionForTab(
        SessionTabHelper::IdForTab(target_contents).id(),
        APIPermission::kTabCaptureForTab) &&
    base::CommandLine::ForCurrentProcess()->GetSwitchValueASCII(
        switches::kWhitelistedExtensionID) != extension_id &&
    !SimpleFeature::IsIdInArray(extension_id, kMediaRouterExtensionIds,
                                base::size(kMediaRouterExtensionIds))) {
  return RespondNow(Error(kGrantError));
}
复制代码

其中下面的代码是最主要的:安全

base::CommandLine::ForCurrentProcess()->GetSwitchValueASCII(
        switches::kWhitelistedExtensionID) != extension_id &&
    !SimpleFeature::IsIdInArray(extension_id, kMediaRouterExtensionIds,
                                base::size(kMediaRouterExtensionIds))
复制代码

这段代码会检测当前插件的的id是否和 kWhitelistedExtensionID 同样,而 kWhitelistedExtensionID 就是一个特权列表,当相同时,就能够绕过用户操做,作成自动化。bash

kWhitelistedExtensionID 声明是在 switches.cc 文件里的,定义以下:

// Adds the given extension ID to all the permission whitelists.
const char kWhitelistedExtensionID[] = "whitelisted-extension-id";
复制代码

如今就很清楚了,我只须要在启动 Chrome 的时候,增长一个 --whitelisted-extension-id 参数,来指定当前 Chrome 插件ID 就好了。

惟一的缺陷就是在调用这个 api 的时候,必须保证要录制的tab是激活状态,调用以后就能够跳转到其余页面了

因此如今新的问题来了,我须要让我每次生成的 Chrome 插件,ID都是固定的,不然每次生成的插件,ID都不同就有问题了。在 Stack Overflow 搜了一下,找到了相关的解决方案: Making a unique extension id and key for Chrome extension?

这也就是为何我会在项目里的 插件目录放置一个 key.pem 文件,本质就是为了让插件ID固定下来。

可能有的小伙伴已经发现,这个API没有提供其余的方法了,因此须要咱们手动去完成 暂停 / 恢复 / 中止 的方法,这个时候咱们就能够借助 H5 的 MediaRecorder API 来完成这件事情。

不理解这个API的小伙伴,能够先初步理解成用来管理音视频流的

chrome.tabCapture.capture 这个方法会返回一个 Stream 对象,而这个 Stream 包含了 音/视频。因此咱们就可使用 MediaRecorder 来完成剩下的功能了。

在调用 chrome.tabCapture.capture 后,咱们会建立一个变量。这个变量由 MediaRecorder 实例化而来,而且同时监听新的流进来。

如今咱们写了几个方法(暂停 / 恢复 / 中止),其实本质就是调用 MediaRecorder 的方法。由于 MediaRecorder 自己就提供了: pause / resume / stop 的方法,咱们只须要作一层包装便可。

固然这里有个小问题,就是当你调用 MediaRecorderstop 方法时,还须要遍历每一个 Tracks,否则会照成持续的内存占用。代码以下:

mediaRecorder.stop();
mediaRecorder.stream.getTracks().forEach(track => {
  track.stop();
});
复制代码

你能够初步理解成,这个 stop 只是中止接收流,可是以前的流尚未被关闭/释放。

接下来还出现了一个问题,这个问题让我差点当场去世。

上面说了那么多,基本的录制结构都OK了。不管有没有看懂,都应该知道这个项目的核心是浏览器插件。可是 Chrome 不支持在 headless 模式下注入插件。

能够把headless理解成,在命令行启动 Chrome,经过 命令/API 进行交互,而且没有可视化页面。

由于是在服务器上,而且之后确定是要走 Docker 的方式,这些都是无桌面的,不能使用 headless 模式的话,至关于以上全部的工做都是白费的。

随后翻遍了 Google,找到了一个解决方案,就是使用 xvfb 。你能够理解成这个软件会帮我虚拟出一个桌面出来,个人代码(Chrome) 就会在这个虚拟桌面运行。完美解决刚刚的窘迫。

因此你能在 entrypoint.sh 文件里看到下面的代码:

# open virtual desktop
xvfb-run --listen-tcp --server-num=76 --server-arg="-screen 0 2048x1024x24" --auth-file=$XAUTHORITY node index.js &
复制代码

至此,整个工做其实已经算是OK了,接下来就是一些优化的方案

如今整个项目已经能够安安心心的在服务器(Docker)上进行录制了,可是咱们看不到具体里面的内容。我不知道里面如今处于什么的状况,想进行一些调试。因此我在原有的基础上增长了 VNCChrome Remote Debug 调试模式。

VNC 的话,很简单,只要在 Docker 里安装了 VNC 的套件,再在 entrypoint.sh 文件里增长以下代码便可:

x11vnc -display :76 -passwd password -forever -autoport 5920 &
复制代码

Chrome Remote Debug 则有些麻烦,须要在 Chrome 启动参数里加上 --remote-debugging-port=9222,而后须要在 Docker 里安装 socat 软件,进行端口转发。

由于9222是 Chrome Remote Debug 的端口,可是 Chrome 不支持除本机之外的机器访问它。因此咱们须要使用 socat 把 9222 端口转发到 9223 便可,在 entrypoint.sh 文件的代码以下:

# forward chrome remote debugging protocol port
socat tcp-listen:9223,fork tcp:localhost:9222 &
复制代码

由于这个 Docker 之后可能会部署到 k8s 上,或者其余地方,而部署后,总会遇到被通知说,你自杀吧(通常当集群资源不够时、CPU占用率太高时会通知)。那咱们应该作成,当他们通知到这个 Docker(k8s为Pod)时,应该及时的回滚数据等操做。因此在 entrypoint.sh 文件里有这么一段代码:

# get nodejs process pid
NODE_PID=$(lsof -i:80 | grep node | awk 'NR==1,$NF=" "{print $2}')

# forward SIGINT/SIGKILL/SIGTERM to nodejs process
trap 'kill -n 15 ${NODE_PID}' 2 9 15

# waiting nodejs exit
while [[ -e /proc/${NODE_PID} ]]; do sleep 1; done
复制代码

先获取 node 进程的 PID,再把消息通知到 node 进程里。而 node 代码中又有这么一段:

let status = false;
const exit = message => {
  if (status) return;
  
	console.log('the process was kill:', message);

	// 回滚操做

  status = true;

  process.exit();
};


process.once('exit', () => exit('exit'));
process.once('SIGTERM', () => exit('sigterm'));
process.on('message', message => {
  if (message === 'shutdown') {
    exit('shutdown');
  }
});
复制代码

部署方式

咱们公司由于使用的 k8s 来部署的,因此咱们目前的部署方式是这样的:

首先 Server 那里派发一个录制任务插入到数据库里,这个时候我写了另外一个项目,这个项目会按期去扫数据库(目前为3分钟),扫到一个数据就会调用 k8s 的 API 去建立 Job→Pod。完成一次录制任务,有兴趣能够看我以前写的文章: 基于任务量进行k8s集群的灵活调度处理

其余

目前项目已经开源,欢迎 Star 或 PR: github.com/alo7/rebirt…

相关文章
相关标签/搜索