Spécifier les POJO pour ne plus les écrire

le 15/07/2015 par Nicolas Mouchel
Tags: Software Engineering

Les POJO sont souvent des classes pleines de code boilerplate (getters setters, equals...) qui sont facile à générer par l'IDE.

Or générer le code à la compilation est de plus en plus tendance, comme avec Dagger 2 ou ButterKnife.

Des outils ont récemment été créés pour se substituer à l'écriture manuelle des classes POJO, comme AutoValue (respectivement AutoParcel pour Android).

Il est possible en le mixant avec Jackson de sérialiser et désérialiser du JSON. Cerise sur le gâteau il sera possible d'obfusquer le modèle avec Proguard.

AutoValue et AutoParcel

Mise en place

AutoValue est une librairie faite par Google dans le cadre du projet Auto. Elle permet de générer à la compilation le code d'une classe immuable seulement en spécifiant des accesseurs abstraits. Plusieurs parties de code sont générées :

  • les attributs sont tous finaux, l'objet est immuable ;
  • le constructeur qui se charge de vérifier la nullité des paramètres (le cas échéant lance une NullPointerException) ;
  • l'implémentation des accesseurs ;
  • et les méthodes toString, equals et hascode.

Enfin, un fork de AutoValue a été créé pour Android, se nommant AutoParcel et générant le code permettant à un objet d'être [Parcelable](http://developer.android.com/reference/android/os/Parcelable.html).

Voici l'exemple pour une classe assez simple :

@AutoParcel
abstract class Simple implements Parcelable{
    static Simple newInstance( final int id, final String title) {
        return new AutoParcel_Simple(id, title);
    }
    abstract int id();
    abstract String title();
}

