Ash's Thinking

基于 WebRTC 的实时视频编程面试系统的设计与实现

近年来,随着互联网行业的大发展,诸多互联网企业的崛起为社会提供了大量的工作岗位,每年都有许多相关专业的毕业生参加各类招聘会、投身到互联网行业中,而如何方便快捷的对这些求职者开展面试是互联网企业每年都要经历的问题。线上面试正在逐渐成为主流,其消除了地理因素的影响,降低了参会双方的沟通成本,非常适合作为面试初期的实施方案。

本文根据程序员面试会议过程中的实际需求,结合具体业务流程,设计并实现了一个基于 WebRTC 的一对一音视频编程面试系统。本文的主要研究工作为:

  1. 通过 WebRTC 技术为面试官和候选人实现一对一的视频会议平台,当面试官在本系统中创建会议、候选人加入该会议后,参会双方通过浏览器就能够进行低延迟的音视频通话,在线实时沟通交流。
  2. 本系统在会议进行期间,通过 OT 算法为参会双方实现协同编辑功能,参会双方不但可以进行音视频通话,还能够进行结对编程。面试官可以在文本域内写下编程题目对候选人进行考核,而候选人写下的解答代码也会同步展示给面试官,在这个过程中,面试官可以随时对候选人所写的程序进行修改指正,而候选人也会同步收到面试官的编辑操作。
  3. 本系统在会议进行期间,为参会双方实现了 Java、C/C++、Python2/3 等主流编程语言的在线运行功能,并通过 Docker 容器和 Seccomp 技术加固了系统的安全防护。参会双方在协同编辑时所输入的代码文本可以通过后端服务器进行在线编译、运行,使得参会双方能够直接验证程序的运行结果。

最后,对本系统的功能进行具体测试和详细分析,测试结果均符合预期,各功能运行正常。在未经过 CDN 加速的前提下,音视频通话功能的媒体数据往返延迟达到预期,符合实时会议需求,协同编辑和在线编程功能运行良好。表明本系统在解决针对程序员职业的在线面试会议中具有一定的应用价值。

关键词: WebRTC;视频会议;协同编辑;在线编程

第一章 绪论

1.1 研究背景

互联网行业是当今时代发展的趋势,也是我国发展的战略目标,每年有大量技术型人才涌入互联网行业,持续发展的互联网企业又为社会提供了大量的工作岗位。目前互联网企业的面试流程一般为“一、二、三面”,对于技术要求高的工作岗位甚至能达到“四、五面”。由于新型冠状病毒的流行,“远程上课”、“远程办公”等互联网产品的兴起开始改善人们在疫情下的教育与工作等问题。利用这些互联网技术,可以远程参与视频会议讨论、完成一定量的工作。如果目标企业的办公地点与候选人之间存在地理因素影响,“一、二面”则多为通过远程视频、电话连麦的方式来进行基础问答,仍然难以对候选人的代码编写能力进行考核,需要后续的现场面试才能得以实施。

1.2 研究意义

随着互联网行业的大发展与互联网技术的提高,有望利用新型互联网技术,开发出一套能够进行远程视频会议、并实时考核候选人代码编写能力的系统,来降低互联网企业开展面试的成本。在面试流程的前期,企业可以通过”即点即用”的远程视频与候选人会话,候选人不必亲身来到面试现场,消除了办公地点与候选人间的地理差异;还可以通过协同编辑、在线编译的方式来考核候选人的代码编写能力,为后续的现场面试提供参考,如此完成一场方便、有效且快捷的远程面试。

1.3 国内外研究现状

视频会议一直是国内外研究的热点,市面上有许多为教育办学和企业办公所开发的视频会议系统,其主要注重的是多人参会和大规模混流,而本系统的设计目标是一对一的视频面试会议,主要针对程序员职业,提供在线代码编辑器,参会双方能够以协同编辑的方式进行结对编程,并在线验证结果。

1.4 本文的结构安排

本文将分为八个章节,从分析、设计、实现、测试四个阶段进行说明。具体的内容结构安排如下:

第一章 绪论。在这一章节中将对本课题的研究背景及其研究意义进行说明,以及对比国内外研究的现状,详细的介绍了本系统的特点。

第二章 系统相关技术简介。在这一章节中将对本系统开发所涉及到的相关技术进行简单介绍,如 WebRTC、OT 算法、Seccomp 等。

第三章 系统需求分析。在这一章节中将从本系统的功能性需求进行说明,对面试官与候选人两个角色进行了用例分析。

第四章 系统总体设计。在这一章节中将对系统的整体架构进行设计说明,确定下本系统的总体架构和对各服务模块的职责划分。

第五章 系统功能设计。在这一章节中将对本系统的一对一音视频通话功能、协同编辑功能、在线编程功能进行详细的设计说明,确定下这三个主要功能的设计原理。

第六章 系统功能实现。在这一章节中将根据设计原理对本系统的一对一音视频通话功能、协同编辑功能、在线编程功能进行具体的开发实现。

第七章 系统测试。在这一章节中将对本系统的一对一音视频通话功能、协同编辑功能、在线编程功能进行功能测试。

第八章 总结与展望。在这一章节中将对本系统的全部工作进行总结,并提出改进方向和完善目标。

第二章 系统相关技术简介

2.1 Spring Cloud 介绍

Spring Cloud 是由 Spring 团队开发的微服务架构,能够快速的构建分布式系统,其不但兼容 Spring 家族中的各种优良框架,还通过 Spring 社区集合了由其他开发者开发的各种框架(如 Spring Cloud Alibaba、Spring Cloud Netflix 等)。

2.2 WebSocket 介绍

WebSocket 是一种位于 OSI 模型的应用层中的网络传输协议,其基于 TCP 协议实现了在单个连接上进行全双工通信。前后有RFC 6455RFC 7936为其补充标准,最后由W3C规范浏览器中的 WebSocket API 实现。

2.3 Rabbit MQ 介绍

Rabbit MQ 是一款实现了高级消息队列协议(AMQP)的中间件,通过 Rabbit MQ 可以对业务逻辑进行解耦,实现异步处理、流量削峰等功能。

2.4 React 介绍

React 是由 Facebook 公司开发的 Javascript 库,能够以组件化的方式进行前端开发,通过简单语法便可让数据渲染为 HTML 页面,能够简化前端页面的开发流程。

2.5 WebRTC 简介

WebRTC 是一套采用 ICE(Interactive Connectivity Establishment)技术建立对等连接(Peer-to-Peer,P2P),基于 RTP(Real-time Transport Protocol)协议实现音视频传输的 W3C 标准 API,如今已广泛存在于各大主流浏览器内核中(如 Chromium、Webkit、Gecko 等)。如果双方计算机设备都拥有公网 IP 地址或位于同一局域网下,则可以直接访问到对方。但现实情况是,我们的计算机设备都处于大大小小的局域网环境中,位于诸多网络设施(如路由器、防火墙)之后,而又无法保证每个计算机设备都拥有独立的公网 IP 地址。为了找到这些藏在网络设施背后的设备,需要利用到 NAT(Network Address Translation)穿越技术,也就是俗称的“打洞”。

2.5.1 NAT 穿越技术介绍

NAT 技术解决了 IPv4 地址短缺问题,其将内网设备的 IP 地址与公网路由器分配的端口号建立映射关系:从内网 IP 地址发出的数据包经过路由器时,会被分配一个端口号并记录在表中;公网服务器的回包经过该路由器时,根据反向查表后可将数据包转发至对应的内网设备。

根据RFC 3489的定义,能划分出四种经典 NAT(NAPT)种类:

可知这四种 NAT 类型对网络通信的限制力度是在逐渐提升的,按从低限制到高限制的顺序排序:完全圆锥型 NAT、受限圆锥型 NAT、端口受限圆锥型 NAT、对称型 NAT。完全圆锥形 NAT 是最低限制的 NAT,一旦得知外部地址后即可开始通信;而对称型 NAT 只能通过中继服务器来进行转发通信。

2.5.2 STUN/TURN 介绍

ICE 是包括了 STUN/TURN 等各种 NAT 穿越技术的组合。STUN(Session Traversal Utilities for NAT)是一种由 RFC 5389 定义的网络传输协议,其可以根据一段固定的算法判断出被 NAT 后的客户端的 NAT 类型,并与该客户端创建 UDP 连接。

以下图 2-1 是来自RFC 3489定义的 STUN 经典算法示意图:

图2-1 STUN经典算法示意图

图 2-1 STUN 经典算法示意图

TURN(Traversal Using Relay NAT)也是一种网络传输协议,当对称型 NAT 的客户端向 TURN 服务器发起通信时,TURN 服务器会为该连接创建一个中继端口号,任何发往该中继端口号的网络数据包都将被转发至客户端,达到穿越对称型 NAT 的目的。

2.5.3 Coturn 介绍

Coturn 是一个从 rfc5766-turn-server 项目演变而来的开源 STUN/TURN 服务器实现,符合由 RFC 定义的诸多 ICE、STUN、TURN 相关规范,支持 TCP/UDP 两种中继协议。其在 rfc5766-turn-server 项目的基础上还额外新增了数据库持久化、认证机制、更高效的内存模型等特性功能,并支持许多操作系统平台。

2.6 OT 算法介绍

OT 算法(Operational Transformation)是一种用于协同编辑场景中的并发控制算法,最早被谷歌公司用于其在线文档产品中,通过 OT 算法对并发操作进行转换后获得最终一致的操作结果。OT 算法提出了三种原子操作,如表 2-1 所示:

原子操作名称 记为 描述
Insert “c” c 表示插入的字符串。
Retain n n>0,表示确定保留的字符数量。
Delete -n n<0,表示向后删除的字符数量。

对内容文本进行的所有操作(如增删改),都可以利用上述三种原子操作组合成列表来描述,如表 2-2 所示:

原文本 新文本 原子操作列表 描述
"" “a” [“a”] 插入一个字符 a。
“a” “ab” [1, “b”] 保留一个字符后再插入一个字符 b。
“abc” “ac” [1, -1, 1] 保留一个字符后删除一个字符,再保留一个字符。

2.7 Docker 介绍

Docker 容器是提供系统层虚拟化的软件,通过一份构建好的镜像就可以部署一个虚拟容器,极大简化了应用的开发、交付、部署、运行流程。

2.8 Seccomp 介绍

Seccomp 是一种在 Linux 系统内核中的计算机安全机制,通过利用 Seccomp 库进行编程,维护一个系统调用函数名单,当进程调用了处于白名单外或黑名单内的系统函数时,Linux 内核将终止该进程,达到屏蔽系统调用的目的。

2.9 本章小结

在本章中对系统开发所涉及到的关键技术进行了简单介绍,让读者对本系统所涉及的前提技术有了一定的了解。

第三章 系统需求分析

3.1 系统设计目标

本系统设计为 B/S 架构,旨在浏览器上为面试官与候选人提供一个开展一对一的编程面试会议的平台。

3.2 功能需求分析

本系统主要针对程序员职业,提供开展一对一面试会议的平台。用户登入账号后可以选择以面试官的身份创建会议,或选择以候选人的身份加入现有会议。每个会议都要对应六个数字或字母组成的邀请码,他人持有邀请码后可以加入到对应会议中,且新生成的邀请码不能与进行中的会议邀请码重复。面试官作为该会议的创建人,拥有关闭会议的权限,能设定该会议的名称和计时器,计时器达到预设时间时将及时提醒所有参会者。

本系统将设计三个主要功能来提高参会体验:一是支持面试官与候选人进行一对一的实时音视频通话,让参会双方及时交流自身想法;二是支持双方在同一块文本域中进行协同编辑;三是能够在线运行文本域中所输入的代码。

考虑到网络情况的影响,如参会者在会议进行过程中发生网络波动时,本系统应能自动尝试重新连接。并且当参会者网络断开或手动退出会议后,自动告知其他参会者,当参会者离开会议后能保留相关参会记录。

3.2.1 一对一音视频通话功能需求分析

为了能够让面试官与候选人能够方便表述自身想法并及时交流,本系统将提供一对一的音视频通话功能。当参会双方在浏览器上通过前端应用进入会议室后,会自动为双方开启一对一的音视频通话功能,在未进行 CDN 流量加速以及处于正常的网络带宽环境下,参会双方的音视频延迟应在 1000 毫秒以内,确保在线会议的即时性。

在音视频通话过程中,参会双方都应有权利选择开启或关闭摄像头设备,并且能够自由调节自身麦克风的采集音量和对方麦克风的接收音量,以此确保参会双方在音视频通话过程中最基本的隐私权利。

