閒談軟體設計:Offline first (Server 篇)

Du Spirit
閒談軟體設計
Published in
13 min readDec 7, 2023
圖片來源:www.freepik.com

如果您以為上一篇 閒談軟體設計:Offline first (Client 篇) 已經是所有需要考慮的眉角,那可就錯了,實作 offline first 不是只有 client 要注意,server 也需要下功夫的。

前言

在上一篇提到 offline first 能帶來更好的使用者體驗,但考慮的眉角很多,client 端要有本地資料,需要有同步機制與衝突排除原則,可是光是這樣還不夠,在 server 設計上也有不少眉角,光是衝突排除原則就需要 client 與 server 相互配合才能夠完成。因此,本篇就來談談 server 需要考慮的幾個要素吧:

  • ID 的產生
  • 歷程記錄 (audit log)
  • Long polling vs. WebSocket
  • 衝突排除原則

ID 的產生

傳統上,ID 的產生大都是由 server 負責,尤其是使用關聯式資料庫的開發者都喜歡用 auto incremental ID,因為最簡單,而且不用擔心有衝突,但實際上,這容易讓程式的核心依賴在 persistence layer,例如下圖,TaskRepositorysave 函式就需要回傳 ID,但如果 ID 是 service layer 可以自己產生,是否意謂著 save 就不需要回傳 ID 了呢?這是一種實作引導或是影響抽象的例子。

若我們把視角拉高,為什麼 client 需要 server 產生 ID?或多或少,client 只是把 server 當作是資料庫?我不確定,但我確實看過有人用 Repository 來實作與 server 的串接。

稍微扯遠了,回到 offline,一般來說,offline 的應用程式在操作上的感覺,會蠻像 閒談軟體設計:Client Server 中提到的類型 (e),client 端已經有大多數的 application 邏輯,而且也有部分的資料儲存在 local 端。

圖片來源:《Distributed Systems: Principles and Paradigms》

由於 client 即便尚未完成 API 呼叫,需要在本地儲存資料才有辦法正常顯示與操作,此時 ID 的產生大致有兩種方式:

  • 直接交由 client 處理,這比較簡單,但選擇不多,為了確保全域的唯一性,大概只能選擇 UUID (參閱 閒談軟體設計:UUID 三部曲),或是類似的 ID 生成演算法來產生 ID。
  • ID 交換,這讓 server 有獨立的 ID 選擇,不受限於 UUID,client 也可以選擇 UUID 以外的類型,讓資料可以先儲存在 local 端,等到完成 API 呼叫,再替換成 server 回傳的 ID,但還有一些事情要考慮,例如,何時該用 client 的 ID?何時該用 server 配的 ID?交換時有衝突怎麼辦?這都會大幅增加 client 存取資料的複雜度。

歷程記錄 (audit log)

在上一回 client 篇有提到,將資料同步到 server 大致有兩種方式:視作檔案的方式同步以及以 request queue 的方式同步,當時有提到,視作檔案的方式有個缺點是較難留有完整的歷程。

但為什麼視做檔案的方式比較難保留歷程呢?假設,在網路不是很好的情況下,客人來到餐廳,餐廳的服務人員詢問電話後找到訂位,於是將狀態改成「已報到」。不久後,保留的位子已經清理完畢,服務人員帶客人到座位上,於是將狀態又改為「已入座」。此時網路恢復,client 開始同步資料到 sever,由於該筆資料最後的狀態是「已入座」,server 並不知道中間原來曾經有過「已報到」的狀態,因此也就少了一筆歷程。

反之,request 的方式,報到是一次 API request,入座是一次 API request,server 是透過處理兩次的 API requests 將資料變成與 client 一致的狀態,同時,server 也能分別記錄兩次 API requests 的歷程。

視應用的使用情境,歷程有時候是非常重要的,一般來說 B2B 或是金融相關的應用,都會很在意誰在什麼時間點做了什麼。這讓我想起之前在 GSS 用 Spring framework 的 interceptor 攔截 API endpoint 的呼叫,把誰呼叫哪個 API,帶了什麼參數,得到什麼內容,下載的檔案內容,通通存到資料庫,然後開發一個介面查詢所有的歷程記錄。

但,為什麼 git 能保留檔案的歷程呢?這就留給讀者去思考了,提示,想想什麼是 commit?

Long polling vs. WebSocket

到剛剛為止,談得比較多的都是如何將資料送到 server 端,但同步機制哩,如何讓 client 端知道有新資料也是很重要,過去 fat client 或是傳統桌面應用程式大多可能選擇建立一個持續的 socket 連線,讓 client 與 server 可以雙向互動。

