JDK 28 里孵化的 JSON API(JEP 540,模块 jdk.incubator.json)已经能在 Spring Boot 里用了,前提是编译和运行都加 --add-modules jdk.incubator.json,它还是孵化 API,不是正式标准 API。我在 JDK 28 EA build 19 上用 Spring Boot 4.1.1 写了个 webhook 接口,再拿同一批输入和 Spring Boot 自带的 Jackson 3.1.5 对照了一遍。差别最大的有三处:重复 key 和字符串型数字 JDK 版本直接报错,超长小数 JDK 版本保留原文而 Jackson 默认丢了精度。
10 月 2 日那篇 JEP 540 的 API 概览,讲的是设计和边界,这篇只讲实际跑出来的行为。
先确认它在哪个 build 里
JEP 540 的标题是 Simple JSON API (Incubator),状态是 Integrated,Release 28,正文明确说它目前是孵化 API。对应的实现任务 JDK-8381976「Implement JEP 540: Simple JSON API (Incubator)」在 2026 年 9 月 10 日(北京时间)解决,Resolved In Build 是 b15。所以 jdk.java.net/28 上 build 15 及之后的 EA 都带这个模块,我用的是当前最新的 build 19:
openjdk version "28-ea" 2027-03-23
OpenJDK Runtime Environment (build 28-ea+19-1485)
OpenJDK 64-Bit Server VM (build 28-ea+19-1485, mixed mode, sharing)
java --list-modules 里能看到 jdk.incubator.json@28-ea。build 19 里公开的类型有 Json、JsonValue、JsonObject、JsonArray、JsonString、JsonNumber、JsonBoolean、JsonNull,还有两个异常 JsonParseException 和 JsonValueException。Json 只有三个静态方法:parse(String)、parse(char[]) 和 toDisplayString(JsonValue, String)。
项目配置
Spring Boot 4.1.1,只引 spring-boot-starter-webmvc。孵化模块默认不参与模块解析,编译器要加参数:
<properties>
<java.version>28</java.version>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>
<arg>--add-modules</arg>
<arg>jdk.incubator.json</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
运行时也一样:
java --add-modules jdk.incubator.json -jar target/json-api-demo-0.0.1.jar
启动第一行会打出孵化模块警告:
WARNING: Using incubator modules: jdk.incubator.json
运行时忘了加这个参数,Spring 在创建用到它的 Bean 时就失败了,应用起不来。我故意不加参数跑了一次,根因是这一行:
Caused by: java.lang.NoClassDefFoundError: jdk/incubator/json/JsonValue
在 Controller 里用
Spring MVC 的 JSON 消息转换还是走 Jackson,Spring 没有接入这个 API。想用它,就把请求体当 String 收进来自己解析,返回值也自己拼成字符串。下面是个 Git 推送 webhook,只取几个字段:
@RestController
public class WebhookController {
@PostMapping(value = "/webhook", consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> webhook(@RequestBody String body) {
try {
JsonValue root = Json.parse(body);
String event = root.get("event").asString();
String repo = root.get("repository").get("full_name").asString();
int commits = root.get("commits").asList().size();
String pusher = root.tryGet("pusher")
.flatMap(p -> p.get("name").tryValue())
.map(JsonValue::asString)
.orElse("unknown");
Map<String, JsonValue> out = new LinkedHashMap<>();
out.put("accepted", JsonString.of("yes"));
out.put("event", JsonString.of(event));
out.put("repo", JsonString.of(repo));
out.put("commits", JsonNumber.of(commits));
out.put("pusher", JsonString.of(pusher));
return ResponseEntity.ok(JsonObject.of(out).toString());
} catch (JsonParseException | JsonValueException e) {
String err = JsonObject.of(Map.of(
"error", JsonString.of(e.getClass().getSimpleName()),
"message", JsonString.of(e.getMessage()))).toString();
return ResponseEntity.badRequest().body(err);
}
}
}
get 找不到成员会抛异常,tryGet 返回 Optional;tryValue 遇到 JSON null 返回空 Optional。两个异常都是非受检异常,统一 catch 掉转成 400。
正常请求:
$ cat push.json
{"event":"push","repository":{"full_name":"spring4all/demo"},"commits":[{"id":"a1"},{"id":"b2"}],"pusher":{"name":"zhai"}}
$ curl -s -X POST localhost:18080/webhook -H 'Content-Type: application/json' -d @push.json
{"accepted":"yes","event":"push","repo":"spring4all/demo","commits":2,"pusher":"zhai"}
JsonObject.of 收的是 Map,我传的 LinkedHashMap,输出顺序和插入顺序一致。
pusher.name 是 null 时走默认值:
$ curl -s -X POST localhost:18080/webhook -H 'Content-Type: application/json' -d '{"event":"push","repository":{"full_name":"x/y"},"commits":[],"pusher":{"name":null}}'
{"accepted":"yes","event":"push","repo":"x/y","commits":0,"pusher":"unknown"} HTTP 200
缺字段时,异常信息带着路径,能直接看出是 repository 下面少了 full_name:
$ curl -s -X POST localhost:18080/webhook -H 'Content-Type: application/json' -d '{"event":"push","repository":{},"commits":[]}'
{"message":"JsonObject member \"full_name\" does not exist. Path: \"{repository\". Location: line 0, position 29.","error":"JsonValueException"} HTTP 400
重复 key 直接拒掉:
$ curl -s -X POST localhost:18080/webhook -H 'Content-Type: application/json' -d '{"event":"push","event":"delete"}'
{"message":"Duplicate member name: \"event\" was already parsed. Path: \"{\". Location: line 0, position 16.","error":"JsonParseException"} HTTP 400
单行输入时,build 19 报的行号是 line 0,位置是从 0 开始数的字符偏移。JEP 正文里的示例文案是 The duplicate member name: ... was already parsed,build 19 实际输出是 Duplicate member name: ...,孵化期的措辞和实现之间已经有出入。
和 Jackson 对照
同一个应用里加了个 ApplicationRunner,把几段输入分别交给 Json.parse 和 Spring Boot 自动配置的 JsonMapper.readTree:
@Component
public class StrictnessReport implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(StrictnessReport.class);
private final JsonMapper mapper;
StrictnessReport(JsonMapper mapper) {
this.mapper = mapper;
}
@Override
public void run(ApplicationArguments args) {
log.info("Jackson {}", tools.jackson.databind.cfg.PackageVersion.VERSION);
Map<String, String> cases = new LinkedHashMap<>();
cases.put("duplicate key", "{\"id\":1,\"id\":2}");
cases.put("trailing comma", "{\"a\":1,}");
cases.put("comment", "{\"a\":1 /* note */}");
cases.put("single quotes", "{'a':1}");
cases.put("big number", "{\"v\":12345678901234567890.123}");
cases.put("123.0 as int", "{\"v\":123.0}");
cases.put("\"42\" as int", "{\"v\":\"42\"}");
for (var c : cases.entrySet()) {
String jdk = attempt(() -> describeJdk(Json.parse(c.getValue()), c.getKey()));
String jackson = attempt(() -> describeJackson(mapper.readTree(c.getValue()), c.getKey()));
log.info("[{}] {}", c.getKey(), c.getValue());
log.info(" JDK -> {}", jdk);
log.info(" Jackson -> {}", jackson);
}
}
private String describeJdk(JsonValue v, String name) {
return switch (name) {
case "big number" -> "asDouble=" + v.get("v").asDouble() + ", toString=" + v.get("v");
case "123.0 as int", "\"42\" as int" -> "asInt=" + v.get("v").asInt();
default -> v.toString();
};
}
private String describeJackson(JsonNode n, String name) {
return switch (name) {
case "big number" -> "asDouble=" + n.get("v").asDouble() + ", toString=" + n.get("v");
case "123.0 as int", "\"42\" as int" -> "asInt=" + n.get("v").asInt();
default -> n.toString();
};
}
private static String attempt(java.util.function.Supplier<String> s) {
try {
return s.get();
} catch (RuntimeException e) {
String msg = e.getMessage() == null ? "" : e.getMessage().lines().findFirst().orElse("");
return e.getClass().getSimpleName() + ": " + msg;
}
}
}
启动日志里的输出:
08:09:29.884 INFO StrictnessReport : Jackson 3.1.5
08:09:29.914 INFO StrictnessReport : [duplicate key] {"id":1,"id":2}
08:09:29.914 INFO StrictnessReport : JDK -> JsonParseException: Duplicate member name: "id" was already parsed. Path: "{". Location: line 0, position 8.
08:09:29.914 INFO StrictnessReport : Jackson -> {"id":2}
08:09:29.915 INFO StrictnessReport : [trailing comma] {"a":1,}
08:09:29.915 INFO StrictnessReport : JDK -> JsonParseException: Expecting a JSON Object member name. Path: "{". Location: line 0, position 7.
08:09:29.915 INFO StrictnessReport : Jackson -> StreamReadException: Unexpected character ('}' (code 125)): was expecting double-quote to start property name
08:09:29.915 INFO StrictnessReport : [comment] {"a":1 /* note */}
08:09:29.915 INFO StrictnessReport : JDK -> JsonParseException: JSON Object is not closed with a brace. Path: "{". Location: line 0, position 7.
08:09:29.915 INFO StrictnessReport : Jackson -> StreamReadException: Unexpected character ('/' (code 47)): maybe a (non-standard) comment? (not recognized as one since Feature 'ALLOW_COMMENTS' not enabled for parser)
08:09:29.916 INFO StrictnessReport : [single quotes] {'a':1}
08:09:29.916 INFO StrictnessReport : JDK -> JsonParseException: Expecting a JSON Object member name. Path: "{". Location: line 0, position 1.
08:09:29.916 INFO StrictnessReport : Jackson -> StreamReadException: Unexpected character (''' (code 39)): was expecting double-quote to start property name
08:09:29.920 INFO StrictnessReport : [big number] {"v":12345678901234567890.123}
08:09:29.921 INFO StrictnessReport : JDK -> asDouble=1.2345678901234567E19, toString=12345678901234567890.123
08:09:29.921 INFO StrictnessReport : Jackson -> asDouble=1.2345678901234567E19, toString=1.2345678901234567E19
08:09:29.921 INFO StrictnessReport : [123.0 as int] {"v":123.0}
08:09:29.921 INFO StrictnessReport : JDK -> asInt=123
08:09:29.921 INFO StrictnessReport : Jackson -> asInt=123
08:09:29.923 INFO StrictnessReport : ["42" as int] {"v":"42"}
08:09:29.923 INFO StrictnessReport : JDK -> JsonValueException: JsonString is not a JsonNumber. Path: "{v". Location: line 0, position 5.
08:09:29.923 INFO StrictnessReport : Jackson -> asInt=42
尾逗号、注释、单引号,两边在默认配置下都拒绝,Jackson 要靠打开对应的 Feature 才放行,JDK 版本没有开关。真正有差别的是下面三处。
重复 key:Jackson 默认静默保留后一个值,得到 {"id":2}。JDK 版本直接解析失败。JEP 在 Alternatives 一节专门讲了这个取舍:多个 JSON 库对重复 key 取值不一致,会带来难排查的错误和安全问题,所以它无条件报错。Jackson 也能做到同样效果,需要打开 StreamReadFeature.STRICT_DUPLICATE_DETECTION,后面一起验证。
超长小数:readTree 默认把 12345678901234567890.123 读成 double,toString 已经变成 1.2345678901234567E19,原文丢了。JDK 版本的 JsonNumber 保留原始文本,toString 原样输出,要无损就 new BigDecimal(jn.toString())。Jackson 这边要打开 DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS 才能保住精度。
字符串型数字:"42" 在 Jackson 的 asInt() 里会被转成 42,JDK 版本直接报类型不对。对接那些把数字写成字符串的老接口时,JDK 版本需要你自己 Integer.parseInt(v.asString()),或者像 JEP 里那样用 switch 按 JsonNumber / JsonString 分支处理。
123.0 两边都能转成 int。JEP 写明了 asInt() 只要数值是整数就接受,345.6 或超出 int 范围才抛异常。
前两处在 Jackson 里都能靠开关补上。我用应用里同一套 Jackson jar(databind 3.1.5、core 3.1.5、annotations 2.21)单独跑了一段:
import tools.jackson.core.StreamReadFeature;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.json.JsonMapper;
void main() {
var strict = JsonMapper.builder()
.enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION)
.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
.build();
try {
IO.println(strict.readTree("{\"id\":1,\"id\":2}"));
} catch (RuntimeException e) {
IO.println(e.getClass().getSimpleName() + ": " + e.getMessage().lines().findFirst().orElse(""));
}
IO.println(strict.readTree("{\"v\":12345678901234567890.123}").get("v"));
}
StreamReadException: Duplicate Object property "id"
12345678901234567890.123
这段是直接用 JsonMapper.builder() 跑的,没有经过 Spring Boot 的自动配置。Spring Boot 4.1.1 里另有 JsonMapperBuilderCustomizer 这个扩展点用来定制自动配置的 JsonMapper,这次没有测它。JDK 这边 Json 只有 parse 和 toDisplayString,没有任何放宽解析的选项。
孵化状态
它现在是孵化模块,每次编译和运行都要显式加 --add-modules,启动时会打出孵化警告。JEP 的 Non-Goals 写的是不打算取代现有的外部 JSON 库,按孵化 API 的惯例,后续版本里包名和方法都可能调整。
参考:
一起交流
分享你的思考,让讨论更进一步。