2.2.1. Jackson#

Un potentísima librería, que tiene la ventaja de que también soporta XML es la proporcionada por el proyecto Jackson, disponible en su repositorio de mvnrepository.com[1].

Permite hacer una conversión automática entre el modelo de objetos y JSON. Para ilustrarlo, podemos definir las tres clases que modelas el archivo json propuesto:

Tutor.java#
public class Tutor {
    private String nombre;
    private String especialidad;

    public Tutor() {}

    public Tutor(String nombre, String especialidad) {
        setNombre(nombre);
        setEspecialidad(especialidad);
    }

    public String getNombre() {
        return nombre;
    }

    public void setNombre(String nombre) {
        this.nombre = nombre;
    }

    public String getEspecialidad() {
        return especialidad;
    }

    public void setEspecialidad(String especialidad) {
        this.especialidad = especialidad;
    }

    @Override
    public String toString() {
        return nombre;
    }
}
Alumno.java#
public class Alumno {
    private String nombre;
    private LocalDate fechaNacimiento;

    public Alumno() {}

    public Alumno(String nombre, int edad) {
        setNombre(nombre);
        setEdad(edad);
    }

    public String getNombre() {
        return nombre;
    }

    public void setNombre(String nombre) {
       this.nombre = nombre;
    }

   public LocalDate getFechaNacimiento() {
       return fechaNacimiento
   }

    public int getEdad() {
       return Perido.between(fechaNacimiento, LocalDate.now()).getYears();
    }

    public void setFechaNacimiento(LocalDate nacimiento) {
        this.fechaNacimiento = nacimiento;
    }

    @Override
    public String toString() {
        return String.format("%s: %d", nombre, edad);
    }
}
Grupo.java#
import java.util.Arrays;

public class Grupo {
    private short nivel;
    private String etapa;
    private char grupo;
    private Tutor tutor;
    private Alumno[] miembros;

    public Grupo() {}

    public Grupo(short nivel, String etapa, char grupo, Tutor tutor, Alumno[] miembros) {
        setNivel(nivel);
        setEtapa(etapa);
        setGrupo(grupo);
        setTutor(tutor);
        setMiembros(miembros);
    }

    public short getNivel() {
        return nivel;
    }

    public void setNivel(short nivel) {
        this.nivel = nivel;
    }

    public String getEtapa() {
        return etapa;
    }

    public void setEtapa(String etapa) {
        this.etapa =  etapa;
    }

    public char getGrupo() {
        return grupo;
    }

    public void setGrupo(char grupo) {
        this.grupo = grupo;
    }

    public Tutor getTutor() {
        return tutor;
    }

    public void setTutor(Tutor tutor) {
        this.tutor = tutor;
    }

    public Alumno[] getMiembros() {
        return miembros;
    }

    public void setMiembros(Alumno[] miembros) {
        this.miembros = miembros;
    }

    public String nombre() {
        return String.format("%dº%s-%c", nivel, etapa, grupo);
    }

    @Override
    public String toString() {
        return String.format("%s (%s): %s", nombre(), tutor, Arrays.toString(miembros));
    }
}

No nos hemos roto mucho la cabeza: estas tres clases recogen las propiedades que se observan en el JSON y, además, cumplen los requisitos para ser un JavaBean, aunque no es indispensable:

  • Disponen de un constructor sin argumentos.

  • Sus atributos son privados.

  • Dispone de getters y setters con la convención de nombres apropiada.

2.2.1.1. Operativa básica#

Empecemos por ver cómo leer o escribir el archivo JSON de referencia.

2.2.1.1.1. Lectura#

Básicamente, necesitamos un mapper y volcar los datos en el almacenamiento que hayamos decidido:

Path ruta = Path.of(System.getProperty("user.home"), "grupos.json");
ObjectMapper mapper = new JsonMapper();

try(InputStream st = Files.newInputStream(ruta)) {
    Grupo[] grupos = mapper.readValue(st, Grupo[].class);
    Arrays.stream(grupos).forEach(System.out::println);
}
catch(IOException err) {
    err.printStackTrace();
}

Obsérvese que necesitamos indicar en qué objeto de Java se convertirán los datos. Cuando es un objeto único es bien sencillo (LaClaseDelObjeto.class) y en el caso de arrays, también. Pero cuando tenemos colecciones de datos basadas en genéricos debemos recurrir a TypeReference:

