iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
Software Development

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

Day 16:JSON-RPC 2.0:協定選型與請求管線

  • 分享至 

  • xImage
  •  

Day 16:JSON-RPC 2.0:協定選型與請求管線

昨天談的是一個呼叫進來之後,框架怎麼知道要建哪一個型別。再往前推一格的問題是:這個呼叫本身長什麼樣子。一套上千張表單的系統對外要開出的方法數以千計,而其中絕大多數是同一組動作套在不同的表單上。協定要決定的是它們怎麼定址、參數怎麼帶、失敗怎麼回報。

框架的答案是一個 POST 端點、一個 method 字串、一條固定順序的管線。這三個決定各自有代價,而管線上有兩個位置是刻意排的。

本篇說明:

  1. 為什麼選 JSON-RPC 而不是 REST,這個選擇換到什麼、放棄什麼
  2. 兩層 payload 各裝什麼,以及為什麼每一支對外方法都只收一個參數
  3. 一次 Ping 的往返實際送了什麼,以及框架沒有照 JSON-RPC 2.0 規格做的四件事
  4. method 字串怎麼切成 ProgId 與 action,兩段各自怎麼解析
  5. 一次請求走過的固定站別,以及決定失敗形式的那一條分界

一、為什麼是 JSON-RPC,不是 REST

這是選擇不是結論。REST 與 RPC 兩邊都有跑了十幾年的系統,本篇不打算判高下,只說明這個框架的機制需求把它推向哪一邊。

動作的數量不跟著表單數走,資源的數量跟著。FormBusinessObject 上表單那一軸的 action 是六個,全部立成常數:

常數 用途
GetList 清單查詢
GetLookup 跨表關連的候選列查詢
GetNewData 取空白骨架
GetData 依列識別載入單筆
Save 依每列狀態寫入
Delete 依列識別刪除

八張表單是這六個,上千張表單還是這六個。變動的只有 ProgId 那一半,而 ProgId 昨天已經是型別註冊表的鍵。一個 method 就是這兩段接起來,多一張表單不必再開任何路由。

有一批動作不是資源的狀態轉換。昨天那個保留字 System 底下的 action 就不是 CRUD 的形狀,LoginLogoutEnterCompanyLeaveCompanyPingGetDefine 全在裡面。要把「進入某一家公司」對映成某個資源的建立或更新,得先發明一個資源出來。發明得出來,但那個資源是為了套進協定而存在的,不是系統裡本來就有的東西。

整個 body 是一份 JSON-RPC payload。請求的 params 與回應的 result 各自是一個欄位,裡面裝什麼、用什麼格式編碼,與方法本身無關。這是「傳輸格式可以整包換掉、而每一支方法的簽章都不必知道」的前提(留給 Day 22)。REST 把語意攤在路徑、動詞、狀態碼與 body 四個地方,能換掉的只有最後一個。

代價有兩筆:

  • HTTP 那一層的能力全部用不上。快取、閘道規則、速率限制都是看路徑與動詞決定的,而這裡只有一條路徑、一個動詞,要分辨是哪一支方法就得拆開 body。
  • 請求不好認。瀏覽器開發者工具裡每一筆都是 POST /api,光看清單分不出誰是誰。框架的補救是在回應裡把 method 送回去,而規格裡沒有這個欄位(第三節談)。

判別法不是哪個協定比較好,是這個系統對外的單位是資源還是程序。以表單為單位、以一組固定動作為介面的系統,答案偏向 RPC;以文件或實體為單位、且要吃到 HTTP 中介設施好處的系統,答案偏向 REST。

二、兩層 payload,以及每一支方法只收一個參數

協定選定之後,實際要決定的是那份 payload 裡放什麼。它分兩層:外面那一層照 JSON-RPC 2.0 的規格走,裡面那一層是這個框架自己加的。

外面那一層四個欄位:

