一次调用失败分两种。一种是业务规则正常挡下来的,订单没有选客户、状态不允许这样转,这段消息是写给用户看的;另一种是程序没有预期到的异常,消息里可能有服务器的文件路径,那是写给开发者看的。两种回到调用端长得一模一样,都是 JSON-RPC payload 里的 error 字段。分不出来就只能一律显示「请求失败」,该让用户知道的那一半也跟着没了。要分得出来,这个字段本身得有契约。
本篇说明:
- 业务规则挡下一个动作时,框架用什么形式让调用端知道
- 错误码怎么分配,哪些落在 HTTP 状态码那一边
- 哪些异常的消息可以原样送出,这条白名单是按什么判的
- 调用端怎么依错误码把它翻回一个异常,两端的映射为什么收成了一份
一、异常当作业务流程的中断消息
一张订单存到一半被规则挡下来。挡它的那段程序可能在三个不同的地方:定义层的规则求值器、BO 重写的检查步骤、或框架自己的前置验证。三个位置离派发那一层都隔了好几层方法调用。
用返回值传这个中断消息,中间每一层都要接一次、判一次、再往上传一次。漏掉其中任何一层,这个消息就停在那里:保存照常走完,调用端什么都没收到,而漏接的那一层在源代码里看起来完全正常。
异常的默认方向相反,没有人接就一路往上,要让它消失,得有人特地写一个 catch 把它吞掉。框架因此选择抛异常,而这个决定在扩展点的签名上看得到:DoBeforeSave(SaveContext) 是 void,它从一开始就没有留一条把消息送回去的路。
UserMessageException 的定位是业务流程的中断消息,不是真正的程序错误。控制流被中止是因为这件事做不下去,而那段消息本来就要原样送到用户面前。
同一个类型接住三种来源:
| 哪一种检查 | 谁丢出来 | 消息从哪里来 |
|---|---|---|
| 定义文件声明的规则 | 表单规则求值器 | FormRule 的 Message 属性 |
| BO 重写的检查 | 应用自己的代码 | 代码里的字符串 |
| 框架自己的前置验证 | 框架 | 框架的字符串 |
第一种是规则求值器(FormExpressionCalculator)验证规则那一段的最后一行,整个声明式规则机制的出口就是它:
csharp
if (!_evaluator.Evaluate<bool>(rule.Condition, NarrowVariables(rule.Condition, variables), timeZoneId))
throw new UserMessageException(rule.Message);
三种来源丢的是同一个类型,所以往上走的每一层都不必知道这个中断消息是声明出来的还是写出来的。派发那一层看到的永远是同一个东西。
挡下来的那一刻还没有 transaction 可以 rollback,这是 Day 14 那条边界的另一面。规则跑在写入那一步的外面,中止一次还没开始的写入不需要任何补偿动作。
二、错误码与 HTTP 语义的分界
昨天在调用端看到的是两个不同的 catch,前天在服务端看到的是同一条分界:失败落在请求受理之前还是之后,决定它走 HTTP 状态码还是走 error 字段。同一个决定还有第三种说法,就是错误码本身。
错误码有两个来源,一批是 JSON-RPC 2.0 的标准码,其余取自规格留给服务端自定义的那一段。实际会回到调用端的是这几个:
| 码 | 值 | 目前由谁产生 | 落点 |
|---|---|---|---|
ParseError |
-32700 | 传输层读不出 JSON | 受理之前,HTTP 400 |
InvalidRequest |
-32600 | 传输层:媒体类型、空 body、缺 method、API 密钥不过、Bearer 格式或 Guid 格式不对 | 受理之前,HTTP 415 / 400 / 401 |
InternalError |
-32000 | 执行器:不在注册表上的异常。传输层:执行器之外的未预期异常 | 受理之后,HTTP 200;传输层那一种 HTTP 500 |
CompanyNotEntered |
-32002 | 执行器:CompanyNotEnteredException |
受理之后,HTTP 200 |
CompanyAccessDenied |
-32003 | 执行器:CompanyAccessDeniedException |
受理之后,HTTP 200 |
PermissionDenied |
-32004 | 执行器:ForbiddenException |
受理之后,HTTP 200 |
ReplayRejected |
-32005 | 执行器:ReplayRejectedException |
受理之后,HTTP 200 |
UserMessage |
-32099 | 执行器:给用户看的中断消息(白名单) | 受理之后,HTTP 200 |
受理之后那几个码分成三种用途。-32000 是真正的异常,程序或基础设施坏了,消息不该离开服务器。-32099 反过来,它装的就是要显示给用户的那句话。中间那几个具名码两者都不是,它们存在是为了让调用端接到之后做不同的事:退回选公司、把界面降级、别再重试。消息未必适合直接显示,「这个 session 还没有进公司」对用户没有意义,对调用端才有。
一次失败走到哪一个码,看的是丢出来的异常类型,不是那是什么失败。找不到 action 丢的是 MissingMethodException,没有注册,于是回 -32000;令牌不存在或已过期丢的是 UnauthorizedAccessException,它注册在 -32099。
UserMessage 取 -32099 是刻意的,它排在自定义区段的尾端,跟具体的分类码隔开一段距离。中间那段空号留给未来的分类,一般业务消息与有明确语义的失败因此不会混在同一段连号里。
传输层验得了凭证的形状,验不了它的有效性
同样是「没有登录」,实测回来的是两种答案:
# 没有 Authorization 头
HTTP 401 {"error":{"code":-32600,"message":"Missing Authorization header."}}
# 格式正确但不存在的令牌
HTTP 200 {"error":{"code":-32099,"message":"Session key not found or expired."}}
传输层检查的是有没有请求头、是不是 Bearer 开头、后面那一段能不能 parse 成 Guid。这三件在请求本身上就看得完,不必问任何一层。令牌还有没有效要问 session,而 session 在后面那一层,所以一个格式正确的无效令牌在协议上算已经受理,失败回到 error 字段里。
运维上的后果是,网关统计到的 401 只包含连格式都不对的那一批,令牌过期造成的失败全部埋在 HTTP 200 里面。
错误码合并,是为了关掉一条枚举通道
CompanyAccessDenied 这个码对应三种原因:公司不存在、公司被禁用、这个用户没有被授权。三种在服务端都丢同一个异常、带同一段消息,实测拿一个不存在的公司代号去打,回的是 Company access denied.。
合并要同时做在两个地方才有意义。码合并了而消息文字分歧,调用端照样可以用消息把有效的公司代号一个一个试出来。这条要求落在那个异常类型的构造函数消息参数上:每一种原因都要用同一段文字。它是一条约定,不是编译器挡得住的东西。
这个手法在错误码上目前只用在这一处。其他错误码都是照失败的种类分的,分得越细对调用端越有用;只有在错误码本身会泄漏「这个标识码存不存在」的时候,分细才变成一条可以被反复探测的通道。
三、哪些消息可以原样送到用户面前
异常消息里什么都可能有。找不到定义文件的那一个装的是完整文件路径,数据库驱动程序那一个可能带着语句片段。这些送到调用端手上,等于把服务器的内部结构免费告诉对方。
框架的判定机制是一份注册表(JsonRpcErrorContract),执行器(JsonRpcExecutor)捕捉到异常之后,做的就是拿它的类型去查那张表:
csharp
internal static (JsonRpcErrorCode code, string message) MapException(Exception ex)
{
if (JsonRpcErrorContract.TryGetCode(ex, out var code))
return (code, ex.Message);
return (JsonRpcErrorCode.InternalError,
SysInfo.IsDebugMode ? ex.Message : "Internal server error");
}
注册成 -32099 的那几行就是那份白名单,七个类型,分两批:
| 类型 | 现状 |
|---|---|
UserMessageException |
新代码的正解,类型本身就是「这句话要给用户看」的声明 |
UnauthorizedAccessException |
过渡期保留 |
ArgumentException 家族 |
过渡期保留 |
InvalidOperationException |
过渡期保留 |
NotSupportedException |
过渡期保留 |
FormatException |
过渡期保留 |
JsonRpcException |
过渡期保留 |
那六个类型留在名单上是迁移路径,会随业务程序改用专属类型而逐步移除。
异常类型白名单的代价是它判的是谁丢的,不是丢了什么。这件事有轻重两种后果,先看轻的。ParseMethod 切不出两段时丢的是 FormatException,于是一个协议层的格式错误走了 -32099,而那个码的契约说这段消息可以直接显示给用户。实测送出一个没有句点的 method,回来的是 HTTP 200 加上 -32099 与 Invalid method format: Ping。这句话给用户看没有意义,但也没有泄漏什么。
另一种贵得多:InvalidOperationException 散落在框架与应用的各个角落,任何一个没被预期到的实例都会把它的消息原样送出去。这正是那份清单被标记为过渡的理由。
屏蔽是整段换掉,不是把敏感的部分挑掉
不在注册表上的异常一律换成一句固定文字。同一个请求在两种设置下的差别:
# 调试标志开着
{"code":-32000,"message":"The file /Users/.../Define/FormSchema/NoSuchProg.FormSchema.xml does not exist."}
# 调试标志关掉
{"code":-32000,"message":"Internal server error"}
第一种把服务器的文件系统路径送到了调用端手上。屏蔽之所以是整段换掉而不是过滤,是因为过滤得先知道哪一段敏感,而那要看每一个异常自己。换掉整段不需要知道任何事。
执行器与传输层看的不是同一个开关。执行器看的是 SysInfo.IsDebugMode,它来自 SystemSettings 这份定义文件;传输层那一段看的是宿主的环境名称。两个都要对,消息才不会外泄。把定义文件里那个标志留成打开就部署出去,宿主设成正式环境也挡不住。
调试分支只带消息,不带 stack trace 或任何更广的转储,那类内容一旦进了 API 响应,外泄的就不只是一行路径。
这个决定另有一笔代价:执行器在这里把异常处理掉了、没有往上抛,所以更上层也没有机会回报它,开发者在正式环境拿到的就只有那一句话。服务端这一侧另有一条记录的路,会把异常的类型名与那段原始消息写成一行,但那条路默认不开,关着的时候那一次失败连类型名都不会留下来。
四、调用端把错误字段翻回一个异常
昨天说过 FinalizeResponse 是把错误字段翻回异常的地方,也说过它在解码之前就先判读错误。位置已经清楚,剩下的是它翻成什么。
整条往返是这样:
BO throw new UserMessageException("Please select a customer for the order.")
执行器 异常类型 → 错误码 (-32099, "Please select ...")
JSON-RPC payload {"error":{"code":-32099,"message":"Please select ..."}}
调用端 错误码 → 异常类型 throw new UserMessageException("Please select ...")
界面 catch (UserMessageException ex) → 显示 ex.Message
调用端(ApiConnector)查的是同一份注册表,方向相反:
csharp
if (JsonRpcErrorContract.TryRebuild(response.Error.Code, response.Error.Message, out var rebuilt))
throw rebuilt;
throw new InvalidOperationException($"API error: {response.Error.Code} - {response.Error.Message}");
调用端因此不必认得任何一个具体的异常类型,那份注册表替它认。
重建不是还原。-32099 那一条是多对一:服务端丢的可能是白名单上七个类型里的任何一个,回到调用端一律是 UserMessageException。类型信息送回来只剩一个整数,能还原的就是这个整数认得的那几种。这是刻意的取舍,调用端要的是「这个消息能不能直接给用户看」,不是「服务端当初丢的是哪个类型」。
添加一种错误因此是一处编辑,不是两个程序集各一处。分开写在两边的话,服务端多认一个码而调用端没跟上,那个码会安静落到通用分支,而编译得过、测试也未必测得到。Day 15 那组返回类型的命名惯例仍然是靠约定接起来的,这一组不是。
同进程调用走的也是完整一趟。近端那条路解析的是同一个执行器,异常照样被接住、变成一个码、再由 FinalizeResponse 重建,所以拿到的一样是一个新的异常实例,原本的 stack trace 与内层异常都不在了。昨天说两条路要让调用端看不出差别,这是那个目标的另一笔账单:差别确实看不出来,代价是近端那条也交出了它本来免费就有的东西。
回到 Northwind
拿一张没有客户的订单去存,回来的是 HTTP 200 加上 -32099 与 Please select a customer for the order.。那段文字不在任何一行代码里,它是案例订单那份 FormSchema(Order.FormSchema.xml)里的一个属性值:
xml
<FormRule RuleId="customer_required" Condition="customer_rowid != Guid.Empty"
Message="Please select a customer for the order." />
从定义文件的一个属性,经过求值器丢出的异常、一个整数错误码、调用端重建出来的异常,到界面上那一行字,中间没有任何一段应用代码碰过它。Day 8 谈声明式规则时只讲到求值那一半,这里是它的另一半:规则的消息本身也是定义的一部分,一路走到用户眼前都没有换过手。
OrderBO 在写入前那一步自己接手的那几个检查,用的也是同一个类型。至少要有一行明细、状态不允许这样转、确认之后明细锁定,三句丢的都是 UserMessageException,所以定义文件声明的规则与代码写的检查到了调用端分不出来。分不出来是对的,调用端要的信息是「这是给用户看的消息」,不是「它是声明的还是写的」。
上面那个 -32003 在案例里走得到:登录之后就会调用进公司那一支,而它对不存在的公司与没被授权的公司回的是同一句话。
-32000 那两种面貌在案例里看得到一种:它的 SystemSettings.xml 把调试标志开着,所以找不到定义文件的失败回的是完整路径。把那个标志关掉重打同一个请求,回的就是那句固定文字。同一份代码、同一个请求、两种答案,差别在一份定义文件的一行。
小结
一份错误契约回答的是:这次失败要让对方知道多少。太少,调用端只能显示「请求失败」,用户不知道该改什么;太多,服务器的内部结构就顺着错误消息流出去。
- 中断消息用异常传递,因为它要跨越好几层,而返回值只跨得了一层
- 失败落在受理的哪一边决定它走 HTTP 状态码还是走
error字段,而传输层验得了凭证的形状、验不了它的有效性 - 消息能不能原样送出由异常类型白名单决定,代价是它判的是谁丢的、不是丢了什么
- 调用端依码重建异常,而两端查的是同一份注册表,添加一种错误是一处编辑
最该带走的是第三点。「可以显示给用户」判的不是这段文字写了什么,是它由谁丢出来。一段消息里有没有路径、有没有语句片段,程序在运行时看不出来;类型看得出来,编译期就定下来,数得出来也改得动。
这一章的每一篇问的都是同一件事:这个东西摆在哪里。一个值属于 session 的哪一段、一个扩展点在 transaction 的哪一边、一个类型在契约的哪一层、一次失败落在受理之前还是之后、一份状态属于一个用户还是整个应用、一段消息由哪一个类型丢出来。位置决定语义,而位置不写在代码里,它散在几份文档、几段注释和几个人的记忆里。一份契约要做的事,就是把它写下来。
明天起换一个方向:同一套框架要让不同客户长得不一样,而不一样的地方可以落在哪里。
本文为 2026 iThome 铁人赛参赛文章,原文为繁体中文。
系列完整目录:ERP 架构师笔记:定义驱动的框架设计