Herança com Jackson

Herança com Jackson

*1. Visão geral *

Neste artigo, veremos como trabalhar com hierarquias de classe em Jackson.

Dois casos de uso típicos são a inclusão de metadados de subtipo e propriedades ignoradas herdadas das superclasses. Vamos descrever esses dois cenários e algumas circunstâncias em que é necessário um tratamento especial dos subtipos.

===* 2. Inclusão de informações sobre subtipos *

Há duas maneiras de adicionar informações de tipo ao serializar e desserializar objetos de dados, como digitação padrão global e anotações por classe.

====* 2.1 Digitação padrão global *

As três classes Java a seguir serão usadas para ilustrar a inclusão global do tipo de metadados.

Superclasse Vehicle:

public abstract class Vehicle {
    private String make;
    private String model;

    protected Vehicle(String make, String model) {
        this.make = make;
        this.model = model;
    }

   //no-arg constructor, getters and setters
}

Subclasse Car:

public class Car extends Vehicle {
    private int seatingCapacity;
    private double topSpeed;

    public Car(String make, String model, int seatingCapacity, double topSpeed) {
        super(make, model);
        this.seatingCapacity = seatingCapacity;
        this.topSpeed = topSpeed;
    }

   //no-arg constructor, getters and setters
}

Subclasse Truck:

public class Truck extends Vehicle {
    private double payloadCapacity;

    public Truck(String make, String model, double payloadCapacity) {
        super(make, model);
        this.payloadCapacity = payloadCapacity;
    }

   //no-arg constructor, getters and setters
}

A digitação padrão global permite que as informações de tipo sejam declaradas apenas uma vez, ativando-as em um objeto ObjectMapper. Os metadados desse tipo serão aplicados a todos os tipos designados. Como resultado, é muito conveniente usar esse método para adicionar metadados de tipo, especialmente quando há um grande número de tipos envolvidos. A desvantagem é que ele usa nomes de tipo Java totalmente qualificados como identificadores de tipo e, portanto, não é adequado para interações com sistemas não-Java e é aplicável apenas a vários tipos de tipos predefinidos.

A estrutura Vehicle mostrada acima é usada para preencher uma instância da classe Fleet:

public class Fleet {
    private List<Vehicle> vehicles;

   //getters and setters
}

Para incorporar metadados de tipo, precisamos ativar a funcionalidade de digitação no objeto ObjectMapper que será usado para serialização e desserialização de objetos de dados posteriormente:

ObjectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping applicability, JsonTypeInfo.As includeAs)

O parâmetro applicability determina os tipos que requerem informações de tipo e o parâmetro includeAs é o mecanismo para a inclusão de metadados do tipo. Além disso, são fornecidas duas outras variantes do método enableDefaultTyping:

  • ObjectMapper.enableDefaultTyping (aplicabilidade ObjectMapper.DefaultTyping) _: permite que o chamador especifique o _applicability enquanto usa WRAPPER_ARRAY como o valor padrão para includeAs *ObjectMapper.enableDefaultTyping (): _ usa _OBJECT_AND_NON_CONCRETE como o valor padrão para applicability e WRAPPER_ARRAY como o valor padrão para includeAs

Vamos ver como isso funciona. Para começar, precisamos criar um objeto ObjectMapper e habilitar a digitação padrão nele:

ObjectMapper mapper = new ObjectMapper();
mapper.enableDefaultTyping();

O próximo passo é instanciar e preencher a estrutura de dados introduzida no início desta subseção. O código para isso será reutilizado posteriormente nas subseções subseqüentes. Por uma questão de conveniência e reutilização, chamaremos de* bloco de instanciação do veículo *.

Car car = new Car("Mercedes-Benz", "S500", 5, 250.0);
Truck truck = new Truck("Isuzu", "NQR", 7500.0);

List<Vehicle> vehicles = new ArrayList<>();
vehicles.add(car);
vehicles.add(truck);

Fleet serializedFleet = new Fleet();
serializedFleet.setVehicles(vehicles);

Esses objetos preenchidos serão serializados:

String jsonDataString = mapper.writeValueAsString(serializedFleet);

A sequência JSON resultante:

{
    "vehicles":
    [
        "java.util.ArrayList",
        [
            [
                "org..jackson.inheritance.Car",
                {
                    "make": "Mercedes-Benz",
                    "model": "S500",
                    "seatingCapacity": 5,
                    "topSpeed": 250.0
                }
            ],

            [
                "org..jackson.inheritance.Truck",
                {
                    "make": "Isuzu",
                    "model": "NQR",
                    "payloadCapacity": 7500.0
                }
            ]
        ]
    ]
}

Durante a desserialização, os objetos são recuperados da sequência JSON com os dados do tipo preservados:

