
昨天談的擴充點,全部掛在某一個具體的 Business Object 上。往前推一步的問題是:一個呼叫進到後端,框架怎麼知道要建哪一個型別、那個型別收到的參數又長什麼樣子。
這兩件事在多數專案裡都是寫死的。型別分派寫成一個 switch,參數型別直接拿傳輸用的那一份給業務邏輯用。上千張表單的系統付不起這兩筆:前者每加一支程式就要改程式碼重新部署,後者讓 client 引用得到業務邏輯層的內部屬性。
本篇說明:
同一個 API 方法的參數,送出去的那一份與 BO 用的那一份,需求不一樣。送出去的那一份要能被序列化、要被 client 引用;BO 那一份可能還帶著 client 看不到的內部屬性,例如一個只在 BO 之間傳遞的旗標。做成同一個型別,client 就得引用業務邏輯組件,而業務邏輯也得跟著傳輸格式一起演進。
框架的切法是把屬性清單抽成合約介面,兩邊各自實作:
| 層 | 組件 | 形狀 | 誰用 |
|---|---|---|---|
合約介面 IXxxRequest / IXxxResponse |
Bee.Api.Contracts |
只有唯讀屬性,不含任何序列化標記 | 兩邊各自實作它 |
API 型別 XxxRequest / XxxResponse |
Bee.Api.Core |
繼承 ApiRequest / ApiResponse,負責序列化送出 |
client 與傳輸層 |
BO 型別 XxxArgs / XxxResult |
Bee.Business |
不帶序列化機制,可加合約以外的內部屬性 | BO 方法簽章 |
以表單存檔為例,ISaveRequest、SaveRequest 與 SaveArgs 三個宣告攤開來是這樣:
// Bee.Api.Contracts.Form:屬性合約,唯一真實來源
public interface ISaveRequest
{
DataSet? DataSet { get; }
}
// Bee.Api.Core.Messages.Form:送出去的那一份
public class SaveRequest : ApiRequest, ISaveRequest
{
public DataSet? DataSet { get; set; }
}
// Bee.Business.Form:BO 方法簽章用的那一份
public class SaveArgs : BusinessArgs, ISaveRequest
{
public DataSet? DataSet { get; set; }
}
三層之間的相依方向是這樣:
Bee.Api.Contracts
├── Bee.Api.Core ── Bee.Api.Client
└── Bee.Business
Bee.Api.Core 與 Bee.Business 彼此不相依,各自只認得最底下那一層。Day 2 談過相依方向本身就是一條檢查線,這裡是同一條線的另一個位置:哪一天 BO 的參數型別非得引用 API 組件才編得過,分層就已經破了,而加一個專案參考是完全合法的動作,編譯器不會有任何意見。
三層之間的黏合刻意不是註冊。進來的方向靠反射:框架找到 BO 上要呼叫的那個方法,把收到的內容轉成它宣告的參數型別。回去的方向靠命名慣例:BO 回傳 LoginResult,框架據此找出 LoginResponse 把屬性抄過去,解析結果以型別為鍵快取,每個型別只解析一次。
{Action}Result → {Action}Response
代價寫在慣例本身:沒有任何東西在檢查它。名稱對不上這個形狀的回傳型別不會被轉換,框架把 BO 那個物件原樣交給序列化,問題要到那個方法第一次被呼叫時才浮出來。換到的是新增一個 API 方法時不必到任何一處登記。
也不是每個方法都要三個型別。合約介面存在的理由是讓兩邊共用同一份屬性清單,所以純粹在 BO 之間流轉、不對外開放的方法只要一個 XxxArgs,沒有合約介面也沒有 API 型別。最多就是這三個。
昨天那三個存檔步驟共用的 SaveContext,以及刪除那一側對應的 DeleteContext,都不在上面那三層裡,命名也刻意不同。判別法是一句話:跨層傳輸用 Args / Result,單一次呼叫的各段之間共享狀態用 Context。
XxxArgs 裝的是呼叫端送進來的內容,XxxResult 的值會被抄進送回去的那一份,兩者本身不碰序列化,只承載 client 看得到的資料。XxxContext 則不會被送出去,它裝的是已經解析好的 Repository 與 FormSchema、刪除前的整筆快照、前一段產生給後一段用的輸出,其中不乏不該讓 client 看到的伺服端物件。
分開命名換到兩件事。一是中間狀態若放在 Args 上,結構上就是可以被呼叫端填的,而下一段程式分不出這個值是伺服端算出來的還是呼叫端送進來的。二是 context 是一個物件而不是一組簽章,所以「多給各段一個共享的值」是新增一個屬性,不是改簽章,而改簽章會讓所有既有的覆寫一次編譯失敗。擴充點的簽章要能長期不動,共享的東西就不能長在簽章上。
一個請求帶著 ProgId 進來,框架要拿它換到一個 BO 的型別。註冊表登記的是那個物件,至於它背後有沒有一張表單,是另一件事。這個對應寫在程式碼裡也行,寫在定義檔裡也行,差別在於加一支程式要不要重新部署。
框架的參照來源是 COM+ 的登錄模型:以機碼登錄 ProgID 與它對應的元件型別,一個 ProgID 代表一個獨立的功能或程式。這個模型有一個常被忽略的特徵:登錄檔只管「ProgID 換到哪一個型別」,完全不管這個程式在功能表的哪個位置。
框架延續了這個模型,一個 ProgId 綁定一個 BO,於是有三個直接推論:
SystemBusinessObject 與 LogBusinessObject 也在裡面,它們不對應任何一張表單Day 13 提過所有 BO 共用同一個建構子簽章,工廠不必先知道要建的是哪一個家族。原因在這裡:工廠手上只有一個 ProgId 和一份註冊表,家族是解析出來的結果,不是它的輸入。
System、AuditLog 與 AuditRule 也是 ProgId,跟其他程式一樣登錄在註冊表裡、走同一條解析路徑,傳輸層沒有為它們留任何分支。連 SystemBusinessObject 都走同一條客製路徑,換的單位一樣是 ProgId。
代價是啟動時的懸崖。註冊表是唯一來源,缺項就解析不到,而這幾筆是框架自己的東西,沒有道理要求每一個 host 手寫進自己的 ProgramSettings.xml。解法沿用 COM+ 的另外一半:元件安裝的時候自己寫登錄檔。框架在啟動時逐筆檢查保留字,缺哪一筆補哪一筆,然後驗證每一筆都解析得到可用的型別。
逐筆而不是逐檔,是因為用「檔案存不存在」當判斷,會正好跳過那些已經有 ProgramSettings.xml、只是裡面沒有 System 的部署。寫回檔案則只決定下一次啟動要不要再做一次,沒登記的保留字本來就解析得到框架自己的型別,所以唯讀部署寫不進去也照樣起得來。
註冊表的前提是 ProgId 是唯一的鍵。這份清單因此是單層攤平的,唯一性由鍵機制本身保證:集合以 ProgId 為鍵(不分大小寫),重複的項目在載入的當下就被擋下來,查找是一次 key lookup。分類的概念只留在選單定義裡,兩邊不會不同步。
選單另立一份定義是另一個決定,理由與 Day 4 談 DatabaseSettings 與 DbCategorySettings 為什麼分成兩份是同一條:
| 型別註冊表 | 選單定義 | |
|---|---|---|
| 讀者 | 只有 server 需要 | 只有 client 需要 |
| 內容 | 組件限定型別名 | 排序、標題、可見性 |
| 送不送出去 | 遠端取定義時直接擋下 | 本來就要送到 client |
| 結構 | 單層攤平,ProgId 為鍵 | 多層樹,節點另有全樹唯一的 Id |
型別名不送給 client 這件事不是額外加的防護,是分家自然的結果:client 要的是選單那一份,用不到註冊表,於是它可以比照 SystemSettings 與 DatabaseSettings 一起被擋在遠端取定義之外。
分家還擋掉一個具體問題。client 建選單時走訪的是定義給它的每一個項目,沒有「這支程式不該出現在選單上」這個概念,選單若直接長在註冊表上,System 與 AuditLog 就會變成兩個選單項,要迴避得替註冊表也加一個可見性旗標或保留一個分類。同樣是保留字的 AuditRule 卻該上選單,它是一張給人維護的表單。同一份清單裡有的要出現、有的不能出現,那就不是同一份清單。
註冊表裡寫的是組件限定型別名,也就是一段字串。打錯字、組件沒有部署到 host、型別改名之後忘了同步,都會讓解析拿不到型別。框架的處置一律是直接拋,差別只在什麼時候拋:
| 綁定 | 型別載不到或基底不符 | 什麼時候發現 |
|---|---|---|
保留字 ProgId 的 BusinessObject |
直接拋,而且啟動時就先驗一次 | 部署當下,host 起不來 |
一般 ProgId 的 BusinessObject |
直接拋 | 這支程式第一次被呼叫 |
任何 ProgId 的 Repository |
直接拋 | 這支程式第一次讀寫資料 |
不留退路,考量的不是嚴不嚴格,是故障的面貌。退回一個框架預設的型別,換到的只有「看起來還在跑」:FormBusinessObject 的建構子接受任何 ProgId,它會建構成功,於是那支程式安靜地表現得很通用,而 Order 本來該有的那些自訂方法一支都不在。呼叫端收到的是一句「找不到這個方法」,真正的成因卻在註冊表那一行字串上。
Repository 那一軸更不能退:綁定打錯,這支程式的讀寫就改跑作者刻意替換掉的通用 SQL,故障要等到資料已經錯了才浮出來。保留字那一道則把發現的時機往前挪到部署當下,System 綁錯就是登入這條路整條不通,與其讓 host 帶著一個錯的綁定開始接請求,不如讓它起不來。
「一個 ProgId 一個 BO 一個 Repository」在表單軌完全成立,在框架軌不成立。取得 Repository 的工廠因此是兩個方法而不是一個,IRepositoryFactory 上的兩行是這樣:
T CreateFormRepository<T>(Guid accessToken, string progId) where T : class, IDataFormRepository;
T Create<T>(Guid accessToken = default) where T : class;
兩個破口撐開了這道分岔。一是 System 這一個 ProgId 對應到多個 Repository,SystemBusinessObject 在同一個 ProgId 底下要用到 session、使用者、API 金鑰好幾張系統表,給了 ProgId 也決定不了該回哪一個。二是有一批消費者不是 BO,也拿不到 BO 那套脈絡:清理過期 session 的背景服務沒有請求、沒有 session、沒有令牌;解析員工脈絡的那一段跑在 session 還說不出自己在哪一家公司的時候,連要讀哪一個資料庫都得由參數帶進來,而且單一方法裡就要用兩個 Repository。替它們發明一個 ProgId 不會讓它們比較像 BO。
這一點完全不影響 BO 軸:一個 ProgId 對一個 BO 型別依然成立。兩個方法都是泛型,所以新增一張系統表不需要動這個介面,而被它取代的前身每加一張就長一個方法。
案例的註冊表是十一筆,ProgramSettings.xml 的項目全在這裡:
<ProgramSettings>
<Items>
<ProgramItem ProgId="System" DisplayName="System" />
<ProgramItem ProgId="Category" DisplayName="Categories" />
<ProgramItem ProgId="Supplier" DisplayName="Suppliers" />
<ProgramItem ProgId="Customer" DisplayName="Customers" />
<ProgramItem ProgId="Shipper" DisplayName="Shippers" />
<ProgramItem ProgId="Product" DisplayName="Products" />
<ProgramItem ProgId="Department" DisplayName="Departments" />
<ProgramItem ProgId="Employee" DisplayName="Employees" />
<ProgramItem ProgId="Order" DisplayName="Orders" BusinessObject="Bee.Northwind.Server.BusinessObjects.OrderBO, Bee.Northwind.Server" Repository="Bee.Northwind.Server.Repositories.OrderRepository, Bee.Northwind.Server" />
<ProgramItem ProgId="AuditLog" DisplayName="AuditLog" BusinessObject="Bee.Business.AuditLog.LogBusinessObject, Bee.Business" />
<ProgramItem ProgId="AuditRule" DisplayName="Audit Rules" />
</Items>
</ProgramSettings>
八張表單裡只有訂單那一筆兩欄都填了,其餘七張兩欄全空,於是全部落在框架預設的 FormBusinessObject 與 DataFormRepository。Day 3 說只有一張需要應用接手,這份檔案裡對應的就是那七筆什麼都沒填的項目。昨天出現過的那一句取得訂單 Repository 的程式,型別的來源正是 Repository 那一欄。
保留字那三筆也在這份清單上:System 與 AuditRule 沒填任何型別,各自解析到框架自己的那一個,AuditLog 則把那個名字寫了出來。保留字納入註冊表換到的就是這個:要接手其中一支,填上 BusinessObject 那一欄就好。
型別註冊表要回答的問題只有一個:這次呼叫由哪一個型別處理。
可以帶走的判準:一個型別註冊表真正要設計的不是「找得到的時候怎麼辦」,是「找不到的時候,故障會以什麼面貌出現」。三個綁定給的是同一個答案,因為退路能換到的只有「看起來還在跑」;差別只在什麼時候發現,保留字在部署當下,其餘在第一次用到那一刻。
明天談這些呼叫是以什麼協定送進來的:為什麼是 JSON-RPC 而不是 REST。
本系列同步發表於 HackMD,完整目錄