Android: Gson désérialiser une classe implémentant une interface

Partager cet article

Temps estimé pour la lecture de cet article : 25 min

Hello, les amis, avant toute chose si vous ne connaissez pas Gson, je vous propose de lire mon introduction à cette magnifique librairie. Ceci étant dit, passons directement au vif du sujet. Il faut savoir que par défaut Gson, n’est pas capable de désérialiser une interface car il ne connaît pas quelle implémentation de classe va être utilisée. Passons à un cas concret.

Interface et généricité

On définit une interface Shape, qui va nous permettre de décrire la façon dont un objet devrait se dessiner à l’écran.

public interface Shape { 
    void draw();     
} 

Maintenant, que notre interface a été spécifié, on crée une classe Circle qui va implémenter cette dernière :

public class Circle implements Shape {
    private double radius;

    public Circle(double radius){
        this.radius = radius;
    }

    @Override
    public void draw() {
        System.out.println("drawing circle");
    }

    public double getRadius(){
        return this.radius;
    }

    @Override
    public String toString() {
        return "Circle [ "
                + "radius=" + radius
                + " ]";
    }
}

Enfin, on va écrire un objet Board, qui va contenir un nom et un type de forme :

public class Board {
    private String name;
    private Shape shape;

    public Board(String name, Shape shape) {
        this.name = name;
        this.shape = shape;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Shape getShape() {
        return shape;
    }

    public void setShape(Shape shape) {
        this.shape = shape;
    }

    @Override
    public String toString() {
        return "Board [ "
                + "name='" + name + '\''
                + ", shape=" + shape
                + " ]";
    }
}

Cette classe pourrait être utilisée dans un projet de jeu de société par exemple, afin de définir l’affichage du plateau.

Un cas concret

Pour ce genre de jeu, on aime pouvoir sauvegarder ou charger l’état d’un plateau directement depuis un fichier. On pourrait donc conserver son état directement en json et gson pourrait alors être une solution. Ni une, ni deux, on esssaye notre code :

public class GsonInterface {  
    public static void main(String[] argv) {
        Board board = new Board("Dobble", new Circle(50));

        Gson gson = new Gson();
        String jsonString = gson.toJson(board);
        System.out.println("jsonString:" + jsonString);

        Board deserializedBoard = gson.fromJson(jsonString, Board.class);
        System.out.println(deserializedBoard);
    } 
}

Et là, c’est le drame… Bon d’abord, la bonne nouvelle, la sérialisation semble se passer correctement :

jsonString:{"name":"Dobble","shape":{"radius":50.0}}

On retrouve nos attributs, on a « juste » perdu l’information sur la classe Circle. Maintenant regardons la désérialisation :

Unable to invoke no-args constructor for interface ...gsoninterface.Shape. Register an InstanceCreator with Gson for this type may fix this problem.
...
Caused by: java.lang.RuntimeException: Unable to invoke no-args constructor for interface ...gsoninterface.Shape. Register an InstanceCreator with Gson for this type may fix this problem.

Et oui, c’est le crash ! En effet, comme Gson a seulement connaissance d’une interface, il ne sait pas quel constructeur il doit appeler. Mais il est gentil, il nous propose une solution, d’utiliser la classe InstanceCreator afin de définir la façon dont il doit déserialiser le type Shape.

Gson et la classe InstanceCreator

Il faut savoir que par défaut, lors de la déserialisation, Gson essaye de créer une instance de la classe en appelant le constructeur sans arguments de cette dernière.

Cependant, dans certains cas, par exemple si vous souhaitez sauvegarder l’état d’un objet d’une librairie tierce. Si ce dernier n’a pas ce type de constructeur, vous allez être coincés. C’est là qu’intervient la classe InstanceCreator. Cette dernière a le même comportement qu’une factory.

Voici, une potentielle solution à notre problème :

public class ShapeInstanceCreator implements InstanceCreator<Shape> {
    private final Shape shape;

    public ShapeInstanceCreator(Shape shape) {
        this.shape = shape;
    }

