iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Software Development

ERP 架構師筆記:定義驅動的框架設計系列 第 15

Day 15:API 合約分層與型別註冊表

  • 分享至 

  • xImage
  •  

Day 15:API 合約分層與型別註冊表

昨天談的擴充點,全部掛在某一個具體的 Business Object 上。往前推一步的問題是:一個呼叫進到後端,框架怎麼知道要建哪一個型別、那個型別收到的參數又長什麼樣子。

這兩件事在多數專案裡都是寫死的。型別分派寫成一個 switch,參數型別直接拿傳輸用的那一份給業務邏輯用。上千張表單的系統付不起這兩筆:前者每加一支程式就要改程式碼重新部署,後者讓 client 引用得到業務邏輯層的內部屬性。

本篇說明:

  1. 合約介面、API 型別與 BO 型別三層各自的職責,以及它們的相依方向
  2. ProgId 作為型別註冊表的鍵
  3. 註冊表為何是單層攤平的清單,選單為何另立一份定義
  4. BO 與 Repository 兩軸解析不到型別時的處置,以及為什麼不留退路

一、同一份屬性清單,兩個互不相識的實作

同一個 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 方法簽章

以表單存檔為例,ISaveRequestSaveRequestSaveArgs 三個宣告攤開來是這樣:

// 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.CoreBee.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 是註冊表的鍵

一個請求帶著 ProgId 進來,框架要拿它換到一個 BO 的型別。註冊表登記的是那個物件,至於它背後有沒有一張表單,是另一件事。這個對應寫在程式碼裡也行,寫在定義檔裡也行,差別在於加一支程式要不要重新部署。

框架的參照來源是 COM+ 的登錄模型:以機碼登錄 ProgID 與它對應的元件型別,一個 ProgID 代表一個獨立的功能或程式。這個模型有一個常被忽略的特徵:登錄檔只管「ProgID 換到哪一個型別」,完全不管這個程式在功能表的哪個位置。

框架延續了這個模型,一個 ProgId 綁定一個 BO,於是有三個直接推論:

  1. 框架自己的 SystemBusinessObjectLogBusinessObject 也在裡面,它們不對應任何一張表單
  2. Repository 比照辦理,同一個 ProgId 底下同時綁定它的 BO 與它的 Repository
  3. 客製化以 ProgId 為單位覆寫型別,套裝與客製各自獨立(機制留給 Day 19)

Day 13 提過所有 BO 共用同一個建構子簽章,工廠不必先知道要建的是哪一個家族。原因在這裡:工廠手上只有一個 ProgId 和一份註冊表,家族是解析出來的結果,不是它的輸入。

保留字 ProgId 也在裡面

SystemAuditLogAuditRule 也是 ProgId,跟其他程式一樣登錄在註冊表裡、走同一條解析路徑,傳輸層沒有為它們留任何分支。連 SystemBusinessObject 都走同一條客製路徑,換的單位一樣是 ProgId。

代價是啟動時的懸崖。註冊表是唯一來源,缺項就解析不到,而這幾筆是框架自己的東西,沒有道理要求每一個 host 手寫進自己的 ProgramSettings.xml。解法沿用 COM+ 的另外一半:元件安裝的時候自己寫登錄檔。框架在啟動時逐筆檢查保留字,缺哪一筆補哪一筆,然後驗證每一筆都解析得到可用的型別。

逐筆而不是逐檔,是因為用「檔案存不存在」當判斷,會正好跳過那些已經有 ProgramSettings.xml、只是裡面沒有 System 的部署。寫回檔案則只決定下一次啟動要不要再做一次,沒登記的保留字本來就解析得到框架自己的型別,所以唯讀部署寫不進去也照樣起得來。

三、註冊表攤平,選單分家

註冊表的前提是 ProgId 是唯一的鍵。這份清單因此是單層攤平的,唯一性由鍵機制本身保證:集合以 ProgId 為鍵(不分大小寫),重複的項目在載入的當下就被擋下來,查找是一次 key lookup。分類的概念只留在選單定義裡,兩邊不會不同步。

選單另立一份定義是另一個決定,理由與 Day 4 談 DatabaseSettingsDbCategorySettings 為什麼分成兩份是同一條:

型別註冊表 選單定義
讀者 只有 server 需要 只有 client 需要
內容 組件限定型別名 排序、標題、可見性
送不送出去 遠端取定義時直接擋下 本來就要送到 client
結構 單層攤平,ProgId 為鍵 多層樹,節點另有全樹唯一的 Id