List<Grupo> grupos = mapper.readValue(sr, new TypeReference<List<Grupo>>(){});

Nota

Este forma particular de tratar a la secuencia se produce cuando es la propia secuencia la raiz del archivo. Si las secuencias son valores de propiedades incluidas en el JSON es irrelevante que en las clases definamos el atributo correspondiente como un List o un Array: la traducción se llevará a cabo de igual forma. En nuestro ejemplo, es el caso del atributo miembros de Grupo, que lo hemos definido como un array, pero podría perfectamente ser una lista.

2.2.1.1.2. Escritura#

Por su parte la escritura a un JSON tampoco es excesivamente complicada:

public static void main(String[] args) {

    Grupo[] grupos = {
        new Grupo(
            (short) 1,
            "ESO",
            'B',
            new Tutor("Pepe M.J.", "Matemáticas"),
            new Alumno[] {
               new Alumno("Pablito", LocalDate.of(2008, 10, 2)),
               new Alumno("Juanito", LocalDate.of(2010, 3, 25))
            }
        ),
        new Grupo(
            (short) 2,
            "ESO",
            'C',
            new Tutor("Pedro J.M.", "Lengua"),
            new Alumno[] {
               new Alumno("Lola", LocalDate.of(2009, 1, 11)),
               new Alumno("Manolito", LocalDate.of(2010, 5, 15))
            }
        )
    };

    Path ruta = Path.of(System.getProperty("java.io.tmpdir"), "grupos.json");
    ObjectMapper mapper = new JsonMapper();

    try (OutputStream st = Files.newOutputStream(ruta)) {
        mapper.writeValue(st, grupos);
    }
    catch (IOException err) {
        err.printStackTrace();
    }
}

Como se ve el método que realiza la escritura al formato recibe los datos, de los cuales puede deducir el tipo y, por consiguiente, no tiene relevancia si volcamos un objeto o una secuencia de la naturaleza que sea.

Truco

Existe el método writeValueAsString que devuelve una cadena con el JSON resultante:

String contenido = mapper.writeValueAsString(grupos);

2.2.1.2. Configuración del mapeo#

Bajo el epígrafe anterior hemos hecho una traducción muy sencilla en la que no hemos definido ninguna característica particular para la lectura o la escritura del JSON ni hemos necesitado afinar la traducción de los atributos del objeto: se llaman igual en Java y en JSON, no hemos necesitado evitar ninguno, todos son traducibles directamente a algún tipo existente en el formato JSON.

Lo habitual en cambio es que necesitemos configurar el mapeo y eso es lo que veremos ahora.

2.2.1.2.1. Creación del mapper#

Cuando queremos configurar las características del mapeo, no debemos crear el mapper directamente como hicimos anteriormente:

ObjectMapper mapper = new JsonMapper();  // Inmutable, no se puede configurar.

ya que a partir de la versión 3 de Jackson los objetos de mapeos son inmutables y no pueden configurarse después de haberse creado.

El proceso completo de creación de un mapeo tiene tres fases:

  1. Creación de un Factory (JsonFactory), en la que podemos ajustar características de serialización (JsonWriteFeature) y deserialización (JsonReadFeature) propias del formato (JSON en este caso).

  2. Creación de un Builder (JsonMapper.Builder), en la que podremos añadir módulos (los veremos más adelante), características generales de serialización (SerializationFeature) y deserialización (DeserializationFeature), y complementar la definición las clases del modelo con la técnica del MixIn.

  3. Creación del Mapper propiamente (JsonMapper).

Ilustrémoslo:

JsonFactory factory = JsonFactory.builder()
     .enable(JsonReadFeature.ALLOW_JAVA_COMMENTS)  // Características particulares de de/serialización
     .build();

// MapperBuilder<?, ?> o JsonMapper.Builder, que es la clase padre.
MapperBuilder<?, ?> builder = JsonMapper.builder(factory)
     .enable(SerializationFeature.INDENT_OUTPUT)  // Características generales de de/serialización
     .addModule(module)      // Supongamos que hemos definido ese modulo. Ya se tratará más adelante
     .addMixIn(Grupo.class, GrupoMixin.class);   // Mezclamos (ya veremos la utilidad)

 // ObjectMapper o JsonMapper, que es la clase padre.
 ObjectMapper mapper = builder.build();

Nota