Fleet deserializedFleet = mapper.readValue(jsonDataString, Fleet.class);

Os objetos recriados terão os mesmos subtipos concretos de antes da serialização:

assertThat(deserializedFleet.getVehicles().get(0), instanceOf(Car.class));
assertThat(deserializedFleet.getVehicles().get(1), instanceOf(Truck.class));

2.2 Anotações por classe

A anotação por classe é um método poderoso para incluir informações de tipo e pode ser muito útil para casos de uso complexos em que é necessário um nível significativo de customização. No entanto, isso só pode ser alcançado à custa de complicações. As anotações por classe substituem a digitação padrão global se as informações de tipo estiverem configuradas nos dois sentidos.

Para usar esse método, o supertipo deve ser anotado com _ @ JsonTypeInfo_ e várias outras anotações relevantes. Esta subseção usará um modelo de dados semelhante à estrutura Vehicle no exemplo anterior para ilustrar anotações por classe. A única alteração é a adição de anotações na classe abstrata Vehicle, como mostrado abaixo:

@JsonTypeInfo(
  use = JsonTypeInfo.Id.NAME,
  include = JsonTypeInfo.As.PROPERTY,
  property = "type")
@JsonSubTypes({
  @Type(value = Car.class, name = "car"),
  @Type(value = Truck.class, name = "truck")
})
public abstract class Vehicle {
   //fields, constructors, getters and setters
}

Os objetos de dados são criados usando o bloco de instanciação do veículo introduzido na subseção anterior e depois serializados:

String jsonDataString = mapper.writeValueAsString(serializedFleet);

A serialização produz a seguinte estrutura JSON:

{
    "vehicles":
    [
        {
            "type": "car",
            "make": "Mercedes-Benz",
            "model": "S500",
            "seatingCapacity": 5,
            "topSpeed": 250.0
        },

        {
            "type": "truck",
            "make": "Isuzu",
            "model": "NQR",
            "payloadCapacity": 7500.0
        }
    ]
}

Essa sequência é usada para recriar objetos de dados:

Fleet deserializedFleet = mapper.readValue(jsonDataString, Fleet.class);

Por fim, todo o progresso é validado:

assertThat(deserializedFleet.getVehicles().get(0), instanceOf(Car.class));
assertThat(deserializedFleet.getVehicles().get(1), instanceOf(Truck.class));

*3. Ignorando propriedades de um supertipo *

Às vezes, algumas propriedades herdadas das superclasses precisam ser ignoradas durante a serialização ou desserialização. Isso pode ser alcançado por um dos três métodos: anotações, mix-ins e introspecção de anotações.

====* 3.1 Anotações *

Existem duas anotações de Jackson comumente usadas para ignorar propriedades, que são _ @ JsonIgnore_ e _ @ JsonIgnoreProperties_. O primeiro é aplicado diretamente aos membros do tipo, dizendo ao Jackson para ignorar a propriedade correspondente ao serializar ou desserializar. O último é usado em qualquer nível, incluindo tipo e membro do tipo, para listar propriedades que devem ser ignoradas.

_ @ JsonIgnoreProperties_ é mais poderoso que o outro, pois nos permite ignorar propriedades herdadas de supertipos sobre os quais não temos controle, como tipos em uma biblioteca externa. Além disso, essa anotação nos permite ignorar muitas propriedades ao mesmo tempo, o que pode levar a um código mais compreensível em alguns casos.

A seguinte estrutura de classes é usada para demonstrar o uso da anotação:

public abstract class Vehicle {
    private String make;
    private String model;

    protected Vehicle(String make, String model) {
        this.make = make;
        this.model = model;
    }

   //no-arg constructor, getters and setters
}

@JsonIgnoreProperties({ "model", "seatingCapacity" })
public abstract class Car extends Vehicle {
    private int seatingCapacity;

    @JsonIgnore
    private double topSpeed;

    protected Car(String make, String model, int seatingCapacity, double topSpeed) {
        super(make, model);
        this.seatingCapacity = seatingCapacity;
        this.topSpeed = topSpeed;
    }

   //no-arg constructor, getters and setters
}

public class Sedan extends Car {
    public Sedan(String make, String model, int seatingCapacity, double topSpeed) {
        super(make, model, seatingCapacity, topSpeed);
    }

   //no-arg constructor
}

public class Crossover extends Car {
    private double towingCapacity;

    public Crossover(String make, String model, int seatingCapacity,
      double topSpeed, double towingCapacity) {
        super(make, model, seatingCapacity, topSpeed);
        this.towingCapacity = towingCapacity;
    }

   //no-arg constructor, getters and setters
}

Como você pode ver, _ @ JsonIgnore_ diz a Jackson para ignorar a propriedade Car.topSpeed, enquanto _ @ JsonIgnoreProperties_ ignora os Vehicle.model e Car.seatingCapacity.

