Polhem.JsonRpc 介紹,與常見 .NET JSON-RPC 套件的差異

JSON-RPC 2.0 是一個很小的規格:用戶端送出 {"jsonrpc":"2.0","method":"...","params":{...},"id":1},伺服器回 result 或 error。比起 REST 要決定路由、HTTP 動詞與狀態碼,它只關心「呼叫哪個方法、帶什麼參數」,前後端都自己掌握的 API 用起來很順手。
Polhem.JsonRpc 是一組 .NET 的 JSON-RPC 2.0 套件,包含與傳輸無關的伺服器、ASP.NET Core 端點與用戶端,只用 System.Text.Json。它原本是 Polhem 框架的 API 層,這次抽成獨立套件,不相依框架,其他專案也能直接引用。原始碼在 GitHub,以 MIT 授權開源,套件可從 NuGet 取得。
null。拆成多個 NuGet 套件,應用程式只引用需要的部分:
| 套件 | 用途 |
|---|---|
| Polhem.JsonRpc | 伺服器與用戶端共用的訊息型別、錯誤碼與傳輸抽象 |
| Polhem.JsonRpc.Server | dispatcher:方法解析、參數繫結、filter |
| Polhem.JsonRpc.AspNetCore | ASP.NET Core 端點 MapJsonRpc |
| Polhem.JsonRpc.Client | 用戶端 JsonRpcConnector |
| Polhem.JsonRpc.Payload | 選用:payload 外殼、壓縮、加密與防重放 |
| Polhem.JsonRpc.Payload.Client | 選用:每次呼叫自動封裝參數、開啟結果的用戶端 |
| Polhem.JsonRpc.Payload.Server | 選用:在伺服器端開啟外殼並檢查重放 |
伺服器引用 Polhem.JsonRpc.AspNetCore,用戶端只引用 Polhem.JsonRpc.Client。拆開是為了讓手機上的用戶端不必連帶引用 ASP.NET Core,以及靠反射運作的 dispatcher。
Native AOT 是發佈時預先編譯成原生碼,執行期不能依賴反射,iOS 與 WebAssembly 都需要它。共用套件、用戶端與 payload 套件都標示為 AOT 相容,CI 也會用 Native AOT 實際發佈一個測試程式來跑。伺服器套件靠反射找方法,所以不支援 AOT。目標框架是 net10.0。
伺服器端用的是 ASP.NET Core 的 Minimal API(直接在 Program.cs 用 MapXxx 對應端點,不經過 MVC controller)。MapJsonRpc 會在指定路徑建立一個 POST 端點,所有 JSON-RPC 呼叫都從這裡進來,不需要 AddControllers。
Program.cs 只需要三件事:註冊物件工廠、註冊 JSON-RPC 服務、對應端點。
using Polhem.JsonRpc.AspNetCore;
using Polhem.JsonRpc.Server;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IJsonRpcObjectFactory, AppObjectFactory>();
builder.Services.AddJsonRpcServer();
var app = builder.Build();
app.MapJsonRpc("/api");
await app.RunAsync();
AddJsonRpcServer 可以傳入兩組設定:第一組是伺服器本身,例如 filter、method policy、batch 上限、序列化選項;第二組是 HTTP 端點,例如 request body 的大小上限、錯誤要回哪個 HTTP 狀態碼。不傳就用預設值。
builder.Services.AddJsonRpcServer(
options =>
{
options.MaxBatchSize = 50;
options.Filters.Add(new ApiKeyFilter(apiKey)); // 見第 5 節
},
http => http.MaxRequestBodySize = 1024 * 1024);
MapJsonRpc 的回傳值和其他 Minimal API 端點一樣,授權、CORS、速率限制都照平常的寫法接在後面,例如 app.MapJsonRpc("/api").RequireAuthorization();。
物件工廠與處理呼叫的類別:
public sealed class AppObjectFactory : IJsonRpcObjectFactory
{
public object? CreateObject(string progId, JsonRpcRequestContext context) => progId switch
{
"Calculator" => new Calculator(),
_ => null,
};
}
public sealed class Calculator
{
public AddResponse Add(AddRequest request) => new() { Sum = request.A + request.B };
}
using var http = new HttpClient { BaseAddress = new Uri("http://localhost:5080/api") };
var rpc = new JsonRpcConnector(new HttpTransport(http));
var added = await rpc.InvokeAsync<AddResponse>("Calculator.Add", new AddRequest { A = 1, B = 2 });
這樣就能呼叫 Calculator.Add 了。注意伺服器端沒有 AddMethod 之類的註冊,Calculator 上也沒有任何 attribute。
不用註冊很方便,但也會讓人擔心:哪天為了內部用途在類別上加了一個 public 方法,是不是從網路上也叫得到?伺服器收到 Calculator.Add 時,會依序做下面幾項檢查。
名稱格式。 方法名稱必須是 ProgId.Action。ProgId(程式識別碼)只能用英數字、底線、連字號,action 只能用英數字、底線,各最多 64 字。格式不對的名稱,在查找之前就會被擋下。
物件由應用程式決定。 ProgId 會交給你實作的 IJsonRpcObjectFactory.CreateObject,由它決定要建立哪個物件,回傳 null 代表不認得。套件本身沒有名稱登錄表,有哪些 ProgId 完全由工廠決定。呼叫結束後,物件會交給 ReleaseObjectAsync 釋放。
方法簽章。 action 只會對應到 public、非泛型、恰好一個參數的 instance 方法。static 方法、屬性存取子、object 宣告的方法都不算。如果有兩個以上同名且符合條件的方法,伺服器不會從中挑一個,而是當作找不到。
命名約定。 參數型別要叫 {Action}Request,回傳型別(或 Task<T>、ValueTask<T> 的結果型別)要叫 {Action}Response。Add 就要收 AddRequest、回 AddResponse。這是預設的 method policy,不符合的 public 方法一律視為不存在,回 -32601 Method not found。內部用的輔助方法只要不照這個命名,就不會被外部呼叫到。
這個規則可以透過 JsonRpcServerOptions.MethodPolicy 替換,Polhem 框架就換成讀取自己的存取控制 attribute。
policy 先於 filter。 method policy 在所有 filter 之前執行,所以授權檢查、解密這類 filter 只會處理允許執行的呼叫。
通過這些檢查後,才會繫結 params。params 必須是 JSON 物件,預設以 camelCase 名稱反序列化成 request 類別;沒有 params 或傳陣列(位置參數)都回 -32602 Invalid params。方法裡要回傳自訂錯誤就丟 JsonRpcErrorException;其他例外一律回 -32603 Internal error,不會把例外訊息傳給用戶端。
伺服器回傳的錯誤會變成 JsonRpcErrorException,可以讀到錯誤碼與訊息:
try
{
await rpc.InvokeAsync<DivideResponse>("Calculator.Divide", new DivideRequest { Dividend = 1, Divisor = 0 });
}
catch (JsonRpcErrorException ex)
{
Console.WriteLine($"Error {ex.Code}: {ex.Message}");
}
notification 與 batch:
// 伺服器會執行,但不回應。
await rpc.NotifyAsync("Calculator.Log", new LogRequest { Message = "Hello" });
// 兩個呼叫放在同一則訊息送出。
var batch = rpc.CreateBatch();
var first = batch.Add<AddResponse>("Calculator.Add", new AddRequest { A = 2, B = 3 });
var second = batch.Add<AddResponse>("Calculator.Add", new AddRequest { A = 4, B = 5 });
await batch.SendAsync();
Console.WriteLine($"{(await first)!.Sum}, {(await second)!.Sum}");
request 與 response 類別通常放在兩端共用的專案,repo 的範例就是這樣安排的。
伺服器端的 filter 包在每個呼叫外層執行,可以拒絕呼叫,也可以在執行前改寫參數、執行後改寫結果。例如檢查 API key:
public sealed class ApiKeyFilter(string expectedKey) : IJsonRpcFilter
{
public ValueTask InvokeAsync(JsonRpcRequestContext context, JsonRpcFilterDelegate next)
{
context.Transport.Headers.TryGetValue("X-Api-Key", out var key);
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(key ?? ""), Encoding.UTF8.GetBytes(expectedKey)))
{
throw new JsonRpcErrorException(-32001, "Unauthorized");
}
return next(context);
}
}
builder.Services.AddJsonRpcServer(options => options.Filters.Add(new ApiKeyFilter(apiKey)));
用戶端要加 HTTP header,用 HttpClient 原本的 DelegatingHandler 就好,套件沒有另外包一層。要在每次呼叫時改寫參數與結果,則加一個 IJsonRpcClientInterceptor。
Polhem 框架的 session 與授權檢查,都是透過物件工廠、method policy、filter 這幾個擴充點接上的,套件本身不包含這些邏輯。
HTTPS 保護的是連線。如果參數與結果還需要端對端保護,或是同一個呼叫不能被重送後再執行一次,可以加上選用的 payload 套件。
它把 params 與 result 放進一個外殼,外殼有三種格式:一般 JSON、編碼(序列化後 gzip 壓縮)、加密(AES-256-CBC 加 HMAC-SHA256,先加密再算訊息驗證碼)。另外還可以在內容前面加一段 frame,記錄時間戳與序號,讓伺服器拒絕過期或重複的請求,達到防重放的效果。
// 伺服器:金鑰與哪些方法要加密、要防重放,由應用程式的 policy 決定。
builder.Services.AddJsonRpcServer(options => options.UsePayload(payloadOptions, new MyPayloadPolicy()));
// 用戶端:寫法和一般呼叫相同,每次呼叫自動封裝參數、開啟結果。
var rpc = new PayloadConnector(connector, new PayloadProcessor(payloadOptions),
new PayloadConnectorOptions { KeyProvider = () => sessionKey });
var added = await rpc.InvokeAsync<AddResponse>("Calculator.Add", request);
安全上還有幾個細節:
金鑰怎麼協商(登入後交換、預先配置……)不在套件範圍內,由應用程式決定。
.NET 上的 JSON-RPC 套件不少,這裡挑兩個常見的來比較。表頭是 2026 年 10 月 NuGet 上的最新穩定版,內容依各套件該版的文件與 NuGet 相依清單整理,之後的版本可能會有變化:
| StreamJsonRpc 2.25.29 | Tochka.JsonRpc 7.4.1 | Polhem.JsonRpc 1.2.0 | |
|---|---|---|---|
| JSON 函式庫 | 預設 Newtonsoft.Json,可換 System.Text.Json;套件仍相依 Newtonsoft.Json 與 MessagePack | System.Text.Json | System.Text.Json,沒有其他相依 |
| 傳輸模型 | Stream、WebSocket、Pipe 上的全雙工 | ASP.NET Core MVC 上的 HTTP | ASP.NET Core Minimal API 上的 HTTP 一問一答 |
| 用戶端 | 有,可由介面產生 proxy | 有 | 有 |
| 方法怎麼註冊 | AddLocalRpcTarget 加入目標物件,其 public 方法可被呼叫 |
繼承 JsonRpcControllerBase 的 controller,action 就是方法 |
不註冊,依 ProgId.Action 與命名約定 |
| Native AOT | 部分支援,要照文件的限制使用 | 綁 MVC,不支援 | 共用套件、用戶端、payload 支援;伺服器不支援 |
| 適合的場景 | 雙向通訊,例如 Visual Studio 擴充、LSP | 已經在用 MVC,想沿用 controller 寫法 | 前後端都自己掌握的 API,用戶端可能在手機上 |
StreamJsonRpc 的功能最完整,取消、進度回報、伺服器反向呼叫用戶端都有,但它是為串流與全雙工設計的,要做成 HTTP 一問一答的 API 得自己架端點。
Tochka.JsonRpc 和 Polhem.JsonRpc 的範圍最接近。Tochka.JsonRpc 沿用 MVC 的 controller,好處是 MVC 的 filter、model binding 都能直接用,代價是和 MVC 一樣不支援 AOT。
Polhem.JsonRpc 走的是另一個方向:用固定的呼叫形狀,換取「不必註冊」與「API 能演進」,下一節說明。
固定的呼叫形狀,是為了讓 API 能演進。 ProgId.Action 名稱、一個 request 類別、一個 response 類別,這個限制是刻意的。request 或 response 類別新增成員後,舊版前端沒送的成員,在伺服器端會繫結成預設值;舊版前端不認得的結果成員,讀取時直接略過。新舊前端呼叫同一個方法,不需要維護 v1、v2 兩套端點。位置參數就做不到這點,只要增加、刪除或調換一個參數,所有呼叫端都得跟著改。
預設照規格走。 內部錯誤碼是 -32603,回應只帶 jsonrpc、result 或 error、id,id 一律寫出,無法判斷時寫 null。Polhem 框架原本的回應格式和規格有些出入,抽成套件時改的是框架,讓它照規格走,而不是讓套件遷就舊格式。
各項決策的理由都以 ADR(Architecture Decision Record,架構決策紀錄)記錄在 repo 的 maintainers/adr/,安全性的設計另外整理在 docs/security.md。
安裝伺服器與用戶端套件:
dotnet add package Polhem.JsonRpc.AspNetCore
dotnet add package Polhem.JsonRpc.Client
repo 裡附了範例:ASP.NET Core Minimal API 伺服器、主控台用戶端(一般呼叫、錯誤處理、notification、batch),以及加密 payload 的伺服器與用戶端。
這組套件做的是「依約定運作的 JSON-RPC」,不是通用的 JSON-RPC。方法名稱與參數由別人訂好的協定,例如 Language Server Protocol、MCP、以太坊節點 API(textDocument/didOpen、eth_call 這類名稱加位置參數),它都無法實作,這類需求比較適合 StreamJsonRpc 或 MCP C# SDK。它也不提供伺服器反向呼叫用戶端的全雙工連線,一次呼叫就是一個 HTTP 請求加上它的回應。
如果你的 API 前後端都自己掌握,想用只靠 System.Text.Json 的 JSON-RPC,或用戶端要跑在手機上,可以試試看。歡迎回饋、開 issue 或送 pull request。
📘 HackMD 原文筆記:
👉 https://hackmd.io/@jeff377/polhem-jsonrpc
📢 歡迎轉載,請註明出處
📬 歡迎追蹤我的技術筆記與實戰經驗分享
Facebook | HackMD | GitHub | NuGet