型別名不送給 client 這件事不是額外加的防護,是分家自然的結果:client 要的是選單那一份,用不到註冊表,於是它可以比照 SystemSettingsDatabaseSettings 一起被擋在遠端取定義之外。

分家還擋掉一個具體問題。client 建選單時走訪的是定義給它的每一個項目,沒有「這支程式不該出現在選單上」這個概念,選單若直接長在註冊表上,SystemAuditLog 就會變成兩個選單項,要迴避得替註冊表也加一個可見性旗標或保留一個分類。同樣是保留字的 AuditRule 卻該上選單,它是一張給人維護的表單。同一份清單裡有的要出現、有的不能出現,那就不是同一份清單。

四、一個 ProgId 綁兩個型別,兩個都不留退路

註冊表裡寫的是組件限定型別名,也就是一段字串。打錯字、組件沒有部署到 host、型別改名之後忘了同步,都會讓解析拿不到型別。框架的處置一律是直接拋,差別只在什麼時候拋:

綁定 型別載不到或基底不符 什麼時候發現
保留字 ProgId 的 BusinessObject 直接拋,而且啟動時就先驗一次 部署當下,host 起不來
一般 ProgId 的 BusinessObject 直接拋 這支程式第一次被呼叫
任何 ProgId 的 Repository 直接拋 這支程式第一次讀寫資料

不留退路,考量的不是嚴不嚴格,是故障的面貌。退回一個框架預設的型別,換到的只有「看起來還在跑」:FormBusinessObject 的建構子接受任何 ProgId,它會建構成功,於是那支程式安靜地表現得很通用,而 Order 本來該有的那些自訂方法一支都不在。呼叫端收到的是一句「找不到這個方法」,真正的成因卻在註冊表那一行字串上。

Repository 那一軸更不能退:綁定打錯,這支程式的讀寫就改跑作者刻意替換掉的通用 SQL,故障要等到資料已經錯了才浮出來。保留字那一道則把發現的時機往前挪到部署當下,System 綁錯就是登入這條路整條不通,與其讓 host 帶著一個錯的綁定開始接請求,不如讓它起不來。

Repository 這一軸為什麼不能完全 ProgId 化

「一個 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 型別依然成立。兩個方法都是泛型,所以新增一張系統表不需要動這個介面,而被它取代的前身每加一張就長一個方法。

回到 Northwind

案例的註冊表是十一筆,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>

八張表單裡只有訂單那一筆兩欄都填了,其餘七張兩欄全空,於是全部落在框架預設的 FormBusinessObjectDataFormRepository。Day 3 說只有一張需要應用接手,這份檔案裡對應的就是那七筆什麼都沒填的項目。昨天出現過的那一句取得訂單 Repository 的程式,型別的來源正是 Repository 那一欄。

保留字那三筆也在這份清單上:SystemAuditRule 沒填任何型別,各自解析到框架自己的那一個,AuditLog 則把那個名字寫了出來。保留字納入註冊表換到的就是這個:要接手其中一支,填上 BusinessObject 那一欄就好。

小結

型別註冊表要回答的問題只有一個:這次呼叫由哪一個型別處理。

  • 合約介面決定屬性有哪些,API 型別與 BO 型別各自決定怎麼被送出去、怎麼被業務邏輯使用,兩邊互不相識
  • ProgId 是那個鍵,一個呼叫進來,型別由註冊表決定,不由程式碼裡的分支決定
  • 攤平成一層,是為了讓唯一性由結構保證,而不是靠人記得別把同一個 ProgId 寫兩次

可以帶走的判準:一個型別註冊表真正要設計的不是「找得到的時候怎麼辦」,是「找不到的時候,故障會以什麼面貌出現」。三個綁定給的是同一個答案,因為退路能換到的只有「看起來還在跑」;差別只在什麼時候發現,保留字在部署當下,其餘在第一次用到那一刻。

明天談這些呼叫是以什麼協定送進來的:為什麼是 JSON-RPC 而不是 REST。


本系列同步發表於 HackMD,完整目錄


上一篇
Day 14:業務物件的擴充點與覆寫時機
下一篇
Day 16:JSON-RPC 2.0:協定選型與請求管線
系列文
ERP 架構師筆記:定義驅動的框架設計26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言