jdaugherty commented on PR #16414:
URL: https://github.com/apache/grails-core/pull/16414#issuecomment-5962785988
I asked AI to summarize some of the differences to help this discussion,
it's included below (it was done against the 7.x branch, but I think this
comparison brings to light several of the problems):
# Grails JSON: OpenJSON versus Jackson
**Jackson is the stronger long-term option, but neither replacement
preserves Grails behavior automatically.** OpenJSON is closer to the original
API; Jackson offers more control over parsing, numeric precision, and resource
limits.
I compared the Grails `7.0.x` source and ran parsing probes against **Grails
7.0.4, OpenJSON 1.0.13, Jackson 2.20.0, and Jackson 3.0.0**. Jackson results
below use untyped `Object`/`Map`/`List` deserialization, not POJO binding.
These are compatibility findings for those versions, not a test of the
particular proposal.
**The syntax differences are substantial.** Grails deliberately accepts
several nonstandard forms, documented in its API. Jackson rejects most by
default, with optional features for selected extensions. Sources: [Grails
JSONObject API][grails-object] and [Jackson JsonReadFeature API][jackson-read].
| Input or behavior | Grails fork | OpenJSON | Jackson defaults |
|---|---|---|---|
| Single quotes: `{'x':'a'}` | Accepts | Accepts | Rejects; configurable |
| Comments: `//`, `/* */`, `#` | Accepts | Accepts | Rejects; configurable |
| Unquoted string value: `{x:hello}` | Accepts | Accepts | Rejects; allowing
unquoted **field names** does not permit bare string values |
| Uppercase literals: `{"x":TRUE}` | Boolean `true` | Boolean `true` |
Rejects |
| Alternate separators: `{x=>1;y:2}` | Accepts | Accepts | Rejects |
| Object trailing comma: `{"x":1,}` | Accepts | **Rejects** | Rejects;
configurable |
| Array trailing comma: `[1,]` | `[1]` | **`[1,null]`** | Rejects;
configurable |
| Missing array element: `[1,,2]` | `[1,null,2]` | `[1,null,2]` | Rejects;
configurable |
| Octal number: `{"x":010}` | Integer `8` | Integer `8` | Rejects;
permitting leading zeros interprets this as decimal `10` |
| Numeric object key: `{12:3}` | Key becomes `"12"` | Rejects | Rejects by
default |
| Hex string escape: `{"x":"\x41"}` | String `"A"` | **String `"x41"`** |
Rejects |
| Literal newline inside a quoted string | Rejects | Accepts | Rejects;
configurable |
| `{"x":new Date(0)}` | Java `Date` | Rejects | Rejects |
| Duplicate keys: `{"x":1,"x":2}` | Last value wins | Last value wins | Last
value wins; rejection can be enabled |
| Valid object followed by garbage | Ignores suffix | Ignores suffix |
Jackson 2 ignores it by default; **Jackson 3 rejects it** |
The trailing-content difference is a documented Jackson 3 default change:
`FAIL_ON_TRAILING_TOKENS` became enabled. Source: [Jackson 3 migration
guide][jackson-migration].
That exposes a weakness in the “OpenJSON is a compatible replacement”
argument: it can both reject previously accepted input **and silently change
the resulting data**.
**Numeric handling is probably the most consequential difference—even for
valid JSON.**
These are results from the probes:
| Numeric token | Grails | OpenJSON | Jackson, untyped defaults |
|---|---|---|---|
| `42` | `Integer` | `Integer` | `Integer` |
| `9223372036854775808` | Exact `BigInteger` | Rounded `Double` | Exact
`BigInteger` |
| `0.123456789012345678901` | Exact `BigDecimal` | Rounded `Double` |
Rounded `Double` |
| `1.00` | `BigDecimal`, scale 2 | `Double` `1.0` | `Double` `1.0` |
| `1e3` | `BigDecimal` | `Double` | `Double` |
Grails uses a custom selection rule: integral numbers generally become
`Integer`, `Long`, or `BigInteger`; other numbers become `Double` only when its
`BigDecimal` comparison succeeds, otherwise they remain `BigDecimal`.
Consequently, **scale and notation can affect the Java type**.
Jackson can preserve decimal precision with `USE_BIG_DECIMAL_FOR_FLOATS`,
but that makes *all* untyped floating-point values `BigDecimal`. It does not
reproduce Grails’ mixed `Double`/`BigDecimal` rule. Exact compatibility would
need custom numeric conversion, ideally using the original token text. Source:
[Jackson DeserializationFeature API][jackson-deser].
OpenJSON’s parser instead tries `Integer`, `Long`, then `Double`. Moving to
it without adaptation would undo Grails’ large-number preservation. Source:
[OpenJSON JSONTokener source][openjson-tokener].
**The returned object model is a separate compatibility problem.**
- **Grails:** `JSONObject` implements `Map`; `JSONArray` implements `List`.
Parsed JSON null becomes Java `null`.
- **OpenJSON:** similarly named containers, but they do not implement those
collection interfaces. Parsed null uses `JSONObject.NULL`.
- **Jackson:** untyped binding gives ordinary maps/lists and Java `null`;
tree parsing gives `ObjectNode`, `ArrayNode`, and `NullNode`.
Grails’ collection interfaces are part of its public API. Replacing them
affects casts, collection operations, nested access, and callers expecting the
Grails classes—not merely imports. Sources: [Grails JSONObject
API][grails-object] and [Grails JSONArray API][grails-array].
My assessment of the tradeoffs:
| Choice | Pros | Cons |
|---|---|---|
| **Keep the Grails fork** | Best preservation of existing syntax, numeric
rules, and public types | Grails continues maintaining parser code, quirks, and
defensive limits |
| **OpenJSON** | Small standalone dependency; familiar API; Apache-licensed
clean-room implementation | Precision regressions; subtle syntax changes; null
sentinel and collection-interface differences still require adaptation |
| **Jackson** | Streaming support; configurable syntax, duplicate handling,
precision, and processing limits; broader integration opportunities | Greater
migration scope; defaults differ; cannot reproduce every legacy extension
through feature switches |
OpenJSON explicitly describes itself as an Android-derived clean-room
implementation, rather than the same source Grails forked. Its inspected parser
also has a default nesting limit of 100. Jackson provides configurable limits
for nesting and number/string lengths through `StreamReadConstraints`. These
limits themselves need compatibility testing. Sources: [OpenJSON
project][openjson-project], [OpenJSON JSONTokener source][openjson-tokener],
and [Jackson processing-limit documentation][jackson-limits].
I would not assume a performance winner without measuring the actual Grails
path. Jackson’s streaming advantage can disappear if the implementation
constructs a Jackson tree and then copies it into Grails containers.
**My recommendation is Jackson behind a Grails compatibility facade**, with
explicit decisions about which legacy behaviors to retain.
An alternative framing is to separate two proposals:
1. **Replace the parsing engine:** preserve Grails’ public containers, null
handling, numeric contract, and exception behavior.
2. **Modernize the JSON contract:** deliberately reject legacy syntax or
change returned types in a documented breaking release.
That separation makes the proposal much easier to evaluate. Switching
parsers should not implicitly switch Grails databinding or JSON marshalling to
Jackson POJO behavior.
Before accepting either replacement, I’d require a differential test suite
covering the examples above, nested containers, missing versus explicit null,
numeric precision and scale, errors, and size/depth limits. **OpenJSON needs
that suite just as much as Jackson does; API resemblance is insufficient
evidence of compatibility.**
[grails-object]:
https://grails.apache.org/docs/7.0.4/api/org/grails/web/json/JSONObject.html
[grails-array]:
https://grails.apache.org/docs/latest/api/org/grails/web/json/JSONArray.html
[jackson-read]:
https://javadoc.io/static/com.fasterxml.jackson.core/jackson-core/2.16.2/com/fasterxml/jackson/core/json/JsonReadFeature.html
[jackson-migration]:
https://github.com/FasterXML/jackson/blob/main/jackson3/MIGRATING_TO_JACKSON_3.md
[jackson-deser]:
https://fasterxml.github.io/jackson-databind/javadoc/2.14/com/fasterxml/jackson/databind/DeserializationFeature.html
[openjson-tokener]:
https://github.com/openjson/openjson/blob/master/src/main/java/com/github/openjson/JSONTokener.java
[openjson-project]: https://github.com/openjson/openjson
[jackson-limits]:
https://github.com/FasterXML/jackson/wiki/Jackson-Release-2.15
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]