
【七牛云校园黑客马拉松获奖作品】SCUT01在线协作白板技术解决方案
在七牛云校园黑客马拉松中,来自华南理工大学的SCUT01团队,为我们带来了UI精美、体验优秀的白板作品,在大赛中获得二等奖的好成绩。以下是这款在线协作白板的技术解决方案。
背景
疫情背景下,线上课堂、线上会议等业务背景下都有着在线协作白板的需求。如何实现图形的绘制和实时同步,这是核心的两个问题。本文介绍一种基于原生Canvas和Websocket通信协议的协作白板解决方案。
基础技术介绍
Canvas
<canvas>元素是HTML5新增的,一个可以使用脚本( 通常为JavaScript )在其中绘制图像的HTML元素。它可以用来制作照片集制作简单的动画,甚至可以进行实时视频处理和渲染。 <canvas>由API构成,除了具备基本绘图能力的2D上下文,<canvas>还具备一个名为WebGL的3D上下文。
API参考:Canvas - Web API 接口参考 | MDN (mozilla.org)
WebSocket
WebSocket是在H5中常被使用的全双工通信协议,它有以下特点
建立在单个TCP连接上的全双工通信应用层协议,支持服务端主动向客户端推送消息
握手阶段采用HTTP协议 (101状态码,Upgrade),与HTTP协议良好兼容
既可以发送文本数据,也可以发送二进制数据
WebSocket完美继承了 TCP 协议的全双工能力,并且还贴心的提供了解决粘包的方案。
它适用于需要服务器和客户端(浏览器)频繁交互的大部分场景,比如网页/小程序游戏,网页聊天室,以及一些类似飞书这样的网页协同办公软件。
对于白板应用的同步功能实现,就使用了Websocket进行实现。
协作技术下WebSocket实践
前置知识
首先需要介绍一下浏览器与服务器是如何建立WebSocket连接的。
浏览器在 TCP 三次握手建立连接之后,都统一使用 HTTP 协议先进行一次通信
如果建立 WebSocket 连接,就会在 HTTP 请求里带上一些特殊的header 头
Connection: Upgrade Upgrade: WebSocket Sec-WebSocket-Key: T2a6wZlAwhgQNqruZ2YUyg==\r\n
服务器收到带有
Connection: Upgrade
请求头的HTTP请求之后,会调用upgrade
方法,将连接更改为websocket连接,然后给该次HTTP请求响应101状态码至此,Websocket连接已经建立,可以使用已经建立的连接进行双工通信
连接处理
服务端采用高性能的Go语言进行开发,github.com/gorilla/websocket
开源库已经封装好完成了upgrade、返回101响应等方法,这里我们直接使用该库进行开发
定义服务器结构体字段
type WstServer struct { listener net.Listener upgrade *websocket.Upgrader onConnectHandlers OnConnectHandler}
该结构体实现ServeHTTP方法,并在方法中调用
Upgrade
方法实现websocket协议的切换
func (thisServer *WstServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { conn, err := thisServer.upgrade.Upgrade(w, r, nil) if err != nil { log.Println("[ws upgrade]", err) return } log.Println("[ws client connect]", conn.RemoteAddr()) thisServer.onConnect(conn, r.URL.Path) //每个连接开启协程进行处理}
白板业务下的websocket服务架构
将每一个白板抽象为一个Hub,所有进入该白板的Client都需要使用WebSocket进行连接到WebSocket服务器中白板对应的Hub;其数据结构定义如下
type Hub struct { BoardId string //白板id Connections *utils.ConcurrentMap[string, *UserConnection] //当前白板下所有的连接}
BoardId
为该Hub对应的白板IDConnections
为该Hub中所有已经建立的WebSocket连接,key为UserId
当其中一个Client进行操作之后(如绘制、删除、移动一个图形等),Client将该操作抽象为一个
Cmd
的消息,发送给WebSocket服务器WebSocket服务器会将来自Client的消息广播给其他Client,其他Client会调用注册的回调函数进行处理渲染
func (hub *Hub) Broadcast(obj any) { //遍历每一个连接,发送消息 hub.Connections.Data().Range(func(key, value any) bool { userId := key.(string) conn := value.(*UserConnection) err := conn.SendJSON(obj) if err != nil { log.Println("[Error] Send To ===============> ", userId, err) return true } return true })}
Websocket集群解决方案
如果在单机情况下,当websocket需要给用户推送消息时,由于用户已经与websocket服务建立连接,消息推送能够成功。
但如果在集群情况下,用户甲向websocket发起连接请求,有多台服务时,只能与一台服务建立连接(以服务器A为例),而这些websocket服务都是有可能会给用户甲推送消息,这时候的服务器B和服务器C并没有建立连接。
为避免这种情况,以及更方便实现同步,我们需要尽可能让同一个白板内的所有Client连接到同一台服务器上。
这需要引入MQ来实现。所有的websocket服务都绑定到一个名称为locate的exchange中并接收来自网关的定位消息。如果对应白板的连接管理(Hub)在本机中,就把本节点的IP和端口等信息发送给网关服务,网关与对应Websocket服务建立连接。如果都没有找到,说明目前白板的Hub尚未创建,便使用负载均衡等策略随机与某个Websocket服务器建立连接。
Web端白板应用实现
整体架构展示
Web端使用React框架来搭建应用,整体架构分为三层:UI层,逻辑层,渲染层
UI层:处理用户交互,显示最终展示白板的Canvas。
逻辑层:实现白板核心逻辑(比如undo/redo,使用ws同步白板等),与渲染层进行交互。
渲染层:渲染整个白板以及其中的元素,使用双缓冲加快渲染效率。
基于原生Canvas的白板渲染方案
我们将白板及其包含的所有元素构成的画面,抽象为RenderScene,其负责渲染自身元素以及在渲染结束后将自身传递到UI层展现给用户。
元素状态
每个元素都有两种状态:激活状态和正常状态,所谓激活状态就是容易发生变动的状态(比如说被选中时,或者正在创建中,这个时候就需要让其从背景缓冲中分离出来。
双缓冲
渲染层中有两个Canvas画板,其中一个作为背景缓冲,另一个用于整个白板显示,从而提高渲染效率,渲染时先绘制背景缓冲,再绘制激活元素。
渲染流程
当逻辑层调用RenderScene的render()方法时
RenderScene会先将背景缓冲绘制到真实画布上
如果有被激活的元素,则再绘制被激活元素
当逻辑层激活场景内元素时
RenderScene重新绘制整个背景缓冲,包括除了激活元素之外的所有元素
调用render() 进行渲染
当逻辑层取消激活场景内元素时
RenderScene将激活元素绘制到背景缓冲上
调用render() 进行渲染
事件传递机制
UI层可能接收到两种事件,来自桌面端的鼠标事件MouseEvent和移动端的触摸事件TouchEvent
我们根据window.devicePixelRatio对事件坐标进行变换,从而实现dpi的适配
将其分别转化成InteractMouseEvent和InteractTouchEvent,两者都继承自InteractEvent,分别对外提供统一的接口type(类型,比如down,up...) 和 x, y,从而实现事件类型的统一
传递到场景时,再根据画布缩放比例scale,再次进行坐标变化,将其映射到场景画布中成为SceneEvent,场景事件的去向有两个。
通过逻辑层与渲染层的桥梁——工具(Tool类)的op方法操作RenderScene,对激活元素进行操作
通过dispatchSceneEvent方法传递给元素,由元素反馈该事件是否与自己相关(通过范围判断,返回布尔值)。
同步机制的实现
数据结构
前后端之间使用命令(Cmd)进行同步,Cmd和Cmd的载荷(CmdPayload)数据结构如下
enum CmdType { //枚举从最后开始添加 Add, // 添加元素 Delete, // 删除元素 Withdraw, // 撤回 Adjust, //调整单个属性 SwitchPage, //切换页面 SwitchMode, // 切换模式 LoadPage // 加载新页面}
class Cmd<T extends CmdType> extends SerializableData { id: string; // 命令id pageId: string; // 操作页面id type: T; // 命令类型 elementType: ElementType; // 命令操作元素类型 o?: string; // 操作对象的id payload: string; // 操作的 payload, 由于go无法绑定到确定类型,使用string time: number; // 操作的时间戳 boardId: string; // 操作所属的白板 creator: string; // 操作创建人的userId}
type CmdPayloads = { [CmdType.Add]: ElementBase, //需要增加的元素 [CmdType.Delete]: null //需要删除的元素 [CmdType.Withdraw]: Cmd<CmdType> //需要撤销的操作 [CmdType.Adjust]: Record<string, [any, any]> //p键值为操作的属性,[0]:before, [1]:after [CmdType.SwitchPage]: {from: string, to: string} //从from页面切换到to页面 [CmdType.SwitchMode]: number //新的mode [CmdType.LoadPage]: null}
同时Cmd也是实现撤销/重做的OperationTracker的状态维护者,可以与逻辑层统一一个命令执行接口
export class WhiteBoardApp implements IWebsocket, ToolReactor {
/* ... */ public cmdTracker:OperationTracker<Cmd<any>>; /* ... */ }
同步机制
每种工具都可能是创建者(Creator)或者修改者(Modifier),由逻辑层注册对应onCreate和onModify回调。
在创建或修改的时候,构建对应Cmd,通过Websocket客户端发送到服务器,服务器广播命令到房间内其他用户。
其他用户收到Cmd时,通过白板逻辑层的 add/delete/adjustElemByCmd() 等接口,使用Cmd的Payload对白板进行同步。
频繁写场景下的存储架构实践
对于白板类应用,在极大部分情况下数据的操作为更改操作(写操作),并且频率非常高;
应对如何应对高并发的频繁写入操作,成为白板技术下非常重要的问题。
Redis Buffer
如果写入操作直接操作数据库(如MySQL),高并发场景下,数据库的压力会非常大。所以我们选用分布式内存数据库Redis进行数据的缓存,待合适的时机将数据持久化到数据库。
Redis数据结构的选择
Redis的数据结构包括以下五种:
String
:字符串类型List
:列表类型Set
:无序集合类型ZSet
:有序集合类型Hash
:哈希表类型
下面介绍一下页面上元素的数据结构:
class ElementBase extends SerializableData { public id:string; public type:ElementType; public x:number; // 左上角点的x坐标 public y:number; public width:number = 0; public height:number = 0; public angle:number = 0; // 弧度制 public strokeColor:string = "#ff5656"; // 十六进制整数 ... }
要存储这样一个含有许多属性的对象在Redis中,一般有以下两种方案:
方案一:将整个对象序列化为一个JSON字符串,使用Redis的简单String,进行存储;
优点:实现简单
缺点:如果每次修改只会更改其中某少量属性(如移动只会更改有元素x,y属性),但是采用简单字符串的方式每次都需要重新序列化整个对象,再进行覆盖存储,效率比较低(主要从网络传输的网络包大小考虑)
方案二:将对象存储于Hash结构中,field存储对象的属性名,value存储属性值
优点:可以实现对该对象的某个或多个属性的精准控制
缺点:实现起来复杂
在我们的应用场景下,只更改单个或少数属性的场景较多,所以我们选用Hash结构进行存储
同时,如果我们要知道一个页面内所有的所有的元素的集合,如果采用元素的key值内拼接页面id的方式,必须使用Scan进行全局键的遍历。为了避免全局,选用一个Set结构用于存储一个页面内所有元素的id
Redis Pipeline操作
在白板业务场景下,无法避免需要执行多个Redis命令的场景(如读取整个页面上的所有的元素数据的hash结构)
管道(pipeline)可以一次性发送多条命令给服务端,服务端依次处理完完毕后,通过一条响应一次性将结果返回,pipeline 通过减少客户端与 redis 的通信次数来实现降低往返延时时间,而且 Pipeline 实现的原理是队列,而队列的原理是时先进先出,这样就保证数据的顺序性。
使用pipeline可以批量执行Redis命令,非常有效地提高系统吞吐量
Redis集群方案
在整个系统中,需要缓存页面上大量的元素数据,应用的拓展性受到Redis存储容量的限制,并且单节点Redis可用性较低。所以有必要在架构中引入集群方案。
Redis 集群提供了一种运行 Redis 的方式,其中数据在多个 Redis 节点间自动分区。Redis 集群还在分区期间提供一定程度的可用性,即在实际情况下能够在某些节点发生故障或无法通信时继续运行。
Redis集群有以下特点:
每一个master节点都有其对应的一个或多个slave节点,他们之间为主从关系,会进行主从复制
每增加一个key会通过一定哈希算法分配到某一个master节点,理论上可以实现存储能力的扩展
在白板应用中一般读取的场景相对较少,所有每一个master节点有一个从节点即可实现高可用的架构。