在七牛云校园黑客马拉松中,来自华南理工大学的SCUT01团队,为我们带来了UI精美、体验优秀的白板作品,在大赛中获得二等奖的好成绩。以下是这款在线协作白板的技术解决方案。
背景
疫情背景下,线上课堂、线上会议等业务背景下都有着在线协作白板的需求。如何实现图形的绘制和实时同步,这是核心的两个问题。本文介绍一种基于原生Canvas和Websocket通信协议的协作白板解决方案。
基础技术介绍
Canvas
元素是HTML5新增的,一个可以使用脚本( 通常为JavaScript )在其中绘制图像的HTML元素。它可以用来制作照片集制作简单的动画,甚至可以进行实时视频处理和渲染。 由API构成,除了具备基本绘图能力的 2D上下文 , 还具备一个名为WebGL的 3D上下文 。
API参考:Canvas - Web API 接口参考 | MDN (http://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/adjustElem ByCmd () 等接口,使用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节点有一个从节点即可实现高可用的架构。