当前位置: 首页 > Web前端 > HTML

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

时间:2023-03-28 18:24:47 HTML

在七牛云校园创客马拉松中,来自华南理工大学的SCUT01团队为我们带来了UI精美、体验极佳的白板作品,并获得比赛成绩二等奖。这是此在线协作白板的技术解决方案。背景在疫情背景下,在线课堂、在线会议等业务场景中出现了对在线协作白板的需求。如何实现图形绘制和实时同步是两个核心问题。本文介绍一种基于原生Canvas和Websocket通信协议的协作白板解决方案。基本技术介绍Canvas元素是HTML5新增的,一种可以使用脚本(通常是JavaScript)在其中绘制图像的HTML元素。它可以用来创建照片集、制作简单的动画,甚至可以进行实时视频处理和渲染。由API组成,除了具有基本绘图功能的2Dcontext外,它还有一个称为WebGL的3Dcontext。API参考:Canvas-WebAPI接口参考|MDN(http://mozilla.org)WebSocketWebSocket是H5中经常使用的全双工通信协议,具有以下特点一个基于单TCP连接层协议的全双工通信应用,支持服务端主动向客户端推送消息。握手阶段采用HTTP协议(101状态码,Upgrade),很好的兼容HTTP协议。它可以发送文本数据或二进制数据。WebSocket完美继承了TCPWork能力的完整双协议,也贴心地提供了解决粘包的方法。适用于大多数需要服务端和客户端(浏览器)频繁交互的场景,比如网页/小程序游戏、网页聊天室,以及一些像飞书这样的网页协同办公软件。对于白板应用的同步功能的实现,使用了Websocket来实现。协作技术下的WebSocket实践前置知识首先需要介绍一下浏览器和服务器是如何建立WebSocket连接的。TCP三次握手建立连接后,浏览器统一使用HTTP协议先进行通信。如果建立了WebSocket连接,HTTP请求中会包含一些特殊的标头。Connection:UpgradeUpgrade:WebSocketSec-WebSocket-Key:T2a6wZlAwhgQNqruZ2YUyg==\r\n服务端收到带有Connection:Upgrade请求头的HTTP请求后,会调用upgrade方法将连接改为websocket连接,然后使用101状态代码响应HTTP请求。至此,Websocket连接已经建立,您可以使用建立的连接进行双工通信和连接处理。服务器采用高性能Go语言开发。github.com/gorilla/websocket开源库已经通过升级、返回101响应等方式打包完成。这里我们直接使用这个库开发定义服务器结构体字段类型WstServerstruct{listenernet.Listenerupgrade*websocket.UpgraderonConnectHandlersOnConnectHandler}这个结构体实现了ServeHTTP方法,在方法中调用Upgrade方法实现了websocket协议func(thisServerWstServer)ServeHTTP(whttp.ResponseWriter,rhttp.Request){conn,err:=thisServer.upgrade.Upgrade(w,r,nil)iferr!=nil{log.Println("[ws升级]”,错误)返回}日志。println("[wsclientconnect]",conn.RemoteAddr())thisServer.onConnect(conn,r.URL.Path)//每个连接启动一个协程进行处理}白板业务下的websocket服务架构会连接每一个白板抽象为一个Hub,所有进入白板的客户端都需要使用WebSocket连接到WebSocket服务器中白板对应的Hub;其数据结构定义如下typeHubstruct{BoardIdstring//whiteboardidConnectionsutils.ConcurrentMap[string,UserConnection]//当前白板下的所有连接}BoardId对应于Hub中的白板IDConnections都是已建立的WebSocket连接Hub,key是UserId。当其中一个Client执行一个操作(如绘制、删除、移动图形等)时,Client将这个操作抽象成一个Cmd消息发送给WebSocketServerWebSocketServer会将Client的消息广播到otherClients,另外一个Client会调用注册的回调函数进行处理和渲染func(hub*Hub)Broadcast(objany){//遍历每一个连接,发送消息hub.Connections.Data().Range(func(key,valueany)bool{userId:=key.(string)conn:=value.(*UserConnection)err:=conn.SendJSON(obj)iferr!=nil{log.Println([Error]SendTo==============>",userId,err)returntrue}returntrue})}Websocket集群解决方案如果是单机的情况下,当websocket需要向用户推送消息时,由于用户已经与websocket服务建立了连接,消息推送才能成功。但是,如果在集群情况下,用户A向websocket发起连接请求,当有多个服务时,只能建立一个服务(以服务器A的形式为例),这些websocket服务可能会推送消息给用户A。此时服务器B和服务器C还没有建立连接。为了避免这种情况,更方便的实现同步,我们需要尽可能将同一个白板中的所有客户端都连接到同一个服务器上。这需要通过引入MQ来实现。所有websocket服务都绑定到名为locate的交换器,并从网关接收位置消息。如果白板对应的连接管理器(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层可能会收到两个事件坐标,我们根据window.devicePixelRatio进行变换,实现dpi的适配,分别转化为InteractMouseEvent和InteractTouchEvent,这两个继承自InteractEvent,分别是external的。提供统一的接口类型(type,比如down,up...)和x,y,实现事件类型统一传递到场景,然后根据画布缩放比例重新改变坐标,以及将它映射到场景画布成为SceneEvent,场景事件有两个目的地。通过逻辑层和渲染层的桥梁——工具(Tool类)的op方法操作RenderScene,通过dispatchSceneEvent方法操作被激活的元素给元素,元素反馈事件是否与自身相关(根据作用域判断,??返回一个布尔值)。同步机制的实现命令(Cmd)用于数据结构前后端之间的同步。Cmd的数据结构和Cmd的载荷(CmdPayload)如下enumCmdType{//Enumeration从末尾添加Add,//添加元素Delete,//删除元素Withdraw,//withdrawAdjust,//调整单个属性SwitchPage,//切换页面SwitchMode,//切换模式LoadPage//加载新页面}classCmdextendsSerializableData{id:string;//命令idpageId:string;//操作页面idtype:T;//命令类型elementType:ElementType;//命令操作元素类型o?:string;//操作对象的idpayload:string;//操作的payload,因为go无法绑定判断类型,使用stringtime:number;//操作时间戳boardId:string;//操作所属的白板创建者:string;//操作创建者的userId}typeCmdPayloads={[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//新模式[CmdType.LoadPage]:null}同时,Cmd也是OperationTracker的状态维护者,实现undo/redo,可以和逻辑层统一一个命令执行接口。导出类WhiteBoardApp实现IWebsocket,ToolReactor{/*...*/publiccmdTracker:OperationTracker>;/*...*/}同步机制每个工具可能是Creator或Modifier,对应的onCreate和onModify回调由逻辑层。创建或修改时,构造相应的Cmd,通过Websocket客户端发送给服务端,服务端将命令广播给房间内的其他用户。其他用户收到Cmd后,通过白板逻辑层的add/delete/adjustElemByCmd()等接口,使用CmdPayload同步白板。写频繁场景下的存储架构实践对于白板应用,大部分情况下数据操作都是变更操作(写操作),频率非常高;如何应对高并发和频繁的写操作成为白板技术下的重要课题。非常重要的问题。RedisBuffer的写操作如果直接操作数据库(比如MySQL),在高并发场景下,对数据库的压力会非常大。因此,我们选择分布式内存数据库Redis来缓存数据,在合适的时候将数据持久化到数据库中。Redis数据结构的选择Redis的数据结构包括以下五种类型:String:字符串类型List:列表类型Set:无序集合类型ZSet:有序集合类型Hash:哈希表类型先介绍下元素的数据结构页面:ElementBase类扩展SerializableData{publicid:string;publictype:ElementType;publicx:number;//左上角x坐标publicy:number;publicwidth:number=0;publicheight:number=0;publicangle:number=0;//弧度系统publicstrokeColor:string="#ff5656";//16进制整数...}在Redis中存储这样一个具有很多属性的对象,一般有两种选择:方案一:将整个对象序列化成一个JSON字符串,使用Redis的简单String进行存储;优点:实现简单;缺点:如果每次只改变少量属性(比如移动只改变元素x,y属性),但是简单的字符串方式每次都需要重新序列化整个对象,然后覆盖存储,这效率比较低(主要考虑网络传输的网络包大小)方案二:将对象存储在Hash结构中,field存储对象的属性名,value存储属性值优点:可以实现精确控制对象的一个??或多个属性缺点:实现复杂在我们的应用场景中,只改变一个或几个属性相对困难很多,所以我们选择Hash结构进行存储。同时,如果我们想知道一个页面中所有元素的集合,如果采用在元素的key值中拼接页面id的方式,就必须使用Scan来遍历全局key。为了避免全球化,一个Set结构用于存储页面中所有元素的id。Redis管道操作在白板业务场景中,难免要执行多个Redis命令(比如读取整个页面所有元素数据的哈希结构)管道(pipeline)可以一次向服务器发送多个命令。服务器依次处理完后,会通过response一次性返回结果。管道通过减少客户端和redis之间的通信次数来减少往返延迟。时间,而Pipeline实现的原则是队列,队列的原则是先进先出,从而保证数据的顺序。使用管道可以批量执行Redis命令,可以有效提高系统吞吐量。Redis集群方案需要在整个系统中缓存大量的页面元素数据。应用的扩展性受限于Redis的存储容量,单节点Redis可用性低。.所以有必要在架构中引入集群方案。RedisCluster提供了一种运行Redis的方法,其中数据在多个Redis节点之间自动分区。RedisCluster还提供分区期间的可用性级别,即在实际情况下,如果某些节点发生故障或无法通信,则能够继续运行。Redis集群具有以下特点:每个主节点都有其对应的一个或多个从节点,它们之间存在主从关系,会进行主从复制。每增加一个key,都会通过一定的哈希算法分配给某个master。节点理论上可以扩展存储容量。一般白板应用中的阅读场景比较少,所以每个主节点都有一个从节点来实现高可用的架构。