欄位 型別 內容
jsonrpc 字串 固定 2.0
method 字串 ProgId.action
params 物件 一份 API payload,裝著這次呼叫的參數
id 字串 呼叫端自選,回應原樣送回

params 這一格與規格的距離最遠。它允許兩種形狀:陣列代表位置參數,物件代表具名參數。框架兩種都不是,它固定是一份 API payload:

欄位 型別 內容
format 整數 value 用哪一種形式編碼,0 是不編碼
value 任意 參數本體,呼叫端送出的 Request 物件,伺服端轉成方法宣告的 Args
type 字串 value 的型別名,要不要填由 format 決定

編碼那一半留給 Day 22,本篇只用到一件事:formattype 在 API payload 上,不在 value 裡面。

這不是繞過規格,是因為「具名還是位置」這個選擇在這裡不存在。每一支對外方法都只收一個參數:

public virtual PingResult Ping(PingArgs args)
public virtual GetListResult GetList(GetListArgs args)
public virtual SaveResult Save(SaveArgs args)

派發那一段以單一元素的引數陣列呼叫,所以「一個方法一個參數物件」不是慣例而是硬性要求。換到的是新增一個輸入欄位只要在 Args 型別上加一個屬性。簽章不動,既有覆寫不必重編,這是編譯期那一側。

執行期還換到一件事:一套 ERP 服役期間這種欄位會一路長出來,而還沒升級的呼叫端仍然叫得動這支方法,那個新屬性在它送來的請求裡就是宣告上的預設值(傳輸格式怎麼撐住這件事,是 Day 22 的題目)。代價是連只需要一個字串的方法也得先有一個型別。

三、一次 Ping 的往返,以及沒有照單全收的四件事

把上面那兩層填上實際的值,就是一次請求送出去的樣子。format 給 0,所以 value 是一棵讀得懂的 JSON 樹:

{
  "jsonrpc": "2.0",
  "method": "System.Ping",
  "params": {
    "format": 0,
    "value": { "clientName": "curl", "traceId": "t-001" },
    "type": ""
  },
  "id": "abc"
}

成功的回應:

{
  "jsonrpc": "2.0",
  "method": "System.Ping",
  "result": {
    "format": 0,
    "value": { "status": "ok", "serverTime": "…", "traceId": "t-001" },
    "type": ""
  },
  "id": "abc"
}

resultparams 是同一種 API payload,value 裡才是這支方法自己的回傳內容。兩個方向各有一份屬性清單,寫在昨天那三層裡最底下的合約介面上(節選自 IPingRequestIPingResponse):

public interface IPingRequest
{
    string? ClientName { get; }
    string? TraceId { get; }
}

public interface IPingResponse
{
    string Status { get; }
    DateTime ServerTime { get; }
    ApiKeyStatus ApiKeyStatus { get; }
    string? Version { get; }
    string? TraceId { get; }
}

欄位名就是屬性名,中間沒有一層對映設定:請求的 value 兩個欄位對上 IPingRequest,回應的 value 對上 IPingResponse,上面那段回應只節錄了它五個屬性裡的三個。traceId 在兩份清單裡都有,它是請求送進去的值原樣回來。伺服端的 PingArgs / PingResult 與呼叫端的 PingRequest / PingResponse 各自實作這兩個介面,所以 value 那一格在兩端讀的是同一份清單。

比對規格,框架有四處不一致:

JSON-RPC 2.0 規格 框架 照規格寫的 client 會遇到
request 可以是陣列,代表一次批次 只收單一物件 送批次在反序列化就失敗
沒有 id 的請求是 notification,伺服端不回應 一律回應;id 缺席時回應裡就沒有這個欄位 沒有「送出去不等回覆」這條路
id 可以是字串、數字或 null 宣告為字串 送數字的請求在反序列化就失敗
response 只有 jsonrpc / result / error / id 多回一個 method 多出一個規格沒有的欄位