到網頁時代,由於瀏覽器的限制,發展出 long polling 的機制,建立一個 HTTP 連線,但 server 故意讓它連著一段時間,等到有資料才回覆。更後期,則出現了 WebSocket,建立在 HTTP 協議上,建立持續的連線,可以雙向互動。我想很多人都知道這些背景,也不少人選擇 WebSocket。

替 WebSocket 的 server 實作 horizontal scale 時要注意一件事情, HTTP 連線即便有 keep alive,連線也不會真的一直持續不斷,但 WebSocket 則是真的持續保持連線,這會導致即便開了新機器 (這邊用比較容易理解的說法,機器可能是一個新的 pod 或是一台新的 VM),連線仍會集中在最一開始的幾台機器。

以下圖為例,一開始有三台機器,最初的連線進來 (藍色),平均分散到三台機器,後來根據設定自動加開了第四台機器,於是連線又分散到四台機器 (紅色),但最一開始的三台機器的連線並不會斷,於是這三台的負載仍然很高,第五台機器加開後,後續的連線分散到五台機器 (綠色),第六台機器加開後,又有更多的連線進來分散到六台機器 (紫色)。

scale up 與連線分布

要讓連線能平均分散到加開的機器,需要不同的 load balance 機制,否則,連線其實很容易集中在最初的機器上。剛剛提到的是 scale up,但 scale down 也很重要,不然,費用會居高不下。一般來說,當要 graceful shutdown 一台機器時,會讓 load balancer 知道不要再讓請求進到準備關閉的機器,等既有的請求都完成後才真正的關機。但尷尬的是 WebSocket 沒有結束的時候,因此,要記得處理 OS 送進來的訊號,強制讓連線中斷,讓 client 知道要重新連線。

衝突排除原則

在上回其實就討論了衝突排除原則,當時有個前提:永遠要以 server 作為最終的資料來源,以及一定要有可以辨識版本的資訊。因此,其實 server 在衝突排除原則中扮演的角色更吃重。

版號

為了讓 client 與 server 處理好衝突,需要同步的資源勢必要有能識別的版號,這裡的版號不一定要是連續的數字,雖然連續的數字版號在樂觀鎖中很常被使用,但在 offline first 的應用中,樂觀鎖適不適用取決於排除原則。

用樂觀鎖,是假設當要更新資料庫時,資料庫中資源的版號正是被編輯的版號。因此,若兩個 client 同時對資源 A 的版本 5 進行修改,這兩個修改都想同步到 server 上時,一定有某個請求先被處理,另一個請求會因為 transaction 的關係會等到正在處理中的請求完成後才會執行。第一個請求在更新資料庫時發現資料庫中的資源 A 也是版本 5,所以可以安心更新,並將版本提升為 6。第二個請求在更新資料庫時發現,資源 A 已經是版本 6 了,表示有其他請求在它之前修改了資源 A。

這時問題來了,若不是 offline first 的應用,server 最簡單的方式就是捨棄修改並回傳錯誤給 client,讓 client 決定該怎麼處理衝突。但 offline first 的應用,client 通常不會等待 server 回傳的結果,假設 server 回傳錯誤,client 當下也許早就不在當時的畫面,跳出錯誤訊息,在情境不對稱的情況下,反而讓使用者一頭霧水。因此,多數介紹如何開發 offline first 的文章都會提到 Last write wins,這是最容易處理的方式,可惜的是,樂觀鎖就無法發揮原有的功能了。

所以,就不需要版號了?要,Last write wins 只是簡化 server 怎麼處理衝突,要讓 client 端有較充足的資訊,優化回溯可能造成的體驗,版號仍是一個有用的資訊。在上回提到,client 端是可以有條件無視 server 回傳的資料,當時的說法是,client 可以等自己的 request 送達 server 後再接受 server 的回傳值,這時版號就是一個可參考的資訊。

注意,即便有版號,在極端的情境下:多個 client 在網路不佳的環境中仍無法避免回溯,只是有可能減少回溯的次數,或是讓回溯發生時不要那麼的突兀。

狀態機

一般來說,server 在設計功能時,大多會考慮狀態轉換是否合法,這時,會透過狀態機來描述,例如,下圖是個訂單的簡單狀態機,happy path 是消費者完成付費訂餐,一張新訂單進來 [new],接著餐廳人員接單 [accepted],廚房開始準備 [preparing],餐點完成通知消費者 [ready],然後消費者取餐 [picked-up],就一般很常見的訂單處理流程。

