昨天谈的是一个调用进来之后,框架怎么知道要建哪一个类型。再往前推一格的问题是:这个调用本身长什么样子。一套上千张表单的系统对外要开出的方法数以千计,而其中绝大多数是同一组动作套在不同的表单上。协议要决定的是它们怎么寻址、参数怎么带、失败怎么回报。
框架的答案是一个 POST 端点、一个 method 字符串、一条固定顺序的管道。这三个决定各自有代价,而管道上有两个位置是刻意排的。
本篇说明:
- 为什么选 JSON-RPC 而不是 REST,这个选择换到什么、放弃什么
- 两层 payload 各装什么,以及为什么每一支对外方法都只收一个参数
- 一次 Ping 的往返实际送了什么,以及框架没有照 JSON-RPC 2.0 规格做的四件事
- method 字符串怎么切成 ProgId 与 action,两段各自怎么解析
- 一次请求走过的固定站别,以及决定失败形式的那一条分界
一、为什么是 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 铁人赛参赛文章,原文为繁体中文。
系列完整目录:ERP 架构师笔记:定义驱动的框架设计