更新於 2023/09/01閱讀時間約 16 分鐘

閒談軟體設計:設計抉擇的因素

圖片來源:www.freepik.com
圖片來源:www.freepik.com

前言

會有這一篇文章,是在 DZone 看到《Yet Another Evil Suffix for Object Names: Client》討論 AWS S3 Client 的設計不夠 OO,這讓我想起之前曾替公司設計過 Java SDK 給第三方使用,當初與團隊成員討論時,也有針對這一點進行討論,不過我們最後的決定其實是跟 AWS 一樣。這是程式開發有趣的地方,同樣的目標,不同的團隊會因不同的因素做出不同的設計抉擇。而這往往也是為什麼一個資深的工程師在開發速度上不一定比較快的原因之一,一個越是資深的工程師,思考的因素會更多,不過,不是考慮得越多就結果就一定越好,有時還會變成 over design 較糟的結果。

背景說明

在開始探討當初我們做出的設計決策之前,先說明當初 SDK 的使用情境 (不能透漏之前工作的細節,因此是修改過的版本),SDK 的主要使用者是下圖中橘色的第三方服務的 server 端應用程式,透過 SDK 與我們的服務溝通 (下圖中紫色的 host server),類似 OAuth,第三方服務的 Web client 端可取得代表我們服務的使用者的 token,第三方服務的 server 端用此 token 換發真正與我們服務溝通的憑證 ,我們的服務可以用憑證得知哪個第三方服務代表誰發出請求。
Figure 1 SDK 的目標使用情境
SDK 有個功能是存取服務提供的某種容器,就暫稱為 Box 吧,每個 Box 都有一個唯一的 ID,第三方服務可以用 ID 透過 SDK 操作 Box,像是修改名稱、說明、圖示,上傳檔案到這容器中,並設定容器的管理員以及那些人可以看到這個容器內的東西。
當時團隊討論時,我曾問要不要把管理者設定的函式放在 Box 物件裡?就如同《Yet Another Evil Suffix for Object Names: Client》所說的,將 Box 設計成一個 proxy,依實作可以分成在 Box 建立時,就將遠端資訊取回的 remote proxy,或是 Box 建立時不取回遠端資訊,等到真正要取得資訊時再請求資訊的 virtual proxy。也就是說 Box 有三種設計方式:AWS 的 client、remote proxy 和 virtual proxy。

Request 數量

說明完背景後,回到設計抉擇,由於 SDK 主要的使用情境在 third-party App server 上,從 response time 的考量上會希望減少 third-party App server 發向Host server 的 request 數量。所以比較在不同情境下,三種方式所需要發出的 request 數量。

情境一:取得既有 Box 的基本資訊
從下方程式可以看出,在取得基本資訊的情境下,三種設計都可用 1 次 request 就完成任務,在 virtual proxy 的情況下,boxManager.getBox("box-id") 並不會真正發出 request,會等到 info() 被呼叫時才真正發出 request。

情境二:加入一個使用者作為成員
從範例可以看到,remote proxy 較其他二種設計多了一次 request,因為不管有沒有使用到,boxManager.getBox("box-id") 馬上發出 request 取回 Box 的基本資訊,然後 addMember("user-id") 再次發出 request 真正完成任務。
到目前為止,virtual proxy 似乎是個不錯的選擇,request 數量和 client 方式一樣,設計也比較 OO,remote proxy 雖然好像會多一次 request,但若將第一次取得的 Box 實體放在記憶體當中,後續的操作也不會再發出 request,似乎也是不錯的方式。

憑證的處理

