时间、金额、ID、枚举、Null、分页与错误码:跨语言数据怎样保持语义
JSON 可以把 9007199254740993 写成一个数字,但 JavaScript 的 Number 无法精确保存这个整数。Java 服务返回的 ID 一旦经过这种转换,客户端再请求的对象就可能变了。字段类型需要同时考虑发送端、线格式和接收端的表示能力。
为每个值定义范围、单位和含义
ID 与 JSON 数字
JSON 规范允许实现限制数值的范围和精度。许多 JavaScript 客户端采用 IEEE 754 双精度 Number,安全整数范围为 -(2^53 - 1) 到 2^53 - 1。范围外仍有部分整数可精确表示,却无法保证相邻整数彼此可区分。数据库 BIGINT 和 Java long 的范围更大,因此资源 ID 通常以不透明字符串传输。
JSON.parse('{"id":9007199254740993}').id
// 9007199254740992
JSON.parse('{"id":"9007199254740993"}').id
// "9007199254740993"字符串 ID 不参与算术,不要求调用方猜测它来自自增序列、UUID 还是分布式编号。长度、允许字符与大小写是否敏感应固定;将来换一种内部生成方式,只要保持外部约定,客户端就无需知道数据库主键的细节。JavaScript 的安全整数含义见 Number.MAX_SAFE_INTEGER。
金额与计量值
金额常用两种格式:十进制字符串配币种,或最小货币单位整数配币种及单位约定。{"amount":"12.30","currency":"CNY"} 保留了十进制表达;整数 1230 则必须明确表示分,不能只写一个 amount 字段让各方猜。
币种的小数位数和业务计费精度不一定相同,税率、汇率和中间计算也可能需要更多位。入口限制允许的小数位,计算使用确定的精度,结算时在明确步骤舍入。分摊后的尾差由哪一项吸收也属于业务规则,不能依赖不同语言的默认四舍五入。
BigDecimal total = new BigDecimal("0.10").add(new BigDecimal("0.20"));
String wireAmount = total.toPlainString(); // "0.30"
new BigDecimal("1.001").setScale(2, RoundingMode.UNNECESSARY);
// ArithmeticException:输入需要舍入,但契约不允许从字符串构造 BigDecimal 可避免先经过 double 的误差。比较金额值可使用 compareTo,equals 还考虑 scale;1.0 和 1.00 在业务上是否视为同一种格式,应另行决定。BigDecimal API说明精度、scale 和舍入行为。
其他计量值同样需要单位:超时是毫秒还是秒,大小是 byte 还是 MiB,比例是 0–1 还是百分数,温度是哪种单位。字段名带单位可以减少误解,Schema 还应限制合法范围,避免负长度或溢出的持续时间进入运行配置。
时间的四种对象
| 业务含义 | Java 常用类型 | 应保留的信息 |
|---|---|---|
| 已发生事件的时间点 | Instant | UTC 时间线上的位置与精度 |
| 带偏移的日期时间 | OffsetDateTime | 本地日期时间与 UTC offset |
| 某地区的业务日程 | ZonedDateTime / ZoneId | 地区规则、日期时间、DST 处理策略 |
| 生日、账期日期 | LocalDate | 纯日期,不伪造午夜时间点 |
RFC 3339 表达的日期时间通常包含 Z 或明确 offset,具体格式见 RFC 3339。offset 是某个时刻与 UTC 的偏移,ZoneId 还包含地区历法规则;今天偏移相同的两个地区,不保证所有时刻都相同。
LocalDateTime 本身不能唯一定位时间点,转换时需要时区;夏令时跳变可能产生不存在的本地时刻,或同一个本地时刻对应两个时间点。预约“每天当地九点”应保留地区规则,已发生事件则保存确定 Instant。调度中的空缺和重叠见时区与 Misfire。Java 类型关系见 java.time。
接口还应约定小数秒精度、超出精度是拒绝还是截断,以及排序比较采用哪种精度。持续时长使用明确单位或 Duration,日历周期使用对应的日期规则;“一个月”无法一般地换成固定秒数。
字符串和二进制
JSON 文本交换使用 UTF-8。长度限制应说明按字节、UTF-16 代码单元还是 Unicode 码点计数,表情符号和组合字符会让这几种计数不同。用户看到的一个字符还可能由多个码点组成,显示名限制与数据库容量上限可以分别设置。
字符串归一化、大小写折叠、去空格都可能改变标识。登录名、搜索关键字和签名输入应各自定义转换步骤;签名通常针对确定字节,不能由中间节点任意重新格式化后再比较。二进制在 JSON 中常用 Base64,需说明编码变体、填充和解码后长度限制;大文件更适合流式传输。
缺失、Null、默认值与未知枚举
四种输入状态
{} 没有提供 note
{"note": null} 明确提供 null
{"note": ""} 提供空字符串
{"note": "ready"} 提供具体值创建请求可以规定缺失时采用默认值;局部更新通常把缺失解释为不修改。null 是否清空、空字符串是否合法,应分别约定。Java 把这些输入全部绑定到一个可空 String 后,缺失与 null 可能失去区别;可保留 JSON 节点的 presence、使用专门的补丁对象或携带字段掩码。
Jackson 的树模型可以直接观察差别:
JsonNode missing = mapper.readTree("{}");
JsonNode cleared = mapper.readTree("{\"note\":null}");
missing.has("note"); // false
cleared.has("note"); // true
cleared.get("note").isNull(); // trueJSON Schema 中 required 检查成员存在,允许 null 由类型规则决定,default 通常不改变输入,详见OpenAPI 与 JSON Schema。JSON Merge Patch 把 null 定义为删除成员;若要保存真实 null 值,需要另外选择格式,不能在同一补丁语义中混用。
默认值还会影响演进。客户端没传 sort 时,服务端从“按创建时间升序”改为降序,即使字段和类型都没变,也已经改变了结果顺序。默认分页大小、单位、时区和空值处理应像显式输入一样测试。
请求枚举与响应枚举
请求中的未知值通常应明确拒绝,让调用方修正;响应中的未知值则要允许旧客户端做出可控处理。例如订单状态新增 REVIEWING,旧客户端可以显示“处理中/未知状态”并禁止不支持的操作,而不是崩溃或自动当成 PAID。
可扩展枚举适合保留原始字符串或数字,再提供已知值解析函数。仅添加 UNKNOWN 常量还不够:反序列化器必须确实把未知输入映射到可处理形态,且不能丢掉诊断所需的原值。Protobuf 的存在性和未知数值处理见Protobuf 与 gRPC。
错误响应也不应依赖可翻译的 message 作为枚举。稳定 code/type 用于程序分支,title/detail 供人阅读;新增错误类型时,客户端至少具备通用兜底,不把未知错误默认视为可重试成功。
分页定义的是遍历规则
Offset、Keyset 与快照
分页首先需要明确排序。只按非唯一的创建时间或分值排序,同值记录之间的顺序可能变化;通常在最后增加唯一 ID 作为 tie-breaker。没有确定排序的 LIMIT/OFFSET 查询,不能保证各页组成稳定结果,见 PostgreSQL LIMIT/OFFSET。
Offset 表示跳过当前结果集前面的若干行,适合浅页和需要跳页的场景。前一页之后若发生插入或删除,偏移所指位置会变化;深偏移还会增加数据库扫描成本。Keyset 则从上一页最后一个排序元组继续。
对于升序 (rank_value, id),下一页条件可以写成:
SELECT id, rank_value
FROM item
WHERE tenant = :tenant
AND (rank_value > :last_rank
OR (rank_value = :last_rank AND id > :last_id))
ORDER BY rank_value, id
LIMIT :page_size;这是应用绑定参数的 SQL 结构。降序、多列混合方向、null 排序都要写出对应比较条件。数据库索引也应配合租户过滤与排序,不能把“改成游标”当成任意查询自动加速。
Keyset 避免前方删除造成的偏移漂移,但页间更新排序字段仍可能让记录跨过游标而遗漏或重复。它也不会阻止后来插入的新记录进入后续页。如果需要某个时刻的完整数据视图,应使用数据库一致快照、物化导出结果或搜索 PIT,并承担长事务、版本保留或存储成本。事务隔离决定同一事务里能看到哪些变化。
给导出记录一个“最大 ID”只能限定部分输入范围;如果范围内旧行继续变化,它仍不是内容快照。跨请求保持快照还需要管理有效期和服务端资源,失效后不能承诺从新快照无缝恢复旧遍历。
游标携带位置,不授予权限
游标可保存最后排序值、过滤条件标识、租户、快照 ID、格式版本和过期时间。客户端把它当作不透明字符串传回,服务端验证后恢复查询。Base64 只是编码,若要防篡改,可以采用 HMAC 或服务端随机句柄;内容包含敏感字段时,签名也不能提供保密性。
收到 cursor
→ 限制长度并解析格式
→ 校验完整性或查服务端会话
→ 校验过期时间、当前授权租户、查询条件和排序
→ 使用参数化 SQL 继续查询
→ 每次仍检查资源访问权限CursorCodec 实验使用 JCA HmacSHA256 对编码后的载荷计算 MAC,再用固定时间比较函数核对;接口见 Mac API。密钥由测试运行时生成,不进入文章或输出。密钥轮换、集群分发、算法选择和撤销策略属于正式服务的密钥管理;需要这些能力时可采用成熟的令牌封装或服务端游标会话。
过滤条件应经过一致的规范化再生成查询标识。把 status 从 paid 改为 all 后沿用旧游标,可能跳过从未读过的结果,应该拒绝。服务端可返回 CURSOR_INVALID 或 CURSOR_EXPIRED,让客户端重新开始,而不是静默从另一位置继续。
返回结构与总数
分页响应至少让客户端知道数据在哪里、是否还有后续以及怎样继续。常见字段是 items、nextCursor、hasMore;total 若需要精确计数,应说明它与 items 是否来自同一视图。异步估计值或近似聚合不应命名为精确总数。
如果取 page_size + 1 条来判断还有没有下一页,多取的那一条通常不返回给当前页,游标应对应最后一条已经返回的记录。最大页大小、默认排序和空结果行为都要稳定。批量导出还需限制累计条数、运行时长与连接占用,不能只限制单页。
在 Java、JavaScript 和数据库中核对结果
运行值转换和分页实验
下载 HTTP 与数据契约工程,Linux amd64、Docker、Bash 环境下解压进入 contract-http。构建以宿主 UID/GID 运行,Java 编译目标 17,Maven 3.9.12;实验用 Jackson 2.18.3 和 H2 2.3.232 的真实 JDBC 查询。
unzip contract-http-lab.zip
cd contract-http
bash run-docker.sh整包测试覆盖 HTTP、Schema、SDK、数据和游标;数据相关位于 DataTest 与 CursorTest。测试创建独立内存数据库,没有连接真实业务库。需要切换 JDK 时,可设置 BUILD_IMAGE=maven:3.9.12-eclipse-temurin-25 后重跑同一命令。
DataTest 检查十进制计算得到 0.30、超精度输入被拒绝,以及 JSON 的缺失与 null 不同。时间测试将同一 Instant 转成带 +08:00 的表示后再转回,仍得到原时间点;只有时分秒、缺少日期与 offset 的字符串无法被 OffsetDateTime 解析。
数据库测试的初始数据为:
| tenant | id | rank_value |
|---|---|---|
| a | 1 | 10 |
| a | 2 | 10 |
| a | 3 | 20 |
| a | 4 | 30 |
| b | 5 | 40 |
读取租户 a 的前两条得到 [1,2]。删除 id=1 后,Offset 2 跳过当前前两行,下一页只得到 [4],id=3 被漏掉。按上一页末尾 (10,2) 做 Keyset 查询则得到 [3,4]。
随后把 id=3 的 rank_value 改成 5,再以同一个 (10,2) 查询,只得到 [4]。这条真实更新证明 Keyset 仍会受排序字段变化影响。测试使用 H2 默认自动提交的逐条语句,观察的是页间新数据视图,没有声称建立 PostgreSQL 的快照。
CursorTest 给 (10,2) 生成令牌,合法租户和查询可以读回位置;换租户、换过滤条件、篡改载荷或到达失效时刻都被拒绝。失效后重新签发从头遍历的新令牌,可以再次正常读取位置。
再经过 JavaScript 一次
事件契约工程内的第四个测试实际运行 JSON.parse 和 JSON.stringify,确认大整数数字发生变化,而字符串 ID 保持原值。需要 Node.js 22.18.0 与 npm:
cd contract-events
npm ci --ignore-scripts --no-audit --no-fund
npm test先解压到独立目录,再从其父目录进入,避免在 contract-http 内误找这个路径。对于同时支持 Web、Java 和其他客户端的接口,类似样本应经过各端实际序列化器,而不是只用 Java 自己往返一次。
输入错误、位置错误和服务错误
错误应让调用方知道修正什么。非法小数位可指向 /amount,未知请求枚举可指向 /status;游标失效要求重新开始遍历,版本冲突则要求读取当前资源并合并。临时依赖失败与这些确定性输入错误不同,不能都映射成“稍后重试”。
Problem Details允许在通用问题类型之外扩展字段错误列表。字段路径的语法应固定,响应状态与 body.status 保持一致;原始输入里可能包含个人数据,不宜自动完整回显。客户端按稳定 type/code 分支,展示文字可本地化。
最大 ID、过精度金额、缺失/null/空串、未知枚举、同值排序和页间更新,适合作为长期保留的回归样本。格式修正后,旧游标和缓存也要按版本处理,避免后续查询继续读到旧编码。
权威资料与规范地址
数据编码、数值与时间
- JSON 数字、字符串与编码:https://www.rfc-editor.org/rfc/rfc8259.html
- JavaScript 安全整数:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER
- BigDecimal:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/math/BigDecimal.html
- 时间表示:https://www.rfc-editor.org/rfc/rfc3339.html
- Java 时间类型:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/package-summary.html
分页顺序、隔离与游标签名
- LIMIT/OFFSET 与排序:https://www.postgresql.org/docs/18/queries-limit.html
- 事务隔离:https://www.postgresql.org/docs/18/transaction-iso.html
- HMAC API:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/javax/crypto/Mac.html
结构化错误响应
- Problem Details:https://www.rfc-editor.org/rfc/rfc9457.html
