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 的形状,Login、Logout、EnterCompany、LeaveCompany、Ping、GetDefine 全在里面。要把「进入某一家公司」映射成某个资源的创建或更新,得先发明一个资源出来。发明得出来,但那个资源是为了套进协议而存在的,不是系统里本来就有的东西。

整个 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,本篇只用到一件事:format 与 type 在 API payload 上,不在 value 里面。

这不是绕过规格,是因为「具名还是位置」这个选择在这里不存在。每一支对外方法都只收一个参数:

csharp 复制代码
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 树:

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

成功的响应:

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

result 与 params 是同一种 API payload,value 里才是这支方法自己的返回内容。两个方向各有一份属性清单,写在昨天那三层里最底下的契约接口上(节选自 IPingRequest 与 IPingResponse):

csharp 复制代码
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,就这几行:

csharp 复制代码
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 那一层,其余在派发那一层。

text 复制代码
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 任意 补充信息,多数路径不填

result 与 error 互斥,一次响应只会有其中一个带值。错误码一部分是 JSON-RPC 2.0 自己的标准码,其余取的是规格留给服务端自定义的那一段,至于各个码的语义、哪些消息可以原样送到用户面前、client 怎么依码把它翻回一个异常,是 Day 18 的题目。

这条分界的实务后果落在运维那一侧:监控与网关看得到的只有第一段。一次被业务规则挡下来的保存,在 HTTP 那一层是一笔成功的请求,要看见它得读 body。这是选了 RPC 之后必然要接受的代价之一,跟第一节那两笔是同一件事的不同位置。

回到 Northwind

案例把这个端点开出来的代码在 ApiController:

csharp 复制代码
public class ApiController : ApiServiceController
{
}

整个文件就是这几行加上一段注释,路由、POST handler、请求解析、凭证检查、派发全部在框架的基类里。之所以还要这个空子类,是因为框架那一支是抽象类,controller 的探索挑不到它。

这几行换到的是每一站都可以替换:ReadRequestAsync、ValidateAuthorization、HandleRequestAsync 都是 protected virtual,而案例一个都没有重写。一次 System.Ping 回得出来,第五节那九站就全部走过一遍,而为此写的代码就是上面那个空类。Day 3 说八张表单只有一张需要应用接手,那是业务逻辑那一侧;传输这一侧连那一张都不必接手。

小结

一份协议真正在规范的不是字段叫什么名字,是一次调用的边界:什么算是一个请求、它在哪一刻算被受理、被受理之后的失败要用什么形式回报。

  • 选 JSON-RPC 是因为动作的数量不跟着表单数走,而且有一批动作不是资源的状态转换;代价是 HTTP 那一层的能力全部用不上
  • params 那一格装的是一份 API payload,里面固定是一个参数对象,因为每一支对外方法都只收一个参数
  • method 这个字符串在一次请求里被三个机制读过,三个的大小写规则不一样,而症状会出现在最早的那一个
  • 失败落在请求受理之前还是之后,决定它走 HTTP 状态码还是走 error 字段

可以带走的判准来自第三节那张对照表:一份实现偏离公开规格时,付代价的不是实现的人,是照着规格写 client 的那个人。那四项各自都是合理的取舍,但没有一项会在文档以外的地方被告知。偏离本身不是问题,不写下来才是。

明天谈另一侧:同一份契约,桌面端、浏览器端与同一个进程内的调用,怎么收敛成同一条路径。


本文为 2026 iThome 铁人赛参赛文章,原文为繁体中文。

iThome 原文 | HackMD 版

系列完整目录:ERP 架构师笔记:定义驱动的框架设计

相关推荐
IT规划师1 小时前
Redis 数据类型全解 - Stream 深度:Pending 回收、重试死信与消息队列选型(十一)
redis·.net
学以智用18 小时前
ASP.NET MVC5(.NET Framework)底层完整运行逻辑
.net
木头左1 天前
【架构设计】教育类 App 服务端化改造:AI 上移、题库分层与个人题库归属
android·架构设计·系统设计·后端开发·数据库设计
学以智用1 天前
FilterAttribute(ASP.NET Core MVC 过滤器特性)
.net
Jeff-Tsai1 天前
Day 15:API 契约分层与类型注册表
.net·架构设计·erp·definition-driven
1-1=01 天前
C# 数据结构和特殊类型
数据结构·c#·.net·.netcore
Flynn Liang1 天前
Apache Superset 重要特性详解(三):语义层 / 权限 / 缓存 / 嵌入的实战最佳实践(2026 版)
大数据·clickhouse·数据可视化·架构设计·superset·数据分析平台
IT规划师1 天前
Redis 数据类型全解 - Stream 深度:消费者组、Pending 队列与死信处理(十)
redis·.net
Jeff-Tsai2 天前
Day 14:业务对象的扩展点与重写时机
.net·架构设计·erp·definition-driven