Jackson 2 to 3 migration companion code
Three Maven modules - jackson2-before (2.22.1), jackson3-after (3.2.1) and a coexistence module with BOTH majors on one classpath - so every claim in the two migration guides is executed rather than asserted. Paired class names make the before/after outputs directly diffable via run-all.sh. Confirms the guides on wire-format equivalence (10-case suite, zero mismatches), classpath coexistence and the collapse of four artifacts into one. Corrects nine points, including that enableDefaultTyping() is still present in Jackson 2.22.1 rather than removed in 2.16, and that the published "after" mapper snippet does not compile.
This commit is contained in:
60
jackson2-before/pom.xml
Normal file
60
jackson2-before/pom.xml
Normal file
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>jackson2-to-3-migration</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>jackson2-before</artifactId>
|
||||
<name>jackson2-before</name>
|
||||
<description>The "before" side: Jackson 2 with the three modules it needs</description>
|
||||
|
||||
<dependencies>
|
||||
<!-- Jackson 2 group ID: com.fasterxml.jackson.core -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>${jackson2.version}</version>
|
||||
</dependency>
|
||||
<!-- java.time support: a SEPARATE artifact in Jackson 2. Gone in Jackson 3. -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
<version>${jackson2.version}</version>
|
||||
</dependency>
|
||||
<!-- Optional<T> support: a SEPARATE artifact in Jackson 2. Gone in Jackson 3. -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jdk8</artifactId>
|
||||
<version>${jackson2.version}</version>
|
||||
</dependency>
|
||||
<!-- Reliable record deserialisation: a SEPARATE artifact in Jackson 2. Gone in Jackson 3. -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.module</groupId>
|
||||
<artifactId>jackson-module-parameter-names</artifactId>
|
||||
<version>${jackson2.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<!-- Jackson 2 needs -parameters for constructor-based deserialisation.
|
||||
Jackson 3 does not; jackson3-after has no compilerArgs at all. -->
|
||||
<compilerArgs><arg>-parameters</arg></compilerArgs>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.ankurm.migration.before;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* BEFORE — Jackson 2. Post: https://ankurm.com/jackson-3-migration-guide/ (Steps 2 and 3)
|
||||
*
|
||||
* The canonical Jackson 2 mapper: mutable, built with chained setters, and needing
|
||||
* two explicit module registrations. Compare with after/S01MapperConstruction.
|
||||
*/
|
||||
public class S01MapperConstruction {
|
||||
|
||||
public record Booking(Long id, LocalDate travelDate, Optional<String> seatPreference, String note) { }
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule()) // required for java.time
|
||||
.registerModule(new Jdk8Module()) // required for Optional
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
.setSerializationInclusion(JsonInclude.Include.NON_NULL);
|
||||
|
||||
Booking booking = new Booking(1L, LocalDate.of(2026, 9, 15), Optional.of("aisle"), null);
|
||||
System.out.println("output : " + mapper.writeValueAsString(booking));
|
||||
|
||||
// The Jackson 2 hazard: the mapper stays mutable after it has been shared.
|
||||
// This call takes effect, changing behaviour for every other holder.
|
||||
mapper.enable(SerializationFeature.INDENT_OUTPUT);
|
||||
System.out.println("after mutation : "
|
||||
+ mapper.writeValueAsString(booking).replace("\n", " ").replaceAll("\\s+", " "));
|
||||
System.out.println("mutation stuck : "
|
||||
+ mapper.getSerializationConfig().isEnabled(SerializationFeature.INDENT_OUTPUT));
|
||||
|
||||
System.out.println("set*() mutators : " + java.util.Arrays.stream(ObjectMapper.class.getMethods())
|
||||
.filter(m -> m.getName().startsWith("set")).count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.ankurm.migration.before;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* BEFORE — Jackson 2. Post: https://ankurm.com/jackson-3-vs-jackson-2/
|
||||
* Section: "JacksonException Is Now Unchecked: A Silent Production Bug"
|
||||
*
|
||||
* In Jackson 2 the whole hierarchy sits under IOException, so `catch (IOException)`
|
||||
* catches mapping failures — and the compiler forces you to catch something.
|
||||
*/
|
||||
public class S02ExceptionHierarchy {
|
||||
|
||||
public record OrderDto(Long orderId, String customerName) { }
|
||||
|
||||
public static void main(String[] args) {
|
||||
System.out.println("JsonProcessingException extends IOException : "
|
||||
+ IOException.class.isAssignableFrom(JsonProcessingException.class));
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
String malformed = "{\"orderId\": not-json}";
|
||||
|
||||
// This is the code that exists all over Jackson 2 codebases. It works.
|
||||
try {
|
||||
mapper.readValue(malformed, OrderDto.class);
|
||||
System.out.println("unreachable");
|
||||
} catch (IOException e) {
|
||||
System.out.println("caught by catch (IOException) : " + e.getClass().getSimpleName());
|
||||
}
|
||||
|
||||
// And the checked exception has to be declared or handled everywhere, which
|
||||
// is why Jackson 2 code is full of wrapper helpers around lambdas.
|
||||
System.out.println("writeValueAsString declares : "
|
||||
+ java.util.Arrays.toString(getDeclared(mapper)));
|
||||
}
|
||||
|
||||
private static Class<?>[] getDeclared(ObjectMapper mapper) {
|
||||
try {
|
||||
return ObjectMapper.class.getMethod("writeValueAsString", Object.class).getExceptionTypes();
|
||||
} catch (NoSuchMethodException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.ankurm.migration.before;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.core.Version;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* BEFORE — Jackson 2. Post: https://ankurm.com/jackson-3-vs-jackson-2/
|
||||
* Section: "Custom Serializers and Deserializers: Class Renames"
|
||||
*
|
||||
* Note every element that changes in Jackson 3:
|
||||
* JsonSerializer -> ValueSerializer
|
||||
* JsonDeserializer -> ValueDeserializer
|
||||
* SerializerProvider -> SerializationContext
|
||||
* throws IOException -> removed
|
||||
* writeNumberField -> writeNumberProperty
|
||||
* writeStringField -> writeStringProperty
|
||||
* new SimpleModule(name, Version) -> new SimpleModule(name)
|
||||
* mapper.registerModule(m) -> builder.addModule(m)
|
||||
*/
|
||||
public class S03CustomHandlers {
|
||||
|
||||
public record Money(BigDecimal amount, String currencyCode) { }
|
||||
|
||||
static class MoneySerializer extends JsonSerializer<Money> {
|
||||
@Override
|
||||
public void serialize(Money value, JsonGenerator gen, SerializerProvider provider)
|
||||
throws IOException {
|
||||
gen.writeStartObject();
|
||||
gen.writeNumberField("amount", value.amount());
|
||||
gen.writeStringField("currency", value.currencyCode().toUpperCase());
|
||||
gen.writeEndObject();
|
||||
}
|
||||
}
|
||||
|
||||
static class MoneyDeserializer extends JsonDeserializer<Money> {
|
||||
@Override
|
||||
public Money deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
|
||||
var node = p.getCodec().readTree(p);
|
||||
return new Money(
|
||||
new BigDecimal(((com.fasterxml.jackson.databind.JsonNode) node).path("amount").asText("0")),
|
||||
((com.fasterxml.jackson.databind.JsonNode) node).path("currency").asText("GBP"));
|
||||
}
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
SimpleModule module = new SimpleModule("MoneyModule", new Version(1, 0, 0, null, null, null));
|
||||
module.addSerializer(Money.class, new MoneySerializer());
|
||||
module.addDeserializer(Money.class, new MoneyDeserializer());
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper().registerModule(module);
|
||||
|
||||
System.out.println("serialised : " + mapper.writeValueAsString(new Money(new BigDecimal("19.99"), "usd")));
|
||||
System.out.println("deserialised : " + mapper.readValue("{\"amount\":20.00,\"currency\":\"USD\"}", Money.class));
|
||||
System.out.println("base classes : " + MoneySerializer.class.getSuperclass().getName()
|
||||
+ " / " + MoneyDeserializer.class.getSuperclass().getName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.ankurm.migration.before;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* BEFORE — Jackson 2. Post: https://ankurm.com/jackson-3-migration-guide/ (Step 5)
|
||||
* and https://ankurm.com/jackson-3-vs-jackson-2/ ("Dependency Footprint")
|
||||
*
|
||||
* Three separate artifacts and three registerModule calls, and the build needs
|
||||
* -parameters (see this module's pom.xml). Everything below is deleted in
|
||||
* after/S04ModulesAndRecords.
|
||||
*
|
||||
* The bare mapper at the bottom shows exactly what breaks without them.
|
||||
*/
|
||||
public class S04ModulesAndRecords {
|
||||
|
||||
public record TravelPlan(Long id, LocalDate departure, Optional<String> seat) { }
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
ObjectMapper fullyEquipped = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.registerModule(new Jdk8Module())
|
||||
.registerModule(new ParameterNamesModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
|
||||
TravelPlan plan = new TravelPlan(1L, LocalDate.of(2026, 9, 15), Optional.of("12A"));
|
||||
String json = fullyEquipped.writeValueAsString(plan);
|
||||
System.out.println("with modules : " + json);
|
||||
System.out.println("round-trip : " + fullyEquipped.readValue(json, TravelPlan.class));
|
||||
|
||||
// What a bare Jackson 2 mapper does with the same object.
|
||||
ObjectMapper bare = new ObjectMapper();
|
||||
System.out.println("bare mapper : " + describe(bare, plan));
|
||||
System.out.println("bare, dates only: " + describe(bare, java.util.Map.of("d", LocalDate.of(2026, 9, 15))));
|
||||
}
|
||||
|
||||
private static String describe(ObjectMapper mapper, Object value) {
|
||||
try {
|
||||
return mapper.writeValueAsString(value);
|
||||
} catch (Exception e) {
|
||||
return "FAILS -> " + e.getClass().getSimpleName();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.ankurm.migration.before;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
|
||||
import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator;
|
||||
|
||||
/**
|
||||
* BEFORE — Jackson 2. Posts: https://ankurm.com/jackson-security-best-practices/
|
||||
* and https://ankurm.com/jackson-3-migration-guide/ (Step 4)
|
||||
*
|
||||
* Two things to notice:
|
||||
* 1. CORRECTION TO THE POSTS. Both guides state that enableDefaultTyping() was
|
||||
* "deprecated in 2.10, removed in 2.16". It is still present on ObjectMapper in
|
||||
* Jackson 2.22.1 — deprecated, but there. The reflective check below prints the
|
||||
* truth. This matters for migration planning: a Jackson 2 codebase can still be
|
||||
* compiling against it today, so the Jackson 3 upgrade is where it finally
|
||||
* breaks, not the 2.16 upgrade.
|
||||
* 2. activateDefaultTyping is an INSTANCE method on the mapper here. In Jackson 3
|
||||
* it moves to the builder — see after/S05DefaultTyping.
|
||||
*/
|
||||
public class S05DefaultTyping {
|
||||
|
||||
public abstract static class BasePayload { }
|
||||
public static class SafePayload extends BasePayload {
|
||||
public String note;
|
||||
public SafePayload() { }
|
||||
public SafePayload(String n) { note = n; }
|
||||
@Override public String toString() { return "SafePayload[" + note + "]"; }
|
||||
}
|
||||
public static class Envelope {
|
||||
public Object body;
|
||||
public Envelope() { }
|
||||
public Envelope(Object b) { body = b; }
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
boolean enableExists = java.util.Arrays.stream(ObjectMapper.class.getMethods())
|
||||
.anyMatch(m -> m.getName().equals("enableDefaultTyping"));
|
||||
System.out.println("enableDefaultTyping on Jackson 2.22 ObjectMapper : " + enableExists);
|
||||
|
||||
PolymorphicTypeValidator validator = BasicPolymorphicTypeValidator.builder()
|
||||
.allowIfSubType(Envelope.class)
|
||||
.allowIfSubType(BasePayload.class)
|
||||
.build();
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
mapper.activateDefaultTyping(validator, // instance method
|
||||
ObjectMapper.DefaultTyping.NON_FINAL, // nested enum
|
||||
JsonTypeInfo.As.PROPERTY);
|
||||
|
||||
String json = mapper.writeValueAsString(new Envelope(new SafePayload("ok")));
|
||||
System.out.println("written : " + json);
|
||||
System.out.println("read : " + mapper.readValue(json, Envelope.class).body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.ankurm.migration.before;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.MapperFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* BEFORE — Jackson 2. Post: https://ankurm.com/jackson-3-vs-jackson-2/
|
||||
* Section: "Removed and Changed Default Features"
|
||||
*
|
||||
* These produce no compile error on upgrade. They change behaviour silently, which
|
||||
* makes them the most expensive category to find. Run this and after/S07 side by
|
||||
* side and diff the two outputs.
|
||||
*/
|
||||
public class S07DefaultsThatFlipped {
|
||||
|
||||
public record OrderDto(Long orderId) { }
|
||||
|
||||
public static void main(String[] args) {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
System.out.println("FAIL_ON_TRAILING_TOKENS : "
|
||||
+ mapper.getDeserializationConfig().isEnabled(DeserializationFeature.FAIL_ON_TRAILING_TOKENS));
|
||||
System.out.println("FAIL_ON_UNKNOWN_PROPERTIES : "
|
||||
+ mapper.getDeserializationConfig().isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES));
|
||||
System.out.println("ALLOW_FINAL_FIELDS_AS_MUTATORS: "
|
||||
+ mapper.getSerializationConfig().isEnabled(MapperFeature.ALLOW_FINAL_FIELDS_AS_MUTATORS));
|
||||
System.out.println("DEFAULT_VIEW_INCLUSION : "
|
||||
+ mapper.getSerializationConfig().isEnabled(MapperFeature.DEFAULT_VIEW_INCLUSION));
|
||||
System.out.println("AUTO_DETECT_CREATORS exists : "
|
||||
+ java.util.Arrays.stream(MapperFeature.values())
|
||||
.anyMatch(f -> f.name().equals("AUTO_DETECT_CREATORS")));
|
||||
System.out.println();
|
||||
|
||||
System.out.println("concatenated JSON -> " + read(mapper, "{\"orderId\":1} {\"orderId\":2}"));
|
||||
System.out.println("trailing garbage -> " + read(mapper, "{\"orderId\":1}garbage"));
|
||||
System.out.println("unknown property -> " + read(mapper, "{\"orderId\":1,\"nope\":2}"));
|
||||
}
|
||||
|
||||
private static String read(ObjectMapper mapper, String json) {
|
||||
try {
|
||||
return "accepted: " + mapper.readValue(json, OrderDto.class);
|
||||
} catch (Exception e) {
|
||||
return "rejected: " + e.getClass().getSimpleName();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user