Module: io.github.microsphere-projects:microsphere-java-core · Source line: release branch, 0.3.20-SNAPSHOT
Package analysed: io.microsphere.io.serializer — 18 main-source types.
Companion user-facing page: User Guide — Serialization.
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 io/serializer/AbstractSerializer.java:40-55.
io.serializer sits in the Service layer of the developer index layer table — the same band as
convert, event, logging, json and metadata, and for the same reason: it is an SPI family discovered through
META-INF/services, and its implementations are looked up rather than wired.
The index draws two edges, and both are confirmed against the source rather than assumed:
reflect→io.serializer(it consumes the type model). Three distinct reflective facilities are used, all inio.microsphere.reflect:TypeUtils.resolveTypeArgumentClasses(...)derives the registry key for every provider (io/serializer/Serializers.java:74,io/serializer/Deserializers.java:74).JavaType.from(getClass()).as(AbstractSerializer.class).getGenericType(0).toClass()resolves the encoded typeTin theAbstractSerializerconstructor (io/serializer/AbstractSerializer.java:65-66).MethodUtils.invokeStaticMethod(true, enumType, "values")calls the generatedEnum.values()withforceAccess = true(io/serializer/EnumSerializer.java:39; the flag's meaning is the parameter documented atio/reflect/MethodUtils.java:898and implemented at:908-910). Alsoio.microsphere.annotation.Nonnull,io.microsphere.io.*buffers,io.microsphere.util.*andio.microsphere.collection.*— all at or below the type-model layer.
convert→io.serializer(it is a consumer, not a dependency).ObjectToByteArrayConverterimportsDefaultSerializer.DEFAULT_SERIALIZER(io/convert/ObjectToByteArrayConverter.java:21) and calls it at:39;ByteArrayToObjectConverterimportsDefaultDeserializer.DEFAULT_DESERIALIZER(io/convert/ByteArrayToObjectConverter.java:22) and calls it at:41. A grep forimport io.microsphere.io.serializeracross all main sources returns exactly those two files plus the test tree — so the direction is one-way, andio.serializercan be used withconvertentirely absent.
Boundary it owns: io.serializer is the only place in core that converts between Object/T and byte[].
It does not own framing, length prefixes, type tags, charset negotiation, or any of the record-level concerns that
io.event, json and metadata handle elsewhere. That narrow scope is why 18 types are enough.
| # | Type | Kind | Encodes | Extends / implements | In SPI file? |
|---|---|---|---|---|---|
| 1 | Serializer<S> |
interface | — | — | is the service type |
| 2 | Deserializer<T> |
@FunctionalInterface |
— | — | is the service type |
| 3 | AbstractSerializer<T> |
abstract base | any T |
implements Serializer<T>, Deserializer<T> |
— (abstract) |
| 4 | BooleanSerializer |
final | Boolean |
extends AbstractSerializer<Boolean> |
both, line 1 |
| 5 | ByteSerializer |
Byte |
extends AbstractSerializer<Byte> |
both, line 2 | |
| 6 | CharacterSerializer |
Character |
extends AbstractSerializer<Character> |
both, line 3 | |
| 7 | ShortSerializer |
Short |
extends AbstractSerializer<Short> |
both, line 4 | |
| 8 | IntegerSerializer |
Integer |
extends AbstractSerializer<Integer> |
both, line 5 | |
| 9 | LongSerializer |
final | Long |
extends AbstractSerializer<Long> |
both, line 6 |
| 10 | FloatSerializer |
final | Float |
extends AbstractSerializer<Float> |
both, line 7 |
| 11 | DoubleSerializer |
final | Double |
extends AbstractSerializer<Double> |
both, line 8 |
| 12 | StringSerializer |
String |
implements Serializer<String> |
Serializer, line 9 |
|
| 13 | StringDeserializer |
String |
implements Deserializer<String> |
Deserializer, line 9 |
|
| 14 | DefaultSerializer |
Object |
implements Serializer<Object> |
Serializer, line 10 |
|
| 15 | DefaultDeserializer |
Object |
implements Deserializer<Object> |
Deserializer, line 10 |
|
| 16 | EnumSerializer<E> |
E extends Enum |
implements Serializer<E>, Deserializer<E> |
no | |
| 17 | Serializers |
registry | — | — | — |
| 18 | Deserializers |
registry | — | — | — |
Evidence for the inheritance column: io/serializer/BooleanSerializer.java:12, ByteSerializer.java:31,
CharacterSerializer.java:29, ShortSerializer.java:11, IntegerSerializer.java:11, LongSerializer.java:11,
FloatSerializer.java:31, DoubleSerializer.java:16, StringSerializer.java:44, StringDeserializer.java:56,
DefaultSerializer.java:47, DefaultDeserializer.java:51, EnumSerializer.java:17.
Two structural notes fall out of this table:
- Only the eight numeric classes extend
AbstractSerializer. TheString,ObjectandEnumcodecs implement the raw interfaces and therefore re-implement thenulland width handling themselves — or, in theStringcase, do not implement it at all (§3.4). - Exactly four of the eighteen are
final(BooleanSerializer,LongSerializer,FloatSerializer,DoubleSerializer), so the open-class pattern is not a package-wide convention.
Serializers.loadSPI() (io/serializer/Serializers.java:72-79) is the whole registration mechanism:
for (Serializer serializer : loadServicesList(Serializer.class, classLoader, true)) {
List<Class<?>> typeArguments = resolveTypeArgumentClasses(serializer.getClass());
Class<?> targetClass = first(typeArguments);
List<Serializer> serializers = typedSerializers.computeIfAbsent(targetClass, k -> new LinkedList());
serializers.add(serializer);
}Four things are decided here, and the third is the one people misread.
(a) The list is Prioritized-sorted, always. loadServicesList(Class, ClassLoader, boolean) — the third
argument is cached, not sorted (io/util/ServiceLoaderUtils.java:468-470). Sorting is unconditional inside
the uncached worker: sort(serviceList, COMPARATOR) at io/util/ServiceLoaderUtils.java:890, with COMPARATOR
imported from io.microsphere.lang.Prioritized (:30). Because none of the eighteen types implements Prioritized
(a grep across the package finds no reference to it), the comparator takes its final branch and returns 0 for every
pair (io/lang/Prioritized.java:71-75), and Collections.sort is stable — so the effective order is the SPI
file's declaration order. Serializers.java:76-77 then appends in that order, so first(...) is "earliest line in
the file". This is why BooleanSerializer beats everything for Boolean, and DefaultSerializer — being line 10 —
is always getLowestPriority(Object.class).
(b) The key is derived reflectively, from the provider's own type argument.
resolveTypeArgumentClasses (io/reflect/TypeUtils.java:1259-1265) maps over resolveTypeArguments, which walks
up the superclass chain, at each level collecting generic-superclass arguments then generic-interface
arguments, stopping at Object.class (io/reflect/TypeUtils.java:1198-1209); null results from asClass are
filtered out (:1263). So:
| Provider | Why first(...) yields |
Registry key |
|---|---|---|
IntegerSerializer |
generic superclass AbstractSerializer<Integer> is seen at the first level |
Integer.class |
StringSerializer |
no superclass of interest; generic interface Serializer<String> |
String.class |
DefaultSerializer |
generic interface Serializer<Object> (io/serializer/DefaultSerializer.java:47) |
Object.class |
The consequence is that the registry is flat, not polymorphic. It is keyed on the literal type argument, so
get(Integer.class) never consults the Number or Object buckets — get is a single HashMap.getOrDefault
(io/serializer/Serializers.java:127-129). Only getMostCompatible bridges the gap, and it bridges it in exactly
one direction (§3.2).
(c) EnumSerializer is excluded by construction, not by oversight. It is generic over E and declares
implements Serializer<E>, Deserializer<E> (io/serializer/EnumSerializer.java:17), so its type arguments resolve to
a TypeVariable, which asClass cannot turn into a Class and resolveTypeArgumentClasses therefore drops
(io/reflect/TypeUtils.java:1261-1264). A key-producing mechanism cannot name a class for it, which matches the fact
that it appears in neither SPI file. It is instantiated per enum type instead (EnumSerializer.java:27-31).
(d) loadSPI is append-only and non-idempotent. Each call re-runs the loop and adds to the existing
LinkedList (io/serializer/Serializers.java:76-77). Nothing checks for duplicates. Whether a second call
doubles the lists depends entirely on the cached flag:
Serializers.loadSPI |
Deserializers.loadSPI |
|
|---|---|---|
| call shape | loadServicesList(Serializer.class, classLoader, true) (io/serializer/Serializers.java:73) |
loadServicesList(Deserializer.class, classLoader) (io/serializer/Deserializers.java:73) |
| resolves to | cached = true → servicesCache consulted |
2-arg overload → cached = SERVICE_LOADER_CACHED (io/util/ServiceLoaderUtils.java:410-412) |
| default | always cached | false, from microsphere.service-loader.cached, default "false" (io/util/ServiceLoaderUtils.java:107, :112, :138) |
| effect of calling twice | provider instances reused (same list), but the per-type lists still grow — see below | provider instances re-created and lists grow |
Note the residual bug even on the cached path: the returned service list is stable, but serializers.add(serializer)
at :77 still executes a second time, so loadSPI() is not idempotent for either registry. Call it exactly once
per Serializers / Deserializers instance. The tests bear this out — both call loadSPI() once in a
@BeforeEach against a freshly constructed registry (SerializersTest.java:51-54,
SerializersAndDeserializersTest.java:40-46).
(e) The only throw. loadServicesAsList raises IllegalArgumentException when the service iterator is empty
(io/util/ServiceLoaderUtils.java:882-886). That is the failure mode of a stripped META-INF/services directory.
No lookup path in Serializers/Deserializers throws — misses surface as null or emptyList().
Serializers exposes four accessors over one Map<Class<?>, List<Serializer>> field
(io/serializer/Serializers.java:60). Deserializers is the exact mirror (:60, :87-129).
| Accessor | Body | Behaviour |
|---|---|---|
get |
getOrDefault(type, emptyList()) (:127-129) |
@Nonnull; no fallback |
getHighestPriority |
first(get(type)) (:102-105) |
null when the bucket is empty — ListUtils.first returns null if size < 1 (io/collection/ListUtils.java:111-113) |
getLowestPriority |
last(get(type)) (:114-117) |
null likewise (io/collection/ListUtils.java:132-135) |
getMostCompatible |
getHighestPriority(type), else getLowestPriority(Object.class) (:87-93) |
unconditional JDK fallback |
The last row is the design decision worth arguing with. getMostCompatible does not walk the supertype chain
searching for a compatible codec; it tries the exact bucket and otherwise always returns the lowest-priority
Object-keyed entry — DefaultSerializer. Since Object is populated by the SPI file's last line, and since first
and last of a one-element list coincide, getMostCompatible(anything) is DefaultSerializer for every type
without a dedicated codec. The SerializersTest.java:70-80 assertions confirm the observable half of this: after
loadSPI(), getMostCompatible is non-null for all nine SUPPORTED_CLASSES, including Object.class itself —
where getHighestPriority and getMostCompatible return the same instance.
So the selection rule, stated exactly:
- exact class match → highest-priority (first-in-file) codec;
- no exact match →
DefaultSerializer/DefaultDeserializervia theObjectbucket; - never an exception, never a
Number-or-supertype search.
Rule 2 is the reason this page carries a security section: a generic-looking API quietly routes unknown types into
unfiltered ObjectInputStream.readObject().
flowchart TD
START["getMostCompatible(SomeType.class)"] --> B0{"loadSPI() was called?"}
B0 -- no --> RN1["null / emptyList()<br/>Serializers.java:127-129"]
B0 -- yes --> HP["getHighestPriority(SomeType.class)"]
HP --> LOOK["HashMap.getOrDefault(SomeType, emptyList())"]
LOOK --> HAS{"bucket non-empty?"}
HAS -- yes --> FIRST["first(list) → earliest line in the SPI file<br/>Prioritized sorting is a no-op here:<br/>no type implements Prioritized<br/>(ServiceLoaderUtils.java:890, Prioritized.java:75)"]
FIRST --> DONE["dedicated codec:<br/>BooleanSerializer … DoubleSerializer,<br/>StringSerializer / StringDeserializer"]
HAS -- no --> FALLBACK["getLowestPriority(Object.class)<br/>Serializers.java:89-91"]
FALLBACK --> JDK["DefaultSerializer / DefaultDeserializer<br/>= java.io.ObjectOutputStream / ObjectInputStream<br/>DefaultSerializer.java:52-62<br/>DefaultDeserializer.java:61-65"]
JDK --> RISK["⚠ untrusted bytes reach readObject()<br/>with NO ObjectInputFilter"]
SEEN["EnumSerializer"] -. "not in either SPI file;<br/>key mechanism cannot resolve E<br/>TypeUtils.java:1261-1264" .-> LOOK
AbstractSerializer<T> is the package's only framework class, and both public methods are final — the extensibility
points are the three protected abstracts (io/serializer/AbstractSerializer.java:119, :128, :137).
The constructor does the work once, at construction (:64-68):
JavaType type = from(getClass()).as(AbstractSerializer.class).getGenericType(0);
this.targetType = type.toClass();
this.bytesLength = calcBytesLength();bytesLength is therefore a cached virtual-method result captured into a final field, and the subclass's
calcBytesLength() is invoked exactly once per instance — which is what makes the width guard on the hot path a
single integer comparison (:87). It also means calcBytesLength() must not depend on mutable subclass state, since
it runs before the subclass constructor body.
serialize/deserialize (:70-92) implement the degradation policy:
| Guard | Line | Result |
|---|---|---|
t == null |
:73-75 |
null |
bytes == null |
:81-84 |
null |
bytesLength != UNBOUND_BYTES_SIZE && bytesLength != bytes.length |
:87-89 |
null — silent |
The width guard is a length equality check only. It does not validate content, so a correctly sized but corrupt
buffer passes into doDeserialize and the subclass decides what happens — the numeric codecs do not check anything
further and simply reinterpret the bits (io/serializer/IntegerSerializer.java:33-39).
The task brief asks about "the class-descriptor writing order". Factually: this package writes no class descriptor of its own. Two very different mechanisms exist, and only one is self-describing.
Fixed-width codecs write no descriptor and no tag. Every AbstractSerializer subclass emits only the value's
bytes, in a hand-written order, and EnumSerializer emits only an ordinal (io/serializer/EnumSerializer.java:52-57).
There is no type tag, no length prefix, no magic. The byte order sequence is:
| Family | Index 0 holds | Evidence |
|---|---|---|
Character, Short |
high byte | io/serializer/CharacterSerializer.java:41-44 ((byte) (c >>> 8) first), io/serializer/ShortSerializer.java:23-25 ((byte) (shortValue >>> 8) first) |
Integer, Float |
most-significant byte | io/serializer/IntegerSerializer.java:23-28 (>> 24 first); FloatSerializer inherits it by delegating to INTEGER_SERIALIZER.doSerialize (io/serializer/FloatSerializer.java:41-44) |
Long, Double |
least-significant byte | io/serializer/LongSerializer.java:23-32 ((byte) longValue first); DoubleSerializer inherits it by delegating to LONG_SERIALIZER.serialize (io/serializer/DoubleSerializer.java:26-30) |
Enum (2-byte case) |
high byte, via SHORT_SERIALIZER |
io/serializer/EnumSerializer.java:56, :69 |
The consequences are real and permanent:
- The 4-byte and 8-byte families use opposite byte orders, and because
Floatroutes throughIntegerwhileDoubleroutes throughLong, the split follows the delegation target, not any deliberate design. A record with anintfield and alongfield is big-endian then little-endian. Nothing in the bytes says so. - Because nothing is tagged, the format is not self-describing and not versionable: widening a field, or swapping
IntegerforLong, produces plausible garbage rather than an error. Thedeserializewidth guard catches a 4-vs-8 swap (io/serializer/AbstractSerializer.java:87-89) and nothing subtler. StringSerializeradds no charset marker (io/serializer/StringSerializer.java:57-59) andEnumSerializerpersistsordinal()rather thanname()(:49), so reordering enum constants silently reassigns the meaning of already-stored bytes. Both sides must be pinned to the same enum declaration order.
DefaultSerializer writes whatever ObjectOutputStream writes. The class descriptor — magic 0xAC 0xED, stream
version, class name, serialVersionUID, field descriptors — is emitted by the JDK, not here; the code contributes
exactly one call, objectOutputStream.writeObject(source) (io/serializer/DefaultSerializer.java:58).
Consequences: format stability is the JDK's guarantee, and serialVersionUID behaviour is the JDK's
recomputation rule. Grep for serialVersionUID across all 18 types returns nothing, so the package neither pins
nor validates any serialVersionUID. DefaultDeserializer likewise does not override resolveClass, install an
ObjectInputFilter, or bound the graph (:61-65); its only input validation is an emptiness check (:57-59).
They are asymmetric in a way that is easy to miss:
DefaultSerializer |
DefaultDeserializer |
|
|---|---|---|
catch block |
none (io/serializer/DefaultSerializer.java:52-62) |
catch (Exception e) { throw new IOException(e); } (:66-68) |
non-Serializable argument |
propagates NotSerializableException as-is |
n/a |
ClassNotFoundException on read |
n/a | collapses into IOException |
RuntimeException from a target's readObject |
n/a | also collapses into IOException — the catch (Exception) is unconditional |
close() interference |
cannot throw — FastByteArrayOutputStream.close() is a no-op (io/io/FastByteArrayOutputStream.java:188-189) |
cannot throw — FastByteArrayInputStream.close() is a no-op (io/io/FastByteArrayInputStream.java:157-158) |
null argument |
not checked; behaviour is ObjectOutputStream's |
null/empty → null (:57-59) |
toByteArray() returns Arrays.copyOf(buf, count) (io/io/FastByteArrayOutputStream.java:159-161), so the returned
array is a private copy and the buffer's later GC cannot affect it.
The single unchecked-throw site reachable from this package is outside it:
ByteArrayToObjectConverter.doConvert casts the result to Serializable and wraps IOException in
new RuntimeException(e) (io/convert/ByteArrayToObjectConverter.java:41-44).
graph TD
SUB["«interface»<br/>Serializer<S><br/>Serializer.java:40-42"]
DES["«interface»<br/>Deserializer<T><br/>Deserializer.java:40-43<br/>@FunctionalInterface"]
ABS["AbstractSerializer<T><br/>abstract · both methods final<br/>AbstractSerializer.java:54"]
ABS -. "implements both" .-> SUB
ABS -. "implements both" .-> DES
B["BooleanSerializer"] --> ABS
BY["ByteSerializer"] --> ABS
C["CharacterSerializer"] --> ABS
SH["ShortSerializer"] --> ABS
I["IntegerSerializer"] --> ABS
L["LongSerializer"] --> ABS
F["FloatSerializer"] --> ABS
D["DoubleSerializer"] --> ABS
SS["StringSerializer<br/>StringSerializer.java:44"] --> SUB
SD["StringDeserializer<br/>StringDeserializer.java:56"] --> DES
DS["DefaultSerializer<br/>DefaultSerializer.java:47"] --> SUB
DD["DefaultDeserializer<br/>DefaultDeserializer.java:51"] --> DES
ES["EnumSerializer<E><br/>not in SPI<br/>EnumSerializer.java:17"] --> SUB
ES --> DES
F -- "delegates to INTEGER_SERIALIZER<br/>FloatSerializer.java:41-44" --> I
D -- "delegates to LONG_SERIALIZER<br/>DoubleSerializer.java:26-30" --> L
ES -- "2-byte case delegates<br/>EnumSerializer.java:56, 69" --> SH
SEER["Serializers<br/>registry<br/>Serializers.java:58"] -. "discovers via SPI" .-> SUB
DEER["Deserializers<br/>registry<br/>Deserializers.java:58"] -. "discovers via SPI" .-> DES
CONV["convert<br/>ObjectToByteArrayConverter:39<br/>ByteArrayToObjectConverter:41"] -- "uses singletons" --> DS
CONV -- "uses singletons" --> DD
sequenceDiagram
autonumber
participant Caller
participant Reg as Serializers
participant Ser as StringSerializer
participant Buf as FastByteArrayOutputStream
participant Des as StringDeserializer
Note over Caller: callers must load once, before lookups
Caller->>Reg: loadSPI()
Reg->>Reg: loadServicesList(Serializer, cl, cached=true) Serializers.java:73
Reg->>Reg: sort(list, Prioritized.COMPARATOR) ServiceLoaderUtils.java:890
Reg->>Reg: resolveTypeArgumentClasses(provider) → key Serializers.java:74-75
Reg->>Reg: typedSerializers.computeIfAbsent(key, LinkedList).add(p) :76-77
Caller->>Reg: getHighestPriority(String.class)
Reg-->>Caller: StringSerializer (first line-9 match; no fallback taken)
Caller->>Ser: serialize("Test")
Ser->>Ser: source.getBytes(charset) StringSerializer.java:58
Note over Ser,Buf: not used by StringSerializer — see the alt path below
Ser-->>Caller: byte[4] = 54 65 73 74
Caller->>Des: deserialize(bytes)
Des->>Des: new String(bytes, charset) StringDeserializer.java:70
Des-->>Caller: "Test"
alt unknown type — getMostCompatible path
Caller->>Reg: getMostCompatible(Foo.class)
Reg->>Reg: getHighestPriority(Foo.class) → null Serializers.java:88
Reg->>Reg: getLowestPriority(Object.class) → DefaultSerializer :90
Caller->>Ser: DefaultSerializer.serialize(foo)
Ser->>Buf: try-with-resources, new ObjectOutputStream(this) DefaultSerializer.java:54-55
Buf-->>Ser: toByteArray() = copyOf(buf, count) FastByteArrayOutputStream.java:160
Ser-->>Caller: JDK stream bytes (0xAC 0xED …)
Note over Caller: reverse path builds an ObjectInputStream<br/>and calls readObject() with NO filter DefaultDeserializer.java:62-65
end
| Pattern | Implementing class(es) | Evidence |
|---|---|---|
| Strategy | Serializer<S> / Deserializer<T>; selected per type by the registries |
The interfaces declare one behaviour with no state (io/serializer/Serializer.java:40-42, io/serializer/Deserializer.java:40-43); the registry swaps implementations at runtime (io/serializer/Serializers.java:87-117) |
Registry (of Strategies, keyed by Class) |
Serializers, Deserializers |
Map<Class<?>, List<Serializer>> typedSerializers (io/serializer/Serializers.java:60) plus loadSPI/get* (:72-129); the mirror at io/serializer/Deserializers.java:60-129 |
| Template Method | AbstractSerializer<T> |
serialize/deserialize are final (io/serializer/AbstractSerializer.java:71, :80) and delegate to abstract hooks calcBytesLength/doSerialize/doDeserialize (:119, :128, :137); the constructor calls the hook once and caches it (:67) |
| Service Provider / Plug-in (SPI) | Serializers, Deserializers |
keyed on META-INF/services/io.microsphere.io.serializer.Serializer and …Deserializer; loaded via loadServicesList (io/serializer/Serializers.java:73) |
| Flyweight / canonical instance | the eight numeric …SERIALIZER singletons, DEFAULT_SERIALIZER, DEFAULT_DESERIALIZER |
each is public static final and stateless after construction — e.g. io/serializer/IntegerSerializer.java:13, io/serializer/BooleanSerializer.java:14, io/serializer/DefaultSerializer.java:49, io/serializer/DefaultDeserializer.java:53. ServiceLoader instantiates a second set of these at loadSPI() time, since the SPI files name the same classes (Serializers.java:73) |
| Null Object (as neutral degradation) | AbstractSerializer, DefaultDeserializer |
invalid input yields null instead of throwing: io/serializer/AbstractSerializer.java:73-75, :81-84, :87-89; io/serializer/DefaultDeserializer.java:57-59. Matches the index degradation policy |
| Adapter / delegation over codecs | FloatSerializer, DoubleSerializer, EnumSerializer |
bit-layout reuse rather than duplication: io/serializer/FloatSerializer.java:41-44 → INTEGER_SERIALIZER, io/serializer/DoubleSerializer.java:26-30 → LONG_SERIALIZER, io/serializer/EnumSerializer.java:56, :69 → SHORT_SERIALIZER. Note this is composition/delegation, not the GoF Decorator — no codec wraps another's interface to add behaviour |
| Facade (over the JDK stream) | DefaultSerializer, DefaultDeserializer |
collapse OutputStream + ObjectOutputStream + lifecycle into one byte[] call (io/serializer/DefaultSerializer.java:52-62) |
| Type-token resolution | AbstractSerializer, Serializers, Deserializers |
JavaType (io/serializer/AbstractSerializer.java:65) and resolveTypeArgumentClasses (io/serializer/Serializers.java:74) recover T that erasure removed |
| Reflection-based method invocation | EnumSerializer |
invokeStaticMethod(true, enumType, "values") (io/serializer/EnumSerializer.java:39) |
- Implement
io.microsphere.io.serializer.Serializer<S>— or, for a scalar type, extendAbstractSerializer<S>(io/serializer/AbstractSerializer.java:54) and inherit thenulland width handling.EnumSerializershows the alternative: implement the two interfaces directly (io/serializer/EnumSerializer.java:17). - Declare the encoded type as a resolvable type argument.
Serializers.loadSPI()keys purely onfirst(resolveTypeArgumentClasses(...))(io/serializer/Serializers.java:74-75), so a class generic overTwithout a concrete argument in its supertype/interface list resolves to an empty list and lands under anullkey (io/reflect/TypeUtils.java:1261-1264) — unreachable byget(...). Bind the parameter:extends AbstractSerializer<MyType>. - Append the fully qualified class name to
microsphere-java-core/src/main/resources/META-INF/services/io.microsphere.io.serializer.Serializer. Position matters: the current file is 10 lines ending withio.microsphere.io.serializer.DefaultSerializer, and earlier lines wingetHighestPriority(io/serializer/Serializers.java:102-105). - Add
implements Prioritizedonly if you must override file order —Prioritizedentries sort ahead of non-Prioritizedones regardless of line position (io/lang/Prioritized.java:65-73). No built-in serializer does. - Ensure thread safety; it is a documented contract requirement (
io/serializer/Serializer.java:24-25).
The same steps apply to Deserializer, with the …serializer.Deserializer service file (also 10 lines, ending
io.microsphere.io.serializer.DefaultDeserializer). Note Deserializers.loadSPI() uses the uncached overload
(io/serializer/Deserializers.java:73), so a provider's constructor runs on every loadSPI() call.
Object.class is the fallback bucket for getMostCompatible (io/serializer/Serializers.java:89-91), so overriding
the Default* entries is the supported way to change behaviour for all unknown types. Two options:
- Register a filtered
Deserializerbound to a concrete supertype and prefer it by line position orPrioritized. This works only for types you can name. - Stop using
getMostCompatibleand usegetHighestPriority, treatingnullas "unsupported type — refuse". This is the cheaper fix and the one this page recommends, because it makes the JDK fallback opt-in instead of default. It is a behavioural change for existing callers, so it belongs to the module owner, not to a downstream patch.
What is not an extension point. DefaultDeserializer's ObjectInputStream is created inside the method and never
exposed (io/serializer/DefaultDeserializer.java:61-65); there is no hook, no subclass, and no protected factory
method. You therefore cannot install a per-stream java.io.ObjectInputFilter on it. The only JDK lever that
reaches it is the process-global jdk.serialFilter / jdk.io.objects.readData property, which the library does not
read, set, or document. Any filtered deserialization must be a Deserializer of your own, registered as above.
Prefer the singletons over the registry when the type is known at compile time — that is what convert does
(io/convert/ObjectToByteArrayConverter.java:21,39, io/convert/ByteArrayToObjectConverter.java:22,41). It avoids
loadSPI() entirely. Keep the dependency direction convert → io.serializer; io.serializer must not import
convert, and today it does not.
| Field | Location | Type | Mutated after construction? | Consequence |
|---|---|---|---|---|
Serializers.typedSerializers |
io/serializer/Serializers.java:60 |
Map<Class<?>, List<Serializer>>, from MapUtils.newHashMap() → plain HashMap (io/collection/MapUtils.java:478-480) |
yes, by loadSPI() (:76-77) |
not concurrent-safe during load; safe to read concurrently once loading finished, provided no other writer |
Deserializers.typedDeserializers |
io/serializer/Deserializers.java:60 |
same | yes (:76-77) |
same, and more likely to be re-loaded because caching is off |
Serializers.classLoader / Deserializers.classLoader |
io/serializer/Serializers.java:62, Deserializers.java:62 |
final ClassLoader |
no | registry identity is fixed per instance |
ServiceLoaderUtils.servicesCache |
io/util/ServiceLoaderUtils.java:140 |
ConcurrentMap<Class<?>, List<?>> |
yes, but atomically via computeIfAbsent (:846) |
the only genuinely concurrent cache in this path; shared process-wide across every cached = true load |
AbstractSerializer.targetType |
io/serializer/AbstractSerializer.java:56 |
final Class<T> |
no | resolved once in the constructor (:65-66); free afterwards |
AbstractSerializer.bytesLength |
io/serializer/AbstractSerializer.java:58 |
final int |
no | captures calcBytesLength() at :67, so the width check is a field read, not a virtual call |
EnumSerializer.enums |
io/serializer/EnumSerializer.java:23 |
final E[] |
no, but the array's elements are the live enum constants | values() invoked once per instance (:29, :38-40) |
EnumSerializer.bytesLength |
io/serializer/EnumSerializer.java:25 |
final int |
no | width chosen once from the constant count (:33-36) |
StringSerializer.charset / StringDeserializer.charset |
io/serializer/StringSerializer.java:46, StringDeserializer.java:58 |
final Charset |
no | the javadoc's thread-safety claim rests on this (io/serializer/StringDeserializer.java:31-34) |
Verdict. Every codec is immutable-after-construction and safe to share; that is the pattern the package follows,
and it is what the Serializer/Deserializer contracts demand (io/serializer/Serializer.java:24-25).
The two registries are not thread-safe to populate. HashMap + LinkedList under a non-atomic
computeIfAbsent-then-add sequence (io/serializer/Serializers.java:76-77) means concurrent loadSPI() calls can
lose entries or expose a partially built map during a resize. Populate on one thread at startup; publish before
readers exist.
- Fixed-width codecs are allocation-minimal and branchless.
IntegerSerializerbuilds its array with four shifts and noByteBuffer(io/serializer/IntegerSerializer.java:23-28);BooleanSerializerandByteSerializereach allocate exactly one byte (BooleanSerializer.java:26-30,ByteSerializer.java:41-43). One array per call is the floor thebyte[]-returning interface imposes; there is no internal pooling. Float/Double/Enumpay one extra array copy because they delegate to another codec and take its result (io/serializer/FloatSerializer.java:43,DoubleSerializer.java:29,EnumSerializer.java:56). The delegation is to the singleton, so no extra object is created.EnumSerializeramortises a reflectivevalues()call across all uses of one instance (io/serializer/EnumSerializer.java:29), which is why it must be constructed once per enum type and cached, not per value.DefaultSerializeris heavyweight by construction. Per call it allocates aFastByteArrayOutputStream(32-byte initial buffer,io/io/FastByteArrayOutputStream.java:68-69), anObjectOutputStream, the JDK's block-data machinery, and finallyArrays.copyOfintoByteArray()(:159-161). It also emits a full class descriptor per object, which is why the round trip inDefaultSerializerAndDeserializerTest.java:39-45yields many bytes for a four-characterString. Treat it as a compatibility escape hatch, not a codec.loadSPI()caching. Withcached = truethe provider list is memoised inservicesCache(io/util/ServiceLoaderUtils.java:845-846), so repeat loads are cheap — but the registries' own per-type lists still grow (§3.1d), so caching does not makeloadSPI()idempotent. The default is uncached forDeserializer(:107,:138), meaning everyDeserializers.loadSPI()re-instantiates all ten providers reflectively.Serializable-driven costs are not bounded here. There is no size cap beforeObjectOutputStream.writeObject(io/serializer/DefaultSerializer.java:58) and no depth/reference cap beforereadObject(io/serializer/DefaultDeserializer.java:65), so a large or deeply nested graph is limited only by the heap.
microsphere-java-core/src/test/java/io/microsphere/io/serializer/ holds 16 test classes. They partition cleanly
into three strategies.
(a) Inherited round-trip harness for the AbstractSerializer family.
AbstractSerializerTest<T> (io/serializer/AbstractSerializerTest.java:21-64) is itself abstract and @Test-free of
assertion logic: test() (:23-26) and testNull() (:28-31) both delegate to test(Supplier<T>) (:33-51),
which performs the entire contract check:
byte[] bytes = serializer.serialize(value);
T deserialized = serializer.deserialize(bytes);
assertEquals(serializer.calcBytesLength(), serializer.getBytesLength()); // :38
...
assertSame(targetType, parameterType); // :50Three assertions are worth noting for anyone extending the package:
:38provescalcBytesLength()is called more than once in the harness — once by the constructor (:67) and once directly here — so it must stay side-effect-free and must not depend on mutable subclass state.:40-44makes thenullcase a value case rather than a separate exception test, so a codec that throws onnullfails the suite. This is the enforcement mechanism behind the §3.5 table.:46-50recoversTfrom the test subclass's own type argument viaJavaType, then assertsassertSameagainstgetTargetType()— a genuinely two-sided check that both reflection paths agree. It is also whygetTargetType()matters beyond ergonomics.
IntegerSerializerTest, BooleanSerializerTest, ByteSerializerTest, CharacterSerializerTest,
ShortSerializerTest, LongSerializerTest, FloatSerializerTest and DoubleSerializerTest each supply just
getSerializer() and getValue() — e.g. IntegerSerializerTest.java:33-41 returns INTEGER_SERIALIZER and
MAX_VALUE. Eight codecs are thereby covered for round trip, null, width consistency and type resolution at about
nine lines each.
(b) Registry tests that assert the empty-before-load state. SerializersTest.java:37-47 declares
SUPPORTED_CLASSES as the contract surface, and each of its four tests follows the same shape — assert
empty/null, call loadSPI(), assert populated (:56-67, :69-80, :82-93, :95-106). Note
SerializersTest.java:57 is spelled testtGet (a typo in the method name; it does test get). get is asserted
against emptyList() while the other three are asserted against null, pinning the @Nonnull distinction between
:127-129 and :102-117. DeserializersTest.java:35-98 mirrors it for String and Object only — the two keys
whose codecs are asymmetric (StringSerializer vs StringDeserializer) — with the same before/after pattern
(:44-53, :55-64, :66-75, :77-86, :88-97).
SerializersAndDeserializersTest.java is the only test that couples the two registries and asserts the bytes, not
just non-null-ness: :49-56 pins getHighestPriority(String.class) to raw UTF-8 (proving the String bucket is
populated ahead of the Object fallback), and :59-65 exercises getMostCompatible(Integer.class) through both
registries. Both loadSPI() calls happen in @BeforeEach on freshly built registries (:40-46) — the correct
pattern given §3.1(d).
(c) Direct behaviour tests for the non-AbstractSerializer codecs.
StringSerializerAndDeserializerTest.java:34-74 is the most thorough in the package: default UTF-8 (:40-46), custom
ISO_8859_1 (:48-57), empty string → new byte[0] → "" (:59-64), and a UTF-16 round trip (:66-73).
It notably does not test null, which is exactly where these two classes deviate (§3.5, and pitfall 3 on the user
page). EnumSerializerTest.java covers the width boundary by pairing a small enum (TimeUnit → 1, :45) against a
200-constant nested enum (:62, declared :72-93), round-trips every constant in both, and asserts both null
directions (:54-55). DefaultSerializerAndDeserializerTest.java:37-46 round-trips a String and an Integer
through the JDK stream via a variable typed Object. DefaultDeserializerTest.java:20-23 is a one-method test of the
null → null degradation.
Coverage gaps that follow from this reading, in rough order of consequence:
- No test asserts anything about untrusted or hostile input, and no test references
ObjectInputFilter. TheDefault*pair is tested only with trusted, JDK-round-trippable values. ByteSerializeris registered in both SPI files but is missing fromSUPPORTED_CLASSES(io/serializer/SerializersTest.java:37-47) and has no registry-level test — only its inheritedAbstractSerializerTestround trip (ByteSerializerTest.java:30-40).- No test for
StringSerializer/StringDeserializeronnull, so the NPE asymmetry is undocumented by the suite. - No test for
loadSPI()called twice, so the non-idempotent append (io/serializer/Serializers.java:76-77) is unprotected by the build. EnumSerializerhas no registry test at all, consistent with its absence from the SPI files; itsordinal-instability on enum reordering is untested.- No negative test for the width guard: nothing asserts
deserialize(new byte[3])isnullforIntegerSerializer, even though that is the behaviour atio/serializer/AbstractSerializer.java:87-89. SerializersTest.java:57(testtGet) shows the suite is not name-checked by tooling.
- Reflection, types and beans — the type model this package resolves its registry keys through (
TypeUtils.resolveTypeArgumentClasses,JavaType,MethodUtils.invokeStaticMethod) - Type conversion —
ObjectToByteArrayConverter/ByteArrayToObjectConverterare the only main-source consumers ofio.serializer - I/O, NIO, scanning and file filters — the buffer and
io.eventside of the framing boundary described in §1 - JSON — the neighbouring
io-layer family that owns record-level structure this package deliberately does not - Language abstractions — the
Prioritizedordering contract behindSerializers.getHighestPriority - Serialization — User Guide — caller-facing API reference and examples
- Package index and layer diagram
- 中文版本