SCUT01在线协作白板技术解决方案

类别:
技术道场
发布时间:
2022年12月19日

在七牛云校园黑客马拉松中,来自华南理工大学的SCUT01团队,为我们带来了UI精美、体验优秀的白板作品,在大赛中获得二等奖的好成绩。以下是这款在线协作白板的技术解决方案。

在七牛云校园黑客马拉松中,来自华南理工大学的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对应的白板ID
  • Connections为该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的适配
  • 将其分别转化成InteractMouseEventInteractTouchEvent ,两者都继承自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的数据结构包括以下五种:

  1. String:字符串类型
  2. List:列表类型
  3. Set:无序集合类型
  4. ZSet:有序集合类型
  5. 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节点有一个从节点即可实现高可用的架构。

微信咨询
微信咨询
电话咨询
智能客服