在七牛云校园黑客马拉松中,来自华南理工大学的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
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.Adjust]: Record //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>;
/* ... */
}
同步机制每种工具都可能是 创建者(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节点有一个从节点即可实现高可用的架构。