Module: io.github.microsphere-projects:microsphere-java-core · Source line: release branch, 0.3.20-SNAPSHOT
Package analysed: io.microsphere.json — 8 main-source types (JSON, JSONArray, JSONException,
JSONObject, JSONStringer, JSONTokener, JSONUtils, package-info.java).
Companion user-facing page: User Guide — JSON.
All file:line references are relative to microsphere-java-core/src/main/java/io/microsphere/
(main) or microsphere-java-core/src/test/java/io/microsphere/ (test), read from the release worktree,
then shortened to json/JSONUtils.java:40-55.
io.microsphere.json is not a design choice the project made for its own sake; it is a vendored fork. The
package declares that in one line: json/package-info.java:19 — "Fork the source code from org.json" — and six of
the eight files repeat it in their licence header (Copyright (C) 2010 The Android Open Source Project at
json/JSON.java:2, json/JSONArray.java:2, json/JSONException.java:2, json/JSONObject.java:2,
json/JSONStringer.java:2, json/JSONTokener.java:2). Only json/JSONUtils.java and json/package-info.java are
Microsphere-original and carry the ASF header plus @author Mercy / @since 1.0.0
(json/JSONUtils.java:2-16, :109-112; json/package-info.java:2-16, :21-22).
Four reasons make the fork the right call for this codebase, and they are all verifiable from the tree:
- Zero-dependency packaging.
microsphere-java-core/pom.xml:22-74declares exactly one compile dependency (microsphere-java-annotations) plus optional logging back-ends. There is no Jackson, Gson ororg.jsonartifact anywhere in the reactor. A library whose selling point is "almost empty classpath" (docs/current/developer-guide/microsphere-java-core/README.md:11-12) cannot route its metadata documents through a third-party JSON stack. metadataneeds the output, not the model. The configuration-property generator writes JSON by streamingname:valuepairs into a caller-suppliedStringBuilder— that isJSONUtils.append(json/JSONUtils.java:146-467), used atmetadata/DefaultConfigurationPropertyGenerator.java:29-30,88-117. None of the third-party trees would survive that call pattern unchanged.- A stable, familiar surface.
JSONObject/JSONArray/JSONTokener/JSONStringer/JSONExceptionis the Androidorg.jsonAPI, which is frozen upstream, so the fork carries no invention risk. - The bridge is where the value is.
JSONUtils(1480 lines — larger than the six forked classes combined) is what turns JSON intoList/Set/Queue/Enumeration/arrays/JavaBeans and back (json/JSONUtils.java:884-1212), and it does it through the library's ownbeans,convert,reflectandcollectionlayers. That integration is the reason the package is incoreat all.
Boundary it owns: io.microsphere.json owns the JSON object model, the JSON text grammar, and the JSON↔Java
bridge. It owns no transport, no charset negotiation, no Reader/InputStream parsing (the tokener reads only a
String, json/JSONTokener.java:75,87-93), no type registry and no Mapper-style configuration. It is
additive to io.serializer rather than part of it — the serializer family covers binary Object↔byte[] only.
The developer index puts json in the "Service layers" band, whose contract column reads
"Each is an SPI family discovered through META-INF/services, ordered by Prioritized"
(docs/current/developer-guide/microsphere-java-core/README.md:70).
That is not true of io.microsphere.json, and the index's own diagram already reflects it: of the eight
packages given a dashed -.runtime SPI.-> edge (README.md:55-62), JSON is the one node in the Services subgraph
that has none (README.md:29). Verified against the tree:
microsphere-java-core/src/main/resources/META-INF/services/contains 14 files — forArtifactResourceResolver,URLClassPathHandle,Converter,MultiValueConverter,EventDispatcher,Serializer,Deserializer,LoggerFactory, threemetadatainterfaces,ExtendableProtocolURLStreamHandlerandProcessIdResolver. There is noio.microsphere.json.*service file.- No type in the package implements, references or is ordered by
io.microsphere.lang.Prioritized.
So io.microsphere.json is a plain value-model/utility package, not an SPI family, and the index table row should
either drop json from that band or re-word the contract. The diagram's other omission is worth noting for anyone
reading it as a dependency graph: json has exactly one incoming edge (REFL --> JSON, README.md:48), yet the
package genuinely imports collection, util, constants and lang.function too (§10).
| # | Type | Kind | Visibility | Lines | Origin | Role |
|---|---|---|---|---|---|---|
| 1 | JSONObject |
class | public |
888 | AOSP fork | ordered name→value map; hosts NULL, wrap, quote, numberToString (json/JSONObject.java:83, :101, :859, :831, :803) |
| 2 | JSONArray |
class | public |
747 | AOSP fork | dense indexed List wrapper with the same getter family (json/JSONArray.java:76, :78) |
| 3 | JSONTokener |
class | public |
583 | AOSP fork | lexer + recursive-descent parser over one String (json/JSONTokener.java:70-75) |
| 4 | JSONStringer |
class | public |
478 | AOSP fork | the model's only writer; Scope stack + indent (json/JSONStringer.java:70-75, :81-118) |
| 5 | JSONException |
class | public |
47 | AOSP fork | the package's only checked exception, one constructor (json/JSONException.java:41-45) |
| 6 | JSON |
class | package-private, final-by-private-ctor |
127 | AOSP fork | coercion helpers + checkDouble + the two typeMismatch factories (json/JSON.java:25, :27, :108, :117) |
| 7 | JSONUtils |
abstract class … implements Utils |
public |
1480 | Microsphere | StringBuilder writer (34 append overloads) and the JSON↔collections/beans reader (json/JSONUtils.java:113, :146-467, :884-1212) |
| 8 | package-info.java |
package doc | — | 25 | Microsphere | records the fork (json/package-info.java:19-22) |
Three structural facts fall out of the table and are worth stating before the source analysis:
- The fork keeps Android's internal split:
JSONis package-private, so every coercion rule is shared byJSONObjectandJSONArrayrather than duplicated — both import the same five statics (json/JSONObject.java:30-35,json/JSONArray.java:26-31). JSONStringer's output buffer is package-visible (final StringBuilder out,json/JSONStringer.java:75) andJSONArray.joinwrites to it directly (json/JSONArray.java:684,689). Two of the eight types are therefore coupled through a field, not a method.JSONObjectandJSONUtilsimport each other:JSONObjectpullsisJSONObject/isJSONArray(json/JSONObject.java:36-37) whileJSONUtilspullswrap(json/JSONUtils.java:64). At the type level that is a real cycle inside one package; it compiles only because both directions are static utility calls.
The entire lexer state is private final String in + private int pos (json/JSONTokener.java:75, :81). The
constructor strips an optional UTF-8 BOM and otherwise stores null happily, which the javadoc admits produces a
tokener that "throws NullPointerExceptions when methods are called" (:84-85, code :87-93). There is no
Reader, no buffer, no charset handling — the input is one in-memory String.
Four package-private accessors do all character movement: charAt(int) :567-569, currentChar() :559-561,
nextChar() :563-565 (charAt(this.pos++)), and the parser backtracks by mutating pos directly —
this.pos-- at :119, :375, :445 — rather than through the public back() helper (:553-557). Because
charAt never bounds-checks, every "is there more input" question must be answered by hasNext() (:501-503)
first; the parse path does this consistently, the legacy API path partly does not (next(char) at :505-511
substitutes '\0' for exhaustion and then reports it as a mismatch).
nextValue() is the whole grammar (json/JSONTokener.java:102-122): it calls nextCleanInternal() once, then
switches on the character. nextCleanInternal() (:124-176) is the lexer proper — a loop that discards
\t\n\r, consumes /* … */ (unterminated ⇒ syntaxError("Unterminated comment"), :141-149), // …
(:151-155) and # … (:161-168), and returns any other character; a bare / that is not a comment opener is
returned as a literal (:157-158). Exhaustion returns the -1 sentinel (:175).
flowchart TD
IN["nextValue() JSONTokener.java:102-122"] --> CLEAN["nextCleanInternal() :124-176"]
CLEAN --> WS{"char class?"}
WS -- "space / tab / CR / LF" --> CLEAN
WS -- "'/' then '*'" --> CMT["skip /* … */ to indexOf(\"*/\")<br/>:141-149 — unterminated → syntaxError"]
WS -- "'/' then '/'" --> EOL1["skipToEndOfLine() :151-155, :182-190"]
WS -- "'#'" --> EOL2["skipToEndOfLine() — non-RFC leniency :161-168"]
CMT --> CLEAN
EOL1 --> CLEAN
EOL2 --> CLEAN
WS -- "-1 (exhausted)" --> EOIE["throw syntaxError(\"End of input\")<br/>:105-106, :470-472"]
WS -- "'{'" --> RO["readObject() :367-413"]
WS -- "'['" --> RA["readArray() :423-461"]
WS -- "'\"' or \"'\"" --> NS["nextString(quote) :203-240"]
WS -- "anything else" --> PUSH["this.pos-- (un-read one char) :119"]
PUSH --> RL["readLiteral() :292-340"]
NS --> ESC{"c == '\\\\' ?"}
ESC -- yes --> RE["readEscapeCharacter() :251-283<br/>u → Integer.parseInt(hex,16) :258-260"]
ESC -- no --> NS
RE --> NS
RL --> LIT{"literal form?"}
LIT -- "null/true/false, ignore-case" --> SENT["NULL / TRUE / FALSE :297-303"]
LIT -- "no '.' → parseLong base 10/16/8" --> PROMOTE["≤ Integer range → int<br/>else → long :306-322"]
LIT -- "valueOf(literal)" --> DBL["Double :332-336"]
LIT -- "NumberFormatException" --> RAW["bare String :338-339"]
The three composite paths recurse back into nextValue(): readObject calls it for the name (:379) and the
value (:401); readArray calls it for each element (:448). That is the whole parser — one dispatcher, two
bracket loops, one string scanner, one literal scanner.
readObject accepts : or = as the separator and then eats a following > to allow =>
(json/JSONTokener.java:388-399), and accepts ; as well as , between pairs (:403-411). readArray treats a
separator with no preceding value as an element and inserts a Java null — result.put(null) at :438-443,
and again for a trailing ,:] at :433-437. Its own javadoc spells out the consequence: "[]" is empty but
"[,]" is a two-element array equivalent to [null,null] (:415-418).
That is the single most important internal fact about the parse path: the class javadoc lists nine accepted
syntax errors as deliberate non-errors (json/JSONTokener.java:44-62), so nextValue() succeeding says nothing
about the input being JSON. Note also that the two null flavours produced here are not the same object —
readLiteral returns the NULL sentinel for the word null (:297-298) while readArray puts the raw Java
null for a dangling separator (:441). One parser, two null outputs, chosen by which rule matched.
sequenceDiagram
autonumber
participant Caller
participant Obj as JSONObject
participant Tok as JSONTokener
participant Lit as JSON (pkg-private)
participant Str as JSONStringer
Caller->>Obj: new JSONObject("{a:1,b:[2,true]}") json/JSONObject.java:178-180
Obj->>Tok: this(new JSONTokener(json))
Note over Tok: ctor strips BOM, stores the whole String JSONTokener.java:87-93
Obj->>Tok: nextValue() :159 → JSONTokener.java:102
Tok->>Tok: nextCleanInternal() → '{' :103, :124-176
Tok->>Tok: readObject() :109 → :367
Tok->>Tok: nextCleanInternal() → 'a' :371
Tok->>Lit: nextValue() → readLiteral() → Integer 1 :379, :292-340
Note over Tok: separator '=' accepted, no colon present :393-399
Tok->>Obj: result.put("a", Integer.valueOf(1)) :401
Note over Obj: put(name,Object) → checkDouble for Number JSONObject.java:279-283
Tok->>Tok: nextCleanInternal() → ',' :403-408
Tok->>Tok: nextValue() → readLiteral() → "b" :379
Tok->>Lit: nextValue() → '[' → readArray() :401, :423
Tok->>Lit: put(Integer 2), put(TRUE) :448, :299-302
Tok->>Obj: result.put("b", JSONArray) :401
Tok->>Tok: nextCleanInternal() → '}' → return :403-405
Obj->>Obj: this.nameValuePairs = the map the tokener built JSONObject.java:163-165
Note over Obj: "steal the data" comment at JSONObject.java:159-162
Caller->>Str: (later) toString(2) → writeTo(stringer) JSONObject.java:762-774
Str->>Str: value(JSONArray) → JSONArray.writeTo(this) JSONStringer.java:266-271
The map hand-off is the trick the fork inherited and the source comments openly: "Getting the parser to
populate this could get tricky. Instead, just parse to temporary JSONObject and then steal the data from that"
(json/JSONObject.java:159-162, code :163-165; the array mirror is json/JSONArray.java:121-127). The
temporary object becomes garbage on every new JSONObject(String) — two allocations where one would do, in exchange
for keeping nameValuePairs final.
Neither readObject (json/JSONTokener.java:367-413) nor readArray (:423-461) carries a counter, and neither
checks anything like a maximum nesting level; the only stop conditions are the closing bracket and -1. Combined
with JSONStringer's field comment "Unlike the original implementation, this stack isn't limited to 20 levels of
nesting" (json/JSONStringer.java:120-124) — which flatly contradicts its own class javadoc at :61-62 — the
package has no depth protection on either side. Deep input therefore fails with StackOverflowError, an
Error outside both JSONException and RuntimeException. There is no test for it anywhere in
json/JSONTokenerTest.java or json/JSONStringerTest.java.
JSONObject stores everything in one final field: private final Map<String, Object> nameValuePairs
(json/JSONObject.java:116), and every construction route reaches it through the no-arg constructor's
newLinkedHashMap() (:121-123). That helper is the library's own — collection/MapUtils.java:574-577 returns a
plain LinkedHashMap<>() with no capacity argument — so:
- iteration order is insertion order, always, including after
putof an existing key (whichLinkedHashMapdoes not re-order) and afterremove+re-put(which moves the key to the end); toString()output is deterministic, which is whyjson/JSONObjectTest.java:419can assert an exact string;- the map is never wrapped, copied or made unmodifiable —
keys()iteratesnameValuePairs.keySet()directly (:722-724) andnames()builds an array from it (:732-734), so both are live views.
That last point collides with the documentation. keys()' javadoc states "the order of the keys is undefined"
(json/JSONObject.java:715-716) — inherited from the AOSP text, written when the field could legitimately be a
HashMap. On this branch it is provably ordered, and json/JSONObjectTest.java:381-392 asserts exactly that order.
A reader who trusts the javadoc will avoid depending on behaviour the code guarantees; that is the wrong direction of
surprise, and the javadoc should be corrected rather than the field changed.
Write paths. put(String,Object) is the only one with real semantics (json/JSONObject.java:274-285): a Java
null value removes the key (:275-278), any Number goes through JSON.checkDouble (:279-283, comment at
:280-281 explains the deliberate deviation from the original which only checked floats/doubles), and only then
doPut (:340-343) after checkName rejects a null name (:345-350). The four primitive overloads
(:218-260) box and delegate, and still declare throws JSONException because checkName does. accumulate
(:317-338) repeats the checkDouble guard for the new value only — and the comment at :323 says why:
"check in accumulate, since array.put(Object) doesn't do any checking". That comment is an admission, not an
explanation: JSONArray.put(Object) really is unguarded (json/JSONArray.java:225-228), so accumulate(name, NaN)
is checked while array.put((Object) NaN) is not, and the failure only surfaces at serialization time
(json/JSONObject.java:803-809 → json/JSONStringer.java:278-279).
Read paths. get(String) distinguishes absent from sentinel: nameValuePairs.get(name) returning null
throws "No value for <name>" (json/JSONObject.java:393-399), while a NULL value passes through because it is a
real object. But getBoolean and getString start from opt() instead of get() (:421, :594), so for them a
missing key and an explicit null both surface as "Value at <name> is null." from JSON.typeMismatch
(json/JSON.java:108-115). Seven getters, two error vocabularies, decided by which helper the getter happens to
call. isNull (:370-373) and has (:382-384) are the pair that lets you tell the two nulls apart, and
isNull uses reference identity (value == NULL) plus a null check — it does not call the sentinel's broken
equals.
The sentinel. NULL is an anonymous Object subclass field (json/JSONObject.java:101-114) that overrides
equals to accept null — with the in-line comment // API specifies this broken equals implementation
(:105-106) and the class javadoc stating the contract violation explicitly (:98-99). Its toString() returns
"null" (:109-112), which is what makes it serialise correctly without special-casing
(json/JSONStringer.java:276-277). Because equals is asymmetric here, NULL.equals(null) is true while
null.equals is unreachable, and NULL is a singleton only by convention — nothing prevents a second anonymous
instance elsewhere. wrap therefore tests o == null || o.equals(NULL) (json/JSONObject.java:861), invoking
caller-controlled equals on the very first line of the conversion ladder.
Equality. equals compares the backing maps (json/JSONObject.java:777-788) and hashCode delegates to the
map's (:790-793); equals is not final while JSONArray.equals is (json/JSONArray.java:728-739).
Because the sentinel breaks equals symmetry, two JSONObjects holding NULL compare equal (both maps see
NULL.equals(NULL)), which is intended — but the same is true of a JSONObject holding NULL versus one holding
Java null, and the latter can never be built through put (:275-278), only through readArray's dangling
separator (json/JSONTokener.java:441) or the Map constructor (json/JSONObject.java:147, which stores
wrap(value) — and wrap(null) returns NULL, so the Map route does normalise).
JSONArray holds private final List<Object> values (json/JSONArray.java:78) created by
ListUtils.newArrayList() (:83-85, helper at collection/ListUtils.java:313-315 → plain ArrayList). The
primitive-array constructor is the one place that sizes it up front — newArrayList(length)
(json/JSONArray.java:155, helper at collection/ListUtils.java:337-339) — and getLength/Array.get come from
java.lang.reflect.Array, so any array type is accepted, not just primitives, even though the javadoc promises
"primitive array" (:144-149) and the failure message says "Not a primitive array: " (:150-153).
Four constructors, three different null disciplines:
JSONArray(Iterable) guards its argument (:104-111, :106); JSONArray(Enumeration) does not — the raw
while (enumeration.hasMoreElements()) at :88-94 NPEs on a null argument, breaking the pattern its sibling
establishes; JSONArray(JSONTokener) steals the parsed list (:120-131) and throws
typeMismatch(object, "JSONArray") (json/JSON.java:117-123) when the tokener's first value is not an array;
JSONArray(Object array) rejects non-arrays with a checked JSONException (:150-153).
put(int, Object) is the only indexed writer that grows the list itself, null-padding the gap
(json/JSONArray.java:300-310, the padding loop at :305-307), which means put(5, x) on an empty array produces
a list of six entries whose first five are Java null — not NULL. Those five elements then fail get(int)
with "Value at 0 is null." (:333-343, :336-338), pass isNull(int) (:319-322), return null from
opt(int) (:352-357) — because opt bounds-checks but does not null-normalise — and serialise as null
(json/JSONStringer.java:276-277, since out.append(null) appends the four characters null). So
[null,null,null,null,null,"x"] round-trips through toString() yet its elements are not addressable through
get. Same story for readArray's dangling separators (§3.3). This is the concrete origin of the dual-null warning
in the class javadoc (json/JSONArray.java:49-52).
equals on the list (json/JSONArray.java:728-739) means two arrays differing only in Java-null vs NULL
elements are unequal even though both print identically — ArrayList.equals uses Objects.equals, and
NULL.equals(null) is true while null.equals(NULL) never runs, so the comparison is element-order-dependent.
It is final, unlike JSONObject.equals (§4).
JSONStringer is a state machine over a StringBuilder, and its only state is the scope stack —
private final List<Scope> stack = newArrayList() (json/JSONStringer.java:124). Scope is a package-private
nested enum with six constants (:81-118), five of them real grammar states and one (NULL) a hack for
bracketless output. Separators and nesting validity are derived entirely from the top of that stack, via
peek() (:236-241), replaceTop() (:248-250), beforeKey() (:421-430) and beforeValue() (:439-457).
stateDiagram-v2
[*] --> Nothing: out.length()==0 and stack empty
Nothing --> EMPTY_ARRAY: array() open(EMPTY_ARRAY,"[") :153-155, :196-204
Nothing --> EMPTY_OBJECT: object() open(EMPTY_OBJECT,"{") :174-176
EMPTY_ARRAY --> NONEMPTY_ARRAY: beforeValue() :445-447
NONEMPTY_ARRAY --> NONEMPTY_ARRAY: beforeValue() appends ',' :448-450
NONEMPTY_ARRAY --> Nothing: endArray() close(EMPTY_ARRAY,NONEMPTY_ARRAY,"]") :163-165, :216-228
EMPTY_OBJECT --> DANGLING_KEY: beforeKey() replaceTop(DANGLING_KEY) :421-430
DANGLING_KEY --> NONEMPTY_OBJECT: beforeValue() appends ":" or ": " :451-453
NONEMPTY_OBJECT --> DANGLING_KEY: beforeKey() appends ',' :423-429
NONEMPTY_OBJECT --> Nothing: endObject() :184-186
Nothing --> NULL: open(Scope.NULL,"") used by quote() and join() JSONObject.java:837 / JSONArray.java:681
NULL --> Nothing: close(NULL,NULL,"") JSONObject.java:839 / JSONArray.java:688
EMPTY_OBJECT --> Error: beforeValue() before beforeKey() → "Nesting problem" :454-456
NONEMPTY_OBJECT --> Error2: array() when stack empty and out non-empty → "multiple top-level roots" :197-199
NULL --> Error3: key() in Scope.NULL → beforeKey() peek()==NULL → "Nesting problem" :425-427
Indent is a per-instance constant, not a per-call argument: the constructor builds one space string
(char[indentSpaces] filled by Arrays.fill) and anything <= 0 sets indent = null
(json/JSONStringer.java:136-144). newline() then appends \n plus indent repeated once per stack entry
(:388-397), which is why the closing bracket sits one level out — close() pops before calling newline()
(:222-225). beforeValue() picks ":" vs ": " off the same field (:452), so compact and pretty output differ
in exactly one character per pair, and the shape asserted at json/JSONObjectTest.java:420
("{\n \"name\": \"Mercy\",\n …\n}") is that combination and nothing more.
Escaping is the switch at json/JSONStringer.java:338-386: ", \ and / are backslash-escaped in one
branch (:349-353), six named escapes follow, and everything <= 0x1F becomes format("\\u%04x", c) (:375-381).
U+2028/U+2029 are not escaped here — the comment at :343-347 cites only RFC 4627 — while JSONUtils.escape
does escape them (json/JSONUtils.java:115-117, :1358-1361) and does not escape / (its table at :130-144
has no / entry). The two encoders are byte-incompatible in both directions, and json/JSONStringerTest.java:188-194
and json/JSONUtilsTest.java:806-815 each pin only their own half.
Scope.NULL is how JSONObject.quote(String) and JSONArray.join(String) reuse the machine for bracketless
output: both open(Scope.NULL, "") and close(Scope.NULL, Scope.NULL, "")
(json/JSONObject.java:835-841; json/JSONArray.java:680-689). beforeValue() never touches the stack for that
scope — it falls through every branch to the context != Scope.NULL test at :454, which is false, so nothing is
appended and nothing is replaced (:439-457). That is the only way join can interleave a separator —
and join does it by writing straight into stringer.out (:684) and returning stringer.out.toString() (:689)
instead of stringer.toString(). That matters: JSONStringer.toString() returns null for an empty buffer,
violating Object.toString by its own admission (:465-466, :470-473), so quote would return null for null
input if it did not short-circuit at json/JSONObject.java:832-834.
JSONUtils (1480 lines) is where the fork stops. Two responsibilities live in one class, and they are architecturally
unrelated:
(a) A StringBuilder writer with no object model. 34 append(StringBuilder, name, value) overloads
(json/JSONUtils.java:146-467) plus appendValue (:469-486), appendArray (:488-498),
appendName (:500-508) and the private helpers appendMap/appendIterable/appendString/appendType
(:1388-1425). It is the path metadata uses, because it needs no tree
(metadata/DefaultConfigurationPropertyGenerator.java:88-117). Design observations:
appendNamewrites'"' + name + '"' + ':'with no escaping at all (:500-506), and — worse — wraps the whole write inif (isNotBlank(name))(:501), so anullor blank name emits nothing, not even the colon. The followingappendvalue then lands adjacent to whatever preceded it. The caller has no signal.appendValue's first statement isvalue.getClass()(:469-470), so anullvalue is anNullPointerException— and viaappendMap/appendIterable(:1388-1415) a singlenullinside any map or collection propagates the same way. The model classes are tolerant ofnull; this class is not.- No finiteness check exists anywhere in the class, so
append(builder, "n", Double.NaN)emitsNaN(:176-179and theelsebranch at:483-485). The model path refuses the same value (json/JSON.java:27-32). REPLACEMENT_CHARSis aString[128]indexed bychar(:130-144), soescapeis O(n) with abuilder == nullfast path — but only when the input actually needs no escaping.
(b) The JSON↔Java bridge. readValue/readValues/readArray in nine overloads (:884-1212) plus
writeValueAsString (:1234-1241) and writeBeanAsString (:1272-1277). The mechanism composes four other core
packages: ConstructorUtils.newInstance(true, beanClass) for instantiation and MethodUtils.invokeMethod for the
setter call, BeanUtils.getBeanMetadata/findWriteMethod to locate properties, Converter.convertIfPossible for
scalar coercion and TypeUtils.asParameterizedType/asClass to recover generic types
(json/JSONUtils.java:988-1007, :1427-1442; imports at :23, :63, :65-67). Two design smells are visible in
this half:
readValues(JSONArray, Class, Class)is a six-branchif/else ifladder that falls off the end intoreturn null(:1125-1141) — an unsupported target type is not an error, it is a silentnull, asserted atjson/JSONUtilsTest.java:639-645.determineElementClassis annotated@Nonnull(:1302) but dereferenceselement.getClass()without a guard (:1306-1308), so an array containing a Javanull— producible by §5'sput(int,Object)padding or §3.3's dangling separator — throwsNullPointerExceptionfrom a method whose contract promises non-null. It is also not a common superclass computation despite the javadoc examples claiming otherwise (:1288-1297): the loop only narrows between concrete classes and jumps straight toObject.classon the first mismatch (:1312-1318).
isUnknownClass (:510-512) with UNKNOWN_CLASS = Void.class (:119) is the internal sentinel that lets the
Type-aware reader (:1073-1094) mean "the caller did not tell me, infer it" — a third null-like convention in
one package.
JSONException is the package's only declared exception, and it is minimal to the point of being awkward:
public class JSONException extends Exception (json/JSONException.java:41) with one constructor taking a
String (:43-45). Consequences that matter to anyone adding code here:
- No cause constructor.
throw new JSONException(msg, cause)does not compile, which is why every re-wrap path in the library must go throughExceptionUtils.wrapinto a different exception type (json/JSONUtils.java:803-812,:843-852), and why the class javadoc tells callers to give up and wrap it in aRuntimeExceptioninstead (:30-39). - No
serialVersionUID— grep across all eight files returns nothing, so the class relies on the JDK's computed UID, and it is also untestable for the same reason the fork has no@author/@since(below). - No
JSONException()and noJSONException(Throwable), so a caughtJSONExceptioncannot be rethrown unchanged with a new message.
Four vocabularies are in active use, and the mapping is not principled:
| Vocabulary | Raised by | Where |
|---|---|---|
checked JSONException |
malformed input, missing/out-of-range key, type mismatch, NaN/infinite writes, null names, nesting violations |
json/JSONTokener.java:470-472; json/JSONObject.java:345-350, :393-399, :803-806; json/JSONArray.java:150-153, :333-343; json/JSONStringer.java:196-204, :216-228, :236-241, :261-264, :406-409, :421-430, :439-457; via json/JSON.java:108-123 |
unchecked RuntimeException |
toString(int) on either model type, quote |
json/JSONObject.java:762-766, :831-842; json/JSONArray.java:713-717; both via ThrowableAction.execute → handleException throw new RuntimeException(failure) (lang/function/ThrowableAction.java:105-107, :79-81) |
unchecked IllegalArgumentException |
JSONUtils.jsonObject / jsonArray only |
json/JSONUtils.java:803-812, :843-852, via util/ExceptionUtils.java:97-104, :304-308 |
unchecked NullPointerException |
new JSONTokener(null) reads, appendValue(null), determineElementClass, JSONArray(Enumeration), JSONObject(Map) with a null key |
json/JSONTokener.java:501-503; json/JSONUtils.java:470, :1308; json/JSONArray.java:88-94; json/JSONObject.java:144-146 (documented at :131) |
unchecked NumberFormatException |
a malformed \uXXXX — Integer.parseInt(hex,16) with no guard |
json/JSONTokener.java:258-260, admitted by the javadocs at :200 and :248 |
silent null / no-op |
wrap on failure, writeValueAsString, readValues on an unsupported target, put(name,null) removing the key, names() on an empty object |
json/JSONObject.java:867-885, :732-734; json/JSONUtils.java:1234-1241, :1125-1141; json/JSONObject.java:275-278 |
JSONStringer is worth separating out: it is the only class that reports grammar violations from writing
rather than reading, and it does so with one of exactly two messages — "Nesting problem" (:219, :238, :426,
:455) and the more specific "Nesting problem: multiple top-level roots" (:198) / "Names must be non-null"
(:408). Callers cannot distinguish an unbalanced bracket from a key/value ordering error from the message alone.
| Pattern | Implementing class(es) | Evidence in this package |
|---|---|---|
| Composite | JSONObject, JSONArray over the value tree |
Each holds children of the same union it belongs to (json/JSONObject.java:116, json/JSONArray.java:78) and the writer recurses component-first: JSONStringer.value(Object) hands a nested JSONArray/JSONObject back to writeTo(this) instead of serialising it (json/JSONStringer.java:266-272 ↔ json/JSONArray.java:719-725, json/JSONObject.java:768-774). No Component interface exists — the union is checked with instanceof, which is why the recursion is open to StackOverflowError (§3.5) |
| Sentinel / Null Object | JSONObject.NULL |
a public static final Object anonymous instance whose equals deliberately accepts null and whose toString returns "null" (json/JSONObject.java:101-114); the contract violation is documented at :98-99 and the behavioural list at :87-97. It makes "present with no value" a first-class member, and it is the reason JSONStringer needs out.append(value) at :276-277 |
| Fluent Builder | JSONObject put family, JSONArray put family, JSONStringer verbs |
every mutator returns this — json/JSONObject.java:218-260, :284, :296-301, :317-338; json/JSONArray.java:176-228, :240-310; json/JSONStringer.java:153-155, :174-176, :261-287, :406-413 (javadoc @return this stringer, :150). Note there is no separate builder type and no build() step — the object is mutable throughout, so this is fluent-chaining, not the GoF Builder |
| State Machine (table-free) | JSONStringer.Scope |
six states in a package-private enum (json/JSONStringer.java:81-118), transitions encoded as if chains over peek()/replaceTop() in beforeKey/beforeValue/open/close (:196-250, :421-457); the javadoc names the two duties as separator insertion and nesting-error detection (:77-80) |
| Strategy (coercion), shared via package-private statics | JSON.toBoolean / toDouble / toInteger / toLong / toString |
one coercion implementation, statically imported by both composite nodes (json/JSONObject.java:30-35, json/JSONArray.java:26-31; definitions at json/JSON.java:34-106). Each returns null to mean "cannot coerce" and the caller chooses throw-vs-default (json/JSONObject.java:420-427 vs :436-452) |
| Visitor (non-GoF, javadoc-acknowledged) | JSONUtils.appendValue + JSONStringer.value(Object) |
two hand-written runtime-type ladders rather than double dispatch: json/JSONUtils.java:469-486 (array → map → iterable → char-family → Type → fallback) and json/JSONStringer.java:261-287. JSONAnnotationValueVisitor in microsphere-lang-model consumes append/appendName as its visit results (microsphere-lang-model/src/main/java/io/microsphere/lang/model/util/JSONAnnotationValueVisitor.java:36-37) |
| Adapter (Java ⇄ JSON) | JSONObject.wrap, JSONUtils.readValueAsBean, writeBeanAsString |
wrap adapts Iterable/array/Enumeration/Map/Class/CharSequence/Enum/java.*/JavaBean into the JSON union (json/JSONObject.java:859-886), the bean path adapts back through BeanUtils.resolvePropertiesAsMap and findWriteMethod (json/JSONObject.java:25,881; json/JSONUtils.java:988-1007) |
| Template-less Factory Methods | the five JSONObject and six JSONArray constructors |
each constructor is a parse-then-steal or copy-and-wrap factory rather than one overloading an existing one (json/JSONObject.java:121-198; json/JSONArray.java:83-159) |
| Static utility (marker-conformant) | JSONUtils |
public abstract class JSONUtils implements Utils (json/JSONUtils.java:113) with a private constructor (:1477-1478) — the library-wide convention described in Utilities and constants. JSON is package-private with a private JSON() (json/JSON.java:25, :125-126); JSONObject/JSONArray are not utility classes and have no such guard |
Absent on purpose, and worth recording so nobody re-adds it: there is no Registry (nothing is discovered), no
Factory interface, no Mapper-style configuration object, no Context/Config parameter on any method, and no
Visitor interface. The whole package is closed for extension: JSONObject and JSONArray are non-final but
their javadocs, copied verbatim from AOSP, instruct against inheritance (json/JSONArray.java:71-74,
json/JSONStringer.java:64-68, json/JSONTokener.java:64-68) while json/JSONObject.java:47-82 carries no such
warning at all — an asymmetry introduced by the fork, not inherited from it.
Verified with a grep over import io.microsphere.* and import static io.microsphere.* in all eight files
(main sources only), plus a repository-wide grep for io.microsphere.json outside the package.
graph TD
%% edge direction reads "is imported by"; all citations are in the node labels
subgraph Ext["Separate artifact"]
ANN["annotation · Nonnull/Nullable · JSONUtils.java:20-21"]
end
subgraph Foundation["core · Foundation layer"]
UTIL["util · ClassUtils JSONObject.java:39-45 / JSONArray.java:35 / JSONStringer.java:29<br/>+ ExceptionUtils, IterableUtils, StringUtils, Utils · JSONUtils.java:24-25,68-73"]
COLL["collection · MapUtils + ListUtils + EnumerationUtils · JSONObject.java:26-29<br/>+ QueueUtils, SetUtils · JSONArray.java:25 · JSONStringer.java:21 · JSONUtils.java:46-55"]
CONST["constants · SymbolConstants x7 · JSONUtils.java:56-62"]
LMF["lang.function · ThrowableAction.execute · JSONObject.java:38 · JSONArray.java:34"]
end
subgraph Model["core · Type model layer"]
BEANS["beans · BeanUtils resolvePropertiesAsMap / findWriteMethod / getBeanMetadata<br/>JSONObject.java:25 · JSONUtils.java:22,43-45"]
REFL["reflect · ConstructorUtils, MethodUtils, TypeUtils · JSONUtils.java:23,65-67"]
CONV["convert · Converter.convertIfPossible · JSONUtils.java:63"]
end
subgraph Pkg["io.microsphere.json · 8 types"]
TOK["JSONTokener"]
STR["JSONStringer"]
OBJ["JSONObject"]
ARR["JSONArray"]
J["JSON · pkg-private"]
EX["JSONException"]
JU["JSONUtils"]
end
subgraph Consumers["core peer + other modules"]
META["metadata · Default…Generator:29-30 / Reflective…Generator:22,24 / Default…Reader:29"]
LM["microsphere-lang-model · JSONAnnotationValueVisitor:36-37"]
AP["microsphere-annotation-processor · ConfigurationPropertyAnnotationProcessor:22"]
end
ANN --> JU
UTIL --> OBJ & ARR & STR & JU
COLL --> OBJ & ARR & STR & JU
CONST --> JU
LMF --> OBJ & ARR
BEANS --> OBJ & JU
REFL --> JU
CONV --> JU
TOK --> J & OBJ & ARR
STR --> J & OBJ & ARR
OBJ --> J & STR
ARR --> J & STR
JU <--> OBJ
JU --> ARR
JU & OBJ & ARR --> META
JU --> LM
ARR --> AP
Read the graph against the index claim in §1.1: json sits at the top of the Service layer, importing every
lower layer but imported by only one peer (metadata) and two downstream modules. That makes it safe to move
later — nothing in core below the type-model layer depends on it — but it also means json cannot be extracted
into its own artifact without also extracting or inlining beans, reflect, convert, collection, constants,
util and lang.function, because JSONUtils alone touches all seven
(json/JSONUtils.java:22-25,43-73). The forked six classes need only beans, collection, util,
lang.function (json/JSONObject.java:25-45, json/JSONArray.java:25-35, json/JSONStringer.java:21,29) and
would extract more cheaply, which argues for splitting JSONUtils into a json-dependent module of its own.
Verified non-dependencies. io.microsphere.net.classpath, io.microsphere.io*, io.microsphere.nio.*,
io.microsphere.concurrent, io.microsphere.process, io.microsphere.management, io.microsphere.security,
io.microsphere.logging, io.microsphere.event, io.microsphere.classloading and io.microsphere.filter
contain zero references to io.microsphere.json. io.microsphere.convert and io.microsphere.reflect use
JSONUtils's dependants, not it — the only convert symbol json consumes is the
Converter.convertIfPossible static (json/JSONUtils.java:63), so convert → json never occurs and the
CONV --> SERIAL style edge does not exist here.
Test-only surface. src/test/java/io/microsphere/JSONTestUtils.java:34-64 is a shared helper that builds
JSONObject/JSONArray fixtures for non-JSON tests, and the two beans it round-trips are
src/test/java/io/microsphere/test/Data.java and MultipleValueData.java; three resources feed the parser tests —
src/test/resources/test/json/data.json and array.json for testNextValueOnReadObject/
testNextValueOnReadArray (json/JSONTokenerTest.java:59-73), and clean.txt for testNextCleanInternal, which
loops until it reaches an 'a' and thereby proves comment and whitespace skipping (:152-159). The Integer→Long
promotion §3.2 describes is pinned two methods later at :241-242.
Three of the four forked mutable classes document themselves as not thread-safe, in identical AOSP wording:
json/JSONTokener.java:64-68, json/JSONArray.java:71-74, json/JSONStringer.java:64-68. JSONObject does
not — its javadoc block at json/JSONObject.java:47-82 never mentions thread safety, even though its field is a
plain LinkedHashMap (:116) that put/remove/accumulate mutate without synchronisation. The omission is
harmless today (a LinkedHashMap is as unsafe as an ArrayList) but it is the one class a reviewer would expect a
warning on, because JSONObject is also the class people pass across threads. Recommend adding the paragraph for
parity.
Two further points a reviewer should not miss:
JSONStringercannot be shared even in principle:outis a singleStringBuilder(json/JSONStringer.java:75) andtoString()reads it without copying (:470-473). Itsstackis anArrayList(:124), so reuse across threads corrupts both.- The statics are safe because they are stateless.
JSONUtils.REPLACEMENT_CHARSis astatic final String[]populated once inside astaticblock (json/JSONUtils.java:130-144) and never mutated afterwards — but anarrayof mutable references is not truly immutable, andU2028/U2029(:115-117) plusUNKNOWN_CLASS(:119) are effectivelyfinalconstants too.JSONObject.NULLis a genuine singleton whoseequalsis non-symmetric-with-respect-to-nullby design (:101-114); that is safe to share, butwrap's first line callso.equals(NULL)on caller-supplied objects (:861), so a hostile or buggyequalscan block or throw inside what looks like a pure static function.
The fork optimised for one thing — not leaking the input string's char array — and every hot path pays for it:
nextString's escape-free fast path returnsnew String(this.in.substring(start, this.pos - 1))(json/JSONTokener.java:219) with the comment "a new string avoids leaking memory". On Java 8+substringalready copies, so the extranew String(...)is now a second full copy of every JSON string value. Same pattern inreadLiteral's give-up branch:return new String(literal); // a new string avoids leaking memory(:339). These two lines are pure legacy cost with no legacy benefit.nextToInternalproduces anothersubstringper literal (:349-358), andreadLiteralthen re-parses it, so a number costs twoStringallocations before it becomes anInteger.- Only
nextStringallocates aStringBuilderlazily (:209-232), which is the one genuinely good decision in the lexer. - Per serialisation call:
toString(int)constructs a freshJSONStringer— hence a freshStringBuilder, a fresh scope-stackArrayListand a fresh indentString— per object, andJSONStringer.java:136-144builds the indent once per instance (so a recursivewriteToshares it within one call, correctly).json/JSONObject.java:762-766,json/JSONArray.java:713-717. There is no reuse across calls, so serialising a list of 1000JSONObjects allocates 1000 stringers plus 1000 lists. JSONUtilssidesteps all of that, which is exactly whymetadatauses it: it appends into the caller'sStringBuilderand allocates nothing per document except whatStringBuildergrowth requires (json/JSONUtils.java:146-508). It has nojoin/escape-builder allocation either, becauseescapekeeps abuilder == nullfast path until the first character that needs replacing (:1349,:1378-1380).JSONObject(Map)copies the map entry-by-entry throughwrap(json/JSONObject.java:135-149) rather thanputAll, andJSONArray(Object array)pre-sizes (:155);JSONArray(Iterable)/Enumerationdo not (:88-111), so they re-grow.
The house policy is silent degradation — return a neutral value rather than throw. io.microsphere.json is the
library's stated exception, and the exception is real but not consistently applied. Reviewing it in order of
severity:
JSONObject.getStringnames the wrong type.json/JSONObject.java:597callstypeMismatch(name, object, "long"). Its twinjson/JSONArray.java:556passes"String". Copy-paste defect, message-only impact, and untested because no test asserts ongetString's message text.JSONUtils.escape's documented contract is inverted. Javadoc@returnsays "the escaped string, or an empty string if the input isnull" (json/JSONUtils.java:1340); the code returnsv— i.e.null— for any zero-length input (:1343-1346).json/JSONUtilsTest.java:760-762asserts the code's behaviour, so the suite has pinned the divergence rather than the contract.jsonObject(null)throwsNullPointerExceptionwhere it promisesIllegalArgumentException.@throwsatjson/JSONUtils.java:801and:841;new JSONTokener(null)stores thenull(:87-93, javadoc:84-85) andhasNext()dereferences it at:501-503, which is not the caughtJSONExceptionat:806-810. No test covers it —json/JSONTokenerTest.java:49-51builds the tokener and never reads it.JSONStringer's nesting limit is documented twice and implemented zero times. Class javadoc "at most 20 levels … may fail with aJSONException" (json/JSONStringer.java:61-62) versus field comment "isn't limited to 20 levels of nesting" (:120-123). Neither is enforced; the actual outcome isStackOverflowError(§3.5). Same class, twelve lines apart — the worst kind of documentation defect.keys()denies a guarantee the code provides. "the order of the keys is undefined" (json/JSONObject.java:715-716) againstLinkedHashMap(:116,:122) and a test asserting the order (json/JSONObjectTest.java:381-392). §4.- A
JSONExceptioncan be smuggled past acatchas aNumberFormatException.json/JSONTokener.java:258-260—"\uZZZZ"is the input, and the javadocs already say so (:200,:248). accumulatedocuments, in a code comment, that another method is unsafe.json/JSONObject.java:323versusjson/JSONArray.java:225-228. The comment is a known-defect marker; it should either be fixed by adding the check toput(Object)or the gap should be stated input's javadoc.- Silent-degradation inside the exception-throwing package.
wrap'scatch (Exception ignored) {}(json/JSONObject.java:867-885),writeValueAsStringreturningnull(json/JSONUtils.java:1234-1241),readValuesfalling off the end toreturn null(:1125-1141),readValueAsBeanskipping unmatched keys quietly (:988-1007, proven byjson/JSONUtilsTest.java:573-581) andappendNameemitting nothing for a blank name (:500-508). Five places where the package returns to the house policy without saying so. None of them distinguish "nothing to do" from "I failed".
- Six classes carry no
@authorand no@since— a grep across the package finds exactly two of each, both in the original files (json/JSONUtils.java:109,:111;json/package-info.java:21,:22). Anyone reading the Javadoc cannot tell the forked from the original, which is the opposite of what the licence headers achieve. json/JSONTokener.java:571-581dehexcharispublic staticand unused in all main sources. It is legacy surface kept "because the original implementation exposed it" — the comment block at:485-491says so — but unlikemore()/next()/skipTo()it is dead in the parser and untested here.json/JSONUtils.java:1358-1361handles the two Unicode line separators bycharcomparison and string constants against the siblingREPLACEMENT_CHARStable at:130-144that handles everything else by array lookup. Two escaping mechanisms in one 45-line method; the table could hold both ifchar-indexing were widened, and0x2028 > 127is presumably why it does not.json/JSONArray.java:150-153rejects non-arrays by callingarray.getClass()on a possibly-nullargument afterisArray(array)returnedfalsefor it —util/ClassUtils.java:329-331delegates throughgetClass(Object)(:1967-1971,null-tolerant) toisArray(Class)(:347-348,type != null && type.isArray()). Sonew JSONArray((Object) null)throwsNullPointerExceptioninstead of the documentedJSONException.json/JSONUtilsTest.java:319-334(testAppendOnObjectAsMap) contains no assertion at all. It exercises the map branch ofappendValueand discards the result, so theMappath of the second encoder is effectively untested despite the test existing.json/JSONTokenerTest.java:48-51(testConstrcutorWithNull) constructsnew JSONTokener(null)and never reads from it, which is exactly why defect 3 in §11.3 survives — the NPE the tokener's own javadoc promises (json/JSONTokener.java:84-85) is unexercised. The method name is also misspelled ("Constrcutor"), echoing thetesttGettypo recorded on the Serialization page (io/serializer/SerializersTest.java:57): test method names are evidently not checked by any tool in the build.- No test in the package asserts on an error message that the code produced. A grep for
"cannot be converted","No value for"and"is null"acrosssrc/test/java/io/microsphere/json/matches onlyjson/JSONExceptionTest.java:36and:48, both asserting on messages the test itself constructs —new JSONException(message)andnew JSONException(null). EveryJSON.typeMismatchstring (json/JSON.java:108-123) and everysyntaxErrorstring (json/JSONTokener.java:470-472) is therefore unpinned, which is why defect 1 in §11.3 could sit atjson/JSONObject.java:597undetected.
json/JSONObject.java:597—"long"→"String". One token, no behaviour change.- Add the
checkDoubleguard tojson/JSONArray.java:225-228, delete the apologetic comment atjson/JSONObject.java:323, and add a test forput((Object) NaN)→toString(). - Fix
json/JSONStringer.java:61-62to match:120-123, then either add a realMAX_NESTING_DEPTHtoJSONTokener.readObject/readArrayor remove the claim entirely — the current state invites callers to believe a hostile input is bounded. - Change
json/JSONUtils.escape's javadoc:1340to@return null if the input is null or empty, or make the code match the doc; either way, do not keepjson/JSONUtilsTest.java:760-762asserting a contradiction. - Wrap
NumberFormatException-era escapes:catch (NumberFormatException e)aroundjson/JSONTokener.java:258-260and rethrowsyntaxError("Invalid hexadecimal number in \\uXXXX escape sequence"). This is the AOSP upstream text and the fork dropped it. - Guard
json/JSONUtils.java:470,:1308,json/JSONArray.java:88-94and:150-153fornull, or document each as NPE-throwing with@throws NullPointerExceptionthe wayjson/JSONObject.java:131already does for itsMapconstructor. - Correct
docs/current/developer-guide/microsphere-java-core/README.md:70to dropjsonfrom the SPI-family contract, or add the four dashed edgesjsongenuinely has in §10.
- User Guide — JSON — the API surface, runnable examples, the throw-vs-default table and the thirteen pitfalls.
- Configuration property metadata —
metadatais the only in-coreconsumer, on both sides of the wire (metadata/DefaultConfigurationPropertyGenerator.java:29-30,88-117,metadata/ReflectiveConfigurationPropertyGenerator.java:22,24,metadata/DefaultConfigurationPropertyReader.java:29,43). - Reflection, types and beans —
BeanUtils.resolvePropertiesAsMap/findWriteMethod/getBeanMetadataandConstructorUtils/MethodUtils/TypeUtilsare what make the JavaBean conversions work at all (json/JSONObject.java:25,json/JSONUtils.java:22-23,43-45,65-67). - Type conversion —
Converter.convertIfPossibleis the leaf every JSON→Java scalar reaches (json/JSONUtils.java:63,1427-1442), andconvertitself does not depend onjson. - Collections and filters —
MapUtils.newLinkedHashMap(collection/MapUtils.java:574-577) andListUtils.newArrayList(collection/ListUtils.java:313-315) decideJSONObject's key ordering andJSONArray's storage. - Language abstractions —
ThrowableAction.executeis the checked→unchecked translation layer behindtoString(int)(lang/function/ThrowableAction.java:79-81,105-107). - Serialization — the sibling Service-layer package, and the genuine SPI family that
jsonis often mistaken for (§1.1).