鉴于面试所具有的考核性质,本系统还应提供焦点检测功能,当候选人的浏览器页面失去焦点后应及时在画面中提醒面试官,由面试官来判断候选人在面试过程中离开页面的行为是否合理,防止候选人在面试过程中操作其他程序来辅助面试,确保面试会议的严谨和公平。

3.2.2 协同编辑功能需求分析

尽管本系统提供音视频交流功能,但在面试会议的过程中,难免需要进行更进一步的具体表述,特别是在针对程序员职业的面试会议中,面试官可能还会对候选人的代码编写能力进行考核。

图3-1 协同编辑功能操作流程图

图 3-1 协同编辑功能操作流程图

因此本系统将实现协同编辑功能,如图 3-1 协同编辑功能操作流程图所示,支持面试官与候选人在会议的交流过程中,共同编辑同一个公共文本域(下文简称交互板),要求参会双方对交互板所做出的任何编辑操作(如新增、修改、删除、撤销、重做等),浏览器中的前端应用都能得到服务端的实时同步更新。

由多用户同时在编辑同一块交互板,很可能会因为并发操作而导致同步文本冲突等问题,本系统在实现协同编辑功能的基础上,应对多用户间的编辑操作进行正确的同步处理,以确保交互板内容在前端与服务端中都达到最终一致性。

3.2.3 在线编程功能需求分析

作为主要针对程序员职业的面试会议,提供在线编程功能是十分必要的。在面试会议过程中,面试官可能需要对候选人的代码编写能力进行考核。因此,在实现协同编辑功能的基础上,还应使参会双方都能将交互板中的代码文本提交至后端服务中进行远程编译及运行,并及时收到用户程序的运行结果,达到在线编程的目的。

图3-2 在线编程功能操作流程图

图 3-2 在线编程功能操作流程图

如图 3-2 在线编程功能操作流程图所示,整个编译过程都不应依赖于用户的本地环境,而是交由在后端服务器中统一部署的编译环境来进行解释、编译、运行,通过中立的公共编译环境来保证参会双方得到一致的编译结果。该功能应支持 Java、Python2/3、C/C++等主流编程语言。

考虑到将在系统服务器中运行未知的用户代码所面临的安全问题,在运行用户代码的过程中,本系统需要做好足够的防护措施来应对常见的服务器渗透攻击,以保证整个系统的安全与稳定。在设计在线编程功能的基础上,提出以下三个方面的安全要求:

  1. 环境隔离:避免评判环境与其他环境产生影响。
  2. 屏蔽系统调用:禁止用户程序调用系统命令。
  3. 资源限制:防止用户程序申请大量内存空间或进行耗时操作。

3.3 角色用例分析

3.3.1 候选人用例

图3-3 候选人用例图

图 3-3 候选人用例图

如图 3-3 候选人用例图所示,当参与者为候选人时,可以进行个人资料填写、查看自己的参会记录、参与和退出进行中的会议。在参会会议的过程中还可以使用音视频通话、协同编辑、在线编程功能。

3.3.2 面试官用例

图3-4 面试官用例图

图 3-4 面试官用例图

如图 3-4 面试官用例图所示,当参与者为面试官时,与候选人不同的是,其多出了创建会议、主动关闭会议的用例。

3.4 本章小结

在本章中对系统进行了需求分析,确定下了系统将要实现的功能和要求,还根据业务需求确定下了角色用例。

第四章 系统总体设计

面试官与候选人通过浏览器页面进行面试会议,故本系统将划分为面向用户操作的前端应用与支撑各功能的后端服务。

4.1 系统用户界面主要交互流程设计

图4-1 系统主页界面图

图 4-1 系统主页界面图

如图 4-1 系统主页界面图所示,当用户登录到系统后,可以在主页上输入会议邀请码作为候选人来加入到指定的会议室中,或作为面试官来发起一场面试会议。

当用户点击加入后,会跳转至指定的会议室页面,如图 4-2 系统会议室界面图所示。

图4-2 系统会议室界面图

图 4-2 系统会议室界面图

图 4-2 为参会页面,顶部显示了当前会议室的邀请码编号。左侧为交互板区域,参会者对交互板作出的所有操作都将同步展示给对方。交互板支持针对当前编程语言的语法进行关键字高亮显示,并提供简单的关键字代码提示,参会双方均可在交互板中进行协同编辑,也可以点击运行按钮来执行交互板中的程序代码,一方参会者选择了编程语言后,对方参会者当前的编程语言也将会同步切换。

图4-3 代码运行记录界面图

图 4-3 代码运行记录界面图

如图 4-3 代码运行记录界面图所示,双方均可查看当前会议室内运行过的代码记录,包括其提交时间、目标语言、CPU 用时、运行用时、内存消耗、提交的代码和运行结果等。

图4-4 会议室内的个人笔记界面图

图 4-4 会议室内的个人笔记界面图

如图 4-4 会议室内的个人笔记界面图所示,参会者们均可在会议进行中记录会议笔记,此处笔记区域的内容支持 markdown 语法高亮,并且笔记区域的内容属于个人私有,不会展示给对方参会者。 图 4-2 的右侧为音视频通话区域,如图 4-5 音视频通话界面图所示,参会者们可以看到当前会议已开启时长,可以设置倒计时提醒,结束或退出会议室。

图4-5 音视频通话界面图

图 4-5 音视频通话界面图

参会者们可以开启或关闭自己的摄像头和麦克风或调整来自对方的通话音量,还可以查看当前浏览器与信令子服务间的通信延迟、媒体传输上行比特率、媒体传输下行比特率、对等连接延迟等信息。

4.2 系统技术架构设计

本系统采用前后端分离的方式进行开发,前端采用 React 框架实现,后端采用微服务的方式进行开发和部署,根据业务种类划分为五个子服务,数据库为 MySQL、缓存中间件 Redis 和 Rabbit MQ 消息队列,如图 4-6 系统服务架构图所示:

如图4-6 系统服务组件架构图

如图 4-6 系统服务组件架构图

4.2.1 认证子服务

认证子服务承担了整个系统的用户认证功能,除此之外的子服务被统称为资源服务。认证服务与资源服务的通信都有一个密钥参与,认证服务存储的是私钥,资源服务存储的是公钥。当资源服务向认证服务申请认证用户凭证时,会将其公钥与认证服务的私钥进行配对,防止子服务间的通信被篡改。

4.2.2 用户子服务

用户子服务负责提供与用户操作相关的功能,如提供用户登录接口、用户注册相关业务流程、用户信息获取、用户信息修改等。

4.2.3 会议子服务

会议子服务负责提供与会议相关的功能,如用户创建新会议、会议创建者关闭会议、根据会议邀请码获取会议信息、获取用户的参会记录等。

4.2.4 评判子服务

评判子服务负责提供与用户代码评判相关的功能,如对用户提交的代码进行在线运行得出结果、获取某会议室中的代码运行记录、获取某一次提交的源代码等。

4.2.5 信令子服务

本系统设计在会议进行中时前端应用与后端服务将通过 WebSocket 协议进行全双工通信,前端应用的收发工作由浏览器实现的 Web API 提供,后端服务的收发工作由其中的信令子服务来完成。在前端应用与信令子服务通信的环节中,如本文没有特别说明,则有关网络通信的相关描述全部为基于 WebSocket 的网络通信协议。

信令子服务将作为后端服务中的一员,可以类比为消息收发中心,其主要作用是实现浏览器与服务端之间的长连接全双工通信。当长连接握手成功后可以多次发送数据包,只要该连接不中断,后续发送数据包时不必再与服务端进行握手,对于本系统会议室业务中频繁的即时通讯来说十分有利;全双工通信这一特点让服务端也能主动向已连接的浏览器发送数据包,也是本系统会议室业务中,实现广播机制所不可或缺的条件。

信令子服务名称中的“信令”,是本系统为信令子服务设计的单次网络通信个体。信令本质上是一个 JSON 格式字符串,其有两个键值对,分别为:

浏览器与信令子服务进行通信时,单次发送的是一个信令,单次接收的也是一个信令,信令被按照统一的规范定义,两边只需要持有一份相同的信令事件列表,就能解析出信令所属的事件并作出针对性的处理。

为了记录各个正在进行中的会议数据,信令子服务设计了一种数据结构,用于存储对应会议室内的共享数据。该结构本质上是一个键值对集合,其键名为会议室的通用唯一标识符(UUID),其值域中存储了会议共享对象,字段为:

会议共享对象主要的作用是:当参会者首次加入会议或重新连接后,将会从服务端获取到和其他参会者同步的最新会议内容。

4.3 系统总体模块设计

根据需求分析与对各业务模块进行划分,建立本系统的模块结构图,如图 4-7 所示:

图4-7 系统模块架构图

图 4-7 系统模块架构图

4.4 系统数据库设计

通过分析具体业务流程,建立本系统的数据库实体关系 E-R 图,如图 4-8 所示:

图4-8 系统数据库实体关系E-R图

图 4-8 系统数据库实体关系 E-R 图

4.5 本章小结

在本章中根据业务需求确定了系统的用户界面和总体架构,说明了各项服务模块的设计目的、对系统数据库进行了建模。

第五章 系统功能设计

5.1 系统基础功能设计

5.1.1 用户认证设计

在后端服务中设计一个认证子服务,为各业务流程及各子服务提供用户认证功能。本系统采用 JWT(JSON Web Token)形式作为验证用户身份的凭据。用户在前端应用中进行登录时,前端应用将其账号和密码交由后端服务中的认证子服务进行处理,认证子服务在收到账号密码后首先会判断其账号是否存在,如果存在则从数据库中将对应的用户信息取出。将提交的密码进行 BCrypt 加密编码后与数据库记录的密码进行比对,比对通过后就会为其生成一段编码后的字符串,称其为 JWT 令牌,其中包含了用户名、令牌过期时间等信息。该 JWT 令牌将被返回至前端应用,以表示登录成功,前端应用可以将其存储在 Cookie 或 SessionStore 中。对于所有要求校验用户凭证的 API 接口,前端应用在调用时都需要在请求头中携带该用户的 JWT 令牌,否则认证子服务无法解析 JWT 令牌信息、无法验证用户凭证,导致请求失败。

5.1.2 会议室设计

在后端服务中设计一个会议子服务,提供与会议相关的操作功能。会议室是本系统提供给面试官与候选人开展面试的“场所”,也是整个系统中最基本的业务。

用户在前端应用中登录到系统后,可以在创建一个新的会议室或加入一个已有的会议室,如果该用户是会议室创建者,那么该用户在该会议室中所属的角色为“面试官”,拥有关闭该会议室的权限;如果该用户是作为被邀请者加入到已有的会议室中,那么该用户在该会议室中所属的角色为“候选人”,仅能进入会议室参加面试或离开会议室,无法变更会议室的开启状态。

当前端应用中发起创建会议的请求时,后端服务中的网关子服务会将该请求交由会议子服务处理,会议子服务首先会向认证子服务申请验证该用户凭证,判断其是否已登录。然后为即将创建的会议室生成一个由六个字母或数字组成的邀请码,邀请码生成完毕后还需要向数据库进行验证,确保不会与进行中的会议冲突。如果有冲突,则会重新生成再继续验证,单个请求最多持续生成五次。

正常情况下随机生成的邀请码与现有的邀请码冲突的概率很低,但随着系统的运行,会有越来越多的邀请码被使用,所以本系统对邀请码冲突的定义仅限于正在进行中的会议,如果与已结束的会议邀请码冲突也不会影响到系统的正常运行,后续可以视系统运行时长和数据库记录数来决定是否增加邀请码的生成长度,拥有更长的邀请码就拥有更多的字符组合,冲突的概率也会更小。但考虑到如果邀请码过长,会导致用户在分享过程中容易丢失信息,所以六个字符的长度是本系统考虑为起始阶段的最佳长度。

当会议室中不存在任何参会者时(如面试官忘记关闭会议而是直接关闭浏览器),该会议室将最多保留十分钟,如果在这个期间内还是没有任何参会者加入会议的话,会议室将被自动关闭。

5.2 一对一音视频通话功能设计

一对一音视频通话功能的主要流程为:在浏览器前端应用上,当面试官与候选人进入同一个会议室页面后,前端应用将会采集浏览器本地计算机媒体设备,通过 WebRTC 为参会双方建立对等连接,并实时地向对方传输本地流媒体数据,解决一对一音视频通话的需求。

如 2.5 WebRTC 一节中所述,WebRTC 是一个针对浏览器平台的 RTC 协议实现,包含了 RTC 协议处理、流媒体传输等功能。鉴于本系统主要通过浏览器页面来提供服务,属于 B/S 架构,所以非常适合采用 WebRTC 技术来建立对等连接、处理流媒体通信。

WebRTC 在建立通信的过程中需要双方浏览器进行媒体协商与网络协商、交换双方浏览器的媒体格式信息与网络地址信息,这一步骤可以通过信令子服务来完成转发。