    public Shape createInstance(Type type) {
        return shape;
    }
}

On crée donc un objet qui prend dans son constructeur une Shape, lors de la déserialisation, on va donc pouvoir tout simplement retourner l’objet ainsi conservé. Il ne reste plus qu’à enregistrer l’InstanceCreator avant de créer notre instance de Gson, à savoir :

Gson gson = new GsonBuilder().
        registerTypeAdapter(Shape.class, new ShapeInstanceCreator(board.getShape()))
        .create();

Et là, la déserialisation se passe bien ! Toutefois, il y a un problème un peu gênant, notre processus dépend de l’objet Shape du plateau, or si la forme du plateau venait à changer, il faudrait alors recréer à chaque fois une nouvelle instance de gson.

On dépend fortement du type de la forme ! La classe InstanceCreator fonctionne mieux avec des classes dont les attributs ne peuvent pas être récréer à la déserialisation comme un context android, par exemple. Dans le cas d’une interface, ce n’est donc pas optimal.

Une solution générique grâce aux interfaces: JsonSerializer et JsonDeserializer

Heureusement pour nous, on va pouvoir définir un adapter générique capable de sérialiser ou désérialiser un objet du type d’une interface. Pour ce faire, on va utiliser les deux interfaces suivantes: JsonSerializer et JsonDeserializer. L’objectif de ces interfaces étant simplement de changer la façon dont gson sérialise ou désérialise un objet. Cela va ainsi nous permettre plus de contrôles.

Voici, donc une réponse plus générique à notre problème :

public final class InterfaceAdapter<T> implements JsonSerializer<T>, JsonDeserializer<T> {
    private static final String CLASSNAME = "gson.interface.adapter.classname";
    private static final String DATA = "gson.interface.adapter.data";

    public JsonElement serialize(T object, Type interfaceType, JsonSerializationContext context) {
        final JsonObject wrapper = new JsonObject();
        wrapper.addProperty("type", object.getClass().getName());
        wrapper.add("data", context.serialize(object));
        return wrapper;
    } 
 
    public T deserialize(JsonElement elem, Type interfaceType, JsonDeserializationContext context) throws JsonParseException {
        final JsonObject wrapper = (JsonObject) elem;
        final JsonElement typeName = get(wrapper, CLASSNAME);
        final JsonElement data = get(wrapper, DATA);
        final Type actualType = typeForName(typeName); 
        return context.deserialize(data, actualType);
    } 
 
    private Type typeForName(final JsonElement typeElem) {
        try { 
            return Class.forName(typeElem.getAsString());
        } catch (ClassNotFoundException e) {
            throw new JsonParseException(e);
        } 
    } 
 
    private JsonElement get(final JsonObject wrapper, String memberName) {
        final JsonElement elem = wrapper.get(memberName);
        if (elem == null) {
            throw new JsonParseException("no '" + memberName + "' member found in what was expected to be an interface wrapper");
        }
        return elem;
    } 
}

Comment ça fonctionne ? On réalise une structure qui va nous permettre de distinguer nos objets qui implémentent l’interface Shape. Ainsi, pour la clé CLASSNAME, on sauvegarde le nom de notre classe et pour la clé DATA, on sauvegarde l’actuel de l’objet en JSON.

Il ne nous reste plus qu’à référencer notre objet InterfaceAdapter, grâce à la méthode registerTypeAdapter(), qui prend en paramètre le type pour lequel on souhaite utiliser notre Adapter, ici c’est donc la classe Shape. Ce qui nous donne :

Gson gson = new GsonBuilder()
        .registerTypeAdapter(Shape.class, new InterfaceAdapter<Shape>())
        .create();

Nouveau test de deserialisation

Et enfin, on refait le même test que tout à l’heure à savoir :

String jsonString = gson.toJson(board);
System.out.println("jsonString:" + jsonString);

Board deserializedBoard = gson.fromJson(jsonString, Board.class);
System.out.println(deserializedBoard);

On exécute le tout et… Roulement de tambour :

I/System.out: jsonString:{"name":"Dobble","shape":{"type":"...gsoninterface.Circle","data":{"radius":50.0}}}
I/System.out: Board [ name='Dobble', shape=Circle [ radius=50.0 ] ]

On voit bien que lors de la serialisation le type est sauvegardé, ce qui va nous permettre lors de la déserialisation de récupérer l’objet tel que l’on le souhaite.

And voilà ! Et que la puissance de Gson soit avec vous !

1 comment

  1. Génial !! j’ai pu résoudre mon problème grace à cet article clair et complet. Merci !
    Cependant, il faudrait changer les valeurs de CLASSNAME par « type » et DATA par « data » dans InterfaceAdapter sinon la désérialisation ne marche pas.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.