前三件是規格有而框架沒有跟上,第四件是規格沒有而框架自己加的。協定在這裡的角色因此是格式的共同語言,不是相容性的保證。拿一份通用的 JSON-RPC client 函式庫直接接上去,會在批次與 id 型別這兩處碰壁。框架自己的 client 碰不到,因為兩端的 JSON-RPC payload 是同一份程式碼組出來的(那一側留給明天)。

四、method 怎麼變成一次方法呼叫

method 是一個字串。要把它變成「哪一個型別的哪一個方法」,得先決定字串怎麼切、兩段各自去哪裡查。切法決定了命名空間有多大,查法決定了打錯字的症狀長什麼樣。

切的部分出自 JsonRpcExecutor,就這幾行:

private static readonly char[] s_methodSeparators = new[] { '.' };

var parts = method.Split(s_methodSeparators, 2);
if (parts.Length == 2) { return (parts[0], parts[1]); }
throw new FormatException($"Invalid method format: {method}");

三件事寫在這裡:分隔符只有句點一種;上限是兩段,第二個句點之後的東西整段留在 action 裡;切不出兩段就是格式錯誤,沒有「省略 ProgId 走預設」這種寬容。

method 就是 ProgId 加 action。昨天那份註冊表只有一層,所以 method 只需要這兩段,命名空間就是平的;代價是 ProgId 裡不能有句點。

method ProgId action
System.Login System Login
Order.GetList Order GetList
System.Ping.Extra System Ping.Extra

System.Ping.Extra 那一列是上限兩段的直接後果:多打的那一段不會被當成錯誤擋下來,它會變成一個找不到的方法名。

切完之後兩段走不同的查法。而在這之前,還有一個地方拿整串 method 去比對,所以同一個字串在一次請求裡被三個機制讀過,而它們的大小寫規則不一樣:

讀它的地方 比對什麼 大小寫
傳輸層的免憑證清單 完整的 method 字串 區分
型別註冊表 ProgId 不分
反射 action 區分

三者各自都合理,湊在一起就給出誤導的症狀。實測一支不需要登入的方法:System.Ping 正常回應,system.Ping 只差一個字母,回來的是 HTTP 401。那個字串掉出了免憑證清單,在傳輸層就被擋下來,根本沒走到後面兩個查法。成因是大小寫,症狀說的是憑證。

這不是設計出來的規則,是三個機制各自的預設值湊出來的,沒有一處把它們統一,也沒有任何一道檢查會指出來。框架能做的是把對外的方法名稱立成常數(第一節那張表裡的六個就是),但那只擋得住自己這一側,手寫請求的前端拼的還是字串。

action 不進註冊表也是刻意的。新增一支對外方法,只要在 BO 上宣告成公開的,不必登記;代價是名字打錯要等到第一次被呼叫才知道。這和昨天回傳型別那條命名慣例是同一筆交換:用慣例換到零登記,把錯誤延到執行期。差別是昨天那條還有形狀可以比對,方法名字沒有。

五、一次請求走過的固定站別

請求進來之後的每一步都是固定的,沒有任何一支方法能插隊或跳站。整條管線橫跨兩個組件,前兩站在 HTTP 那一層,其餘在派發那一層。

HTTP 這一層(框架的 controller 基底)
  1  Content-Type 檢查 → 讀 body → 反序列化成請求物件 → method 不得為空
  2  依 method 判定要不要憑證 → 檢查憑證 → 解出存取令牌

派發這一層
  3  method 切成 ProgId 與 action
  4  ProgId → BO 實例(型別註冊表)
  5  action → 方法(反射)
  6  存取控制檢查
  7  還原 params.value
  8  轉成方法宣告的參數型別 → 呼叫
  9  回傳值轉成回應型別 → 放進 result

第 8 與第 9 站是昨天談過的兩個轉換器。

