SCUT01在線協作白板技術解決方案

在七牛雲校園黑客馬拉松中,來自華南理工大學的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的適配
  • 將其分別轉化成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的數據結構包括以下五種:

  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節點有一個從節點即可實現高可用的架構。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章