O comportamento das duas anotações é validado pelo teste a seguir. Primeiro, precisamos instanciar ObjectMapper e classes de dados e, em seguida, usar essa instância ObjectMapper para serializar objetos de dados:

ObjectMapper mapper = new ObjectMapper();

Sedan sedan = new Sedan("Mercedes-Benz", "S500", 5, 250.0);
Crossover crossover = new Crossover("BMW", "X6", 5, 250.0, 6000.0);

List<Vehicle> vehicles = new ArrayList<>();
vehicles.add(sedan);
vehicles.add(crossover);

String jsonDataString = mapper.writeValueAsString(vehicles);

jsonDataString contém a seguinte matriz JSON:

[
    {
        "make": "Mercedes-Benz"
    },
    {
        "make": "BMW",
        "towingCapacity": 6000.0
    }
]

Por fim, provaremos a presença ou ausência de vários nomes de propriedades na sequência JSON resultante:

assertThat(jsonDataString, containsString("make"));
assertThat(jsonDataString, not(containsString("model")));
assertThat(jsonDataString, not(containsString("seatingCapacity")));
assertThat(jsonDataString, not(containsString("topSpeed")));
assertThat(jsonDataString, containsString("towingCapacity"));

====* 3.2 Mix-ins *

Os mix-ins nos permitem aplicar comportamento (como ignorar propriedades ao serializar e desserializar) sem a necessidade de aplicar anotações diretamente a uma classe. Isso é especialmente útil ao lidar com classes de terceiros, nas quais não podemos modificar o código diretamente.

Esta subseção reutiliza a cadeia de herança de classe introduzida na anterior, exceto que as anotações _ @ JsonIgnore_ e _ @ JsonIgnoreProperties_ na classe Car foram removidas:

public abstract class Car extends Vehicle {
    private int seatingCapacity;
    private double topSpeed;

   //fields, constructors, getters and setters
}

Para demonstrar as operações dos mix-ins, ignoraremos as propriedades Vehicle.make e Car.topSpeed e usaremos um teste para garantir que tudo funcione conforme o esperado.

A primeira etapa é declarar um tipo de mix-in:

private abstract class CarMixIn {
    @JsonIgnore
    public String make;
    @JsonIgnore
    public String topSpeed;
}

Em seguida, o mix-in é vinculado a uma classe de dados por meio de um objeto ObjectMapper:

ObjectMapper mapper = new ObjectMapper();
mapper.addMixIn(Car.class, CarMixIn.class);

Depois disso, instanciamos objetos de dados e os serializamos em uma string:

Sedan sedan = new Sedan("Mercedes-Benz", "S500", 5, 250.0);
Crossover crossover = new Crossover("BMW", "X6", 5, 250.0, 6000.0);

List<Vehicle> vehicles = new ArrayList<>();
vehicles.add(sedan);
vehicles.add(crossover);

String jsonDataString = mapper.writeValueAsString(vehicles);

jsonDataString agora contém o seguinte JSON:

[
    {
        "model": "S500",
        "seatingCapacity": 5
    },
    {
        "model": "X6",
        "seatingCapacity": 5,
        "towingCapacity": 6000.0
    }
]

Por fim, vamos verificar o resultado:

assertThat(jsonDataString, not(containsString("make")));
assertThat(jsonDataString, containsString("model"));
assertThat(jsonDataString, containsString("seatingCapacity"));
assertThat(jsonDataString, not(containsString("topSpeed")));
assertThat(jsonDataString, containsString("towingCapacity"));

====* 3.3 Anotação Introspecção *

A introspecção de anotação é o método mais poderoso para ignorar propriedades de supertipo, pois permite customização detalhada usando a API AnnotationIntrospector.hasIgnoreMarker.

Esta subseção utiliza a mesma hierarquia de classes que a anterior. Nesse caso de uso, pediremos que Jackson ignore Vehicle.model, Crossover.towingCapacity e todas as propriedades declaradas na classe Car. Vamos começar com a declaração de uma classe que estende a interface JacksonAnnotationIntrospector:

class IgnoranceIntrospector extends JacksonAnnotationIntrospector {
    public boolean hasIgnoreMarker(AnnotatedMember m) {
        return m.getDeclaringClass() == Vehicle.class && m.getName() == "model"
          || m.getDeclaringClass() == Car.class
          || m.getName() == "towingCapacity"
          || super.hasIgnoreMarker(m);
    }
}

O introspetor ignorará quaisquer propriedades (isto é, tratará-as como se fossem marcadas como ignoradas por um dos outros métodos) que correspondam ao conjunto de condições definidas no método.

