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

七牛雲發表於2022-12-20

在七牛雲校園駭客馬拉松中,來自華南理工大學的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節點有一個從節點即可實現高可用的架構。

相關文章