qinghuan
← 回到知识探索

后端现场 / 可下载小项目

Java 里不同的两个编号,为什么前端认为相同?

让两个相邻 long 编号真正经过 Java、JSON、Node 再返回,定位身份信息在哪一步丢失,并验证字符串传输约定。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先分开看:Java 数值、JSON 文字、客户端数值
  4. 这里不是小数问题:相邻整数也可能合到一起
  5. 最后一位丢了,会影响记录身份
  6. 事后换成 BigInt,不能猜回原来的末位
  7. 把编号当字符串传,是一份需要双方遵守的约定
  8. 把两个文件放在一起,只启动 Node 入口
  9. 核对往返结果,也核对这个小程序没做什么
  10. 最后按字段含义决定类型,别把全部数字改成文字
  11. 记住这三点
  12. 别把例子用过头
  13. 自己试一小步
  14. 查原始资料
开始前,会这些就够了
  • 知道 JSON 的数字与字符串是不同类型。
  • 会运行 Java 和 Node 命令;准备 JDK 17 与 Node 18+。
  • 理解编号用于区分记录;本例无需数据库、浏览器、账号或网络。
  • 先读浮点数的有限刻度,本篇观察大整数编号也可能合并。

虚构列表里有两个编号:9007199254740992 和 9007199254740993。Java 把它们存成 long,比较结果确实不同;发出的 JSON 原文也保留了最后一位。可客户端解析后,把它们放进集合只剩一个键。这时问题可能不在数据库,而在接收端把文字变成运行时数值的那一步。

本项目提供两个文件:Java 产生并核对编号,Node 使用原生 JSON.parse 和 JSON.stringify 完成往返。两个进程真的交换文本,再检查 Java 收回的值。所有输入均为固定虚构数据,临时文件会自动清理;它不是对真实接口或某个序列化框架的测试。

把例子带到自己的电脑上

下载完整练习包

需要 JDK 17 或更新版本与 Node.js 18 或更新版本;制作时实测 Node v24.18.0 + Microsoft OpenJDK 17.0.20.1。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 6 个文件
包里有什么
  • id_roundtrip.mjs:完整示例
  • LongIdRoundTrip.java:配套 Java 程序
  • README.md:环境、范围与独立练习
  • observed.stdout.txt:制作时的观察记录
  • verification.json:对应源码的验证范围
  • SHA256SUMS.txt:文件内容摘要

在这个文件夹打开 PowerShell,逐行运行

java -version
node --version
node id_roundtrip.mjs

解压后在含示例文件的文件夹打开 PowerShell,按命令运行。先预测结果,再与当次输出对照。 真实 Java 与 Node 子进程之间的 JSON 往返;不代表 HTTP、数据库或真实业务校验。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

两个编号进来,还剩几个不同的键?

发送前的编号保留为十进制原文。选择 JSON 的写法,让当前浏览器真正解析一次,再查看收到的值与去重后的数量。

发送 2 个不同编号 → 收到 1 个不同的 Set 键

至少一个编号的整数内容已改变;相同的键无法再区分原来的两个对象。

发送的 JSON 原文

[9007199254740992,9007199254740993]
  1. 编号 1

    原始十进制编号
    9007199254740992
    浏览器收到的值(number)
    9007199254740992
    事后 BigInt(收到的 Number)
    9007199254740992
    整数内容与原编号一致
    是
    Number.isSafeInteger
    false
  2. 编号 2

    原始十进制编号
    9007199254740993
    浏览器收到的值(number)
    9007199254740992
    事后 BigInt(收到的 Number)
    9007199254740992
    整数内容与原编号一致
    否
    Number.isSafeInteger
    false

把收到的值再次写成 JSON

[9007199254740992,9007199254740992]

试着只切换“数字 / 字符串”,保留同一对编号。然后换成较小编号,检查问题是否仍会出现。

此面板真实执行浏览器的 JSON.parse、Set 和 BigInt,没有发送网络请求或操作业务对象。Number.isSafeInteger 为 false 并不表示每个值都已改变:2⁵³ 本身可精确表示,但相邻整数可能已合并。BigInt(Number) 只能转换现有数值,不能补回先前丢失的信息;默认 JSON.stringify 也不能直接序列化 BigInt,本面板回传的是收到的 Number 或 string。

