共计 6224 个字符,预计需要花费 16 分钟才能阅读完成。
在七牛云校园黑客马拉松中,来自华南理工大学的 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 节点有一个从节点即可实现高可用的架构。