Modelo de Árbol y API de Streaming de Jackson
Cuando la forma es desconocida, trepa el árbol; cuando la carga es enorme, cabalga el flujo.
La mayor parte del tiempo mapeas JSON directamente a una data class de Kotlin y la capa databind de Jackson hace el resto. Pero dos situaciones rompen ese modelo cómodo. La primera es cuando la forma del JSON es dinámica o solo se conoce parcialmente: un webhook cuyos campos varían según el tipo de evento, una API de terceros que sigue añadiendo propiedades, o un almacén de documentos donde cada registro luce algo distinto. La segunda es cuando la carga es simplemente demasiado grande para mantenerla en memoria de una vez: una exportación de varios gigabytes, un flujo de logs interminable, o una respuesta que debes transformar al vuelo. Para esto, Jackson ofrece dos herramientas de más bajo nivel que viven debajo de databind: el modelo de árbol y la API de streaming.
El modelo de árbol parsea un documento JSON completo a un grafo en memoria de objetos `JsonNode`. `ObjectMapper.readTree(...)` te da el nodo raíz, y desde ahí navegas con `path("campo")`, indexas en arreglos y lees hojas tipadas con `asText()`, `asInt()`, `asBoolean()` y compañía. El idioma crucial es `path` frente a `get`: `get` devuelve una referencia `null` real cuando falta un campo, fácil de desreferenciar por accidente, mientras que `path` devuelve un `MissingNode` que se encadena con seguridad. Un `MissingNode` reporta `isMissingNode() == true` y entrega el valor por defecto que indiques en los accesores, así que las lecturas profundas y defensivas siguen siendo seguras frente a nulos sin un bosque de operadores `?.`. Esto hace del modelo de árbol la opción natural para JSON heterogéneo donde no puedes comprometerte con una sola clase de antemano.
Cuando necesitas construir o mutar JSON dinámicamente, usa los subtipos mutables. `ObjectMapper.createObjectNode()` devuelve un `ObjectNode` al que puedes hacer `put("clave", valor)`, y `createArrayNode()` devuelve un `ArrayNode` al que puedes hacer `add(...)`. Como son mutables, el modelo de árbol funciona también como constructor de JSON y herramienta de parcheo: lees un documento, insertas un campo calculado, eliminas uno obsoleto y lo escribes de vuelta. La sobrecarga de operadores de Kotlin incluso te permite indexar un nodo con `node["campo"]`, lo que se lee con claridad en pipelines. Solo recuerda que el documento entero vive en el heap, así que el modelo de árbol es incorrecto para cualquier cosa que pueda no caber cómodamente en memoria.
Ese techo de memoria es justo donde la API de streaming demuestra su valor. `JsonParser` es un cursor de solo avance, basado en pull, sobre un flujo de tokens. Llamas a `nextToken()` para avanzar e inspeccionas `currentToken()` para ver si estás ante un `START_OBJECT`, `FIELD_NAME`, `VALUE_STRING`, `END_ARRAY`, etc. Nada se almacena en un árbol, así que la memoria pico se mantiene casi constante sin importar el tamaño del archivo. El compromiso es que tú, no Jackson, llevas el control de dónde estás en la estructura. El patrón canónico para un arreglo enorme de nivel superior es: avanzar hasta `START_ARRAY`, luego iterar mientras `nextToken() != END_ARRAY`, y para cada elemento leer los campos token por token o entregar el subárbol a `readValueAs(MyType::class.java)` para que databind maneje un registro manejable a la vez.
`JsonGenerator` es la imagen espejo para la salida: un escritor de solo avance que emite tokens directamente a un `OutputStream` o `Writer` sin materializar nunca el documento completo. Llamas a `writeStartArray()`, `writeStartObject()`, `writeStringField("nombre", valor)`, `writeEndObject()`, etc., haciendo flush sobre la marcha. Esta es la herramienta correcta para enviar una respuesta HTTP grande: en Spring puedes devolver un `StreamingResponseBody` y manejar un `JsonGenerator` sobre su output stream, produciendo un arreglo de varios megabytes manteniendo solo una fila en memoria a la vez. Siempre cierra el generador (usa el `use {}` de Kotlin) para que los tokens finales se vacíen y el stream subyacente se libere.
Los tres enfoques forman una escalera de decisión clara. Usa primero databind y data classes; es lo más seguro en tipos y el menor código. Baja al modelo de árbol cuando la forma sea dinámica, parcial o necesites mutar JSON arbitrario. Baja aún más a la API de streaming solo cuando el tamaño te obligue, o cuando realmente necesites máximo rendimiento en una ruta caliente. También puedes mezclarlos: transmite el sobre exterior con un `JsonParser`, pero llama a `readValueAs` en cada elemento para que los registros individuales sigan beneficiándose de la comodidad de databind. Este híbrido mantiene la memoria plana preservando un acceso limpio y tipado a cada ítem.
Algunas precauciones prácticas. El código de streaming corre sobre I/O bloqueante, así que cuando lo llames desde una corrutina, envuélvelo en `withContext(Dispatchers.IO)` para mantenerlo fuera del event loop; Jackson en sí es síncrono y basado en pull, no consciente de suspend. Reutiliza un único `ObjectMapper` configurado (o el bean gestionado por Spring) en lugar de construir uno por petición, ya que crearlo es comparativamente costoso y es thread-safe una vez configurado. Registra el módulo de Kotlin (`jacksonObjectMapper()` de `jackson-module-kotlin`) para que se respeten la nulabilidad y los parámetros por defecto. Y prefiere `BigDecimal` para valores monetarios vía `asText().toBigDecimal()` en lugar de `asDouble()`, porque el punto flotante corrompe silenciosamente las divisas. Con estos hábitos, el modelo de árbol y la API de streaming convierten JSON incómodo, sobredimensionado o sin forma en algo que puedes manejar con seguridad y eficiencia.
import com.fasterxml.jackson.databind.JsonNodeimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapperval mapper = jacksonObjectMapper()fun summarize(json: String): String {val root: JsonNode = mapper.readTree(json)// path() chains safely even when fields are absentval name = root.path("user").path("name").asText("unknown")val premium = root.path("user").path("premium").asBoolean(false)// Iterate an array without knowing its size up frontval tags = root.path("tags").mapNotNull { it.asText(null) }// Money as BigDecimal, never asDouble()val total = root.path("order").path("total").asText("0").toBigDecimal()val missing = root.path("nope").isMissingNode // true, no NPEreturn "$name premium=$premium tags=$tags total=$total absent=$missing"}
Tree model: navigate dynamic JSON safely with path() and read typed leaves. path() returns a MissingNode (never null), so deep reads stay null-safe.
import com.fasterxml.jackson.databind.node.ArrayNodeimport com.fasterxml.jackson.databind.node.ObjectNodeimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapperval mapper = jacksonObjectMapper()fun patchOrder(original: String, discount: java.math.BigDecimal): String {val root = mapper.readTree(original) as ObjectNode// splice in a computed fieldval total = root.path("total").asText("0").toBigDecimal()root.put("discounted", (total - discount).toPlainString())// append to a nested array (get-or-create; avoids deprecated withArray)val history = (root.get("history") as? ArrayNode) ?: root.putArray("history")history.add(mapper.createObjectNode().put("event", "discount_applied"))// drop a stale fieldroot.remove("internalNote")return mapper.writeValueAsString(root)}
Build and mutate JSON with ObjectNode/ArrayNode. The tree model doubles as a JSON builder and a patch tool. Get-or-create the array explicitly instead of the ambiguous withArray(String) overload.
import com.fasterxml.jackson.core.JsonTokenimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapperimport kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.withContextimport java.io.InputStreamdata class Record(val id: Long, val amount: java.math.BigDecimal)val mapper = jacksonObjectMapper()suspend fun sumAmounts(input: InputStream): java.math.BigDecimal =withContext(Dispatchers.IO) {mapper.factory.createParser(input).use { parser ->var sum = java.math.BigDecimal.ZEROcheck(parser.nextToken() == JsonToken.START_ARRAY) { "expected array" }while (parser.nextToken() != JsonToken.END_ARRAY) {// hand each element to databind — hybrid streamingval record: Record = parser.readValueAs(Record::class.java)sum += record.amount}sum}}
Streaming a huge top-level array: JsonParser pulls one record at a time so peak memory stays flat. Called from a coroutine on Dispatchers.IO because the I/O is blocking.
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapperimport org.springframework.http.MediaTypeimport org.springframework.web.bind.annotation.GetMappingimport org.springframework.web.bind.annotation.RestControllerimport org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody@RestControllerclass ExportController(private val mapper: com.fasterxml.jackson.databind.ObjectMapper) {@GetMapping("/export", produces = [MediaType.APPLICATION_JSON_VALUE])fun export(): StreamingResponseBody = StreamingResponseBody { out ->mapper.factory.createGenerator(out).use { gen ->gen.writeStartArray()(1..1_000_000L).forEach { id ->gen.writeStartObject()gen.writeNumberField("id", id)gen.writeStringField("label", "row-$id")gen.writeEndObject()}gen.writeEndArray()}}}
Streaming output with JsonGenerator inside a Spring StreamingResponseBody: emit a large array while holding only one row in memory. use{} flushes and closes.
🧠 Comprueba tu comprensión
0/1 · 0/1 answered1. You must parse a 4 GB JSON file that is a single top-level array of order objects, summing one numeric field. Which Jackson approach is the most appropriate?