接著看憑證的處理,如果 SDK 當初設計的使用情境是像 Figure 2 一樣,直接讓使用者的 mobile client 直接存取 Host server 的話,我應該會選擇 remote proxy 的設計,因為不用考慮到多憑證的問題,每個 mobile client 只會有一個憑證代表這個 mobile client 和正在使用的使用者。remote proxy 物件放在記憶體中也沒什麼問題,由 BoxManager 管理所有曾經取得的 proxy 物件,proxy 物件的生命週期和 mobile App 的生命週期一致,可大幅減少需要再次發出 request 的情況。
Figure 2 SDK 的另一種可能使用情境
但在 server 端的程式就不是這樣了,要將 Box 實體放在記憶體中,需要把憑證相關的資訊封裝在 Box 實體中,若想要在發出 request 時使用正確的憑證,還需建立一個使用者與 Box 實體的對照表,每當 third-party App server 要利用 Box 發出 request 時,從對照表拿出該使用者的 Box 實體,然後呼叫對應的函式才會使用到正確的憑證。
這個對照表很難由 SDK 來維護,使用上反而因為對照表的關係變得複雜。而且若考量記憶體的使用量,已經很久沒有存取的 Box 實體,並沒有必要一直放在記憶體當中,只是這還要 SDK 的使用者維護這類似快取管理的機制,並沒有得到太多好處。當然,若不儲存 remote proxy 在記憶體中,憑證的處理就沒有什麼問題,只是會有 request 數量的問題。
相較之下,每當 third-party App server 需要對遠端的 Box 操作時,用使用者的憑證建立 client 物件 (BoxManager) 然後呼叫函式,省事許多。virtual proxy 因為建立成本很低,所以也不需要放在記憶體當中,因此和 client 一樣,要使用的時候重新取得一個 proxy 物件,然後呼叫函式,多憑證沒有太大的問題。
到目前為止,client 和 virtual proxy 都還是不錯的選擇。

Exception

接著思考一下 exception,這其實是最容易被遺忘的一個部分,如果事情不會出錯,自然不用考慮 exception,但 third-party App server 和 Host server 之間透過網路溝通,很難保證不出錯的。問題是 class diagram 通常不會標示 method 會拋出什麼樣的 exception,設計時就不會想到這件事,但這是一個 Java SDK,而 Java 就是一個要把 (checked) exception 宣告在 method 上的一個語言,這時候會發現 exception 讓抽象滲漏法則更為明顯:
所有重大的抽象機制在某種程式上都是有漏洞的。
是否有人注意到上面 virtual proxy 在取得 Box 基本資訊是透過呼叫 info() 的函式,為什麼是這樣的設計呢?先看原本的 info() 版本:
再看看沒有 info() 的版本:
很明顯,第二個版本比較噁心一點,連 getName() 都可能拋出 exception,這裡有將 request 回傳的錯誤碼中具有意義的轉成 domain layer exception,像是 BoxNotFoundException 及 UserNotFoundException,其他低階的錯誤則以 IOException 拋出。那這和抽象滲漏有什麼關係呢?Proxy 設計的目的就是想提高抽象程度,讓使用的開發者覺得好像 Box 這個物件就在他們的環境中,但 IOException 暴露出其實取得 Box 或更動 Box 都是需要網路溝通的,BoxNotFoundException 則透露出 Box 實體是一個可能不存在的 proxy。
那單純的 remote proxy 呢?就 exception 宣告的位置來看,remote proxy 比 virtual proxy 稍微好一些,至少 getName() 和 getDescription() 不會拋出BoxNotFoundException,畢竟已經取得 Box 實體時這些資訊已經一併取回了,但還是有一個函式 addMember(userId) 會拋出,這等等再談為什麼。
最後是 client 的版本,可以看出來 exception 都集中在 BoxManager 身上,而 Box 實體則沒有任何會拋出 exception 的函式。
其實不管哪一種方式,都免不了抽象滲漏,因為不論怎樣設計,都還是會拋出 IOException、BoxNotFoundException 或是 UserNotFoundException,只是看哪種方式讓使用者覺得比較自然,exception handling 比較方便,以這樣的角度來看,我個人覺得 client 的方式優於 remote proxy,remote proxy 優於 virtual proxy。

Asynchronous

開始開發 SDK 之前,團隊先訂下幾個共識,像是 (1) 最低相容的 Java 版本 (Java 6)、(2) 全部都是 synchronous API、(3) 盡可能減少相依的第三方函式庫並提供整合的單一 JAR 檔 (JAR with all dependencies),以及 (4) 至少所有 public API 都有 JavaDoc 說明並提供使用文件。既然全是 synchronous API,那標題為什麼是 Asynchronous 呢?
當初考量到 Servlet 雖然從 3.0 開始便支援 asynchronous requests,但使用既有框架 (像是 Struts 2、J2EE、Spring framework 等) 寫的應用程式,大多是以 synchronous 的方式撰寫,要在 synchronous 的程式中取得 asynchronous API 的回傳值,或是捕捉錯誤 (基本上要自己重新拋出,不然無法用 try-catch 處理),那是一件很麻煩的事情。反之,asynchronous 程式 (例如用 future 方式撰寫) 中要把 synchronous API 串在一起相對比較容易 ,Java 有提供工具類別能簡單封裝成 future 串起來。
但這跟上述三個設計方式有什麼關係?主要是哪種設計方式能讓 SDK 的使用者更容易知道那些 method 的呼叫是會發出 request 的,有必要的話,可以用包裝成 future 的作法以 asynchronous 的方式呼叫,讓 server 端的處理效率更高。事實上,SDK 的主要使用情境是在 server 端,但這 SDK 要在 Android 上使用也是可以的,我相信應該有不少多開發者,在呼叫某些沒有標示會發出 request 的 method 時遇到 NetworkOnMainThreadException,然後在心中暗念三字經的經驗吧!
在開發 SDK 前,有稍微研究一下 Google、Amazon 和 Microsoft 的幾個 SDK 的原始碼 (開源就是方便研究),有注意到 @WebMethod 這類的 annotation,標註在會發出 request 的 method 上,這似乎是個解法 (我離開前也替公司的 SDK 加入類似的 annotation),但我個人覺得即使沒有 annotation,所有發出 request 的 API 都在 client 身上,比較容易讓 SDK 使用者意會到 BoxManager 的所有函式都是會發出 request 的,反之,remote proxy 和 virtual proxy 則沒那麼明顯。

