前言

由於 HackMD 提供了非常好用的線上 Markdown 共筆平台,但我們公司內部自己使用上還有其他想達到的需求,如:進階檔案共享權限、團隊的概念、檔案資料夾管理、筆記備註等功能,於是我們就決定仿效 HackMD 自己重新開發屬於自己的一套 UniMD

開發技術

我們團隊熱愛 PHP 語言和擅長 Laravel 框架,所以和 HackMD 所採用的 NodeJS 不同,我們採用 PHP 為主要開發的技術,並且搭配 Swoole 做為我們的 WebSocket Server 及其他進階的異步應用。

關於 Websocket

NodeJS 中要使用 WebSocket 非常方便,前後端只需要同時使用 socket.io 這個 package 就能夠快速建立起一切你要開發 WebSocket 所需要的基礎、建立連線的協定轉換、前後端溝通的格式、房間的概念...等等。

雖然 Swoole 一樣提供了 WebSocket Server,但是目前並沒有相容 socket.io 規格的 package,所以初期我們決定就使用原生的 WebSocket 來開發,大概會有以下的限制和注意事項:

  • 瀏覽器必須支援 Websocket 功能,無法自動 fallback 使用 long polling 的方式
  • 前後端傳輸格式需自己定義,並沒有像 socket.ioonemit 這種現成的傳輸包裝
  • 需實作出房間的機制,將 Client 依據房間分出不同的 broadcast 對象
  • 需實作 Socket 斷線重新連接的機制
  • 需整合 Laravel 的驗證至 Websocket 連線中

Websocket 的房間機制

在實作房間機制之前,我們先假設:

  • 一個 Socket 連線只能同時在一個房間內(一個畫面中只有一個共筆筆記)
  • Client 觀看筆記即是加入房間,斷線或切換至其他頁面即離開房間
  • 一篇共筆筆記就等同於一個房間
  • 每個在房間內的 Client 可以同步彼此的訊息
  • 每篇共筆只有當至少一個 Client 連線時才需建立房間
  • 當房間內的 Client 數量為 0 時,Server 須回收房間資源

在了解以上需求後,我們使用 Redis 中的 set 集合來實作我們房間的功能:

  • 每一個 set 就是一個屬於該篇共筆筆記的房間
  • set 內的成員即是每個連線的 socket 客戶端連線 id

整合 Laravel 的身份驗證

由於登入驗證這個部分我們想保留使用 session,故暫時沒有考慮使用像 JWT Token 的機制,而是利用在 Laravel 驗證後核發給客戶的 session id 來當作 WebSocket 的驗證依據,聽起來好像很容易,但實際上整合中有一些需要解決的問題:

  • 由於 Swoole 擔任 Websocket Server 的角色,所以 TCP 連線資訊皆是由 Swoole 來處理,而 Swoole 對於取得 client 的 header、cookies 等相關資訊的方法與原本 PHP 並不相同
  • Laravel 在 client 中所儲存的 cookies 是經過加密的
  • Swoole 需經過 StartSession Middleware 的處理才能將該 Laravel 所核發的 session id 還原成 session 中所記錄的資訊
  • Swoole 以 Console 的方式啟用 WebSocket Server 時並不會經過任何的 Middleware
  • 希望最後能在 Swoole 中直接使用 auth()->user() 就能存取到該 socket 連線的驗證身份

為了解決以上的問題,我們大致採取以下的措施來達成我們的需求:

  • 當 socket client 連線進來時,手動將 Swoole 的 request 包裝成 illuminate request
  • 利用 Laravel 中的 Crypt::decrpyt() 方法將 cookies 解密
  • 將前面包裝的 illuminate request 經過一次 StartSession Middleware 的流程
  • 客製化自己的 Auth Guard 來解決 Laravel 會將 auth()->user() 快取住,造成 Swoole 每個 Worker 只能取到第一次 user 的情形

客戶端共筆同步

為了讓同一篇筆記的客戶端都能夠同時看到彼此的修改內容,每個客戶端的修改動作都會傳送給 Server 並且廣播至每個用戶的畫面中。但是在共筆文章尚未在 Server 中寫入至資料庫時,如何確保下一個進來的使用者能夠看到最即時的共筆結果?於是有了以下的思路:

  • 不可能每一次的修改動作就寫入一次資料庫,不然資料庫很容易爆掉
  • 既然不每次寫入資料庫,那如何將最新未儲存的修改結果同步至下一個新進來的使用者呢?
  • 修改動作的傳輸內容也很重要,不可能將整篇完整的文本做紀錄,只需傳送有修改的部分

所幸 CodeMirror 有提供 diff 方法,能夠在每次文本的 onChange 事件時觸發,產生出像下面一樣的 diff 結果,讓每次的變動能夠只傳送必要的內容來進行廣播。

{  
   "from":{  
      "line":2,
      "ch":14,
      "xRel":1,
      "outside":true
   },
   "to":{  
      "line":2,
      "ch":14,
      "xRel":1,
      "outside":true
   },
   "text":[  
      "s"
   ],
   "removed":[  
      ""
   ],
   "origin":"+input"
}

但這依然無法解決新進的使用者看不到未儲存進資料庫前最新共筆結果的問題,為了達成這個目的,勢必在 Server 端需要紀錄從資料庫最新筆記到最新共筆狀態的內容,這個部分我們採用了 Swoole 中所提供的 Table 來解決這個問題,Table 能夠讓我們直接使用記憶體當作是資料表來使用,這樣完全不會吃到檔案或資料庫 IO 的資源,目前看起來是最有效率的方式。

也就是說,每一次的客戶端文本修改都會發送兩個 request:

  • 傳送自己的修改記錄,用來廣播給其他用戶
  • 同步從最新資料庫文本到目前為止所有編修過的內容的 diff 結果

其中,記錄 diff 結果的內容我們採用 Google 所開發的 diff_match_patch.js 來取得像下面一樣修改結果:

@@ -0,0 +1,34 @@
+sdfdsgtgege%0Afewfwef%0Afwefwefwefewfw

這樣一但新的客戶端連線要取得最新的筆記內容時,Server 就會回傳:

  • 資料庫最新的文本紀錄
  • 基於最新文本後所有客戶端所做的修改內容

最後在客戶端上手動將兩筆資料合併,呈現最新的共筆狀態,接下來還需加入一些驗算的檢驗來防止客戶端送來的 diff 結果不正確的問題。