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

2023/09/16閱讀時間約 15 分鐘
圖片來源:www.freepik.com

圖片來源: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》

圖片來源:《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 與連線分布

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 多點提示,或是要再次確認,就可以避免誤操作,但有在餐廳現場待過的,就會知道很多現場工作人員是忙到沒有時間確認,跳對話框,直接按確定,等到發現時再找客服。有時,都會猶豫,這樣還要設計狀態機嗎?設計好的狀態機會出現各種特例,這邊不做討論,問題就留給大家思考了。

51會員
100內容數
這是從 Medium 開始的一個專題,主要是想用輕鬆閒談的方式,分享這幾年軟體開發的心得,原本比較侷限於軟體架構,但這幾年的文章不僅限於架構,也聊不少流程相關的心得,所以趁換平台,順勢換成閒談軟體設計。
留言0
查看全部
發表第一個留言支持創作者!