5.2.1 NAT 穿越技术应用

如果用户主机已被 NAT 限制,那么仅仅依靠 WebRTC 是无法建立对等连接的,需要进行 NAT 穿越,获取计算机自身的公网 IP 地址和端口号。如此一来,对方主机向该地址发送的网络数据包才能够顺利到达该用户主机。

如 2.5.1 NAT 穿越技术和 2.5.2 STUN/TURN 介绍一节中所述,对于所有圆锥型 NAT,可以使用 STUN 服务器来进行穿越。而对称型 NAT 只能通过类似 TURN 的服务器来作为转发中继。本系统将采用 Coturn 来搭建 STUN/TURN 服务器,STUN 服务器将为圆锥型 NAT 的客户端实现 NAT 穿越,TURN 服务器则为对称型 NAT 的客户端进行“兜底”,作为其通信中继,保证圆锥型 NAT 和对称型 NAT 的客户端都能够建立对等连接。

5.2.2 WebRTC 的信令交换流程设计

整个 WebRTC 的连接过程与 TCP 协议的握手流程目的相似:以最少的通信次数,使双方浏览器都能得到自己的媒体描述、对方的媒体描述、自己的公网 IP 地址和端口号、对方的公网 IP 地址和端口号、自己的本地媒体流、对方的远端媒体流。

图5-1 圆锥型NAT信令交换时序图

图 5-1 圆锥型 NAT 信令交换时序图

如图 5-1 圆锥型 NAT 信令交换时序图所示,当参会双方的浏览器都已连接上信令子服务后,浏览器 A 与浏览器 B 首先会创建一个对等连接,并采集本地设备的媒体流绑定至 WebRTC。随后浏览器 A 会创建一个协商提议,将本地所支持的媒体格式写入媒体描述中,为 WebRTC 设置本地媒体描述,并将其发送至信令子服务。当信令子服务收到浏览器 A 的媒体协商信令后,会将其原封不动的转发至浏览器 B。浏览器 B 收到浏览器 A 发来的媒体协商提议后,会为 WebRTC 设置远端媒体描述和本地媒体描述,并创建一个协商应答,将浏览器 B 的本地媒体描述也发送至信令子服务。信令子服务依然对该信令原封不动的转发至浏览器 A,浏览器 A 收到浏览器 B 应答的媒体协商提议后,也会为 WebRTC 设置远端媒体描述。至此浏览器 A 与浏览器 B 的媒体协商完成,双方的 WebRTC 都持有自身的本地媒体描述和对方的远端媒体描述。

媒体协商过程完成后是网络协商过程,目前对等连接还只是已创建而未建立连接,所以需要将自身的公网 IP 地址和端口号告知对方。首先,浏览器 A 会通过 STUN 服务器进行 NAT 穿越,获取自身的公网 IP 地址和端口号,并将该网络信息发送至信令子服务。当信令子服务收到浏览器 A 的网络协商信令后,依旧原封不动的将其转发至浏览器 B。浏览器 B 收到浏览器 A 发来的网络协商信息后,会将其添加至 WebRTC 的候选网络中,随后通过 STUN 服务器进行 NAT 穿越,获取浏览器 B 自身的公网 IP 地址和端口号,并将该网络信息发送至信令子服务。信令子服务继续转发至浏览器 A。浏览器 A 收到浏览器 B 的网络协商信令后,也将其网络信息添加至 WebRTC 的候选网络中。至此浏览器 A 与浏览器 B 的网络协商完成,双方的 WebRTC 都持有对方的公网 IP 地址和端口号,可以建立对等连接。

可以看到在建立对等连接前,双方都要需要通过信令子服务来向对方转发信令,而一旦双方建立对等连接后即可进行一对一的媒体通信,浏览器 A 与浏览器 B 都会将对方所传输的远端媒体流绑定至自身的 WebRTC,除开对称型 NAT 的情况外,媒体通信过程不再依赖任何第三方服务器,所有网络数据包都能直达对方浏览器,解决了实时音视频通话的需求。

以上过程针对的是圆锥型 NAT,当计算机通过 STUN 服务器进行 NAT 穿越时,如果 STUN 服务器发现该浏览器的计算机网络属于对称型 NAT,那么 TURN 服务器将会介入,作为像信令子服务一般的中继服务器。

图5-2 对称型NAT信令交换时序图

图 5-2 对称型 NAT 信令交换时序图

如图 5-2 对称型 NAT 信令交换时序图所示,与圆锥型 NAT 情况不同的是,当 STUN 服务器发现浏览器 B 的计算机网络为对称型 NAT 时,因为对称型 NAT 无法穿越的缘故,则会交由 TURN 服务器来进行处理,TURN 服务器会为该连接分配一个中继端口号,并直接返回 TURN 服务器的公网 IP 地址和分配的端口号,而不再是该浏览器 B 计算机的公网信息。浏览器 B 继续以 TURN 服务器的网络信息发送网络协商,而 TURN 服务器将会作为双方对等连接通信的中继桥梁。

5.3 协同编辑功能设计

协同编辑功能的主要设计流程为:当参会者在浏览器中,向前端应用的本地交互板中进行编辑操作时,将通过后端服务中的信令子服务向其他参会者的浏览器广播交互板的内容变化,其他参会者的浏览器收到消息后将对方操作应用至本地交互板中,以此解决协同编辑的需求。

这一看似简单的流程还有着诸多待解决的问题,例如并发编辑操作的内容一致性、发送自身操作的时机,应用对方操作的时机等。下文将会对这些问题进行论述,并设计出一套合理的协同编辑解决方案。

5.3.1 OT 算法抽象设计

如 2.6 OT 算法介绍一节中所述,OT 算法的核心思想都是围绕着原子操作列表来展开的,所以本系统以面向对象的方式设计一个 OT 操作类,该类将是本系统解决并发编辑操作一致性问题的关键。如图 5-3 OT 操作类图所示,原子操作列表为该类属性,向列表中添加插入操作、向列表中添加保留操作、向列表中添加删除操作、OT 应用函数、OT 变换函数、OT 组合函数为该类方法。

图5-3 OT操作类图

图 5-3 OT 操作类图

当进行协同编辑时,前端应用与服务端之间传输的信令数据主要为原子操作数组,“向列表添加操作”方法是为了能够将任何编辑操作都实例化为 OT 操作对象,从而进行统一处理。而“OT 应用函数”、“OT 变换函数”、“OT 组合函数”则是对 OT 算法的具体实现,下文将进行逐一说明。OT 算法有着诸多种具体实现,而本系统会根据自身业务流程,设计一整套与之契合的完整实现,以解决本系统对于协同编辑功能的需求。

为了使本系统的前端应用与后端服务都能够进行原子操作处理,OT 操作类将被封装到前端应用与后端服务中,所以可能需要采用平台对应的编程语言来分别实现。不过对于具体的算法设计思想而言,它们的工作流程都是一致的,除此之外只有编程语言语法上的区别而已。

5.3.2 OT 应用函数设计

OT 应用函数用于将当前的原子操作数组依次应用到给定的文本字符串中,返回应用后的新字符串。

假设定义一个 OT 应用函数:apply(S, A),其接收两个参数,S为给定的文本字符串,A为原子操作数组。例如:apply("abc", [3, "d"]),返回字符串"abcd"

5.3.3 OT 变换函数设计

对于没有进行并发控制的情况下,设有S = "ab",当 A、B 用户对同一S进行并发编辑操作时:

此时 A、B 用户的操作产生了并发冲突,那么当 OT 应用后(应用顺序视网络传输延迟决定):S = apply(apply(S, A), B)S = apply(apply(S, B), A),最终的结果可能会有:S = “abcd”S = "abdc"

这样的情况显然与 3.2.2 协同编辑功能需求分析一节中所述的:“交互板内容在前端与服务端中都达到最终一致性”这一需求不符,而 OT 变换函数就是解决这一冲突的关键:在产生并发操作冲突时,OT 变换函数将根据主操作对副操作进行操作变换,变换后的副操作将得到正确变更,不再与主操作产生操作冲突。

假设定义一个 OT 变换函数:transform(A, B),其接收两个冲突操作:主操作A与副操作B,表示将根据操作A对操作B进行操作变换,返回变换后的[A, B]