先分开看:Java 数值、JSON 文字、客户端数值

Java long 是有范围的整数类型,本例两个值都在范围内,可以精确区分。JSON 则是一种交换文字格式:它可以写出很长的十进制数字,但不会要求每个接收程序都用同一种内部类型。RFC 8259 允许实现对数值范围与精度设限制,并提醒跨实现互通时关注这些边界。

Node 的普通 JSON.parse 会把 JSON 数字变成 JavaScript Number,把 JSON 字符串变成 String。因此响应正文里有正确的数字字符,不足以证明客户端变量仍然保留相同整数。应分别保存发送的原文、解析后的类型和值、重新发送的原文,才能确定变化发生在哪一层。

这里不是小数问题:相邻整数也可能合到一起

JavaScript Number 使用有限精度的二进制浮点表示。安全整数上界为 9007199254740991,也就是二的五十三次方减一。在安全范围内,整数不会与另一个整数映射到同一个 Number;超过这个范围,不能再依赖它逐个区分所有相邻整数。

本例第一项 9007199254740992 本身能精确表示,但下一项 9007199254740993 转成 Number 后也得到前一个值。所以两项的 Number.isInteger 都为真,Number.isSafeInteger 却都为假。“是整数”只说明当前数值没有小数部分;“不是安全整数”也不意味着这个具体输入必然已经变化,它表示相邻整数的区分保证不成立。

最后一位丢了,会影响记录身份

Node 将解析后的两项放进 Set,键数是一;将原始字符串形式的两项放进 Set,键数是二。对于编号,这不是显示时少一位的美观问题:如果应用用该值做缓存键、选中行标识或查找条件,就可能把不同记录当成同一项。实验只验证集合结果,没有真的查询或修改任何订单。

随后,Node 把数字数组重新转成 JSON,两个位置已经都是 9007199254740992。Java 收回后逐项与原编号比较,第一项 exact,第二项 changed。Java 的 long 在这一步没有溢出;它准确接收了客户端送回的另一个合法整数。只在后端检查“能不能转成 long”,发现不了这种身份变化。

事后换成 BigInt,不能猜回原来的末位

如果只拿到已经解析好的 Number,再执行 BigInt,转换的对象是当前保存的那个值。两项都得到 9007199254740992n。给变量换成能装大整数的类型,并不会同时找回它在上一层丢掉的信息;先 String(number) 再处理,也仍从已经变化的值出发。

对照路径从原始十进制字符串调用 BigInt,两项仍然不同。问题不在 BigInt 能否表示它们,而在转换发生之前保留了什么。本实验使用不带 reviver 的默认 JSON.parse;一些解析方案能够在转换时保留原始数字文字,需要另行验证。本文不声称所有 JSON 解析器都一定损失大整数,也不把事后修复与解析时保留混成一件事。

把编号当字符串传,是一份需要双方遵守的约定

本例的正确路径从 Java 输出开始就给编号加上 JSON 字符串引号。Node 解析后保留字符串,比较、作为键和返回时都不先转 Number;Java 收回后再按本例范围转回 long。最终两项都为 exact,键数仍是二。仅在页面显示之前补引号已经太晚,约定必须覆盖发送、接收与再次发送的整条路径。

字符串也要有规则。本例只接受正十进制编号,不允许前导零、正号或科学计数法,并限制到 Java long 的范围;代码对 01、+1、1e3 和超范围输入安排拒绝检查。这是教学接口自己选的政策,不是 JSON 标准禁止这些内容出现在字符串里。真实编号若允许前导零,强行转整数反而会改变它的身份。

如果前端需要计算,可以从经过校验的字符串得到 BigInt;传输时仍需选定表达形式。默认 JSON.stringify 直接遇到本例 BigInt 值会抛 TypeError。自行提供 replacer 或其他转换会改变行为,本例没有做这些定制;它明确使用字符串作为双方共同理解的传输类型。