MapperBuilder y ObjectMapper son clases padre que se comparten con otros formatos como YAML o XML. Si nuestra intención es soportar varios formatos, es probable que nos convenga definir builder y mapper como de estas clases respectivamente en vez de las clases más concretas JsonMapper.Builder y JsonMapper.

Truco

El ejemplo ilustra cómo puede habilitarse con .enable() una característica de serialización que por defecto esté desactiva. y obviamente puede hacerse al contrario con .disable(). También existe .configure() que añade un segundo argumento booleano para expresar si se quiere habilitada o deshabilitada la característica:

JsonFactory factory = JsonFactory.builder()
     .configure(JsonReadFeature.ALLOW_JAVA_COMMENTS, true)
     .build();

Ver también

El control de la serialización puede hacerse de un modo más exhaustivo utilizando el concepto de writer. Lo introduciremos más adelante.

Estás tres etapas pueden simplificarse si necesitamos menos configuración. Ya hemos visto que pueden reducirse a una, si no necesitamos configuración adicional en absoluto:

ObjectMapper mapper = new JsonMapper();

O a dos, si no necesitamos añadir características particulares de serialización o deserialización:

MapperBuilder<?, ?> builder = JsonMapper.builder() // No hemos definido factory.
     .enable(SerializationFeature.INDENT_OUTPUT)
     .addModule(module)
     .addMixIn(Grupo.class, GrupoMixin.class);

ObjectMapper mapper = builder.build();

2.2.1.2.2. Anotaciones#

Hemos visto muy por encima cómo trasladar objetos a JSON y viceversa. La regla es que cada atributo del objeto se traduce en una propiedad JSON con el mismo nombre y que conserva el mismo valor[2]. Centrémonos en Alumno:

public class Alumno {

    // Atributos
    private String nombre;
    private LocalDate fechaNacimiento;

    // Resto de la implementación
}

La serialización de un objeto Grupo será esta:

{
   "nombre": "Matías Sánchez Aguado",
   "fechaNacimiento": "2010-05-15"
}

Ahora bien, ¿cómo puede personalizarse la serialización para cambiar el nombre de un atributo o no serializarlo? La librería para ello utiliza anotaciones que permiten indicar todos estos particulares:

public class Alumno {

    // Atributos
    private String nombre;

    @JsonProperty("nacimiento")
    private LocalDate fechaNacimiento;

    // ...

    @JsonIgnore
    public int getEdad() {
       return Period.between(fechaNacimiento, LocalDate.now()).getYears();
    }

}

De esta forma logramos que en el JSON la propiedad no sea fechaNacimiento, sino nacimiento. Además, el método que nos devuelve la edad tiene forma de getter por lo que Jackson lo entenderá como tal y escribirá en el JSON la propiedad edad, sin que esta exista realmente en el modelo de datos. Por eso añadimos la anotación @JsonIgnore.

El problema de anotar directamente la clase es que quizás no nos resulte elegante o directamente no podamos, porque no dependa de nosotros la definición. Para poder añadir anotaciones sin modificar la clase original, Jackson proporciona un mecanismo: la mezcla. Para ello podríamos definir aparte otra clase distinta que contenga exclusivamente los atributos que necesitan anotación:

public abstract class AlumnoMixin {

   @JsonProperty("nacimiento")
   private LocalDate nacimiento;

   @JsonIgnore
   public abstract int getEdad();
}

Luego, al definir el mapeo. se declara que deseamos mezclar la clase original con la clase abstracta anotada:

MapperBuilder<?, ?> builder = JsonMapper.builder(factory)
     .addMixIn(Alumno.class, AlumnoMixin.class);

Existen otras anotaciones interesante que introduciremos más adelante.

Nota

Al serializar en formato XML con esta librería, las anotaciones se vuelven imprescindibles, porque es la única forma de indicar, entre otras cosas, si un atributo de la clase será un elemento o un atributo XML.

2.2.1.3. Traducción de tipos no primitivos#

En el ejemplo anterior todos los atributos de los objetos de Java tienen una correspondencia con un tipo primitivo de JSON: cadenas, números, secuencias (ya sean Array o ArrayList) excepto uno del que no nos hemos preocupado, por lo que no hemos defino cómo debe ser su serialización.

2.2.1.3.1. Traducciones predefinidas#

Tampoco es necesario definir una traducción específica en otros casos en que no hay correspondencia, pero Jackson nos provee una predeterminada:

Enumeraciones (Enum),