圖中 new -> accepted -> preparing -> ready -> picked-up 都是單向箭頭,也就是無法從 ready -> preparing 或是 ready -> accepted。通常,server 會將試圖把 ready 的訂單改成 accepted 的請求視為無效或是違法的請求,這對一般的應用程式來說,很合理 (先不討論奇怪的商業考量),確保訂單不會進入有問題的狀態。

在搭配金流的情況下,狀態的確保會更重要,例如:餐廳因為廚房的問題,拒絕接單,系統於是發動退款,並將狀態改為 [rejected],如果允許 rejected -> new,這時,訂單就會處在有問題的狀態 (款項已退),而且無法修正 (消費者通常不願意再付一次)。

簡單的訂單狀態機

但在 offline first 的情況下,可能會因為多個 client 同步的順序,導致無法滿足狀態機的限制,例如,餐廳人員在 client A 接單,但因為同步尚未完成,另一位餐廳人員在廚房使用 client B 時發現訂單還沒接,於是也按了接單,同時按下開始準備,將狀態移到準備中。

這裡會有兩種可能,第一種是 client A 的請求先送到 server,server 於是將狀態改為 accepted,接著 client B 的請求送到 server,因訂單已經 accepted ,server 可以忽略將 new 改為 accepted 的請求,接著,處理將 accepted 改為 preparing 的請求,這是較好處理的可能。

第二種可能是 client B 的請求先送到 server,於是訂單狀態被改成 preparing,此時,client A 的請求才送到,此時 server 是要忽略 preparing -> accepted 的修改?還是拒絕 client A 的請求?在這個例子中,狀態的修改不會觸發金流的服務,但如果會觸發外部服務,就會更難以處理,例如,一位餐廳人員在 client A 上拒接訂單,另一位餐廳人在 client B 上接受訂單,這時該怎麼處理?

因此,在需要用狀態機嚴格控制物件狀態的情況下,盡量避免使用 offline first 的設計。

權限控管

上回提到依序執行可能會引起阻塞,導致某些可以很快就能完成的 request 被尚未完成的長 request 擋在後面,這時可以實作特殊的 queue,將會互相影響的 request 排到同個 queue 中,不相關的 request 則排到不同的 queue 中,增加平行處理的能力。

即便真的會互相影響的 request 排到同個 queue 中,在多個 client 的情況下,仍有可能發生問題,權限控管便是一個例子。

假設,使用者 A 在 POS 系統中有建立訂單的權限,使用者 A 在 client X 建立了一筆訂單,client X 在建立訂單時,也用 local 已同步的權限設定確認沒問題,於是訂單建立,並準備同步到 server,這時,管理者因為一些原因,決定將使用者 A 的權限移除,即便訂單在移除權限前就已經建立,但同步並不保證順序。

若建立訂單的請求比移除權限的請求先送達 server,那兩個請求都能順利完成。但如果移除權限的請求比建立訂單的請求早,server 就很難處理了,是要直接相信 client 的權限檢查結果呢?還是要回絕請求?前者是較使用者友善,但不安全的作法,反之,後者是安全的作法,只是使用者可能較痛苦,例如,使用者 A 找另外一位有權限的使用者,再重新輸入一次訂單資料。

雖然,剛剛的例子,可以靠時間戳記進一步檢查訂單建立的時間與移除權限的時間,判斷是否允許訂單的建立,但這牽扯到另一個問題,client 給的時間戳記可以信任嗎?

因此,同樣的,若權限控管是非常嚴格要求的,那可能也盡量避免使用 offline first 的設計。

總結

花了兩篇的篇幅,探討 offline first 在實作上的很多眉角,大多來自身的經驗,我也不知道為什麼?過去開發的產品有很高的比例都是支援 offline first,只能說,使用者體驗是要靠非常多的工夫去設計與開發的。但 offline first 不見得是唯一解法,在沒有對應的需求下,不會輕易說要導入 offline first 的設計。

後記

在狀態機一節有提到,不討論奇怪的商業考量,然而,真正的狀態機完全不是圖中那樣簡單,因為餐廳實際營運時會有各種問題出現。最常發生的便是誤操作,常常遇到客服請工程師改狀態,因為餐廳接錯單或是按錯。有人可能會說 UI 多點提示,或是要再次確認,就可以避免誤操作,但有在餐廳現場待過的,就會知道很多現場工作人員是忙到沒有時間確認,跳對話框,直接按確定,等到發現時再找客服。有時,都會猶豫,這樣還要設計狀態機嗎?設計好的狀態機會出現各種特例,這邊不做討論,問題就留給大家思考了。

--

--