Black-Hole's Blog

In love

服务端录制原理分析

Posted at — Dec 7, 2019

简要

功能简要

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

原理简要

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

难点

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

分析

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

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

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

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

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

这个限制就是当时开发遇到的第一个问题,因为整个录制都是在服务器上运行的,是不可能有人工干预的情况。于是翻了下 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 就是一个特权列表,当相同时,就可以绕过用户操作,做成自动化。

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: https://github.com/alo7/rebirth