diff --git a/conjure-java-jackson-optimizations/build.gradle b/conjure-java-jackson-optimizations/build.gradle index a90f820eb..f7183190e 100644 --- a/conjure-java-jackson-optimizations/build.gradle +++ b/conjure-java-jackson-optimizations/build.gradle @@ -3,4 +3,5 @@ apply plugin: 'com.palantir.revapi' dependencies { api "com.fasterxml.jackson.core:jackson-databind" + implementation "com.fasterxml.jackson.module:jackson-module-blackbird" } diff --git a/conjure-java-jackson-optimizations/src/main/java/com/palantir/conjure/java/jackson/optimizations/ObjectMapperOptimizations.java b/conjure-java-jackson-optimizations/src/main/java/com/palantir/conjure/java/jackson/optimizations/ObjectMapperOptimizations.java index 1208614fc..5b9a92094 100644 --- a/conjure-java-jackson-optimizations/src/main/java/com/palantir/conjure/java/jackson/optimizations/ObjectMapperOptimizations.java +++ b/conjure-java-jackson-optimizations/src/main/java/com/palantir/conjure/java/jackson/optimizations/ObjectMapperOptimizations.java @@ -16,6 +16,7 @@ package com.palantir.conjure.java.jackson.optimizations; +import com.fasterxml.jackson.module.blackbird.BlackbirdModule; import java.util.List; /** @@ -25,11 +26,29 @@ */ public final class ObjectMapperOptimizations { + // Blackbird generates classes at runtime via LambdaMetafactory, which native images cannot do. + private static final boolean NATIVE_IMAGE = System.getProperty("org.graalvm.nativeimage.imagecode") != null; + + /** Equivalent to {@link #createModules(boolean) createModules(false)}: no optimization modules. */ public static List createModules() { - // At one point this conditionally returned List.of(new AfterburnerModule), however afterburner is not - // supported on any supported LTS Java release anymore, and the Blackbird alternative incurs a memory - // leak (https://github.com/FasterXML/jackson-modules-base/issues/147). - return List.of(); + return createModules(false); + } + + /** + * Optionally registers the Blackbird module, which speeds up (de)serialization by generating accessors at + * runtime rather than using reflection. At one point this returned an {@code AfterburnerModule}, however + * afterburner is not supported on any supported LTS Java release anymore. + * + *

Blackbird generates a class per accessor via {@code LambdaMetafactory}. This is bounded and safe when a + * service reuses a small set of long-lived {@code ObjectMapper}s, but creating mappers per-request leaks + * compressed class space (https://github.com/FasterXML/jackson-modules-base/issues/147), so it is opt-in. + * Optimizations are always disabled within native images, where runtime class generation is unsupported. + */ + public static List createModules(boolean useBlackbird) { + if (!useBlackbird || NATIVE_IMAGE) { + return List.of(); + } + return List.of(new BlackbirdModule()); } private ObjectMapperOptimizations() {} diff --git a/conjure-java-jackson-serialization/src/main/java/com/palantir/conjure/java/serialization/ObjectMappers.java b/conjure-java-jackson-serialization/src/main/java/com/palantir/conjure/java/serialization/ObjectMappers.java index f1a906654..f21903472 100644 --- a/conjure-java-jackson-serialization/src/main/java/com/palantir/conjure/java/serialization/ObjectMappers.java +++ b/conjure-java-jackson-serialization/src/main/java/com/palantir/conjure/java/serialization/ObjectMappers.java @@ -50,7 +50,12 @@ private ObjectMappers() {} * */ public static JsonMapper newClientJsonMapper() { - return withDefaultModules(JsonMapper.builder(jsonFactory())) + return newClientJsonMapper(false); + } + + /** Like {@link #newClientJsonMapper()}, optionally enabling Jackson optimization modules (Blackbird). */ + public static JsonMapper newClientJsonMapper(boolean useOptimizations) { + return withDefaultModules(JsonMapper.builder(jsonFactory()), useOptimizations) .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .build(); } @@ -65,7 +70,12 @@ public static JsonMapper newClientJsonMapper() { * */ public static CBORMapper newClientCborMapper() { - return withDefaultModules(CBORMapper.builder(cborFactory())) + return newClientCborMapper(false); + } + + /** Like {@link #newClientCborMapper()}, optionally enabling Jackson optimization modules (Blackbird). */ + public static CBORMapper newClientCborMapper(boolean useOptimizations) { + return withDefaultModules(CBORMapper.builder(cborFactory()), useOptimizations) .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .build(); } @@ -81,7 +91,12 @@ public static CBORMapper newClientCborMapper() { * */ public static SmileMapper newClientSmileMapper() { - return withDefaultModules(SmileMapper.builder(smileFactory())) + return newClientSmileMapper(false); + } + + /** Like {@link #newClientSmileMapper()}, optionally enabling Jackson optimization modules (Blackbird). */ + public static SmileMapper newClientSmileMapper(boolean useOptimizations) { + return withDefaultModules(SmileMapper.builder(smileFactory()), useOptimizations) .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .build(); } @@ -96,7 +111,12 @@ public static SmileMapper newClientSmileMapper() { * */ public static JsonMapper newServerJsonMapper() { - return withDefaultModules(JsonMapper.builder(jsonFactory())) + return newServerJsonMapper(false); + } + + /** Like {@link #newServerJsonMapper()}, optionally enabling Jackson optimization modules (Blackbird). */ + public static JsonMapper newServerJsonMapper(boolean useOptimizations) { + return withDefaultModules(JsonMapper.builder(jsonFactory()), useOptimizations) .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .build(); } @@ -111,7 +131,12 @@ public static JsonMapper newServerJsonMapper() { * */ public static CBORMapper newServerCborMapper() { - return withDefaultModules(CBORMapper.builder(cborFactory())) + return newServerCborMapper(false); + } + + /** Like {@link #newServerCborMapper()}, optionally enabling Jackson optimization modules (Blackbird). */ + public static CBORMapper newServerCborMapper(boolean useOptimizations) { + return withDefaultModules(CBORMapper.builder(cborFactory()), useOptimizations) .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .build(); } @@ -127,7 +152,12 @@ public static CBORMapper newServerCborMapper() { * */ public static SmileMapper newServerSmileMapper() { - return withDefaultModules(SmileMapper.builder(smileFactory())) + return newServerSmileMapper(false); + } + + /** Like {@link #newServerSmileMapper()}, optionally enabling Jackson optimization modules (Blackbird). */ + public static SmileMapper newServerSmileMapper(boolean useOptimizations) { + return withDefaultModules(SmileMapper.builder(smileFactory()), useOptimizations) .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .build(); } @@ -170,13 +200,22 @@ public static ObjectMapper newSmileServerObjectMapper() { *

  • Deserializing a null for a primitive field will throw an exception. * */ - @SuppressWarnings("for-rollout:deprecation") public static > B withDefaultModules(B builder) { + return withDefaultModules(builder, false); + } + + /** + * Like {@link #withDefaultModules(MapperBuilder)}, optionally enabling Jackson optimization modules (Blackbird). + * See {@link ObjectMapperOptimizations#createModules(boolean)} for the tradeoffs of enabling optimizations. + */ + @SuppressWarnings("for-rollout:deprecation") + public static > B withDefaultModules( + B builder, boolean useOptimizations) { return builder.typeFactory(NonCachingTypeFactory.from(builder.build().getTypeFactory())) .addModule(new GuavaModule()) .addModule(new ShimJdk7Module()) .addModule(new Jdk8Module().configureAbsentsAsNulls(true)) - .addModules(ObjectMapperOptimizations.createModules()) + .addModules(ObjectMapperOptimizations.createModules(useOptimizations)) .addModule(new JavaTimeModule()) .addModule(new LenientLongModule()) // we strongly recommend using built-in java.time classes instead of joda ones. Joda deserialization diff --git a/conjure-java-jackson-serialization/src/test/java/com/palantir/conjure/java/serialization/ObjectMappersTest.java b/conjure-java-jackson-serialization/src/test/java/com/palantir/conjure/java/serialization/ObjectMappersTest.java index 813f0827f..fed41d022 100644 --- a/conjure-java-jackson-serialization/src/test/java/com/palantir/conjure/java/serialization/ObjectMappersTest.java +++ b/conjure-java-jackson-serialization/src/test/java/com/palantir/conjure/java/serialization/ObjectMappersTest.java @@ -33,6 +33,7 @@ import com.fasterxml.jackson.databind.type.TypeFactory; import com.fasterxml.jackson.dataformat.smile.SmileFactory; import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.palantir.conjure.java.jackson.optimizations.ObjectMapperOptimizations; import com.palantir.logsafe.Preconditions; import com.palantir.tritium.metrics.registry.SharedTaggedMetricRegistries; import com.palantir.tritium.metrics.registry.TaggedMetricRegistry; @@ -50,6 +51,7 @@ import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.Collections; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -511,6 +513,40 @@ public void testExtraordinarilyLargeStrings() throws IOException { assertThat(parsed).hasSize(size); } + @Test + public void optimizationsDisabledByDefault() { + assertThat(ObjectMapperOptimizations.createModules()).isEmpty(); + assertThat(ObjectMapperOptimizations.createModules(false)).isEmpty(); + assertThat(hasBlackbird(ObjectMappers.newServerJsonMapper())) + .as("Blackbird should not be registered by default") + .isFalse(); + } + + @Test + public void blackbirdOptimizationRegistersModuleAndPreservesOutput() throws IOException { + assertThat(ObjectMapperOptimizations.createModules(true)).hasSize(1); + + JsonMapper optimized = ObjectMappers.newServerJsonMapper(true); + assertThat(hasBlackbird(optimized)) + .as("Blackbird should be registered when optimizations are enabled") + .isTrue(); + assertThat(hasBlackbird(ObjectMappers.newClientJsonMapper(true))) + .as("Blackbird should be registered on client mappers too") + .isTrue(); + + // Blackbird only changes how (de)serialization is performed, not the wire output. + Map value = Map.of("name", "witchcraft", "count", 42); + String optimizedJson = optimized.writeValueAsString(value); + assertThat(optimizedJson).isEqualTo(ObjectMappers.newServerJsonMapper().writeValueAsString(value)); + assertThat(optimized.readValue(optimizedJson, new TypeReference>() {})) + .isEqualTo(value); + } + + private static boolean hasBlackbird(ObjectMapper mapper) { + return mapper.getRegisteredModuleIds().stream() + .anyMatch(id -> id.toString().toLowerCase(Locale.ROOT).contains("blackbird")); + } + private static String ser(Object object) throws IOException { return MAPPER.writeValueAsString(object); } diff --git a/versions.lock b/versions.lock index 03bbb129e..e9400e6cc 100644 --- a/versions.lock +++ b/versions.lock @@ -4,9 +4,9 @@ com.fasterxml:classmate:1.5.1 (1 constraints: 9a122a13) com.fasterxml.jackson.core:jackson-annotations:2.21 (12 constraints: 03de1b79) -com.fasterxml.jackson.core:jackson-core:2.21.4 (14 constraints: d1231840) +com.fasterxml.jackson.core:jackson-core:2.21.4 (15 constraints: 503a606a) -com.fasterxml.jackson.core:jackson-databind:2.21.4 (18 constraints: c86371a8) +com.fasterxml.jackson.core:jackson-databind:2.21.4 (19 constraints: 477ad144) com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.21.4 (2 constraints: 2f204c06) @@ -24,6 +24,8 @@ com.fasterxml.jackson.jakarta.rs:jackson-jakarta-rs-base:2.21.4 (1 constraints: com.fasterxml.jackson.jakarta.rs:jackson-jakarta-rs-cbor-provider:2.21.4 (1 constraints: 3b053a3b) +com.fasterxml.jackson.module:jackson-module-blackbird:2.21.4 (1 constraints: 3b053a3b) + com.fasterxml.jackson.module:jackson-module-jakarta-xmlbind-annotations:2.21.4 (2 constraints: e53097b3) com.fasterxml.jackson.module:jackson-module-scala_2.12:2.21.4 (1 constraints: 3b053a3b)