把两个文件放在一起,只启动 Node 入口

下载并解压本篇练习包,保持 LongIdRoundTrip.java 与 id_roundtrip.mjs 在同一目录,运行下方命令。Node 会创建临时目录,复制 Java 源码,通过 Java 的单文件模式先生成 JSON,再把两种返回文本分别交给 Java 检查。每次子进程有等待上限,失败会明确报错。

如果系统里的 java 不是要使用的 JDK,可以把 java 可执行文件的完整路径作为 Node 命令的第一个参数。程序最终打印一份报告,包含 rawJsonFromJava、afterNativeJsonParse、两种返回 JSON、javaChecks 和清理状态。所有断言通过且临时目录删除后才打印 PASS;没有向当前练习目录写入生成数据。

小实验 · text
java -version
node --version
node id_roundtrip.mjs

# 如果需要指定 JDK,可把 java 可执行文件的完整路径作为参数:
# node id_roundtrip.mjs "C:/path/to/jdk-17/bin/java.exe"

怎样运行:将 LongIdRoundTrip.java 与 id_roundtrip.mjs 放在同一目录,启动 Node 入口即可。JDK 17 与 Node 18+;只用标准库,生成文件在临时目录自动清理。 2026-10-13 已用 Node v24.18.0 + Microsoft OpenJDK 17.0.20.1 执行这份示例并通过自带断言;下方为当次输出。真实 Java 与 Node 子进程之间的 JSON 往返;不代表 HTTP、数据库或真实业务校验。

运行后对照结果
{
  "scope": "two fixture IDs; Java and Node subprocesses; no HTTP or database",
  "nodeVersion": "v24.18.0",
  "rawJsonFromJava": "{\"numbers\":[9007199254740992,9007199254740993],\"strings\":[\"9007199254740992\",\"9007199254740993\"]}",
  "afterNativeJsonParse": {
    "numbers": [
      9007199254740992,
      9007199254740992
    ],
    "strings": [
      "9007199254740992",
      "9007199254740993"
    ],
    "numberKeyCount": 1,
    "stringKeyCount": 2,
    "isInteger": [
      true,
      true
    ],
    "isSafeInteger": [
      false,
      false
    ]
  },
  "bigIntFromParsedNumber": [
    "9007199254740992",
    "9007199254740992"
  ],
  "bigIntFromOriginalString": [
    "9007199254740992",
    "9007199254740993"
  ],
  "numericJsonReturnedToJava": "[9007199254740992,9007199254740992]",
  "stringJsonReturnedToJava": "[\"9007199254740992\",\"9007199254740993\"]",
  "javaChecks": {
    "numericAtJava": {
      "statuses": [
        "exact",
        "changed"
      ],
      "distinct": false,
      "receivedAsStrings": [
        "9007199254740992",
        "9007199254740992"
      ]
    },
    "stringsAtJava": {
      "statuses": [
        "exact",
        "exact"
      ],
      "distinct": true,
      "receivedAsStrings": [
        "9007199254740992",
        "9007199254740993"
      ]
    }
  },
  "defaultBigIntStringify": "TypeError; explicit transport representation is required",
  "javaDecoderScope": "exact compact two-ID arrays from this fixture only; not a general JSON parser",
  "temporaryDirectoryRemoved": true
}
PASS: Java long -> JSON -> Node -> JSON -> Java; exact string IDs preserved

核对往返结果,也核对这个小程序没做什么

先确认 Java 原始输出两项不同,再看 Number 与 String 两组的键数,然后核对返回 Java 时的逐项比较。这个顺序能排除“只是控制台把长数字缩短显示”的猜测:数值路径发送回去的正文已经变化,接收端也读到了不同结果。字符串路径的原文与值则一直保留区别。

为避免引入第三方依赖,Java 回传检查器只识别本程序发出的紧凑双元素数组,再提取十进制编号;它不是通用 JSON 解析器,不支持任意空白、嵌套或转义。生产代码应使用受维护的解析库,把字段绑定为字符串后再校验。本例通过进程标准输入输出交换数据,没有测试 HTTP、数据库字段、Jackson 配置或任何浏览器页面。