這條管線的設計全部落在兩個位置上。存取控制排在還原之前,第 6 站與第 7 站的相對位置是刻意的:驗證要先做,沒有通過的請求不做任何解密工作。這條順序讓一個沒有憑證的呼叫端無法用一份構造過的 payload 逼伺服器把解密與反序列化跑完。代價是存取控制那一段只能依賴 value 以外的東西,也就是 method、格式標記與憑證,看不到 value 裡面裝了什麼(這道檢查本身的語意屬於 Day 23)。

失敗落在第 2 站與第 3 站之間的哪一邊,決定它以什麼形式回報。

落點 語意 HTTP 狀態碼
第 1、2 站 這個請求本身不合格 4xx
第 3 站之後 請求已受理,這次呼叫失敗 200

兩邊回的都是同一種 JSON-RPC payload,差別只在狀態碼。不是 JSON、沒有 method、憑證不過,這些在請求被受理之前就退掉了,用 HTTP 狀態碼回報;受理之後的每一種呼叫失敗,包含找不到方法、權限不足、業務規則擋下來,一律 HTTP 200,錯誤裝進 error 欄位。

error 是一個三欄的物件:

欄位 型別 內容
code 整數 錯誤碼
message 字串 錯誤訊息
data 任意 補充資訊,多數路徑不填

resulterror 互斥,一次回應只會有其中一個帶值。錯誤碼一部分是 JSON-RPC 2.0 自己的標準碼,其餘取的是規格留給伺服端自訂的那一段,至於各個碼的語意、哪些訊息可以原樣送到使用者面前、client 怎麼依碼把它翻回一個例外,是 Day 18 的題目。

這條分界的實務後果落在維運那一側:監控與閘道看得到的只有第一段。一次被業務規則擋下來的存檔,在 HTTP 那一層是一筆成功的請求,要看見它得讀 body。這是選了 RPC 之後必然要接受的代價之一,跟第一節那兩筆是同一件事的不同位置。

回到 Northwind

案例把這個端點開出來的程式碼在 ApiController

public class ApiController : ApiServiceController
{
}

整個檔案就是這幾行加上一段註解,路由、POST handler、請求解析、憑證檢查、派發全部在框架的基底類別裡。之所以還要這個空子類,是因為框架那一支是抽象類別,controller 的探索挑不到它。

這幾行換到的是每一站都可以替換:ReadRequestAsyncValidateAuthorizationHandleRequestAsync 都是 protected virtual,而案例一個都沒有覆寫。一次 System.Ping 回得出來,第五節那九站就全部走過一遍,而為此寫的程式碼就是上面那個空類別。Day 3 說八張表單只有一張需要應用接手,那是業務邏輯那一側;傳輸這一側連那一張都不必接手。

小結

一份協定真正在規範的不是欄位叫什麼名字,是一次呼叫的邊界:什麼算是一個請求、它在哪一刻算被受理、被受理之後的失敗要用什麼形式回報。

  • 選 JSON-RPC 是因為動作的數量不跟著表單數走,而且有一批動作不是資源的狀態轉換;代價是 HTTP 那一層的能力全部用不上
  • params 那一格裝的是一份 API payload,裡面固定是一個參數物件,因為每一支對外方法都只收一個參數
  • method 這個字串在一次請求裡被三個機制讀過,三個的大小寫規則不一樣,而症狀會出現在最早的那一個
  • 失敗落在請求受理之前還是之後,決定它走 HTTP 狀態碼還是走 error 欄位

可以帶走的判準來自第三節那張對照表:一份實作偏離公開規格時,付代價的不是實作的人,是照著規格寫 client 的那個人。那四項各自都是合理的取捨,但沒有一項會在文件以外的地方被告知。偏離本身不是問題,不寫下來才是。

明天談另一側:同一份合約,桌面端、瀏覽器端與同一個行程內的呼叫,怎麼收斂成同一條路徑。


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


上一篇
Day 15:API 合約分層與型別註冊表
下一篇
Day 17:Connector 與呼叫端的職責收斂
系列文
ERP 架構師筆記:定義驅動的框架設計26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言