Synchronization

這裡的 synchronization 不是指會 blocking 的 method,而是指資料的同步,回顧整個使用情境,Box 物件都是一個代表遠端的容器,而這個物件可能由有授權的使用者透過 host server 提供的其他服務進行修改或刪除,在這情況下,不管是哪一種方式設計,一個 Box 物件都可能指向一個已被刪除的容器,因此,剛剛在 exception 一節討論 virtual proxy 時,對一個代表已刪除容器的 Box 物件呼叫 addMember(userId) 時,host server 會發現該 request 想對一個不存在的容器進行操作,最後會拋出 BoxNotFoundException,原因就是資料的不同步。
先不考慮零時差同步,要做到一定時間內,資料完全同步也是需要不小的 effort,像是每個 SDK 實體維持一個固定連線,讓 host server 能推播變更給 SDK 實體,能更新所有物件的狀態。一般來說,proxy 除了可能代表不存在的資源,其內部的狀態也可能過期,例如:remote proxy 的 Box 實體中會有 name 與 description 的資訊,隨時都可能會過期;virtual proxy 的 Box 實體中,若 info() 函式若會保存已取得的資訊,也會有過期的可能;client 的方式,Box 是一個 DTO (Data Transfer Object) 也有會過期的可能。
就過期這一點,不是三種方式都一樣嗎?一般來說,比較會把 client 的 Box 物件當成用過即丟的物件,額外的操作也不依賴在 Box 物件上,而 proxy 則會是持有一陣子的物件,所以才會有明明有一個 Box 物件,呼叫其物件卻拋出 BoxNotFoundException 的詭異現象。

Serialization

文章已經有點長了,最後再說一個因素,考慮到 third-party App server 的節點可能不只一個,例如 Figure 3 有二個節點,前面透過 load balancer 將 Web client 的 request 平均分散到不同的節點,節點之間也可能互相溝通。如果 Box 物件都是用完即丟的情況,那也許不用考慮到要將一個 Box 物件從一個節點轉移到另一個節點的問題,但如果有需要轉移,Box 物件必須支援序列化 (serialization) 和反序列化 (deserialization),使其能在透過網路傳遞,
由於封裝的關係,不管用什麼方式,序列化和反序列化的實作肯定是由 SDK 負責,這也是一個 effort。Client 的 Box 是個 POJO,序列化上很容易,但 proxy,不管是 remote proxy 或是 virtual proxy 都要在另一個節點將與 host server 溝通的物件也全部還原,相對麻煩很多。
Figure 3 多節點情境

結語

說了這麼多,似乎 client 就是唯一解了?其實不然,在寫這篇文章時,內心也在 virtual proxy 和 client 二者中猶豫,只是最後選了 client,可能是因為它相對簡單吧!但如果有更多其他因素加入思考,也許又有不同的結果,我相信還有更多可以考慮的因素,不知道大家在做設計決策時,還有考慮那些因素呢?或是,看完這分析後,有不一樣的決定呢?甚至是上述三者以外不一樣的設計呢?歡迎一起討論。

方格子整體來說還不錯,在引言的排版上是我喜歡的,但比較可惜的是 Gist 區塊的排版略顯壅擠一點,而且無法加上 caption。另一個可惜的點是沒有支援內文程式碼字型的支援,這在軟體開發圈裡可是一件大事,但軟體開發圈在這裡應該是小圈圈 (笑)。
分享至
成為作者繼續創作的動力吧!
© 2024 vocus All rights reserved.