Si se pretende que la traducción a JSON sean las constantes de enumeración convertidas a cadenas. Por ejemplo, en este caso:

public enum Etapa {
  INFANTIL,
  PRIMARIA,
  ESO,
  BACHILLERATO,
  FP,
  UNIVERSIDAD;

  /**
   * Devuelve en enum a partir de su ordinal
   */
  public static Etapa indexOf(int ordinal) {
     return Etapa.values()[ordinal];
  }
}

los valores se verán como las cadenas INFANTIL, PRIMARIA, ESO en el JSON. De hecho, podríamos definir el atributo etapa de Grupo como de tipo Etapa y todo seguiría funcionado perfectamente.

Fechas

Tanto si se usa el antiguo Date como el moderno LocalDate, no es necesario definir ningúna traducción si se pretenden trasladar a cadenas según el estándar ISO-8601, es decir, si se escriben en el JSON de la forma yyyy-MM-dd[3][4].

Si la forma de la cadena es otra (p.ej. dd/MM/yyyy) aún es posible de manera sencilla indicárselo a la librería mediante una anotación. Imaginemos que en vez de almacenar la edad, almacenamos la fecha de nacimiento del alumno:

@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "dd/MM/yyyy")
private LocalDate fechaNacimiento;

/* ... */

/**
 * "Atributo" calculado a partir de la fecha de nacimiento.
 * Como le hemos dado la forma de un getter, Jackson lo tomará como
 * tal y al escribir el alumno incluirá la propiedad "edad". Por eso,
 * especificamos que lo salte.
 */
@JsonIgnore
public int getEdad() {
   return Period.between(fechaNacimiento, LocalDate.now()).getYears();
}
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "dd/MM/yyyy", timezone = "Europe/Madrid")
private Date fechaNacimiento;

Este último método, no obstante, sólo sirve para especificar la traducción de un atributo en concreto. Si se quiere una configuración que afecte a todos los atributos que contengan fechas, entonces el modo de obrar difiere entre uno y otro tipo:

Date

Podemos especificar el formato de fecha al crear el Builder:

MapperBuilder<?, ?> builder = JsonMapper.builder(factory)
   .defaultDateFormat(new SimpleDateFormat("dd/MM/yyyy"))
   .defaultTimeZone(TimeZone.getTimeZone("Europe/Madrid"));  // o TimeZone.getDefault()

Como se ve también es conveniente especificar la zona horaria.

LocalDate

La configuración difiere porque hay que hacerla definiendo un módulo:

DateTimeFormatter pattern = DateTimeFormatter.ofPattern("dd/MM/yyyy");

SimpleModule module = new SimpleModule();
module.addSerializer(LocalDate.class, new LocalDateSerializer(pattern))
module.addDeserializer(LocalDate.class, new LocalDateDeserializer(pattern));

 MapperBuilder<?, ?> builder = JsonMapper.builder(factory)
    .addModule(module);

2.2.1.3.2. Traductor específico#

La librería incluye un mecanismo para hacer traducciones totalmente arbitrarias de cualquier tipo que se nos pueda ocurrir. Ilustrémoslo con la serialización y deserialización artesanal de una tipo LocalDate, aunque ya esté resuelto en la propia librería. Sin embargo, nos sirve para ilustrarlo:

Prudencia

Lo que exponemos ahora ya está resuelto en Jackson con LocalDateSerializer y LocalDateDeserializer (como acabamos de ver) por lo que no tiene mucho sentido perder el tiempo en reimplementarlo. Sin embargo, es didácticamente pertinente porque nos sirve para ilustrar cómo definir serializadores y deserializadores.

public class Traductor {

    public static DateTimeFormatter df = DateTimeFormatter.ofPattern("yyyy-MM-dd");

    public static class DateSerializer extends JsonSerializer<LocalDate> {

        @Override
        public void serialize(LocalDate value, JsonGenerator gen, SerializerProvider sp)
            throws IOException, JsonProcessingException {
                if(value == null) gen.writeNull();
                else gen.writeString(df.format(value));     // LocalDate --> String
        }
    }

    public static class DateDeserializer extends JsonDeserializer<Date> {

        @Override
        public Date deserialize(JsonParser parser, DeserializationContext context)
            throws IOException, JsonProcessingException {
                try {
                   return LocalDate.parse(parser.getText(), df);  // String --> LocalDate
                }
                catch(ParseException err) {
                   throw new RuntimeException(err);
                }
        }
    }
}

