在七牛云校园黑客马拉松中,来自华南理工大学的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; // 命令idpageId: string; // 操作页面idtype: T; // 命令类型elementType: ElementType; // 命令操作元素类型o?: string; // 操作对象的idpayload: string;  // 操作的 payload, 因为go无奈绑定到确定类型,应用stringtime: 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节点有一个从节点即可实现高可用的架构。