Elle est abstraite et annotée avec @AutoParcel (équivalent à @AutoValue, prenant en plus l'implémentation de Parcelable), de cette façon elle sera reconnue à la compilation pour être générée.

Ensuite, le constructeur généré par AutoValue est masqué derrière une méthode statique, ce qui permet de cacher l'utilisation d'AutoValue et l'implémentation produite.

Enfin, tous les accesseurs sont listés. Ici, le style de nommage des méthodes est simple, mais l'écriture JavaBean (get*) est aussi autorisée.

Et voici le code généré par AutoParcel :

final class AutoParcel_Simple extends Simple {

  private final int id;
  private final String title;

  AutoParcel_Simple(
      int id,
      String title) {
    this.id = id;
    if (title == null) {
      throw new NullPointerException("Null title");
    }
    this.title = title;
  }

  @Override
  int id() {
    return id;
  }

  @Override
  String title() {
    return title;
  }

  @Override
  public String toString() {
    return "Simple{"
        + "id=" + id + ", "
        + "title=" + title
        + "}";
  }

  @Override
  public boolean equals(Object o) {
    if (o == this) {
      return true;
    }
    if (o instanceof Simple) {
      Simple that = (Simple) o;
      return (this.id == that.id())
           && (this.title.equals(that.title()));
    }
    return false;
  }

  @Override
  public int hashCode() {
    int h = 1;
    h *= 1000003;
    h ^= id;
    h *= 1000003;
    h ^= title.hashCode();
    return h;
  }

  public static final android.os.Parcelable.Creator; CREATOR = 
        new android.os.Parcelable.Creator() {
    @Override
    public AutoParcel_Simple createFromParcel(android.os.Parcel in) {
      return new AutoParcel_Simple(in);
    }
    @Override
    public AutoParcel_Simple[] newArray(int size) {
      return new AutoParcel_Simple[size];
    }
  };

  private final static java.lang.ClassLoader CL = 
      AutoParcel_Simple.class.getClassLoader();

  private AutoParcel_Simple(android.os.Parcel in) {
    this((Integer) in.readValue(CL), (String) in.readValue(CL));
  }

  @Override
  public void writeToParcel(android.os.Parcel dest, int flags) {
          dest.writeValue(id);
          dest.writeValue(title);
      }

  @Override
  public int describeContents() {
    return 0;
  }

}

En écrivant 8 lignes, 80 lignes ont été générées, soit quasiment 90% de code en moins à maintenir.

Test unitaire

Le piège à éviter, ici la signature du constructeur est assez spécifique (int, String). Elle suit l'ordre des méthodes. Si title avait été placé avant id, la signature aurait été (String, int). En cas de refactoring, l'appel au constructeur ne pourra plus se faire car les signatures entre appelant et appelé ne correspondront plus.

Cependant, imaginons une classe avec trois méthodes retournant une String, la signature du constructeur sera alors (String, String, String). En changeant l'ordre des méthodes, l'ordre des paramètres va changer et leur affectation aussi, mais le constructeur gardera la même signature. Dans ce cas, un petit refacto peut avoir des conséquences désastreuses.

Pour s'assurer que ça n'arrive pas, ou tout du moins le détecter, il faut mettre en place un test unitaire vérifiant la construction de l'objet, que les valeurs retournées soient cohérentes avec celles passées dans le constructeur.

Plus loin dans cette article sera décrit générer des builders qui ne subissent pas ce problème.

Jackson et AutoValue

L'utilisation d'AutoValue pour générer les POJO va changer la façon d'annoter avec Jackson.

Voici le json d'exemple qui sera utilisé dans les cette partie.

"simple" :{
    "id": 1337,
    "title": "test auto parcel and jackson"
  }

Désérialiser un objet JSON en Java

Avec Jackson, il est courant d'annoter les attributs d'une classe avec @JsonProperty, or avec l'utilisation d'AutoValue les attributs sont générés, il ne sera donc pas possible de les annoter. Heureusement, Jackson a plus d'un tour dans son sac. Il existe une annotation pour spécifier d'utiliser une méthode pour instancier l'objet, bienvenue @JsonCreator.

@JsonCreator
static Simple newInstance(
        @JsonProperty("id") final int id,
        @JsonProperty("title") final String title) {
    return new AutoParcel_Simple(id, title);
}

Pour ceux qui ont l'habitude d'avoir des attributs portant le même nom que la clef du JSON et donc évitant les annotations, ce ne sera plus possible car les paramètres perdent leurs noms à la compilation. Il est donc obligatoire d'annoter les paramètres (cf: doc).

Sérialiser un objet Java en JSON

Il reste encore du travail à faire, comme les attributs ne sont plus annotés, Jackson ne sait plus comment sérialiser l'objet. Pour s'en sortir, il va falloir annoter aussi les accesseurs :

@JsonProperty("id")
    abstract int id();
    @JsonProperty("title")
    abstract String title();

Voilà, un objet simple peut maintenant être sérialisé et désérialisé avec un minimum de code. Beaucoup de code boilerplate a été supprimé pour être généré, avec la contrepartie d'écrire plus d'annotation, mais n'est-ce pas le futur d'un développeur Java ?

Aller plus loin avec Jackson

Le cas le plus simple a été résolu, mais ce n'est pas la seule possibilité, voici en détail comment gérer les listes typées avec énumération, les objets construits avec un builder et les listes hétérogènes.

Liste typée avec une énumération

Dans une liste typée, il est fréquent d'avoir un attribut type représenté soit par un entier, soit par une chaîne de caractères. Pour pouvoir travailler avec, il faut souvent maintenir une liste de constantes sans lien fort avec notre objet. Ce lien peut être créé avec une énumération et Jackson peut s'occuper de convertir cette valeur brute en énumération et vice et versa.

Voici une liste typée par un champs type de type chaîne de caractère :

"simple-list":[
    {
      "id": 1,
      "type": "square"
    },
    {
      "id": 2,
      "type": "circle"
    },
    {
      "id": 3,
      "type": "triangle"
    }
  ]

Pour désérialiser, il faut annoter avec @JsonCreator une méthode statique prenant en paramètre la valeur brute et retournant l'énumération.

Pour sérialiser, il faut annoter avec @JsonValue une méthode de l'énumération retournant la valeur à persister :

enum Type {
    SQUARE("square"), CIRCLE("circle");

    private String value;
    Type(final String value) { this.value = value; }

    @JsonCreator
    static Type parseType(final String value){
        Type type = null;
        for(final Type t : Type.values()){
            if(t.value.equals(value)){ type = t; break; }
        }
        return type;
    }

    @JsonValue
    String value() { return value; }
}

Il est maintenant possible de spécifier à Jackson qu'une valeur peut être désérialisée en temps qu'énumération. Par contre, comme la méthode parseType peut renvoyer null (ce sera le cas pour la valeur triangle). Il faut spécifier à AutoValue que la méthode revoyant cette valeur peut être null avec l'annotation @Nullable sinon la vérification faite par le constructeur provoquera une NullPointerException.

@AutoParcel
abstract class Item {
    @JsonCreator
    static Item newInstance(
            @JsonProperty("id") final int id,
            @JsonProperty("type") final Type type) {
        return new AutoParcel_Item(id, type);
    }

    @JsonProperty("id")
    abstract int id();

    @Nullable
    @JsonProperty("type")
    abstract Type type();
}

Petite aparté, si la valeur du type dans le JSON était exactement celle de l'enum :

{"type": "SQUARE"}

Alors Jackson est capable de faire la version.

Par contre cette enum ne pourra plus être obfusquée. Aussi, le code devient très lié au modèle des API, la moindre refacto peut casser ce lien.

Objet complexe construit avec un builder

Voici un objet complexe, avec de nombreux attributs :

"complex":{
    "id": 42,
    "title": "complex date",
    "description": "with many field",
    "tag": "builder",
    "image": null
  }

Les objets peuvent avoir un grand nombre d'attributs. Il est peu conseillé d'avoir un constructeur avec beaucoup de paramètres, le nombre par défaut maximum dans SonarQube est de 7 . Une bonne façon de faire est d'utiliser le pattern builder (Effective Java, chapitre 1, item 2, page 11).

@AutoParcel
@JsonDeserialize(builder = AutoParcel_Complex.Builder.class)
abstract class Complex {
    static Builder builder() {
        return new AutoParcel_Complex.Builder();
    }

    @JsonProperty("id")
    abstract Integer id();

    @JsonProperty("title")
    abstract String title();

    @JsonProperty("description")
    abstract String description();

    @JsonProperty("tag")
    abstract String tag();

    @Nullable
    @JsonProperty("image")
    abstract String image();

    @AutoParcel.Builder
    interface Builder {
        @JsonProperty("id")
        Builder id(final Integer id);

        @JsonProperty("title")
        Builder title(final String title);

        @JsonProperty("description")
        Builder description(final String description);

        @JsonProperty("tag")
        Builder tag(final String tag);

        @JsonProperty("image")
        Builder image(final String image);

        Complex build();
    }
}

Il y a ici beaucoup de choses à spécifier par annotation.

Il faut d'abord annoter la classe avec @JsonDeserialize pour indiquer à Jackson d'utiliser le builder généré par AutoValue.

Puis il faut annoter les accesseurs de l'objet pour qu'il puisse être sérialisé.

Enfin, il faut spécifier le builder :

  • Annoter le builder avec @AutoParcel.Builder, pour qu'AutoValue génère le builder ;
  • Annoter chaque méthode, pour que Jackson puisse faire la correspondance avec le JSON ;
  • Enfin, avoir une méthode build (le nom est important pour l'introspection) retournant l'objet à construire.

Liste hétérogène

Voici une liste hétérogène avec des objets typés :

"complex-typed-list":[
    {
      "type": "twitter",
      "tweet": "use AutoParcel and Jackson",
      "author": "_sagix"
    },
    {
      "type": "facebook",
      "post": "I will be so famous",
      "comments": 438,
      "likes": 8537
    },
    {
      "type": "unknow"
    }
  ]

Il arrive que comme dans l'exemple ci-dessus, il y ait des objets très différents, mais qu'il soit typé. Le but est alors de leur donner une interface commune. L'exemple ci-dessous montre un flux mélangeant différents types de réseaux sociaux, ici Twitter et Facebook. Le routing se déclare dans l'interface.

La première annotation @JsonTypeInfo indique que le routing se fera sur le champs type et qu'il faudra utiliser la valeur de ce champs dans le mapping. Aussi, une implémentation par défaut est défini pour les types inconnus (le pattern de null object est utilisé pour ce cas). Dans l'exemple, il permettra d'instancier un objet pour le type unkown.

La deuxième annotation @JsonSubTypes définit la correspondance entre la valeur du JSON et la classe à instancier.

Pour que la sérialisation fonctionne correctement pour le type, il faut forcer les implémentations à déclarer le type (sans AutoValue, il suffit d'include JsonTypeInfo.As.PROPERTY).

@JsonTypeInfo(
        use = JsonTypeInfo.Id.NAME,
        include = JsonTypeInfo.As.EXISTING_PROPERTY,
        defaultImpl = NullSocial.class,
        property = "type"
)
@JsonSubTypes({
        @Type(value = Twitter.class, name = Twitter.TYPE),
        @Type(value = Facebook.class, name = Facebook.TYPE)
})
interface Social {
    @JsonProperty("type")
    String type();
}

Les implémentations de cette interface sont après très classiques.

@AutoParcel
abstract class Twitter implements Social {
    static final String TYPE = "twitter";

    @JsonCreator
    static Twitter newInstance(
            @JsonProperty("tweet") final String tweet,
            @JsonProperty("author") final String author) {
        return new AutoParcel_Twitter(tweet, author);
    }

    @Override
    public String type() {
        return TYPE;
    }

    @JsonProperty("tweet")
    abstract String tweet();

    @JsonProperty("author")
    abstract String author();
}

Obfuscation avec Proguard

Maintenant que la sérialisation et désérialiasation fonctionnent, il faut essayer de passer Proguard sur les classes modèles. Souvent la stratégie est de placer toutes ces classes dans un même package parent (ex: com.auteur.monprojet.modele) et d'indiquer à Proguard de ne pas toucher.

-keep class com.auteur.monprojet.modele.** { *; }

Ici, le but est de faire mieux que ça :

  1. de pouvoir avoir les classes modèles dans n'importe quel package, ce qui peut être utile quand le découpage de package est fait par fonctionnalités ;
  2. d'avoir une configuration Proguard générique afin de ne plus avoir à y toucher ;
  3. d'essayer d'obfusquer un maximum le code.

Bonne nouvelle, il est possible de faire tout ça avec Proguard :

-dontwarn org.w3c.dom.**
# try to keep jackson running with introspection.
# -- keep jackson annotations
-keepnames @interface com.fasterxml.jackson.** { *; }
# -- keep fields names
-keepclassmembernames class com.fasterxml.jackson.** { ; }
# -- keep all values of enum
-keepclassmembers public final enum org.codehaus.jackson.annotate.JsonAutoDetect$Visibility { public static final org.codehaus.jackson.annotate.JsonAutoDetect$Visibility *; }
# keep jackson annotations on our classes: JsonDeserialize,  JsonCreator, JsonProperty...
-keepclassmembers @com.fasterxml.jackson.databind.annotation.JsonDeserialize class *
-keepclassmembers, allowobfuscation class * { @com.fasterxml.jackson.annotation.JsonCreator *; }
-keepclassmembers, allowobfuscation class * { @com.fasterxml.jackson.annotation.JsonProperty *; }
# keep class implementing JsonTypeInfo
-keep, allowobfuscation class * implements @com.fasterxml.jackson.annotation.JsonTypeInfo * { *; }
# keep builder interface with the build method.
-keep, allowobfuscation interface **$Builder { *; }
-keepclassmembernames interface **$Builder { ** build(); }
-keep, allowobfuscation class **$Builder { *; }
# keep enum values
-keepclassmembers enum * { *; }
# keep generic in signature (useful for list)
-keepattributes Signature

Seul bémol, comme Jackson fait de l'introspection, il faut faire des compromis pour qu'il arrive à s'y retrouver dans le code mouliné avec Proguard. Sur les possibilités qu'offrent Proguard, seul l'obfuscation dans la classe peut être utilisée, sinon elle sera dispersée en plein de petits objets et Jackson ne s'y retrouve pas. Le shrink n'est pas utilisable du tout, car il supprime le code non utilisé de même que pour l'optimization qui va plus loin et suppriment les implémentations d'interfaces.

Voici ce que devient la première classe :

abstract class Simple
  implements Parcelable
{
  @JsonCreator
  static Simple a(
        @JsonProperty("id") int paramInt,
        @JsonProperty("title") String paramString)
  {
    return new AutoParcel_Simple(paramInt, paramString);
  }
  
  @JsonProperty("id")
  abstract int a();
  
  @JsonProperty("title")
  abstract String b();
}

et le code d'AutoParcel

final class AutoParcel_Simple
  extends Simple
{
  private final int a;
  private final String b;
  public static final Parcelable.Creator CREATOR = new g();
  private static final ClassLoader c = 
        AutoParcel_Simple.class.getClassLoader();
  
  AutoParcel_Simple(int paramInt, String paramString)
  {
    this.a = paramInt;
    if (paramString == null) {
      throw new NullPointerException("Null title");
    }
    this.b = paramString;
  }
  
  @JsonProperty("id")
  int a()
  {
    return this.a;
  }
  
  @JsonProperty("title")
  String b()
  {
    return this.b;
  }
  
  public String toString()
  {
    return "Simple{id=" + this.a + ", " + "title=" + this.b + "}";
  }
  
  public boolean equals(Object paramObject)
  {
    if (paramObject == this) {
      return true;
    }
    if ((paramObject instanceof Simple))
    {
      Simple localSimple = (Simple)paramObject;
      return (this.a == localSimple.a())
        && (this.b.equals(localSimple.b()));
    }
    return false;
  }
  
  public int hashCode()
  {
    int i = 1;
    i *= 1000003;
    i ^= this.a;
    i *= 1000003;
    i ^= this.b.hashCode();
    return i;
  }
  
  private AutoParcel_Simple(Parcel paramParcel)
  {
    this(((Integer)paramParcel.readValue(c)).intValue(),
        (String)paramParcel.readValue(c));
  }
  
  public void writeToParcel(Parcel paramParcel, int paramInt)
  {
    paramParcel.writeValue(Integer.valueOf(this.a));
    paramParcel.writeValue(this.b);
  }
  
  public int describeContents()
  {
    return 0;
  }
}

Si la classe n'est pas Parcelable, alors le nom de la classe est aussi modifier.

Conclusion

Il est temps de comparer les deux solutions : faire générer le code par l'IDE ou AutoValue.

D'un côté, c'est très facile de générer le code par l'IDE, il faut écrire les attributs et on génère tout le reste : getter, setter, constructeur, méthodes d'implémentation de Parcelable. Par contre en cas de modification il faut tout supprimer pour regénérer à la main. Comme l'erreur est humaine, il est possible d'oublier de faire cette opération lors d'un ajout ou de le faire manuellement et de casser la logique de Parcelable sans le détecter. De plus, le code généré n'est pas forcément propre, les outils de validation vont lever des alertes : sur du code qui peut être simplifié ou des accolades manquantes, et s'il faut modifier du code généré, le gain de temps n'est plus forcément là.

D'un autre côté, décrire le POJO avec les annotations est un peu plus verbeux, mais AutoParcel génère tout, sans opération manuelle. De plus, il ajoute de étapes de validation, en s'assurant que toutes les méthodes non nulles ne puissent pas retourner nul avec une politique de fail fast.

En cas de modification, tout va se regénérer automatiquement, et assure que Parcelable sera toujours correctement implémentée.

Enfin, les objets sont immuables, c'est qui leurs donnent une résistance forte au changement.

Comme décrit dans l'article de Romain Guy :

  • Ils sont garantis thread-safe
  • Ils peuvent être mis en cache
  • Ils n'ont besoin ni de constructeur par copie, ni d'implémentation de l'interface Cloneable
  • Il n'est pas nécessaire d'en faire une copie défensive
  • Leurs invariants sont testés à la création seulement
  • Ils constituent d'excellentes clés pour les Map et Set
  • Leurs valeurs peuvent être mises en cache par le client sans risque de désynchronisation

Un projet d'exemple est disponible sur github : https://github.com/sagix/auto-jackson