En definitiva, al serializar tenemos que indicar cómo pasar del tipo a una cadena (que será la que se escriba en el JSON), y al deserializar cómo pasar de la cadena que aparece en el JSON al tipo de Java correspondiente.

Y ya sólo quedaría indicar el campo en que se va a usar este traductor:

@JsonSerialize(using=Traductor.DateSerializer.class)
@JsonDeserialize(using=Traductor.DateDeserializer.class)
private Date nacimiento;

Y en caso de que hubiera varios atributos LocalDate en las clases implicadas y todas se quieran traducir igual, podríamos ahorrarnos la anotación individual en cada uno de los atributos y hacer lo siguiente:

 SimpleModule module = new SimpleModule();

 module.addSerializer(java.util.Date.class, new Traductor.DateSerializer());
 module.addDeserializer(java.util.Date.class, new Traductor.DateDeserializer());


MapperBuilder<?, ?> builder = JsonMapper.builder(factory)
 .addModule(module);

Truco

Obsérvese que el mecanismo específico para definir el formato de las fechas LocalDate es equivalente a este que indicamos ahora. Simplemente las clases serializadora (LocalDateSerializer) y deserializadora (LocalDateDeserializer) ya están definidas y nos limitamos a usarlas en vez de definirlas nosotros.

2.2.1.4. Control de la serialización#

Características generales de serialización

Ya hemos dicho que algunas características generales de la serialización independientes al formato de traducción puede configurarse antes de crear el mapeador. Hay, sin embargo, otro mecanismo más potente para definir la serialización hacia el formato: definir un ObjectWriter.

ObjectMapper mapper = JsonMapper.builder().build();  // Mapper inmutable.
ObjectWriter writer = mapper.writer()
   .with(Serialization.INDENT_OUTPUT);

// Ahora usamos el writer (en vez del mapper) para escribir los datos

writer.writeValue(st, datos);

Filtrado de atributos.

Puede darse el caso de que no quiera almacenarse toda la información contenida en el modelo de datos. La solución general es crear DTOs, pero Jackson tiene algunas herramientas que nos permiten ahorrarnos la definición de estos objetos.

En concreto, nos facilita dos herramientas: las vistas, que permiten hacer un filtrado estático basado en anotaciones, y los filtros, que permiten la definición de filtrados programáticos. Para ilustrarlas supongamos que tenemos más información referente a los alumnos:

public class Alumno {
   private String nombre;
   private LocalDate fechaNacimiento;
   private int telefono;
   private String dni;

   // Resto de la definición de la clase.
}

Los datos de los alumnos no tienen la misma confidencialidad, así que nos podría interesar proporcionar más o menos información. Para ello primero tenemos que definir nosotros varias vistas de menor a mayor confidencialidad:

public class Views {
   public static class Public {}                   /* Menos confidencial */
   public static class Private extends Public {}
   public static class Internal extends Private {} /* Más confidencial */
}

Al anotar la clase podemos indicar a qué vista queremos que pertenezca el atributo:

public class Alumno {
   @JsonView(Views.Public.class)
   private String nombre;

   // Sin vista sólo se mostrará cuando no se activa ninguna vista.
   private LocalDate fechaNacimiento;

   @JsonView(Views.Private.class)
   private int telefono;

   @JsonView(Views.Internal.class)
   private String dni;
}

Definida la vista de cada atributo, en el momento de serializar podemos indicar al objeto ObjectWriter qué vista debe escribir. En nuestro ejemplo:

  • Sin vista, sólo se serializará fechaNacimiento[5].

  • Con Views.Public se serializará nombre.

  • Con Views.Private se añadirá telefono

  • Con Views.Internal se serializarán todos los asociados a vista.

Para escribir con vistas, simplemente, debemos crear un writer y ajustar la vista:

ObjectWriter writer = mapper.writer()
   .withWithView(Views.Public.class);

writer.writeValueAsString(grupos);

El otro mecanismo para filtrar atributos es usar filtros:

FilterProvider filters = new SimpleFilterProvider()
   .addFilters("filtro",
      SimpleBeanPropertyFilter.serializeAllExcept("telefono", "dni"));

// También existe .filterOutAllExcept

ObjectWriter writer = mapper.writer(filters);
writer.writeAsValueAsString(grupos);

Notas al pie