
昨天談的是一次呼叫送進伺服端之後怎麼被處理。同一份合約在呼叫端這一側也得有人負責:有的呼叫端連的是網路另一頭的伺服器,有的跑在後端邏輯的同一個行程裡,兩邊要呼叫的卻是同一支方法。
中間那些跟業務無關的事如果留給每一個前端各自處理,有幾個前端就會有幾種寫法,而且同一個錯會在每一個前端各自發生一次。框架的答案是在呼叫端也放一層 Connector,所有前端都經過它。
本篇說明:
一次存檔從畫面出發、到資料庫為止,中間經過的層是固定的:
呼叫端
畫面
↓
DataObject:框架提供的 ViewModel,包住 DataSet 供畫面繫結(Day 2)
↓
Connector(本篇)
↓ 一次 JSON-RPC 呼叫
伺服端
API 派發:method 切成 ProgId 與 action(Day 16)
↓
BO:ProgId 從型別註冊表換來的那一個(Day 15)
↓
Repository:依 FormSchema 現組增修刪查的語句(Day 5)
↓
資料庫
圖上那一層 Connector,是呼叫端這一側處理 JSON-RPC 呼叫的角色:一次呼叫怎麼組成請求、怎麼送出去、回應怎麼還原,都由它負責,而前端不必知道另一端是網路另一頭還是同一個行程。
畫面那一層想做的事只有一句「把這張表單存回去」。要讓這句話真的成立,中間有一串與這張表單無關的事,而且每一件都必須做對。
| 這一件事 | 收斂之後由誰做 | 不收斂的話由誰做 |
|---|---|---|
| 定址 | ProgId 與 action 接成 method | 每個呼叫點自己拼字串 |
| 身分 | 令牌與 API 金鑰放進該放的位置 | 每次呼叫自己組標頭 |
| 傳輸 | 依模式挑一條路送出並收回 | 每個前端各自接一次 |
| 編碼 | 決定這一次要不要編碼與加密 | 每個前端各實作一次 |
| 時區 | 出去換成 UTC、回來換回使用者的時區 | 每個畫面各換一次,還要記得換 |
| 回傳 | API payload 裡的內容轉成呼叫端宣告的型別 | 自己反序列化 |
| 錯誤 | 錯誤欄位翻回一個例外 | 每個呼叫點自己判錯誤碼 |
七件事全部收在 ApiConnector 的 ExecuteAsync 這一條路上。拿掉追蹤與例外包裝之後,它的骨架是這樣:
protected async Task<T> ExecuteAsync<T>(string progId, string action, object value, PayloadFormat format)
{
var timeZoneId = UserTimeZoneId;
T result;
using (PayloadZoneConverter.ToUtc(value, timeZoneId))
{
var (request, actualFormat) = PrepareRequest(progId, action, value, format);
var response = await this.Provider.ExecuteAsync(request);
result = FinalizeResponse<T>(response, actualFormat);
}
PayloadZoneConverter.ToUserZone(result, timeZoneId);
return result;
}
PrepareRequest 組出來的那份請求,Method 就是 $"{progId}.{action}",也就是昨天談過的兩段式 method,而切它的那一段在伺服端。兩端的 JSON-RPC payload 是同一份程式碼組出來的,所以昨天那四處與規格的距離,框架自己的呼叫端一處都碰不到。FinalizeResponse 是把錯誤欄位翻回例外的地方,每個前端因此不必各自認得錯誤碼;那些碼各自是什麼意思,是明天的題目。
這七件事不只是一份清單,它們的先後是固定的。時區要在編碼之前換完,編碼之後 params 那一格裡已經是位元組;回程的解碼要在轉成型別之前。
其中一對是刻意排的:錯誤在解碼之前就判讀了,因為錯誤欄位掛在 JSON-RPC payload 本身、不在被編碼的那一格裡,一次失敗的呼叫不必先解一次密才知道自己失敗了。伺服端那條管線也有一處這樣刻意排的順序,呼叫端這一條短得多,道理是同一個。
昨天那條受理分界在呼叫端也有另一面:受理之前的失敗回的是 HTTP 狀態碼、受理之後走錯誤欄位,而呼叫端送出請求的那一句會檢查狀態碼,於是兩種失敗在這裡是兩個不同的 catch。
基底類別只管上面那條路,ProgId 從哪裡來由子類決定。
| 型別 | ProgId 從哪來 | 負責哪一類呼叫 |
|---|---|---|
ApiConnector |
呼叫時傳入 | 抽象基底,不直接使用 |
SystemApiConnector |
固定 System |
系統層級:登入、進出公司、取定義 |
LogApiConnector |
固定 AuditLog |
系統層級:軌跡查詢 |
FormApiConnector |
建構子帶進來 | 表單層級:任何一張表單 |
兩個固定值都是 Day 15 那份型別註冊表裡的保留字。保留字在伺服端是註冊表的鍵,在呼叫端則是一個不需要參數的建構子,同一個決定的兩面。
表單層級的六個 action,在 FormApiConnector 上各有一支對應的方法:GetListAsync、GetLookupAsync、GetNewDataAsync、GetDataAsync、SaveAsync、DeleteAsync。昨天說八張表單與上千張表單都是那六個 action,這裡是它在呼叫端的形狀:上千張表單共用同一個型別,差別只在建構時給的那個字串。
一套 ERP 的呼叫端不會只有一種部署形狀,桌面、瀏覽器與伺服端自己的背景服務都算。把它們分成兩類的是與後端邏輯之間隔著什麼:
框架的處理是把「怎麼送出去」抽成一個介面,而這個介面只有一支方法:Task<JsonRpcResponse> ExecuteAsync(JsonRpcRequest request)。兩個實作。遠端那一支(RemoteApiProvider)把請求轉成 JSON、貼上兩個標頭、POST 出去、把回來的字串還原:
var headers = new NameValueCollection
{
{ ApiHeaders.ApiKey, ApiClientInfo.ApiKey },
{ ApiHeaders.Authorization, $"Bearer {AccessToken}" }
};
string json = await HttpUtilities.PostAsync(Endpoint, request.ToJson(), headers);
return JsonCodec.Deserialize<JsonRpcResponse>(json)!;
送出用的 HTTP 用戶端不是每次新建,依「協定加主機加通訊埠」共用一份;瀏覽器環境另走一條,因為那裡的連線集區由瀏覽器自己管。
近端那一支(LocalApiProvider)從行程內解析出昨天那個派發器,把令牌設上去,直接呼叫:
executor.AccessToken = AccessToken;
executor.IsLocalCall = true;
return await executor.ExecuteAsync(request);
Day 13 說 BO 的建構子第四個參數是「是否為同行程呼叫」,那個旗標的兩端在這裡才接得起來:近端這一支明確設成 true,走 HTTP 進來的那一條維持預設的 false,中間一路傳到工廠建 BO 的那一行。伺服端有幾支只接受同行程呼叫的維護方法,判準就是它(語意屬 Day 23)。
近端呼叫預設連編碼與加密都不做,格式定成 Plain,那一整段不執行。資料從頭到尾沒有離開過記憶體,那一段的成本在同一個行程裡換不到任何東西。
啟用偵錯模式時,近端呼叫照樣走完整條編碼與加密,於是開發時想確認自己那些資料序列化得正不正確,在同一個行程裡就驗得完,不必為此架一台伺服器。
ConnectType 這個列舉記的是決定,實際生效的是用了哪一個建構子:
protected ApiConnector(Guid accessToken, ApiSessionContext session) // 沒有位址,近端
protected ApiConnector(string endpoint, Guid accessToken, ApiSessionContext session) // 有位址,遠端
位址從何而來、要不要走遠端,在更前面就決定了。ApiConnectValidator 拿到設定的位址,是本機路徑就當近端、是網址就先探一次連通性再打一次 Ping;SupportedConnectTypes 是應用自己的宣告,一個只允許遠端的應用拿到本機路徑會在這裡被擋下,而不是等到第一次呼叫失敗。
兩條路的差別不只是「有沒有走網路」。
| 遠端 | 近端 | |
|---|---|---|
| 另一端是誰 | 一個 HTTP 端點 | 同一個行程裡的派發器 |
| 序列化 | JSON 進、JSON 出 | 完全沒有 |
| 編碼與加密 | 依這一次的要求 | 預設整段跳過 |
| 令牌怎麼帶 | Authorization 標頭 |
派發器上的一個屬性 |
| 送出的物件 | 對方拿到的是複本 | 對方拿到的是同一個物件 |
最後一列才是真正要處理的那一件。走網路時,序列化順手做出一份複本,呼叫端後續怎麼用自己那一份都不影響對方。同行程呼叫沒有這道手續,於是遠端免費得到的東西,近端要手工補回來。
框架補的方式是在時區轉換那一段換掉、用完換回去(PayloadZoneConverter):
case SaveRequest request when request.DataSet != null:
{
var original = request.DataSet;
request.DataSet = DateTimeZoneConverter.UserToUtc(original, timeZoneId);
return new PayloadSwap(() => request.DataSet = original);
}
換上去的是一份新的資料,原本那一份在呼叫結束時放回去。少了這一步,呼叫端手上的資料會被換成 UTC 那一份,然後拿去重畫畫面。回程同理,回應裡的資料是複製一份出來改,不是就地改,因為同行程時那個物件是伺服端的。
兩條路要讓呼叫端看不出差別,代價就是這類「其中一條本來就有、另一條要自己造」的細節。少做一件,症狀不是報錯,是畫面上的值不對。
要對兩條路都成立的事,只能放在它們唯一都會經過的地方,也就是 Connector。時區換算就是這樣掛上去的,換算本身是第七章的題目。
Connector 用到的那些狀態要放在哪裡,答案取決於一個行程裡有幾個使用者。
桌面應用一個行程只有一個使用者,登入拿到的傳輸金鑰與時區放在靜態屬性上完全正確,也最省事。伺服器渲染的網頁前端不是:同一個行程同時服務多條連線、每一條各自登入,而靜態屬性只有一組。後登入的人會蓋掉前一個人的金鑰,被蓋掉的那些人接下來每一次呼叫都用別人的金鑰加密,直到重新登入為止。
框架因此把「屬於一個登入使用者」的狀態收成一個型別 ApiSessionContext,裡面只有兩個值:這條連線的傳輸金鑰,和這個使用者的時區。
判準比機制本身更通用:
| 這個值的擁有者是 | 例子 | 該放哪裡 |
|---|---|---|
| 這個應用本身 | API 金鑰、服務位址 | 整個行程一份 |
| 一個登入的使用者 | 傳輸金鑰、時區 | 逐個 session 一份 |
| 這一次呼叫 | 存取令牌 | 建構子參數,綁在那個 Connector 上 |
應用那一列是容易做錯的方向。API 金鑰識別的是「哪一個應用在呼叫」而不是「誰在呼叫」,每條連線的值都一樣,把它也切成逐 session 一份不會比較安全,只是把擁有者認錯了。
令牌那一列是第三種擁有者:它既不是應用層的,也不是掛在 session 狀態上的,而是每一個 Connector 建構時就帶進去、之後不能換。要換身分就換一個 Connector。遠端路徑把它變成 Bearer 標頭、近端路徑把它設在派發器上,呼叫端不需要知道是哪一種。
兩個前端家族的答案不一樣,源頭就是上面那個「一個行程裡有幾個使用者」。
| 桌面家族 | 伺服器渲染家族 | |
|---|---|---|
| 令牌住在哪 | 一個靜態欄位 | 一個逐連線的元件,往下層層傳遞 |
| session 狀態 | 整個行程共用一份 | 逐連線各一份 |
| Connector 怎麼來 | 靜態方法現建 | 注入一個逐連線的工廠 |
| 換令牌 | 連帶丟掉快取的 Connector 與定義快取 | 重新渲染,下層自己重取 |
換令牌那一列是前面那條規則的直接後果:令牌在建構的時候就綁定、之後換不了,所以換一次令牌等於把手上那個 Connector 整個換掉。
案例有四個前端:桌面、瀏覽器、iOS、Android。四個都跑同一個 Avalonia UI 專案,各自只有一段開機程式碼,而那段程式碼裡與本篇有關的只有三件事:宣告只支援遠端、指定位址與金鑰要存到哪、把出廠金鑰當第一次執行的種子。UI 專案的專案檔裡沒有 Bee.Api.Client 這一筆,它要用的 Connector 由框架的 UI 組件建好之後交給它。
四個前端共用一個 UI 專案,這件事之所以成立,一部分就是因為呼叫端這一層把差異吃掉了。唯一走出不同路徑的是瀏覽器那一頭:那個執行環境產不出 RSA 金鑰對,於是登入時的金鑰交換整段跳過,後續要求加密的呼叫由 Connector 自動降一級。呼叫的程式碼一個字都不用改。
案例示範不出來的有兩件。四個前端全部宣告只支援遠端,所以同行程那條路徑在這套應用裡一次都沒有被走到,它活在測試與示範程式裡。案例也沒有伺服器渲染的前端,所以「一個行程好幾個使用者」那條線在這裡連發生的條件都沒有。
把呼叫端該做的事收成一層,換到的是「可能寫錯的地方」從前端的數量變成一個。
最該帶走的是第三點,因為這條線畫錯的兩個方向不對稱。把應用層的值切成逐 session 一份,代價只是多幾份一模一樣的副本;把使用者的值放成整個行程共用,代價是另一個人的資料。前者浪費,後者是事故,而兩者在單人測試下的表現完全相同。
明天談呼叫失敗的那一半:一個業務規則擋下來的存檔,要用什麼形式讓呼叫端知道,而哪些訊息可以原樣送到使用者面前。
本系列同步發表於 HackMD,完整目錄