例如:设S = "ab",当transform([2, "c"], [2, "d"]),返回[[2, "c"], [3, "d"]]A作为主操作没有被变换,所以A = A``,而B根据A被变换为了B``,所以apply(apply(S, A), B') = apply(apply(S, B), A'),如此只会有一个结果:S = “abcd”`。

5.3.4 OT 组合函数设计

OT 操作类中需要维护一个原子操作数组,这些原子操作不可再被分割,大量的原子操作会产生冗余。OT 组合函数的作用就是将两个连续的原子操作列表组合并压缩为一个原子操作列表,且在应用到字符串后与组合前都具有相等的结果。

假设定义一个 OT 组合函数:compose(A, B)AB为两个连续原子操作数组,有apply(S, compose(A, B)) = apply(apply(S, A), B)

例如:设S = "ab"A = [1, "c", 1]B=[1, "d", 1],压缩后有compose(A, B) = [1, "dc", 1]。当S = apply(S, compose(A, B))S = apply(apply(S, A), B)`,也有`S` = S = “adcb”`。

5.3.5 版本确认机制设计

协同编辑的操作同步方式一般可以分为三种:

本系统的协同编辑解决方案主要采用“增量覆盖”作为传输和应用编辑操作的方式,通过设计一套版本确认机制,让前端应用发送操作时携带版本号、服务端接收操作时确认版本号来解决操作顺序不同导致应用操作后内容不符的问题。

当前会议的交互板最新版本号、交互板最新内容和历史原子操作列表等信息都将临时存放于信令子服务中的会议共享对象内,用于在会议进行过程中同步数据,其以会议为单位被分别存储,当参会者离开该会议或该会议关闭时,这些信息也将被持久化到数据库里。

图5-4 交互板首次同步的时序图

图 5-4 交互板首次同步的时序图

如图 5-4 交互板首次同步的时序图所示,当参会者 A 进入会议,浏览器 A 完成与服务端的握手并建立连接后,服务端首先会将交互板最新内容和最新版本号一并发送至浏览器 A。浏览器 A 将最新内容覆盖至前端应用的本地交互板,并记录下最新版本号,完成浏览器 A 与服务端的首次交互板同步。

图5-5 交互板非并发操作同步的时序图

图 5-5 交互板非并发操作同步的时序图

如图 5-5 交互板非并发操作同步的时序图所示,当参会者 A 的交互板发生内容变动时,浏览器 A 会将原子操作和当前记录的版本号一并发送至服务端。服务端将浏览器 A 的版本号与历史原子操作列表进行比对,寻找该版本中是否存在并发操作冲突,如果不存在并发操作冲突,服务端会将该浏览器 A 的操作 OT 应用至交互板的最新内容中,作为其他浏览器首次同步所需。服务端还会将该操作加入历史原子操作列表,并且根据该操作的长度,增加最新的版本号,作为下次检测并发操作时的依据。最后服务端向浏览器 A 回传一个包含最新版本号的“操作确认”信令,也向其他浏览器广播交互板“内容变更”信令,该信令携带有浏览器 A 的原子操作列表和最新版本号。浏览器 A 在收到“操作确认”信令后,会将本地记录的版本号更新为来自服务端的最新版本号;其他浏览器在收到交互板“内容变更”信令后,会将信令中的原子操作列表 OT 应用至当前的本地交互板中,并更新本地版本号。

图5-6 交互板并发操作同步的时序图

图 5-6 交互板并发操作同步的时序图

如图 5-6 交互板并发操作同步的时序图所示,如果服务端发现存在并发操作冲突,则根据浏览器 A 中的版本号与服务端最新版本号的差异数量,从历史原子操作列表中获取对应范围的并发原子操作列表,以浏览器 A 操作为主操作,依次对并发原子操作列表中的原子操作进行 OT 变换。直到变换完毕,该浏览器 A 的操作不再产生操作冲突后,服务端才将其 OT 应用至交互板的最新内容中,并将该浏览器 A 的操作加入历史原子操作列表,且增加最新的版本号后,向该浏览器 A 回传包含最新版本号和被 OT 变换后的正确原子操作列表的“操作确认”信令,也向其他浏览器广播交互板“内容变更”信令,该信令携带的数据则是浏览器 A 的操作被 OT 变换后的正确原子操作列表。浏览器 A 和其他浏览器一样,在收到信令后都会将正确的原子操作列表 OT 应用至当前的本地交互板中,并更新本地记录的版本号。

5.3.6 前端状态流转机制设计

按照原设计流程,当参会者对交互板进行编辑输入时,每输入一个字符都会触发其内容变化事件,向服务器发送关于该操作的信令。前端应用频繁发送信令,会给信令子服务带来很大的收发负担,但输入的每一个字符都不能被忽略,单单依靠服务端进行 OT 变换和版本号约束也无法完全保证并发编辑的正确性,为此本系统将对前端应用的收发流程做出优化,为协同编辑功能设计三种前端状态:

前端应用所处的状态并不是固定的,而是会根据实际情况而流转,状态流转机制有着固定的规律。例如图 5-7 前端状态流转示意图所示,在“已同步”状态下,只有当发送“操作变更”信令后才会流转至“待确认”状态;在“待确认”状态下,只有收到“操作确认”信令后才会流转回“已同步”状态。

图5-7 前端状态流转示意图

图 5-7 前端状态流转示意图

根据这三种前端状态,可以做出针对性的处理。如上节所述,浏览器与信令子服务建立连接后会获得该会议中最新的交互板内容和版本号,所以此时的前端应用为“已同步“状态,可以发送“操作变更”信令。又如图 5-7 所示,当前端应用继续向服务端发送“操作变更”信令后,则会从“已同步”状态流转至“待确认”状态。

当处于“待确认”状态下的前端应用如果继续发送“操作变更”信令,则会流转至“待确认且待发送”状态。处于此状态下的前端应用,交互板内容变化时将不再发送“操作变更”信令,而是会将交互板所有的编辑操作都加入缓冲区,被加入缓冲区的操作都会通过“OT 组合函数”组合为“一个操作”。直到前端应用收到信令子服务回传的“操作确认”信令后,才会回退为“待确认”状态。“待确认”状态下的前端应用又可以继续将那被组合为“一个操作”的“操作变更”信令发送至信令子服务,发送后将清空缓冲区。此设计将多数操作压缩组合为单一操作,极大地缓解了信令子服务的收发压力。

处于“待确认”状态下的前端应用,在收到信令子服务回传的“操作确认”信令后,也会如期流转回“已同步”状态。

5.4 在线编程功能设计

在线编程功能主要由后端服务中的评判子服务负责提供,旨在接收一段用户代码和指定的编程语言,在后端服务器上进行编译、解释,并运行得出结果后告知前端应用。后图 5-8 为关于在线编程功能的、服务架构间的业务流程示意图,下文将会进行说明。

图5-8 在线编程功能的业务流程示意图

图 5-8 在线编程功能的业务流程示意图

5.4.1 评判子服务业务流程设计

如图 5-8 所示,用户在浏览器前端应用上点击“运行”按钮后,前端应用会获取交互板中的内容,并向后端服务器发起“评判”请求(以 HTTP 方式),后端服务器中的评判子服务会负责处理这些与“评判”相关的所有请求。

评判子服务将根据评判请求生成评判记录和评判任务,前者被插入到数据库中进行持久化,其状态字段为待评判,后者会被发送至消息队列中等待任务处理。至此,该次请求便已结束,它将正常返回至前端浏览器,并告知用户该任务已被加入评判队列。接下来的所有流程对于用户来说都是异步的,在前端浏览器请求完毕后,用户可以继续进行其他的动作,而无需静静的等待评判结果。

评判子服务中的评判任务处理器负责监听消息队列中的“评判任务”消息,并按照队列先进先出的原则依次处理评判任务。任务评判结束后会根据“评判结果”来更新数据库中的记录,随后将其发送至消息队列中,信令子服务将依次接收它们,并主动向浏览器发送消息信令(以 WebSocket 方式)。

该设计引入了消息队列这一中间件,将并发的任务请求一并收入任务队列内即可返回,再以异步的方式进行针对性处理。一来解决了并发情况下对流量的“削峰”,在缓解服务器压力的同时保证了每个任务在队列中被处理的顺序;二来解决了部署评判子服务集群后,多个相同服务间的通信问题,这些相同的评判子服务们只需要向消息队列中接收或发送消息,监听该消息的服务便能依次进行针对性的处理。

以上仅是服务架构间的业务流程,而整个在线编程功能的核心部分则在于评判沙箱、统一评判入口和评判机的设计,它们关乎整个功能的实现以及整个系统的安全性。

5.4.2 评判沙箱设计

本系统在部署评判子服务的物理机上,将额外部署一个 Docker 虚拟化容器,作为运行用户代码的评判沙箱,以达到环境隔离的安全需求。

评判子服务在收到提交的评判任务后,会将用户代码文本写入物理机的文件系统中,并将其传输至 Docker 容器内,随后通过 Docker 容器内的评判入口发起评判。

评判沙箱将采用 Alpine Linux 镜像作为沙箱容器的基础镜像,Alpine Linux 是一个仅 5.8MB 大小的极简操作系统,其依赖策略均是最简依赖,移除了许多非必须的软件包、国际化组件等。对于本系统简单的在线编程需求而言,不会涉及到框架开发、操作系统底层开发等复杂的函数库依赖,所以选择 Alpine Linux 操作系统来承载评判机和用户程序是非常适合的。

根据评判子服务拟支持的编程语言,需要提前在评判沙箱中部署该编程语言所对应的编译器或解释器,如下表 5-1 所示:

编程语言 编译器/解释器 版本号
C 语言 gcc Alpine Linux 10.3.1 20210424
C++语言 g++ Alpine Linux 10.3.1 20210424
Java 语言 javac/java JDK 1.8.0_282
Python2 语言 python2 2.7.18
Python3 语言 python3 3.9.5

评判沙箱首次初始化后将创建一个用于评判任务的 Linux 用户和其用户文件夹,并赋予其 755 权限,所有与评判任务相关的操作都将以该用户的身份执行。

5.4.3 评判机设计

本系统设计一种以 Seccomp 机制来执行程序的评判机,以达到屏蔽系统调用的安全需求,防止用户程序调用系统命令进行不受控的高级操作。

对于 Java 语言的评判任务而言并不需要以 Seccomp 机制来限制用户程序运行:Java 虚拟机(Java Virtual Machine,下文简称 JVM)本身就是一套完善的子系统,拥有独立的沙箱设计,且不与 Seccomp 机制兼容。基于 JVM 的编程语言,评判机将通过 Java 安全管理器(Java Security Manager)来启用 JVM 自身的沙箱机制,以达到限制运行用户程序的目的。

除屏蔽系统调用外,本系统还为评判机设计了运行时资源监控功能,以达到针对用户程序资源限制的安全需求,防止用户程序在运行时申请大量内存空间、执行死循环等操作,评判机程序的具体运行流程图 5-9 如下:

图5-9 评判机运行流程图

图 5-9 评判机运行流程图

评判机程序将产生两个进程和一条线程,分别为评判机进程、用户程序进程和杀手线程:

评判机启动时需要接收的参数如下表 5-2 所示:

参数名 描述 备注
-b 目标可执行文件的绝对路径 例如编译器的绝对路径。
-p 目标可执行文件在调用时所需的参数 例如相关编译命令和源代码文件的绝对路径。
-s 是否以 Seccomp 机制执行 例如 Java 语言不需要以 Seccomp 机制执行。
-m 内存使用量的上限值 防止用户程序申请大量内存空间。
-c CPU 使用时长的上限值 防止用户程序长时间占用 CPU。
-r 总执行时长的上限值 防止用户程序进行死循环等耗时操作。

评判机运行结束时,将根据目标用户程序的执行结果返回 JSON 数据,如表 5-3 所示:

键名 描述 备注
result 结果输出信息 例如用户程序向终端打印的信息或编译错误后输出的相关信息。
statusCode 用户程序的最终状态 例如运行成功、运行失败或超出运行限制后被强制终止。
memory 内存使用量
cpuTime CPU 使用时长
realTime 总执行时长

5.4.4 统一评判入口设计

为了解决各个编程语言间的不同差异,本系统通过 Shell 脚本来设计一种统一的评判入口,作为评判任务处理器与评判机的中间组件。统一评判入口根据来自评判任务处理器的目标语言要求,将评判任务进行参数预处理,并分发至评判机。

图5-10 统一评判入口的工作流程图

图 5-10 统一评判入口的工作流程图

如图 5-10 所示,统一评判入口的预处理步骤如下:

  1. 获取源代码文件路径并对其文件名进行格式化:例如针对目标语言修改为对应的文件后缀名。
  2. 针对目标语言来设定运行时资源上限:例如 CPU 使用时长、总执行时长、内存使用量等,因各类语言存在性能差异,必要时可以有针对性的调整上限阈值。
    1. C/C++语言资源上限:50MB 内存使用量、3000 毫秒 CPU 使用时长、5000 毫秒总执行时长。
    2. Java 语言资源上限:70MB 内存使用量、3000 毫秒 CPU 使用时长、5000 毫秒总执行时长。
    3. Python 2/3 语言资源上限:50MB 内存使用量、3000 毫秒 CPU 使用时长、5000 毫秒总执行时长。
  3. 寻找目标语言的编译器、解释器的绝对路径。
  4. 将上述预处理得到的代码源文件绝对路径、编译或解释所需的命令、目标语言、资源上限做为参数,启动评判机程序(对于编译型语言需要依次执行编译和运行两步操作,而解释型语言只需要后者)。

5.5 本章小结

本章主要介绍了诸如用户认证、会议室操作等系统基础功能的设计,还专门针对一对一音视频通话、协同编辑、在线编程三大主要功能的阐述了详细的设计思路与设计流程。

第六章 系统功能实现

6.1 系统基础功能实现

6.1.1 认证子服务实现

本系统的用户认证功能采用 Spring Security + OAuth2 来实现,首先需要在认证子服务中配置 Spring Security 的加密方式、开放请求的地址、认证失败处理等信息。除了 Spring Security 的配置,还需要配置 OAuth2 的令牌的加密密钥、过期时间(暂时设定为 24 小时)等信息,同时通过加密证书为各个子服务生成密钥对,各个子服务在请求认证子服务的相关服务时也需要携带对应的公钥才能够成功访问。

6.1.2 为登录用户生成凭证

fun login(username: String, password: String): UserLoginVO {
    val user = loadUserByUsernameInternal(username)
    if (!password.matchesBCrypt(user.password)) throw BadCredentialsException(C.BAD_CREDENTIALS.msg)
    // Get the JWT token from the authentication server
    val serviceId = "client:password"
    val base64Secret = Base64.getEncoder().encodeToString(serviceId.toByteArray())
    val jwt = authServiceRPC.getToken("Basic $base64Secret", "password", username, password)
        ?: throw BadCredentialsException(C.BAD_CREDENTIALS.msg)
    // Refresh login date
    user.joinAt = LocalDateTime.now()
    user.flushChanges()
    // Setting UserDetailsDTO authorities
    return UserLoginVO(UserDetailsDTO().copyFrom(user).apply {
        authorities = db.userRoles
            .filter { it.userUUID eq user.uuid }
            .map { it.role.name }
    }, jwt)
}

当登录请求进入后端服务中的网关子服务后,网关子服务会将其转发至用户子服务,在用户子服务中的登录方法中处理登录逻辑。如代码 6-1 所示,首先根据用户名从数据库中检索记录,如果确实存在该用户则将请求体中的密码进行 BCrypt 加密后与数据库中所存储的加密后的用户密码进行比对验证。比对通过后将用户名和密码通过 OpenFeign 调用认证子服务中 OAuth2 的获取凭证接口,该接口会返回一个通过 OAuth2 生成的 JWT 令牌字符串,用户子服务将 JWT 令牌与用户基本信息组合后一起返回至前端应用。

6.1.3 创建会议室实现

fun create(createMeetingDTO: CreateMeetingDTO): MeetingVO {
    Assert.isNull(
        db.meetings.find {
            (it.creatorUUID eq createMeetingDTO.creatorUUID)
            .and(it.endAt.isNull())
        },
        "A meeting is in progress"
    )
    val meeting = Meeting().apply {
        uuid = UUID.randomUUID().toString()
        title = createMeetingDTO.title
        code = generateInviteCode()
        creatorUUID = createMeetingDTO.creatorUUID
        createAt = LocalDateTime.now()
    }
    Assert.isTrue(db.meetings.add(meeting) > 0, "Create meeting error")
    return MeetingVO().copyFrom(meeting)
}

如代码 6-2 创建会议室代码所示,首先确保该用户没有参与正在进行中的会议,然后生成会议邀请码等会议基本信息,将其插入数据库中,完成会议的创建。

6.1.4 关闭会议室实现

根据 5.1.2 会议室设计一节中所述,会议室的关闭动作分为面试官主动关闭和十分钟内不再有参会者后被动关闭两种情况,首先是主动关闭的实现:

fun close(uuid: String): Boolean {
    val meeting = db.meetings.find { (it.creatorUUID eq uuid).and(it.endAt.isNull()) }
    Assert.notNull(meeting, "You don't have an ongoing meeting")
    meeting!!.endAt = LocalDateTime.now()
    meeting.flushChanges()
    rabbitTemplate.defaultConvertAndSend(
        RabbitMQExchanges.MEETING,
        RabbitMQRoutingKeys.MEETING_CLOSE,
        meeting.uuid,
        null
    )
    return true
}

如代码 6-3 主动关闭会议代码所示,首先验证该用户是否为该会议的创建者,并且该会议是否处于进行中状态,如验证通过则会将数据库中的会议的关闭时间设置为当前时间,表示该会议已关闭。然后会通过 Rabbit MQ 消息队列发送一条会议关闭的消息至信令子服务中,信令子服务在收到该消息后,将其广播至该会议所有参会者的浏览器中,当前端应用收到广播后将主动关闭 WebSocket 连接,离开会议室页面,完成会议主动关闭流程。

override fun afterConnectionClosed(session: WebSocketSession, closeStatus: CloseStatus) {
    // ...

    if (getOnlineMember(meetingUUID) <= 0) {
        redisTemplate.opsForValue().set("meeting.close.$meetingUUID", LocalDateTime.now().toString())
        redisTemplate.expire("meeting.close.$meetingUUID", 10L, TimeUnit.MINUTES)
    }
}

如代码 6-4 被动关闭会议 WebSocket 代码所示,在后端服务中的信令子服务内,于 WebSocket 的连接中断事件中,每当有一位参会者离开会议后都会检查一次该会议的在线人数,如果会议不再有参会者,则向 Redis 中存储一条以”meeting.close.会议室 UUID”为键名的缓存,设置其过期时间为十分钟。

@Component
class MeetingExpirationListener(private val db: Database, container: RedisMessageListenerContainer) :
    KeyExpirationEventMessageListener(container) {

    private val logger = KotlinLogging.logger {}

    override fun onMessage(message: Message, pattern: ByteArray?) {
        val expiredKey: String = message.toString()
        val eventPattern: String = pattern?.let { String(it) } ?: ""
        if (expiredKey.startsWith(RedisKeys.MEETING_CLOSE) && eventPattern == RedisPatterns.EX) {
            val meetingUUID = expiredKey.substring(RedisKeys.MEETING_CLOSE.length)
            val now = LocalDateTime.now()
            db.meetings.find { (it.uuid eq meetingUUID).and(it.endAt.isNull()) }?.let {
                it.endAt = now
                it.flushChanges()
            }
            logger.info { "Meeting closed: $meetingUUID, at $now" }
        }
    }
}

再如代码 6-5 被动关闭会议监听 Redis 缓存过期事件代码所示,监听 Redis 的缓存过期事件。每当 Redis 有缓存过期时,判断其键名是否以”meeting.close.”开头,如果是就将数据库中对应的会议记录中的会议关闭时间设置为当前时间,表示会议已关闭,完成会议被动关闭流程。

6.2 一对一音视频通话功能实现

6.2.1 Coturn 搭建

鉴于本项目利用 Docker 来部署评判沙箱,所以对于 Coturn 而言,本系统也选择使用 Docker 来进行部署,如代码 6-6 Coturn 部署命令所示:

docker run -d --network=host coturn/coturn

进入 Coturn 容器后,使用 turnadmin 命令添加一个长期的 TURN 用户,WebRTC 将使用这个用户凭证来连接 TURN 服务器。

6.2.2 加入会议室与连接信令子服务实现

在信令子服务中注册一个路径为”/webrtc”的 WebSocket 处理器,当参会者在前端应用中进入指定的会议室页面后,前端应用首先申请与信令子服务建立连接,如代码 6-7 WebSocket 连接地址示例所示,连接传递的参数为会议邀请码和当前参会者的 JWT 认证令牌。

wss://salen.top:10004/webrtc?code=xxxxxx&access_token=xxxxxx

编写 WebSocket 的握手拦截器,将握手请求统一拦截,并通过会议邀请码参数从数据库中的会议表内查询出使用该邀请码且处于已开启状态的会议记录,如果不存在该会议则直接令其握手失败。会议邀请码验证通过后,进行用户登录状态验证:将参会者的 JWT 认证令牌通过 OpenFeign 调用认证子服务接口获取身份验证,如果令牌无效则令其握手失败。最后确保当前会议的参会人数不超过两人,如第三人申请握手也同样令其握手失败。当同一参会者用户重复加入同一会议后,则关闭其他进行中的会话,确保参会者在同一个会议中的 WebSocket 会话是唯一的。该验证流程保证了仅可让已登录的用户加入已存在的会议,握手完成后信令子服务将会与参会者浏览器建立连接,在连接建立后的回调方法中,通过 WebSocket 主动向参会者的浏览器发送协同编辑功能中所需的交互板内容等信息。

6.2.3 建立对等连接

当前端应用的 WebSocket 连接至信令子服务后,创建一个 WebRTC 对等连接对象(RTCPeerConnection),首先通过 navigator.getUserMedia 该 API 获取该参会者计算机的本地设备媒体流,将其绑定至对等连接对象和 HTML 多媒体组件(Video 标签)中,前者用于发送自身媒体流,后者用于向参会者展示本地媒体画面。

通过对等连接对象创建 WebRTC 媒体协商提议,并获取该参会者浏览器的媒体描述(SDP),先将其设置至对等连接对象的本地媒体描述中,然后发送至信令子服务。信令子服务对 WebRTC 连接的相关信令仅做转发,所以对于对方参会者来说:前端应用在接收信令的回调方法中接收媒体协商信令,将其负载中的媒体描述(SDP)设置至对等连接对象的远端媒体描述中,创建 WebRTC 媒体协商应答并获取到本地媒体描述(SDP),然后再将其通过信令子服务转发至对方的浏览器,对方收到媒体协商应答信令后,将其负载中的媒体描述(SDP)设置至远端媒体描述中,至此,欲建立对等连接的双方都拥有了本地媒体描述和来自对方的远端媒体描述,媒体协商步骤完成。

网络协商的实现步骤相对较少,在创建 WebRTC 对等连接对象(RTCPeerConnection)时,需要指定一个候选网络信息(Candidate),可以填入公共 ICE 服务器地址或自建的 ICE 服务器地址,由于上文中已经描述了 Coturn 的部署过程,所以此时填写 Coturn 所部署的服务器地址及端口,如代码 6-8 所示:

const iceServer = {
  "iceServers": [{
    "url": "stun:salen.top:3478"
  }, {
    "url": "turn:salen.top",
    "username": "ash",
    "credential": "@ash1nch"
  }]
}

pc = new RTCPeerConnection(iceServer)

在 WebRTC 加载该本地候选网络信息的回调方法中,向信令子服务发送网络协商信令,通过信令子服务转发信令至另一参会者的浏览器,并在前端应用的接收信令回调方法内监听该信令,收到该信令后将其添加至 WebRTC 的远端候选网络信息中。

WebRTC 的网络协商与媒体协商流程都完成后,就会按照候选网络信息为目标地址、媒体描述为转码格式,进行点对点的媒体通信。

6.2.4 音视频控制

正如上文所述,本系统需要实现通话音量调节与摄像头开关功能。在前端应用中添加四个控件,分别为麦克风开关、摄像头开关、麦克风音量条、扬声器音量条。

监听麦克风开关按钮的切换事件,当麦克风开关切换至关闭状态时,遍历本地媒体流,找出属于音频类型的采集轨道,将其全部关闭,以停止采集的方式实现麦克风静音。将麦克风切换至开启状态同理。

监听摄像头开关按钮的切换事件,当摄像头开关切换至关闭状态时,同样是遍历本地媒体流,找到属于视频类型的采集轨道并将其全部关闭。切换至开启状态也同理。

创建一个音频内容对象(AudioContext),再通过音频内容对象创建一个增益节点(GainNode)。在获取到本地媒体流后,将其链接至增益节点。监听扬声器音量条的滑动事件,当扬声器音量条的滑动数值变化时,通过改变增益节点的值就能调节麦克风的采集音量。

监听扬声器音量条的滑动事件,当扬声器音量条的滑动数值变化时,为用于展示远端媒体流的媒体组件(Video 标签)设置指定数值的媒体音量,就可实现扬声器音量调节。

6.2.5 媒体通信往返时间和比特率统计

在前端应用的 WebRTC 绑定远端媒体流的回调方法中,创建一个每隔 1000 毫秒就执行一次的定时器(Interval)。定义上次记录的上行字节数(upstreamBytesPrev)和上次记录上行时间戳(upstreamBytesTimestampPrev)变量并初始化为 0。

在定时器的处理方法中,调用 WebRTC 对等连接对象提供的获取统计信息方法(getStats),获取当前时刻 WebRTC 的汇报信息。再从远程 RTP 入站的汇报信息中获取媒体通信的往返时间(RTT),和在 RTP 出站的汇报信息中取出当前发送的视频字节数,再减去上次记录的上行字节数并除以当前时间戳与上次记录的上行时间戳的差,得到上行比特率(KB/s),如代码 6-9 媒体通信数据统计代码所示,下行比特率的统计方式同理。

// 往返时间统计
if (report.type === "remote-inbound-rtp") {
  delay = report.roundTripTime * 1000
}

// 上行比特率统计
if (report.type === 'outbound-rtp' && report.mediaType === 'video') {
  const bytes = report.bytesSent
  if (upstreamBytesTimestampPrev) {
    upstream = (bytes - upstreamBytesPrev) / (now - upstreamBytesTimestampPrev)
    upstream = Math.floor(upstream)
  }
  upstreamBytesPrev = bytes
  upstreamBytesTimestampPrev = now
}

// 下行比特率统计
if (report.type === 'inbound-rtp' && report.mediaType === 'video') {
  const bytes = report.bytesReceived
  if (downstreamBytesTimestampPrev) {
    downstream = (bytes - downstreamBytesPrev) / (now - downstreamBytesTimestampPrev)
    downstream = Math.floor(downstream)
  }
  downstreamBytesPrev = bytes
  downstreamBytesTimestampPrev = now
}

6.3 协同编辑功能实现

6.3.1 OT 应用函数实现

如代码 6-10 OT 应用函数实现代码所示,OT 应用函数接收一个被应用的文本字符串参数(doc),将 OT 操作类中的原子操作列表应用至文本字符串中,并返回一个应用后的新文本字符串。

fun apply(doc: String): String {
    var i = 0
    val parts = mutableListOf<Any>()
    ops.forEach {
        if (it is String) {
            parts.add(it)
        } else if (it is Int) {
            if (isRetain(it)) {
                Assert.isTrue(
                    i + it <= doc.length,
                    "Cannot apply operation: operation is too long."
                )
                parts.add(doc.substring(i, i + it))
                i += it
            } else {
                i -= it
                Assert.isTrue(
                    i <= doc.length,
                    "Cannot apply operation: operation is too long."
                )
            }
        }
    }
    Assert.isTrue(
        i == doc.length,
        "Cannot apply operation: operation is too short."
    )
    return parts.joinToString(separator = "")
}

其算法流程为:定义一个游标变量(i)初始化为 0,游标变量就像是在被应用文本的字符间游走的指针。遍历 OT 操作类中的原子操作列表,逐个判断其类型,如果当前元素为字符串类型,按照 OT 算法的原子操作定义,说明是插入操作(Insert),将其添加至暂存列表变量(parts)中。如果当前元素类型为整型的话,就还需要进一步的判断,如果元素值大于 0 则说明是保留操作(Retain),从被应用的文本中截取出游标到保留值之间的子文本,并将子文本添加至暂存列表中,然后游标增加至与保留值之和。如果元素值小于 0,则说明是删除操作(Delete),将游标减少至与删除值之差。遍历完毕后将暂存列表中的文本全部拼接到一起就是应用后的字符串内容。

6.3.2 压缩原子操作列表实现

在本系统的设计中,OT 组合函数对两个原子操作列表进行组合时,还需要对原子操作进行压缩,以减少数据冗余。所以在实现 OT 组合函数之前,首先需要实现三个复用函数:获取单个原子操作的长度(opLen)、对两个原子操作进行压缩(shortenOps)、压缩指定长度的原子操作(shorten)。

fun opLen(op: Any): Int {
    if (op is String) return op.length
    if (op is Int && op < 0) return -op
    return op as Int
}

如代码 6-11 获取单个原子操作的长度代码所示,对单个原子操作进行判断,如果该操作为插入操作(Insert),则返回操作值字符串长度;如果该操作为删除操作(Delete),则操作值为负数,返回其相反数得到该操作长度;最后剩下的情况为保留操作(Retain),返回其操作值即可。

fun shortenOps(a: Any, b: Any): List<Any?> {
    val lenA = opLen(a)
    val lenB = opLen(b)
    if (lenA == lenB) return listOf(null, null)
    if (lenA > lenB) return listOf(shorten(a, lenB), null)
    return listOf(null, shorten(b, lenA))
}

如代码 6-12 对两个原子操作进行压缩代码所示,shortenOps 函数期望返回一个操作对,该操作对是根据某一操作(如 a 操作)对另一操作(如 b 操作)进行压缩后的结果,所以操作对要么全为空元素(当前的两个操作无法进行压缩),要么只会有一个被压缩后的元素。

其流程为:首先计算出 a 操作和 b 操作的长度,如果两者长度一致,则表示无需压缩,返回两个空元素操作对;如果 a 操作长度大于 b 操作长度,则根据 b 操作的长度对 a 操作进行压缩,返回压缩后的 a 操作与无需压缩的 b 操作(空元素)所组成的操作对;最后只剩下 b 操作长度大于 a 操作长度的情况,根据 a 操作的长度对 b 操作进行压缩,返回无需压缩的 a 操作(空元素)与压缩后的 b 操作所组成的操作对。

private fun shorten(op: Any, by: Int): Any {
    if (op is String) return op.substring(by, op.length)
    if (op is Int && op < 0) return op + by
    return (op as Int) - by
}

如代码 6-13 压缩指定长度的原子操作代码所示,其接收一个被压缩的原子操作参数和一个参考长度参数。如果该原子操作为插入操作(Insert),则返回其从参考长度截取至总长度的字符串子串;如果该原子操作为删除操作(Delete),则返回其与参考长度之和,表示以参考长度来抵消删除长度;最后剩下的情况为保留操作(Retain),返回其与参考长度之差。

6.3.3 OT 组合函数实现

OT 组合函数接收另一个被组合的 OT 操作类作为参数(other),将两个 OT 操作类中的原子操作列表进行合并,并返回一个组合并压缩后的新 OT 操作类。

fun compose(other: TextOperation): TextOperation {
    val iterA = ops.iterator()
    val iterB = other.ops.iterator()
    val operation = TextOperation()
    var a: Any? = null
    var b: Any? = null
    var i = 1
    while (true) {
        if (a == null) a = if (iterA.hasNext()) iterA.next() else null
        if (b == null) b = if (iterB.hasNext()) iterB.next() else null
        if (a == null && b == null) break

        if (a is Int && isDelete(a)) {
            operation.delete(a)
            a = null
            continue
        }
        if (b is String && isInsert(b)) {
            operation.insert(b)
            b = null
            continue
        }

        Assert.notNull(a, "first operation is too short")
        Assert.notNull(b, "first operation is too long")

        val minLen = min(opLen(a!!), opLen(b!!))
        if (isRetain(a) && isRetain(b))
            operation.retain(minLen)
        else if (a is String && isInsert(a) && isRetain(b))
            operation.insert(a.substring(0, minLen))
        else if (isRetain(a) && isDelete(b))
            operation.delete(minLen)

        shortenOps(a, b).let {
            a = it[0]
            b = it[1]
        }
    }
    return operation
}

如代码 6-14 OT 组合函数实现代码所示,对两个 OT 操作类的原子操作列表进行循环遍历。每次遍历都会分别从两个原子操作列表中各取出一个原子操作元素,记为 a、b。当两个原子操作列表都不存在下一个元素时表示两者都已处理,结束遍历。可以将 a、b 两个原子操作划分出六种情况:

a 操作情况 b 操作情况
delete 任意
任意 insert
retain retain
insert retain
retain delete
insert delete

如表 6-1 所示,首先考虑前两种情况:

除开上述两种情况外,首先计算两者的操作长度得出较小值(minLen):

当这三种条件分支处理结束后,将会对 a、b 两个原子操作进行压缩(shortenOps),得到压缩后的操作对,将操作对元素继续赋值至 a 和 b 后进入下次循环。最后只剩下的一种情况:

6.3.4 OT 变换函数实现

OT 变换函数接收 A、B 两个待变换的 OT 操作类作为参数,返回一个操作对,操作对中包含两个已变换的 OT 操作类。

fun transform(operationA: TextOperation, operationB: TextOperation): List<TextOperation> {
    val iterA = operationA.ops.iterator()
    val iterB = operationB.ops.iterator()
    val primeA = TextOperation()
    val primeB = TextOperation()
    var a: Any? = null
    var b: Any? = null

    while (true) {
        if (a == null) a = if (iterA.hasNext()) iterA.next() else null
        if (b == null) b = if (iterB.hasNext()) iterB.next() else null
        if (a == null && b == null) break

        if (a is String && isInsert(a)) {
            primeA.insert(a)
            primeB.retain(a.length)
            a = null
            continue
        }
        if (b is String && isInsert(b)) {
            primeA.retain(b.length)
            primeB.insert(b)
            b = null
            continue
        }

        Assert.notNull(a, "Cannot compose operations: first operation is too short")
        Assert.notNull(b, "Cannot compose operations: first operation is too long")

        val minLen = min(opLen(a!!), opLen(b!!))
        if (isRetain(a) && isRetain(b)) {
            primeA.retain(minLen)
            primeB.retain(minLen)
        } else if (isDelete(a) && isRetain(b)) {
            primeA.delete(minLen)
        } else if (isRetain(a) && isDelete(b)) {
            primeB.delete(minLen)
        }

        shortenOps(a, b).let {
            a = it[0]
            b = it[1]
        }
    }
    return listOf(primeA, primeB)
}

如代码 6-15 OT 变换函数实现代码所示,首先初始化两个 OT 操作类记为 A(primeA)和B(primeB),意为经过变换后的 A、B 操作类。OT 变换函数的具体实现过程与 OT 组合函数类似,都会循环遍历两个原子操作列表,并对其中 a、b 元素进行处理,但 OT 变换函数所划分的六种情况与 OT 组合函数稍有区别:

a 操作情况 b 操作情况
insert 任意
任意 insert
retain retain
delete retain
retain delete
delete delete

如表 6-2 所示,首先考虑前两种情况:

除开上述两种情况外,首先计算两者的操作长度得出较小值(minLen):

当这三种条件分支处理结束后,同 OT 组合函数一样,都会对 a、b 两个原子操作进行压缩(shortenOps),将压缩后的操作对继续赋值至 a 和 b 后进入下次循环。最后只剩下的一种情况:

6.3.5 前端状态流转机制实现

在前端应用中定义三个状态类,分别为:已同步状态类(Synchronized)、待确认状态类(AwaitingConfirm)、待确认且待发送状态类(AwaitingWithBuffer)。再创建一个前端状态类及其对象,类中定义一个状态类成员变量(state)和一个版本号成员变量(revision)。

在“待确认状态类”内部维护一个“未发送区”成员变量(outstanding),其通过构造方法接收一个原子操作列表来初始化;“待确认且待发送状态类”内部维护一个“未发送区”成员变量和一个“缓冲区”成员变量(buffer)。

在四个类中都定义三个名称相同的方法:发送操作消息(applyClient)、收到确认消息(serverAck)、收到操作变更(applyServer)。对于前端状态类来说,调用这四个方法时都会通过状态成员变量所指向的状态对象去调用,并将返回值赋值给状态成员变量,通过改变该变量的值,就能实现前端状态的变更,如代码 6-16 所示:

let synchronized_ = new Synchronized();

export default class Client {

  constructor(revision) {
    this.revision = revision;
    this.setState(synchronized_);
  }

  setState (state) {
    this.state = state;
  };

  applyClient (operation) {
    this.setState(this.state.applyClient(this, operation));
  };

  applyServer (revision, operation) {
    this.setState(this.state.applyServer(this, revision, operation));
  };

  serverAck (revision) {
    this.setState(this.state.serverAck(this, revision));
  };
}

通过为相同名称的方法编写不同的逻辑策略,就可以实现状态流转机制。对于“已同步状态类”:

对于“待确认状态类”:

对于“待确认且待发送状态类”:

6.3.6 服务端对并发编辑操作的冲突处理

在后端服务中的信令子服务中,为 WebSocket 处理器定义一个专门处理“操作变更”信令的方法(onOperation),当检测到属于“操作变更”的信令(operation)时交由该方法处理。需要在该方法中记录交互板内容、记录历史原子操作列表、解决并发编辑操作冲突、发送“操作变更”信令和“操作确认”信令。

如代码 6-17 所示,在“操作变更”处理方法中,首先要判断信令子服务中的最新版本号是否大于 “操作变更”信令内的版本号,如果条件不达成则说明前端应用中的版本号异常,不再继续流程。然后从历史原子操作列表中,截取出前端应用提交的版本号至最新版本号之间的原子操作元素,生成并发原子操作列表。其次 遍历并发原子操作列表,将“操作变更”信令内提交的原子操作列表与每个并发操作元素做连续的 OT 变换,即 OT 变换后得到的原子操作列表将继续作为下一个并发操作元素与之进行 OT 变换的参数。将并发原子操作列表中的元素全部进行 OT 变换后,最后得到的原子操作列表就不再有并发编辑冲突,将其 OT 应用至最新交互板内容,更新版本号并添加至历史原子操作列表中,作为下次并发处理所需。随后将该原子操作列表包含至“操作变更”信令内,发送到其他参会者的浏览器中,也向当前参会者发送一个包含最新版本号的“操作确认”信令。

private fun onOperation(sessionId: String, meetingUUID: String, op: Operation) {
    val doc = meetingPool[meetingUUID]!!.document
    if (op.version!! < 0 || doc.operations.size < op.version) {
        logger.error { "operation revision not in history" }
        return
    }

    val concurrentOperations = CopyOnWriteArrayList(
			doc.operations.slice(op.version until doc.operations.size)
		)

    var operation = TextOperation(op.ops)
    concurrentOperations.forEach {
        operation = TextOperation.transform(operation, it)[0]
    }

    doc.operations.add(operation)
    doc.content = operation.apply(doc.content)
    meetingPool[meetingUUID]!!.document = doc

    emit(meetingUUID, sessionId, Event.ACK, doc.operations.size)
    broadcast(
        meetingUUID,
        Event.OPERATION,
        OpsSignal().apply {
            version = doc.operations.size
            ops = operation.ops
        },
        excludeSessionId = sessionId
    )
}

6.3.7 光标同步与选区同步实现

在前端应用中监听交互板选区事件和光标变化事件,当事件触发时,将光标所在行号和列号包含至“光标变化”信令(cursorChange)中,若是选区事件,则额外包含一组选区起始的行号与列号,通过信令子服务广播至除当前会话外的所有当前会议参会者的浏览器。当前端应用收到由信令子服务发送的“光标变化”信令后,将其中的行列坐标取出,在交互板的对应位置上绘制另一个光标,若是发现不止一组行列坐标,则从起始坐标向终止坐标绘制选区背景,以此实现光标同步与选区同步。

6.4 在线编程功能实现

6.4.1 评判子服务提交接口具体实现

在前端应用中,当参会者点击运行按钮后,前端应用会获取当前交互板内的全部文本,并向后端服务发起 POST 请求,网关子服务会将该请求转发至评判子服务,评判子服务专门负责处理该类业务。

当评判子服务收到评判请求后,首先对传递来的评判代码进行判空校验,确认各项参数无误后则会为该请求创建一个评判任务对象,并初始化该对象的 UUID、当前会议 UUID、评判代码字节数组、目标语言、创建时间,默认任务状态为空表示该任务处于待评判状态。

使用 Ktorm 将该评判任务对象插入评判任务表中。确保对象持久化成功后,再将其发送至 Rabbit MQ 的消息队列中,指定其交换机名称为 JUDGE、路由键名称为 JUDGE_COMMIT,消息头内容为当前会议 UUID。

至此流程结束,该请求将正常返回至前端应用。接下来前端应用可以处理其他的事情,不需要原地等待评判结果,而评判任务将在消息队列中被逐个取出评判。

6.4.2 评判任务处理器具体实现

评判任务处理器专门负责监听 Rabbit MQ 消息队列中采用 TOPIC 路由策略的 JUDGE 交换器、JUDGE_COMMIT 路由键的消息。

当评判任务处理器收到评判任务消息后,首先会进行消息确认(ACK),消费掉该消息。随后从消息负载中获取传递的评判任务实体信息,在本地物理机的文件系统中创建以任务编号(UUID)命名的文件夹,并在其中将用户源代码写入以 main 命名的无后缀名文件。

评判任务处理器使用 docker cp 命令将本地物理机中的 main 文件拷贝至评判沙箱中,并调用评判沙箱根目录中的统一评判入口脚本(run.sh),传递的参数为目标语言编号与评判沙箱内的 main 文件绝对路径,如代码 6-18 所示:

// Copy file from the host to the Docker container
ProcessBuilder(listOf(
    "/usr/local/bin/docker",
    "cp", hostPath, "if-sandbox:/commit"
)).start().run {
    waitFor()
    destroy()
}

// Call the run script inside the Docker container,
// We do not need to quote -c arguments when using arrays.
process = ProcessBuilder(listOf(
    "/usr/local/bin/docker", "exec", "if-sandbox",
    "/bin/sh", "-c", "/run.sh ${attachment.type.id} $containerInnerPath/main"
)).redirectErrorStream(true).start()

随后重定向输出流与错误流,运行命令后线程阻塞,等待从统一评判入口处返回的评判结果,根据评判结果更新数据库中的表字段,如评判状态、输出信息、运行时间、所用资源等。

当这一切完成后,评判任务处理器会将本地物理机和评判沙箱内临时生成的 main 文件及其目录一并删除,随后再以评判结果生成实体对象,并发送至 Rabbit MQ 消息队列中的评判结果队列,指定其交换器为 JUDGE、路由键为 JUDGE_RESULT,该评判任务处理结束。

评判任务处理器每被执行一次,意味着一个任务的评判结束,这些评判结果被一一发送到消息队列中,并由信令子服务中的相关组件进行监听和处理,最终会由 WebSocket 发送至参会者的浏览器。

6.4.3 评判沙箱具体实现

评判沙箱主要是通过 Docker 容器实现的,首先需要编写 Dockerfile 文件,指定其镜像来源为 alpine linux 的最新版本,并作出容器初始化后的操作:

  1. 更新自带的 apk 包管理器。
  2. 根据拟支持的编程语言添加对应的编译器依赖:openjdk8、gcc、g++、python2、python3。
  3. 新建专门用于代码评判的计算机用户与用户目录,并指定其权限为 755。
  4. 将统一评判入口脚本、评判机拷贝至容器内。
  5. 设置必要的环境变量:如 JAVA_HOME 等。

如代码 6-19 所示:

FROM alpine:latest

RUN apk --update add \
openjdk8 g++ gcc python2 python3 && \
rm -rf /var/cache/apk/*

RUN mkdir /commit && adduser -D -h /commit/ -u 12800 -H commit && chmod -R 755 /commit

COPY ./run.sh /
COPY ./run /usr/bin

# Set environment
ENV JAVA_HOME=/usr/lib/jvm/default-jvm
ENV CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar
ENV PATH=${PATH}:${JAVA_HOME}/bin

通过使用 docker build 命令即可根据该 Dockerfile 文件得到一个配置完毕的评判沙箱。

6.4.4 统一评判入口具体实现

评判任务处理器只负责接收消息、写入文件、根据目标语言编号调用评判沙箱。而根据目标语言来确定文件后缀名和其附属参数则是属于统一评判入口的职责。

统一评判入口是评判任务处理器与评判机之间的中间组件,同评判机程序一样部署于评判沙箱中,专门负责根据目标语言编号来预处理 main 文件、格式化评判机参数、运行评判机。统一评判入口采用 Shell 脚本进行实现,其接收两个参数:目标语言编号、main 文件在评判沙箱内的绝对路径。该脚本的命令调用示例为:/run.sh 3 /commit/main。

脚本的工作流程主要分为三个步骤,第一个步骤是进行参数预处理(如代码 6-20 所示,以目标语言 python3 为例):

# ...
case $1 in
  # ...

  # python3
	3)
	    getBin "python3"
	    mv $2 "$2.py"
	    compiler=
	    compilerArgs=
	    runner=$bin
	    runnerArgs="$bin $2.py"
	    maxCpuTime=$((1000 * 3))
	    maxRealTime=$((1000 * 5))
	    maxMemory=$((1024 * 50))
	    seccompRule="general"
	    ;;

  # ...

通过传入的 1 号参数(目标语言编号)来进行分支处理,首先获取 python3 解释器程序的绝对路径,通过 which 命令找到的程序路径并不一定就是绝对路径,还需要进行递归地调用 readlink 命令获取该符号链接所指向的实际位置,如代码 6-21 所示:

getBin(){
    bin=`which $1`
    while [ -L "$1" ]; do
        bin=`readlink $bin`
    done
}

其次将 main 文件的绝对路径(2 号参数)重命名为指定目标语言所对应的后缀名,如 main.py,并为其配置评判机程序运行所需的参数。

python3 为解释型语言,所以不需要指定编译器(compiler)与编译参数(compilerArgs),其中运行器(runner)与运行参数(runnerArgs)就是 python3 解释器和解释命令本身。为其配置 5.4.4 统一评判入口设计一节中所规定的 CPU 使用时长上限值(maxCpuTime)、总执行时长上限值(maxRealTime)、内存使用量上限值(maxMemory)、Seccomp 规则(seccompRule),至此参数预处理步骤结束。

第二个步骤是编译,对于解释型语言(指定编译器为空)会忽略这一步骤,如代码 6-22 所示:

if [ ! -z "$compiler" ]; then
    compileResult=`run -s compile -b $compiler -p "$compilerArgs" -c $maxCpuTime -r $maxRealTime -m $maxMemory`
    if [ $? -ne 0 ]; then
        echo $compileResult
        exit
    fi
fi

传入编译参数来调用评判机程序(run),在这一步也已经支持 seccomp 机制,防止一些编译耗能大的恶意代码。如果编译阶段就已发生编译错误,统一评判入口就会转述评判机输出的信息并退出脚本。

当编译步骤完成后会进入第三个步骤——运行步骤,如代码 6-23 所示:

if [ ! -z "$runnerArgs" ]; then
    su commit -c "run -s $seccompRule -b $runner -p '$runnerArgs' -c $maxCpuTime -r $maxRealTime -m $maxMemory"
else
    su commit -c "run -s $seccompRule -b $runner -c $maxCpuTime -r $maxRealTime -m $maxMemory"
fi

根据是否需要运行参数来运行评判机程序,与编译步骤基本相同,编译命令被替换成了运行命令,但运行步骤还指定了以专门的受限用户(commit)来运行评判机程序,作为统一评判入口的最后一条命令,评判机内输出的信息也作为了统一评判入口脚本所输出的信息。

如果后续需要支持更多的评判语言,或者需要修改某个目标语言的参数,只需要在该脚本内修改即可,如此一来也与评判子服务有了一定程度上的业务解耦。

6.4.5 评判机具体实现

为了更好的操作系统库函数,评判机程序采用 C 语言进行编写,并被编译为可执行的二进制文件。评判机程序的职责是以指定的参数、指定的资源限制要求、指定的 Seccomp 机制来运行指定的目标程序。

评判机程序将自身的启动参数解析完成后,会先初始化评判结果结构体,结构体中包含了 CPU 使用时长、总执行时长、内存使用量、线程信号、运行状态码、输出结果:

此时评判机程序会先初始化 pipe 文件描述符管道,并记录一次当前时间,用于和之后的时间进行比对。随后将调用 fork 函数派生自身进程,分化出的子进程将对标准输出和标准错误的文件描述符进行合并,以便后续读取用户程序的运行结果信息。

合并文件描述符后,会进入专门的子进程逻辑:设置子进程的内存使用量限制、CPU 使用时长限制,最大进程数限制,随后加载 Seccomp 机制,并调用目标用户程序,如代码 6-24 所示。

void child_process(struct config *_config, int pipefd[]) {
    // set memory limit
    setrlimit(RLIMIT_AS, &max_memory);

    // set cpu time limit (in seconds)
    setrlimit(RLIMIT_CPU, &max_cpu_time);

    // set max process number limit is one
    setrlimit(RLIMIT_NPROC, &max_process_number);

    // Load seccomp
    if (_config->seccomp_rule_name != NULL) {
        if (strcmp("general", _config->seccomp_rule_name) == 0) {
            general_seccomp_rules(_config->exe_path);
        }
    }

    // Run it
    execve(_config->exe_path, _config->exe_argv, NULL);

    // Free argv
    if (_config->exe_argv != NULL) {
        for (int i = 0; *(_config->exe_argv + i); i++) {
            free(*(_config->exe_argv + i));
        }
        free(_config->exe_argv);
    }
}

对 Seccomp 机制的实现,需要在支持 Seccomp 机制的 Linux 系统环境中进行开发、调试、运行,普通的 Linux 系统可以使用软件包管理器下载安装 libseccomp 依赖来引入 Seccomp 机制进行软件开发。在包含头文件<seccomp.h>后,首先需要设定系统调用黑名单(被屏蔽的系统调用函数名),如屏蔽 clone、fork、vfork、kill 等系统函数,然后初始化 Seccomp 并将系统调用黑名单添加至 Seccomp 规则中,如代码 6-25 所示。

int syscalls_blacklist[] = {SCMP_SYS(clone),
                            SCMP_SYS(fork), SCMP_SYS(vfork),
                            SCMP_SYS(kill),
};
int syscalls_blacklist_length = sizeof(syscalls_blacklist) / sizeof(int);

// init seccomp rules
ctx = seccomp_init(SCMP_ACT_ALLOW);
if (!ctx) {
    exit(LOAD_SECCOMP_ERROR);
}

// add seccomp rules
for (int i = 0; i < syscalls_blacklist_length; i++) {
    if (seccomp_rule_add(ctx, SCMP_ACT_KILL, syscalls_blacklist[i], 0) != 0) {
        exit(LOAD_SECCOMP_ERROR);
    }
}

除此之外可能还需要根据应用场景的实际情况添加其他被屏蔽的函数,如 socket、open、openat、execve 等函数,其中 execve 函数的作用是调用其他程序,由于评判机程序本身就需要使用 execve 函数来调用指定的目标程序,所以选择屏蔽 execve 函数时,应设定为仅当用户程序进程调用 execve 函数时才会被屏蔽,而评判机程序自身进程则忽略,如代码 6-26 所示。

// add extra rule for execve
if (seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(execve), 1,
                     SCMP_A0(SCMP_CMP_NE, (scmp_datum_t)(exe_path))) != 0) {
    exit(LOAD_SECCOMP_ERROR);
}

最后将编写的 Seccomp 规则加载至当前进程,加载后当前进程就会受到 Seccomp 机制的约束,如代码 6-27 所示。

if (seccomp_load(ctx) != 0) {
    exit(LOAD_SECCOMP_ERROR);
}

以上就是 Seccomp 机制的初始化与加载操作,其加载时机的要求在于:当评判机程序使用 fork 函数派生出两个自身进程后,于子进程中加载 Seccomp 机制并使用 execve 函数调用目标用户程序,这就达到了屏蔽目标用户程序系统调用的同时,而不影响到评判机程序自身的关键之处。

在调用 fork 函数派生出子进程的同时,评判机程序主进程自身将会创建一条“杀手”线程,“杀手”线程一旦被创建完毕后就会进入睡眠,直到睡够总运行时长的上限值后就会醒来,并且使用 kill 函数“杀掉”用户程序进程。

而评判机程序主进程自身在创建完杀手线程后,就会开始等待(wait4)用户程序进程终止(例如正常退出、异常中断、超出限制、被杀手线程终止等情况),并在进程退出后要求系统内核返回该进程所使用的资源汇总信息(resource_usage)。

用户进程终止后再次记录当前时间,并与先前记录的时间计算差值,得出用户程序运行总时长。通过解析进程退出信号得知用户程序进程是正常退出或异常中断,从而判断运行结果后进一步分析。通过解析系统内核返回的资源汇总信息,获取用户程序进程所使用的 CPU 运行时长、内存使用量,与指定上限值进行比对后,更正运行结果。

得到用户程序的运行结果后,将运行结果进行转义,并以 JSON 格式字符串输出。返回给统一评判入口转述评判结果后,评判任务处理器即可直接解析 JSON 格式字符串,生成对应的评判结果实体类。

6.5 本章小结

在本章中对系统的实现进行了详细阐述,列举了例如用户认证、会议室操作等相关基础功能的实现原理,还对一对一音视频通话、协同编辑、在线编程功能说明了详细的实现方法。

第七章 系统测试

7.1 系统环境介绍

7.1.1 系统开发环境介绍

本系统的开发环境信息如表 7-1 所示:

操作系统 macOS 10.15.7(64bit)、Ubuntu 16.04(64bit)
开发工具 IntelliJ IDEA、WebStorm、Clion
开发语言 Kotlin、JavaScript、HTML、C 语言、Shell 脚本

7.1.2 系统测试环境介绍

本系统部署于腾讯云的通用型轻量级应用服务器中,主要配置参数如表 7-2 所示:

地域和可用区 广州         广州三区
CPU 2 核
内存 4G
网络带宽 8Mbps
操作系统 CentOS 7.6(64bit)

本系统需要两部拥有麦克风、摄像头设备的计算机参与测试,测试者 A 的相关设备信息如表 7-3 所示:

计算机类型 笔记本电脑
操作系统 macOS 10.15.7(64bit)
浏览器 Chrome 96.0.4664.110
网络带宽 300Mbps 校园宽带
地域 广西壮族自治区梧州市

测试者 B 的相关设备信息如表 7-4 所示:

计算机类型 笔记本电脑
操作系统 Windows10(64bit)
浏览器 Chrome 96.0.4664.110
网络带宽 100Mbps 家庭宽带
地域 广东省广州市

7.2 系统功能测试

本节主要是对系统的功能进行测试,需要两位测试者通过各自的设备在浏览器上输入本系统所部属的服务器域名,进入本系统的前端应用界面中参与测试,图 7-1 为系统运行效果图。

图7-1 系统运行效果图

图 7-1 系统运行效果图

7.2.1 一对一音视频通话功能测试

由测试者 A 创建一个会议室后,测试者 B 加入到该会议室中,此时系统自动进行媒体协商和网络协商,约经过一秒钟后可以正常开始音视频通话。

通过 Chrome 浏览器自带的 WebRTC 监测工具获取本系统 WebRTC 每秒钟的运行数据图表:

图7-2 实时通信过程中RTP协议出站视频流的监测数据折线图

图 7-2 实时通信过程中 RTP 协议出站视频流的监测数据折线图

图 7-2 为实时通信过程中 RTP 协议出站视频流的监测数据折线图,主要表现数据发送方面的能力,其中包含了由 RTCP 协议统计出的数据包发送数量、数据包每秒发送的数量、数据包重传的数量、数据包每秒重传的数量、总发送的字节数量,每秒发送的字节数量等信息。

可以看到在测试期间,3 分钟内共传输了约 35000 个数据包,其中约 25000 个数据包被用于建立连接。每秒钟有约 30 至 60 个数据包被用于媒体通信,3 分钟内共发送了约 10000 个数据包。每秒上行比特率在 50KB/S 至 350KB/S 间浮动,通信情况正常。

图7-3 实时通信过程中RTP协议入站视频流的监测数据折线图

图 7-3 实时通信过程中 RTP 协议入站视频流的监测数据折线图

图 7-3 为实时通信过程中 RTP 协议入站视频流的监测数据折线图,主要表现数据接收方面的能力,其中包含了由 RTCP 协议统计出的抖动采样率、数据包丢失数量、接收到的数据包数量、每秒接收到数据包的数量、接收到的字节数量、每秒接收到的字节数量等信息。

对于发送端,数据包发送间隔一般是相同的(均匀发送),但由于网络传输过程中会遇到各种情况(如丢包、拥塞等),所以接收端收到的数据包间隔就会不一样,导致时延发生变化,接收端就要对这些抖动进行处理,可以看到测试期间,接收端的每秒抖动采样率在 1%至 5%之间,稳定于 3%左右,有一个 15%的瞬间抖动;由于 RTP 协议基于 UDP 协议,并不是可靠传输,所以在三分钟的测试过程中有接近 1.5KB 大小的数据包丢失,属于正常情况。

图7-4 实时通信过程中远端RTP协议入站视频流的监测数据折线图

图 7-4 实时通信过程中远端 RTP 协议入站视频流的监测数据折线图

图 7-4 为实时通信过程中远端 RTP 协议入站视频流的监测数据折线图,主要统计往返情况,其中包含了由抖动采样率、数据包丢失数量、单次往返时间、发送端估计的丢包率、总往返时间、测量到的有效往返时间的 RTCP RR 块总数等信息。

可以看到在测试期间,测试者 A 与测试者 B 的单次数据包往返时间稳定在 0.065 秒左右,也就是 65 毫秒的数据延迟,符合系统设计要求。

7.2.2 协同编辑功能测试

测试者 A 与测试者 B 进入同一会议室后,通过心跳包统计出浏览器与信令子服务的的传输时间,计算出两位测试者的浏览器与信令子服务间的延迟,在数个随机时刻记录两位测试者们的浏览器与信令子服务间的延迟,得出表 7-5:

测试者 A 测试者 B
27ms 19ms
22ms 21ms
25ms 17ms
30ms 24ms
23ms 22ms

可以看到信令子服务因为仅传输文本信息而不承担流媒体传输工作,所以在延迟上比 STUN/TURN 服务器要低。在交互板中以一行空行为界,分别在其上半部分与下半部分输入一段相同段落的文本,如图 7-5 所示:

图7-5 协同编辑结果示例

图 7-5 协同编辑结果示例

输入完毕后经比对,两段文本内容一致,所有输入的字符都在预定的行列坐标中,没有缺字漏字的现象,功能运行正常。

7.2.3 在线编程功能测试

以冒泡排序算法为例,为 Java、C/C++、Python2/3 语言均准备一段相同逻辑(按升序排序)、相同参数(1 至 9 相同乱序)的代码,当测试者创建并进入会议室后,选择对应的目标编程语言,输入到交互板中点击运行按钮,得出各语言的运行数据。结果如表 7-6 所示,在线编程功能的各语言编译器、解释器运行正常。

编程语言 CPU 用时 运行用时 内存消耗 期望输出 实际输出 测试结果
Java 88ms 169ms 20.1MB 1 2 3 4 5 6 7 8 9 1 2 3 4 5 6 7 8 9 正确
C < 1ms 2ms 484KB 1 2 3 4 5 6 7 8 9 1 2 3 4 5 6 7 8 9 正确
C++ < 1ms 2ms 2.1MB 1 2 3 4 5 6 7 8 9 1 2 3 4 5 6 7 8 9 正确
Python2 6ms 14ms 4.3MB 1 2 3 4 5 6 7 8 9 1 2 3 4 5 6 7 8 9 正确
Python3 14ms 17ms 4.8MB 1 2 3 4 5 6 7 8 9 1 2 3 4 5 6 7 8 9 正确

再为各编程语言编写申请超过 100MB 内存的代码,选择对应的目标编程语言,输入到交互板中并点击运行按钮,得出各语言的运行数据。结果如表 7-7 所示,本系统的在线编程功能达到了期望的内存限制要求。

编程语言 输出 测试结果
Java Exception in thread “main” java.lang.OutOfMemoryError 正确
C MEMORY_LIMIT_EXCEEDED 正确
C++ MEMORY_LIMIT_EXCEEDED 正确
Python2 MEMORY_LIMIT_EXCEEDED 正确
Python3 MEMORY_LIMIT_EXCEEDED 正确

再为各编程语言编写死循环代码,选择对应的目标编程语言,输入到交互板中并点击运行按钮,得出各语言的运行数据。结果如表 7-8 所示,本系统的在线编程功能达到了期望的运行超时限制要求。

编程语言 输出 测试结果
Java REAL_TIME_LIMIT_EXCEEDED 正确
C REAL_TIME_LIMIT_EXCEEDED 正确
C++ REAL_TIME_LIMIT_EXCEEDED 正确
Python2 REAL_TIME_LIMIT_EXCEEDED 正确
Python3 REAL_TIME_LIMIT_EXCEEDED 正确

再为各编程语言编写发起系统调用的相关代码,选择对应的目标编程语言,输入到交互板中并点击运行按钮,得出各语言的运行结果。结果如表 7-9 所示,本系统成功终止了进行系统调用的程序,达到了屏蔽系统函数调用的安全要求。

编程语言 发起系统调用 测试结果
Java 运行终止 正确
C 运行终止 正确
C++ 运行终止 正确
Python2 运行终止 正确
Python3 运行终止 正确

7.3 本章小结

在本章中对系统的各项功能进行测试,一对一音视频通话、协同编辑、在线编程等功能均运行正常,符合预期结果,测试通过。

第八章 总结与展望

8.1 总结

本文通过对 WebRTC、Seccomp、OT 算法等相关技术进行研究,根据需求分析进行系统设计,实现了一个能有效针对程序员职业的一对一音视频面试会议系统,并实际部署到云服务器的生产环境上,经过了具体的实地测试,各项指标运行正常,具有实用价值。本次研究也让我收获到了许多的知识,总结出了宝贵的经验,对音视频通话、协同编辑、在线编程等领域都有了一定了解,收获颇丰。

8.2 展望

本系统基于微服务架构进行开发,还应发挥出其更大的优势:在运维上,可以进行分布式部署、搭建服务集群、为数据库分库分表、实现数据库读写分离等相关扩展;在代码开发上,可以将更多的热点数据和字典常量放到 Redis 缓存中进行预热,提高系统的响应速度,减少数据库读写压力;还可以利用 Rabbit MQ 消息队列中的广播功能实现信令子服务集群和 TURN 服务器集群,提高 WebSocket 的吞吐量和更多的中继服务器节点;还能为 TURN 服务器实现媒体流持久化,达到回放会议录像的效果。有太多的技术没有被应用到,也遗憾有许多想法没能得到实现,也许世界上并没有十全十美的系统,而我们只是在努力着让它看起来更正确而已。

参考文献

[1] Fette and Google, Inc., RFC 6455: The WebSocket Protocol [Z], IETF, December 2011.

[2] Hardie, RFC 7936: Clarifying Registry Procedures for the WebSocket Subprotocol Name Registry [Z], IETF, July 2016.

[3] Rosenberg and J. Weinberger, RFC 3489: STUN - Simple Traversal of User Datagram Protocol (UDP) Through Network Address Translators (NATs) [Z], IETF, March 2003.

[4] Rosenberg and Cisco, RFC 5389: Session Traversal Utilities for NAT (STUN) [Z], IETF, October 2008.

[5] Mahy, RFC 5766: Traversal Using Relays around NAT (TURN): Relay Extensions to Session Traversal Utilities for NAT (STUN) [Z], IETF, April 2010.

[6] OnlineJudge. https://github.com/QingdaoU/OnlineJudge, Dec 2021.

[7] Tim Baumann. ot.js. https://github.com/Operational-Transformation/ot.js, Jul 2020.

[8] Tang and L. Zhang. Audio and Video Mixing Method to Enhance WebRTC [J] IEEE Access, 2020(8):67228-67241.

[9] 王晴.面向微服务架构的会议管理系统的设计与实现[D].华中科技大学,2018.

[10] 曹学琪.在线评测系统的评分方式研究与改进[D].西藏大学,2020.

[11] 于波等.基于 QUIC 的实时通信优化研究与应用[J].小型微型计算机系统,2021,42(08):1753-1757.

[12] 张远等.基于 Janus 网关的 WebRTC 音视频客户端设计与实现[J].中国新通信,2021,23(10):45-47.

[13] 蒋建军等.基于 WebRTC 的交互式视频教学平台的设计与实现[J].信息技术与信息化,2020(12):190-192.

[14] 杨炳华,何俊颖.Internet 视频互动直播教学平台研究[J].中国新通信,2018,20(16):205-207.

[15] 朱明慧.基于 WebRTC 的局域网音视频客户端设计[J].信息与电脑(理论版),2020,32(16):119-121.

[16] 赵永明.基于 WebRTC 的音视频实时教学系统建设探究[J].数字通信世界,2020(08):279-280.

[17] 周华东.WebRTC 实时通信开源技术介绍及实用案例[J].办公自动化,2020,25(04):14-16.

[18] 石芮,成洪豪,孙立民.基于 WebRTC 的视频会议系统开发[J].智能计算机与应用,2019,9(06):132-137.

[19] 康秀谦,赵彦喆.基于 HTML5+WebRTC 技术的远程会诊系统的设计与实现[J].信息与电脑(理论版),2019,31(21):152-153.

[20] 郭瑞香.基于 WebRTC 技术的多媒体教学模式创新研究[J].大庆师范学院学报,2019,39(06):70-75.

[21] 包文祥,胡广朋.基于 WebSocket 的实时通信机制的设计与实现[J].计算机与数字工程,2019,47(07):1836-1840.

[22] Alan B. Johnston.WebRTC 权威指南[M].机械工业出版社,2016.

致谢

感谢李扬先生开源的 QingDao OJ 项目,让我了解到了 OJ 系统的设计思路和其背后的攻防安全策略,也感谢 Tim Baumann 先生开源的 ot.js 项目,让我在钻研 OT 算法落地时得到了指点,正是因为您们二位的开源精神,才让我有了实现这个系统的基础。

也感谢我的指导老师和任课老师们,是您们孜孜不倦的提点和督促,才让我能够在有限的内完成这篇论文。还要感谢我的同学和我的舍友们,是你们与我耐心的交流、为系统和论文出谋划策。最后还要感谢我的父母家人们,是你们一直以来对我的支持才让我走到今天。

#WebRTC #Collaborative #Seccomp