A próxima etapa é registrar uma instância da classe IgnoranceIntrospector com um objeto ObjectMapper:

ObjectMapper mapper = new ObjectMapper();
mapper.setAnnotationIntrospector(new IgnoranceIntrospector());

Agora, criamos e serializamos objetos de dados da mesma maneira que na seção 3.2. O conteúdo da sequência recém-produzida é:

[
    {
        "make": "Mercedes-Benz"
    },
    {
        "make": "BMW"
    }
]

Por fim, verificaremos se o introspetor funcionou como pretendido:

assertThat(jsonDataString, containsString("make"));
assertThat(jsonDataString, not(containsString("model")));
assertThat(jsonDataString, not(containsString("seatingCapacity")));
assertThat(jsonDataString, not(containsString("topSpeed")));
assertThat(jsonDataString, not(containsString("towingCapacity")));

===* 4. Cenários de manipulação de subtipos *

Esta seção tratará de dois cenários interessantes relevantes para o manuseio de subclasses.

====* 4.1 Conversão entre subtipos *

Jackson permite que um objeto seja convertido em um tipo diferente do original. De fato, essa conversão pode ocorrer entre quaisquer tipos compatíveis, mas é mais útil quando usada entre dois subtipos da mesma interface ou classe para proteger valores e funcionalidade.

Para demonstrar a conversão de um tipo para outro, reutilizaremos a hierarquia Vehicle retirada da seção 2, com a adição da anotação _ @ JsonIgnore_ nas propriedades em Car e Truck para evitar incompatibilidade.

public class Car extends Vehicle {
    @JsonIgnore
    private int seatingCapacity;

    @JsonIgnore
    private double topSpeed;

   //constructors, getters and setters
}

public class Truck extends Vehicle {
    @JsonIgnore
    private double payloadCapacity;

   //constructors, getters and setters
}

O código a seguir verificará se uma conversão foi bem-sucedida e se o novo objeto preserva os valores dos dados do antigo:

ObjectMapper mapper = new ObjectMapper();

Car car = new Car("Mercedes-Benz", "S500", 5, 250.0);
Truck truck = mapper.convertValue(car, Truck.class);

assertEquals("Mercedes-Benz", truck.getMake());
assertEquals("S500", truck.getModel());

====* 4.2 Desserialização sem construtores No-arg *

Por padrão, Jackson recria objetos de dados usando construtores no-arg. Isso é inconveniente em alguns casos, como quando uma classe tem construtores não padrão e os usuários precisam escrever sem argumentos apenas para satisfazer os requisitos de Jackson. É ainda mais problemático em uma hierarquia de classes, em que um construtor sem argumento deve ser adicionado a uma classe e a todos os mais altos na cadeia de herança. Nesses casos, os* métodos criadores *são úteis.

Esta seção usará uma estrutura de objeto semelhante à da seção 2, com algumas alterações nos construtores. Especificamente, todos os construtores no-arg são descartados e os construtores de subtipos concretos são anotados com _ @ JsonCreator_ e _ @ JsonProperty_ para torná-los métodos criadores.

public class Car extends Vehicle {

    @JsonCreator
    public Car(
      @JsonProperty("make") String make,
      @JsonProperty("model") String model,
      @JsonProperty("seating") int seatingCapacity,
      @JsonProperty("topSpeed") double topSpeed) {
        super(make, model);
        this.seatingCapacity = seatingCapacity;
            this.topSpeed = topSpeed;
    }

   //fields, getters and setters
}

public class Truck extends Vehicle {

    @JsonCreator
    public Truck(
      @JsonProperty("make") String make,
      @JsonProperty("model") String model,
      @JsonProperty("payload") double payloadCapacity) {
        super(make, model);
        this.payloadCapacity = payloadCapacity;
    }

   //fields, getters and setters
}

Um teste verificará se Jackson pode lidar com objetos que não possuem construtores no-arg:

ObjectMapper mapper = new ObjectMapper();
mapper.enableDefaultTyping();

Car car = new Car("Mercedes-Benz", "S500", 5, 250.0);
Truck truck = new Truck("Isuzu", "NQR", 7500.0);

List<Vehicle> vehicles = new ArrayList<>();
vehicles.add(car);
vehicles.add(truck);

Fleet serializedFleet = new Fleet();
serializedFleet.setVehicles(vehicles);

String jsonDataString = mapper.writeValueAsString(serializedFleet);
mapper.readValue(jsonDataString, Fleet.class);

===* 5. Conclusão*

Este tutorial abordou vários casos de uso interessantes para demonstrar o suporte de Jackson à herança de tipos, com foco no polimorfismo e na ignorância das propriedades do supertipo.

A implementação de todos esses exemplos e trechos de código pode ser encontrada em um projeto GitHub.