Jackson¶
etcd-recipes-jackson provides one class: JacksonCodec<T>, an implementation of core's
EtcdCodec<T> backed by a Jackson ObjectMapper.
Why it is a separate artifact¶
Core already has a JSON codec — KotlinxJsonCodec, reached through jsonCodec<T>(). But
jsonCodec is an inline reified function backed by kotlinx-serialization, which wants a
compiler plugin and an @Serializable annotation on your type. From Java that is a
non-starter, and plenty of Kotlin services already have an ObjectMapper configured to
their taste.
So JacksonCodec is the Java-facing counterpart to KotlinxJsonCodec, split into its own
module so that projects which do not want Jackson never see it on their classpath. Nothing
in core knows the difference: a JacksonCodec is an EtcdCodec, and it drops into every
typed recipe unchanged.
Constructing a codec¶
Two @JvmOverloads constructors, each taking an optional ObjectMapper:
class JacksonCodec<T> : EtcdCodec<T> {
constructor(type: Class<T>, mapper: ObjectMapper = ObjectMapper())
constructor(type: TypeReference<T>, mapper: ObjectMapper = ObjectMapper())
}
// The Class token form. The second constructor argument is the ObjectMapper; omit it
// and you get a vanilla one.
EtcdCodec<Order> codec = new JacksonCodec<>(Order.class);
// Supply your own mapper to control the wire format.
ObjectMapper mapper = new ObjectMapper();
EtcdCodec<Order> configured = new JacksonCodec<>(Order.class, mapper);
jacksonCodec<T>(mapper) is a reified Kotlin helper that builds the TypeReference for
you. It is the closest analogue to core's jsonCodec<T>(), and the form to reach for from
Kotlin.
Generic payloads need a TypeReference¶
A Class token cannot express List<Order> — the type argument is erased, and Jackson
would have nothing to reconstruct the elements from. That is what the second constructor
is for:
// A Class token cannot express List<Order> — its type argument is erased. Use a
// TypeReference (or the reified helper, which makes one internally) instead.
val explicit: EtcdCodec<List<Order>> = JacksonCodec(object : TypeReference<List<Order>>() {})
val reified: EtcdCodec<List<Order>> = jacksonCodec<List<Order>>()
The Class constructor is the one to use for a plain type; reach for TypeReference the
moment a generic appears in the payload.
Kotlin data classes need a configured mapper¶
The default ObjectMapper is vanilla
JacksonCodec(Order::class.java) gives you ObjectMapper() — a bare instance with no
modules registered. That is exactly right for Java beans, records, and anything Jackson
can construct out of the box, and it is why the default exists.
It is wrong for Kotlin data classes. A data class has no no-arg constructor, so a
vanilla mapper cannot instantiate it and decode fails at runtime with
InvalidDefinitionException — at decode time, not construction time, so a codec that
looks fine can fail on the first read. Kotlin nullability is not enforced either: a
vanilla mapper will happily leave null in a non-null field.
Register jackson-module-kotlin and pass the mapper in:
// The default ObjectMapper is vanilla. findAndRegisterModules() ServiceLoader-discovers
// jackson-module-kotlin when it is on the classpath, which is what makes plain Kotlin
// data classes (no no-arg constructor, no @JsonCreator) decodable.
val mapper = ObjectMapper().findAndRegisterModules()
val codec: EtcdCodec<Order> = jacksonCodec<Order>(mapper)
findAndRegisterModules() discovers the Kotlin module via ServiceLoader, so it also
picks up jackson-module-parameter-names, JSR-310, and anything else on the classpath —
usually what you want. ObjectMapper().registerKotlinModule() is the explicit alternative
if you would rather name it.
The other way out is to make the type constructable by a vanilla mapper, which is what the sample type on this page does — the module's own tests use exactly this trick to stay honest about the default:
// @JsonCreator keeps this data class constructable by a *vanilla* ObjectMapper, exactly
// as a Java bean would be. Without it, Kotlin data classes need jackson-module-kotlin.
data class Order
@JsonCreator
constructor(
@param:JsonProperty("id") val id: String,
@param:JsonProperty("qty") val qty: Int,
)
A Java record works with a vanilla mapper with no annotations at all, as does any bean with a no-arg constructor and setters. If you are on Java, this is a non-issue.
Using it¶
Anywhere an EtcdCodec goes. With a typed recipe:
EtcdCodec<Order> codec = new JacksonCodec<>(Order.class);
try (TypedDistributedQueue<Order> queue =
new TypedDistributedQueue<>(client, "/queues/orders", codec)) {
queue.enqueue(new Order("A-1", 3));
// dequeue() hands back an Order, not a ByteSequence.
Order order = queue.dequeue();
System.out.println("Dequeued " + order);
}
With a typed cache:
TypedPathChildrenCache(client, "/config/orders", jacksonCodec<Order>()).use { cache ->
cache.addListener { event -> logger.info { "${event.childName} -> ${event.data}" } }
cache.start(buildInitial = true)
cache.currentData.forEach { child -> logger.info { "${child.key} = ${child.value}" } }
}
Or with typed key/value reads and writes:
EtcdCodec<Order> codec = new JacksonCodec<>(Order.class);
// The typed KV helpers are Kotlin extension functions, so from Java they are statics on
// TypedKVUtils with the client as the first argument. They have no @JvmOverloads, so
// the trailing defaults must be passed explicitly.
TypedKVUtils.putValue(client, "/config/order", new Order("A-1", 3), codec,
PutOption.DEFAULT, RpcResilience.DEFAULT);
Order order = TypedKVUtils.getValue(client, "/config/order", codec, RpcResilience.DEFAULT);
System.out.println("Read back " + order);
The typed KV helpers are clumsy from Java
putValue / getValue are Kotlin extension functions with default arguments and no
@JvmOverloads, so Java sees statics on TypedKVUtils with the client as the first
argument and no short overload — you must pass PutOption.DEFAULT and
RpcResilience.DEFAULT explicitly. The typed recipes have no such wart; their
constructors are @JvmOverloads. See the Java guide.
Service payloads¶
A ServiceInstance carries its payload in an opaque jsonPayload string, so a service
payload codec must emit UTF-8 text. JacksonCodec emits JSON, which qualifies:
// Service payloads ride in the instance's `jsonPayload` String, so the codec must emit
// UTF-8 text. JacksonCodec emits JSON, which qualifies.
val codec = jacksonCodec<Order>()
val instance = serviceInstance("orders", Order("A-1", 3), codec) { apply { port = 8080 } }
ServiceDiscovery(client, "/discovery").use { discovery ->
discovery.registerService(instance)
discovery.queryForInstances("orders").forEach { found ->
val order: Order = found.payload(codec)
logger.info { "${found.name} carries $order" }
}
}
See Service discovery for the registration side, and Typed values for why the text restriction exists.