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 的惯例,后续版本里包名和方法都可能调整。

参考:

— 感谢阅读 —

一起交流

分享你的思考,让讨论更进一步。