最后按字段含义决定类型,别把全部数字改成文字

编号的主要工作是区分对象,本例选择字符串便于保持原样;数量、比例、测量值则有不同的计算需求。不能为了这一项修复,把所有 JSON 数字都改成字符串,随后又让各客户端各自猜怎样转换。若一个字段允许使用 Number,应写明范围并在接收边界验证;若它是文字身份,就避免不必要的数值转换。

上线前还要检查旧客户端、已有缓存、排序和接口文档是否仍按数字处理。字符串排序与数值排序并不自动相同;已经保存成错误值的数据也不会因为新响应加了引号便恢复。本文只证明这两项编号在所列运行时和路径上的行为,不能替代真实系统中所有读写入口的兼容检查。

记住这三点

  • JSON 原文精确,不代表默认解析后的 Number 仍能区分相邻大整数。
  • 先失去信息、后转 BigInt,不能恢复原编号。
  • 字符串编号需要端到端约定、格式与范围校验,以及旧客户端兼容检查。

别把例子用过头

  • 把 Number.isInteger 当成没有发生整数精度损失的证明。
  • 先让 JSON 数字变成 Number,显示时才转字符串。
  • 把教学用双元素数组识别器直接当成生产 JSON 解析器。
  • 为了保住 ID,把所有数值字段改成文字却没有更新接口合同。

自己试一小步

如果旧客户端只接收数字编号,新客户端改用字符串编号,怎样设计并验证过渡合同?

  1. 自选安全整数边界内外的编号,并加入一项带前导零的文字编号;先写明各项的身份含义。
  2. 设计一份能够区分旧客户端与新客户端能力的输入输出规则,分别保存原文、运行时类型与返回结果。
  3. 自行实现一条拒绝不安全数字的入口,再验证字符串排序与数值排序是否符合你的页面需求。

做到这些,就算完成

  • 提交自己的边界表与实际往返记录,没有把失败样本悄悄改成更小的数。
  • 解释何时拒绝、何时保留文字身份,已损失的信息没有被宣称自动修复。
  • 明确过渡需要哪些客户端与存储证据,不将两个虚构编号的成功推广为全系统已经兼容。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • RFC 8259:JSON 数字的互操作边界 (新标签页)↗IETF · 英文

    阅读第 6 节的数字语法、实现可设范围/精度限制,以及 binary64 整数互操作区间;支持 JSON 文字与接收端表示需要分别核对。

    公开原始规范或官方 API 文档;本篇只采用注明的条款,实验输出单独验证。

  • ECMAScript:安全整数与最大安全整数 (新标签页)↗Ecma TC39 · 英文

    阅读 Number.isInteger、Number.isSafeInteger 与 Number.MAX_SAFE_INTEGER;核对 2^53 和 2^53+1 映射到同一 Number 的明确例子。

    公开原始规范或官方 API 文档;本篇只采用注明的条款,实验输出单独验证。

  • ECMAScript:JSON.parse 与 JSON.stringify (新标签页)↗Ecma TC39 · 英文

    阅读 JSON.parse 的类型映射,以及 SerializeJSONProperty 的 Number、String、BigInt 分支;仅说明无 reviver/replacer/toJSON 定制的本例默认行为。

    公开原始规范或官方 API 文档;本篇只采用注明的条款,实验输出单独验证。

  • ECMAScript:BigInt 与 NumberToBigInt (新标签页)↗Ecma TC39 · 英文

    阅读 BigInt(value)、NumberToBigInt 及 BigInt.prototype.toString;支持转换使用当前 Number 的整数值,不能由该值恢复此前文字。

    公开原始规范或官方 API 文档;本篇只采用注明的条款,实验输出单独验证。

  • Java 17:Long.parseLong (新标签页)↗Oracle · 英文

    阅读十进制字符串到 long 的解析与 NumberFormatException 条件。本例额外禁止前导零/正号的规范形式,是教学合同而非 parseLong 自带限制。

    公开原始规范或官方 API 文档;本篇只采用注明的条款,实验输出单独验证。