Jackson 3 series companion code
37 runnable examples covering the eight feature posts on ankurm.com, verified against Jackson 3.2.1 on Temurin 21.0.5. Every output committed under docs/ was produced by run-all.sh. Also documents 11 places where the published snippets do not compile or do not behave as printed against a real Jackson 3 build - most notably that writeValueAsString(List<Base>) silently drops the polymorphic type discriminator, so the post's serialised output cannot be read back.
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
package com.ankurm.jackson3.beyond;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.StringReader;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* BEYOND THE POSTS — the change most likely to reach production unnoticed.
|
||||
*
|
||||
* In Jackson 2, JsonProcessingException extended IOException, so `catch (IOException)`
|
||||
* caught mapping failures. In Jackson 3, JacksonException extends RuntimeException.
|
||||
*
|
||||
* There are two cases, and only one of them is safe:
|
||||
*
|
||||
* A. The try block contains ONLY Jackson calls. `catch (IOException)` becomes a
|
||||
* COMPILE ERROR — "exception java.io.IOException is never thrown in body of
|
||||
* corresponding try statement". The compiler saves you. Good.
|
||||
*
|
||||
* B. The try block also contains real I/O — reading a file, a socket, a request
|
||||
* body. IOException is still thrown by that code, so the catch block compiles
|
||||
* fine and simply stops catching the Jackson half. This is the dangerous case,
|
||||
* and it is by far the more common shape in real code.
|
||||
*
|
||||
* Case B is what runs below.
|
||||
*/
|
||||
public class Y01UncheckedExceptions {
|
||||
|
||||
public record OrderDto(Long orderId, String customerName) { }
|
||||
|
||||
public static void main(String[] args) {
|
||||
System.out.println("JacksonException extends RuntimeException : "
|
||||
+ RuntimeException.class.isAssignableFrom(JacksonException.class));
|
||||
System.out.println("JacksonException extends IOException : "
|
||||
+ IOException.class.isAssignableFrom(JacksonException.class));
|
||||
System.out.println();
|
||||
|
||||
JsonMapper mapper = JsonMapper.builder().build();
|
||||
String malformed = "{\"orderId\": not-json}";
|
||||
|
||||
// CASE B: genuine I/O plus a Jackson call in the same try block. Compiles
|
||||
// cleanly, catches the I/O half, and lets the Jackson half straight through.
|
||||
System.out.println("-- catch (IOException) around I/O + Jackson --");
|
||||
try {
|
||||
try (StringReader reader = new StringReader(malformed)) {
|
||||
reader.read(); // makes IOException reachable
|
||||
mapper.readValue(malformed, OrderDto.class); // no longer covered
|
||||
System.out.println(" unreachable");
|
||||
} catch (IOException e) {
|
||||
System.out.println(" caught by IOException handler");
|
||||
}
|
||||
} catch (JacksonException escaped) {
|
||||
System.out.println(" ESCAPED the IOException handler -> "
|
||||
+ escaped.getClass().getSimpleName());
|
||||
}
|
||||
|
||||
// The correct Jackson 3 idiom: catch both, separately.
|
||||
System.out.println("-- catch (JacksonException) then catch (IOException) --");
|
||||
try (StringReader reader = new StringReader(malformed)) {
|
||||
reader.read();
|
||||
mapper.readValue(malformed, OrderDto.class);
|
||||
} catch (JacksonException e) {
|
||||
System.out.println(" caught: " + e.getClass().getSimpleName());
|
||||
} catch (IOException e) {
|
||||
System.out.println(" I/O error: " + e.getClass().getSimpleName());
|
||||
}
|
||||
|
||||
// The upside of unchecked: Jackson calls now compose inside lambdas without a
|
||||
// wrapper, which was genuinely painful in Jackson 2.
|
||||
System.out.println("-- unchecked exceptions inside a stream --");
|
||||
List<String> json = Stream.of(new OrderDto(1L, "Alice"), new OrderDto(2L, "Bob"))
|
||||
.map(mapper::writeValueAsString) // no try/catch, no helper needed
|
||||
.toList();
|
||||
System.out.println(" " + json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ankurm.jackson3.beyond;
|
||||
|
||||
import tools.jackson.databind.DeserializationFeature;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* BEYOND THE POSTS — a default that flipped between Jackson 2 and Jackson 3.
|
||||
*
|
||||
* FAIL_ON_TRAILING_TOKENS was OFF in Jackson 2 and is ON in Jackson 3. Concatenated
|
||||
* or double-encoded JSON that Jackson 2 quietly accepted (reading the first document
|
||||
* and discarding the rest) now throws. This is a correctness improvement, but it will
|
||||
* surface as new runtime failures on payloads that used to "work".
|
||||
*/
|
||||
public class Y02TrailingTokens {
|
||||
|
||||
public record OrderDto(Long orderId) { }
|
||||
|
||||
public static void main(String[] args) {
|
||||
JsonMapper strict = JsonMapper.builder().build(); // Jackson 3 default
|
||||
JsonMapper relaxed = JsonMapper.builder()
|
||||
.disable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) // Jackson 2 behaviour
|
||||
.build();
|
||||
|
||||
System.out.println("FAIL_ON_TRAILING_TOKENS default : "
|
||||
+ strict.deserializationConfig().isEnabled(
|
||||
DeserializationFeature.FAIL_ON_TRAILING_TOKENS));
|
||||
System.out.println();
|
||||
|
||||
String concatenated = "{\"orderId\":1} {\"orderId\":2}";
|
||||
|
||||
System.out.println("Jackson 3 default -> " + read(strict, concatenated));
|
||||
System.out.println("2.x behaviour -> " + read(relaxed, concatenated));
|
||||
|
||||
String trailingGarbage = "{\"orderId\":1}garbage";
|
||||
System.out.println("garbage, default -> " + read(strict, trailingGarbage));
|
||||
System.out.println("garbage, relaxed -> " + read(relaxed, trailingGarbage));
|
||||
}
|
||||
|
||||
private static String read(JsonMapper mapper, String json) {
|
||||
try {
|
||||
return "accepted: " + mapper.readValue(json, OrderDto.class);
|
||||
} catch (Exception e) {
|
||||
return "rejected: " + e.getClass().getSimpleName();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.ankurm.jackson3.beyond;
|
||||
|
||||
import tools.jackson.databind.cfg.DateTimeFeature;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZonedDateTime;
|
||||
|
||||
/**
|
||||
* BEYOND THE POSTS — where WRITE_DATES_AS_TIMESTAMPS actually lives.
|
||||
*
|
||||
* Several of the blog snippets carry `.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)`
|
||||
* over from Jackson 2. That constant does NOT exist on SerializationFeature in Jackson 3;
|
||||
* the code does not compile. The flag moved to tools.jackson.databind.cfg.DateTimeFeature
|
||||
* and, more usefully, it now defaults to OFF — so ISO-8601 output needs no configuration
|
||||
* at all and there is nothing to disable.
|
||||
*/
|
||||
public class Y03DateTimeDefaults {
|
||||
|
||||
public record Meeting(LocalDate day, LocalDateTime startsAt,
|
||||
Instant recordedAt, ZonedDateTime zoned, Duration length) { }
|
||||
|
||||
public static void main(String[] args) {
|
||||
System.out.println("SerializationFeature has WRITE_DATES_AS_TIMESTAMPS : "
|
||||
+ hasSerializationFeature("WRITE_DATES_AS_TIMESTAMPS"));
|
||||
System.out.println("DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS default : "
|
||||
+ JsonMapper.builder().build().serializationConfig()
|
||||
.isEnabled(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS));
|
||||
System.out.println();
|
||||
|
||||
Meeting meeting = new Meeting(
|
||||
LocalDate.of(2026, 9, 15),
|
||||
LocalDateTime.of(2026, 9, 15, 10, 30),
|
||||
Instant.parse("2026-09-15T10:30:00Z"),
|
||||
ZonedDateTime.parse("2026-09-15T10:30:00Z"),
|
||||
Duration.ofMinutes(45));
|
||||
|
||||
JsonMapper defaults = JsonMapper.builder().build();
|
||||
System.out.println("defaults (ISO-8601):");
|
||||
System.out.println(" " + defaults.writeValueAsString(meeting));
|
||||
|
||||
JsonMapper timestamps = JsonMapper.builder()
|
||||
.enable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.build();
|
||||
System.out.println("with WRITE_DATES_AS_TIMESTAMPS enabled:");
|
||||
System.out.println(" " + timestamps.writeValueAsString(meeting));
|
||||
|
||||
// Both forms read back, so an upgrade does not break existing stored payloads.
|
||||
String numeric = timestamps.writeValueAsString(meeting);
|
||||
System.out.println("numeric form reads back: " + defaults.readValue(numeric, Meeting.class).day());
|
||||
}
|
||||
|
||||
private static boolean hasSerializationFeature(String name) {
|
||||
for (var f : tools.jackson.databind.SerializationFeature.values()) {
|
||||
if (f.name().equals(name)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.ankurm.jackson3.beyond;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import tools.jackson.databind.DeserializationFeature;
|
||||
import tools.jackson.databind.PropertyNamingStrategies;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* BEYOND THE POSTS — how to vary configuration once the mapper is immutable.
|
||||
*
|
||||
* The Jackson 2 habit of grabbing the shared mapper and calling configure() on it is
|
||||
* impossible in Jackson 3 — there are no mutators. The replacements are rebuild(),
|
||||
* which forks a builder from an existing mapper, and reader()/writer() views for
|
||||
* per-call tweaks. Neither disturbs the shared instance.
|
||||
*/
|
||||
public class Y04ImmutableMapperAndReaders {
|
||||
|
||||
public record UserProfile(String firstName, String lastName, String middleName) { }
|
||||
|
||||
public static void main(String[] args) {
|
||||
JsonMapper shared = JsonMapper.builder().build();
|
||||
UserProfile user = new UserProfile("Ada", "Lovelace", null);
|
||||
|
||||
System.out.println("shared : " + shared.writeValueAsString(user));
|
||||
|
||||
// 1. rebuild(): fork the shared mapper's configuration and change one thing.
|
||||
JsonMapper snakeCase = shared.rebuild()
|
||||
.propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
|
||||
.changeDefaultPropertyInclusion(i -> i.withValueInclusion(JsonInclude.Include.NON_NULL))
|
||||
.build();
|
||||
System.out.println("rebuilt snake_case : " + snakeCase.writeValueAsString(user));
|
||||
|
||||
// 2. The shared mapper is untouched by the fork.
|
||||
System.out.println("shared unchanged : " + shared.writeValueAsString(user));
|
||||
|
||||
// 3. writer()/reader() views for a single call, no new mapper needed.
|
||||
System.out.println("writer view pretty : "
|
||||
+ shared.writer().withDefaultPrettyPrinter().writeValueAsString(user)
|
||||
.replace("\n", " ").replaceAll("\\s+", " "));
|
||||
|
||||
System.out.println("reader view strict : " + readStrict(shared));
|
||||
}
|
||||
|
||||
private static String readStrict(JsonMapper shared) {
|
||||
String json = "{\"firstName\":\"Ada\",\"lastName\":\"Lovelace\",\"unexpected\":1}";
|
||||
try {
|
||||
shared.reader()
|
||||
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||
.forType(UserProfile.class) // ObjectReader has no readValue(String, Class)
|
||||
.readValue(json);
|
||||
return "accepted";
|
||||
} catch (Exception e) {
|
||||
return "rejected (" + e.getClass().getSimpleName() + ") without touching the shared mapper";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ankurm.jackson3.beyond;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
import tools.jackson.databind.MapperFeature;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* BEYOND THE POSTS — checking a claim rather than repeating it.
|
||||
*
|
||||
* The comparison post states that MapperFeature.AUTO_DETECT_CREATORS was removed and
|
||||
* that, as a result, "any class relying on a single-argument constructor being detected
|
||||
* without an annotation will quietly fail."
|
||||
*
|
||||
* Half right. The enum constant is genuinely gone. But the BEHAVIOUR it controlled is
|
||||
* still there: a single-argument constructor is still detected as a delegating creator.
|
||||
* The wrapper below deserialises with no @JsonCreator at all. Annotate anyway — it is
|
||||
* explicit and free — but do not expect the upgrade to break these classes.
|
||||
*/
|
||||
public class Y05CreatorDetection {
|
||||
|
||||
/** No @JsonCreator anywhere. */
|
||||
public static class ImplicitOrderId {
|
||||
private final String value;
|
||||
public ImplicitOrderId(String value) { this.value = value; }
|
||||
@JsonValue public String value() { return value; }
|
||||
@Override public String toString() { return "ImplicitOrderId[" + value + "]"; }
|
||||
}
|
||||
|
||||
/** The explicit form, which is what you should write. */
|
||||
public static class ExplicitOrderId {
|
||||
private final String value;
|
||||
@JsonCreator public ExplicitOrderId(String value) { this.value = value; }
|
||||
@JsonValue public String value() { return value; }
|
||||
@Override public String toString() { return "ExplicitOrderId[" + value + "]"; }
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
boolean present = Arrays.stream(MapperFeature.values())
|
||||
.anyMatch(f -> f.name().equals("AUTO_DETECT_CREATORS"));
|
||||
System.out.println("MapperFeature.AUTO_DETECT_CREATORS exists : " + present);
|
||||
System.out.println("Nearest surviving features : "
|
||||
+ Arrays.stream(MapperFeature.values())
|
||||
.filter(f -> f.name().contains("CREATOR") || f.name().contains("PARAMETER_NAMES"))
|
||||
.map(Enum::name).toList());
|
||||
System.out.println();
|
||||
|
||||
JsonMapper mapper = JsonMapper.builder().build();
|
||||
System.out.println("implicit single-arg ctor : " + mapper.readValue("\"ord-1\"", ImplicitOrderId.class));
|
||||
System.out.println("explicit @JsonCreator : " + mapper.readValue("\"ord-2\"", ExplicitOrderId.class));
|
||||
System.out.println("round-trip via @JsonValue: " + mapper.writeValueAsString(new ImplicitOrderId("ord-3")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.ankurm.jackson3.beyond;
|
||||
|
||||
import tools.jackson.core.json.JsonFactory;
|
||||
import tools.jackson.core.util.JsonRecyclerPools;
|
||||
import tools.jackson.core.util.RecyclerPool;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* BEYOND THE POSTS — the performance knob the comparison post names but never measures.
|
||||
*
|
||||
* Jackson 3 changed the default buffer RecyclerPool. The post says to restore the 2.x
|
||||
* thread-local pool if you see a regression. Whether that helps depends entirely on your
|
||||
* concurrency profile, so this measures it on the machine you are actually running on
|
||||
* instead of asserting a winner.
|
||||
*
|
||||
* Indicative timings, not JMH. Run it a few times; the numbers move.
|
||||
*/
|
||||
public class Y06RecyclerPoolTuning {
|
||||
|
||||
public record Payload(long id, String name, List<String> tags, double amount) { }
|
||||
|
||||
private static final int ITERATIONS = 40_000;
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
System.out.println("default pool : "
|
||||
+ JsonRecyclerPools.defaultPool().getClass().getSimpleName());
|
||||
System.out.println("cores : " + Runtime.getRuntime().availableProcessors());
|
||||
System.out.println();
|
||||
|
||||
for (int threads : new int[] { 1, 8 }) {
|
||||
System.out.println("--- " + threads + " thread(s), " + ITERATIONS + " round-trips each ---");
|
||||
run(threads, "threadLocalPool (2.x default)", JsonRecyclerPools.threadLocalPool());
|
||||
run(threads, "concurrentDeque (3.x default)", JsonRecyclerPools.newConcurrentDequePool());
|
||||
run(threads, "nonRecyclingPool (no reuse) ", JsonRecyclerPools.nonRecyclingPool());
|
||||
System.out.println();
|
||||
}
|
||||
}
|
||||
|
||||
private static void run(int threads, String label, RecyclerPool<?> pool) throws Exception {
|
||||
@SuppressWarnings({ "unchecked", "rawtypes" })
|
||||
JsonFactory factory = JsonFactory.builder().recyclerPool((RecyclerPool) pool).build();
|
||||
JsonMapper mapper = JsonMapper.builder(factory).build();
|
||||
|
||||
Payload sample = new Payload(1L, "example", List.of("a", "b", "c"), 12.5);
|
||||
for (int i = 0; i < 2_000; i++) { // warm up
|
||||
mapper.readValue(mapper.writeValueAsString(sample), Payload.class);
|
||||
}
|
||||
|
||||
ExecutorService pooled = Executors.newFixedThreadPool(threads);
|
||||
long start = System.nanoTime();
|
||||
var tasks = java.util.stream.IntStream.range(0, threads)
|
||||
.<Callable<Void>>mapToObj(t -> () -> {
|
||||
for (int i = 0; i < ITERATIONS; i++) {
|
||||
mapper.readValue(mapper.writeValueAsString(sample), Payload.class);
|
||||
}
|
||||
return null;
|
||||
}).toList();
|
||||
for (var future : pooled.invokeAll(tasks)) future.get();
|
||||
long ms = (System.nanoTime() - start) / 1_000_000;
|
||||
pooled.shutdown();
|
||||
|
||||
System.out.printf(" %-34s %5d ms%n", label, ms);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user