Codable et object graph complexe

Bonjour à tous,

ça fait un petit bout de temps que je ne suis pas venu par ici. Je ne suis pas développeur pro, mais prof de physique. Je construis une petite appli un peu plus légère qu'une autre appli que j'avais codé en objective-C avec CoreData.

J'ai voulu utiliser le protocole "Codable" à la place de NSCoding. Mon soucis : je pensais naïvement que, comme pour NSCoding, les références circulaires étaient gérées, mais non...

Voici mon modèle

    //
    //  Model.swift
    //  VallComp
    //
    //  Created by Mickaël VALLIER on 28/10/2020.
    //  Copyright © 2020 Mickaël VALLIER. All rights reserved.
    //

    import Foundation

    class Content: NSObject, Codable {
        var lesEleves = Set<Eleve>()
        var lesEvaluations = Set<Evaluation>()
        var lesCompetences = Set<Competence>()
        var lesPeriodes : Set<Periode>

        override init() {
            lesPeriodes = Set()
            let defaultPeriode = Periode()
            defaultPeriode.nom = "Periode 1"
            lesPeriodes.insert(defaultPeriode)
        }
    }

    class Evaluation: NSObject, Codable {
        var nom = ""
        var date = Date.init()
        var sur : Int = 20
        var coefficient : Float = 1.0
        var laPeriode : Periode
        var ramenerSur20 = false
        var bonus = false
        init(unePeriode : Periode) {
            self.laPeriode = unePeriode
            super.init()
        }
    }

    class Eleve: NSObject, Codable {
        var nom = ""
        var prenom = ""
        var commentaire : String?
        var lesNotes = Set<Note>()
        var lesEvalsComps = Set<EvalComp>()

    }

    class Periode : NSObject, Codable {
        var nom = "Periode"
        var dateDebut : Date = Date.init()
        var dateFin : Date?
        var lesEvaluations = Set<Evaluation>()
    }

    class Competence: NSObject, Codable {
        var nom = ""
        var exemples = ""
    }

    class Note: NSObject, Codable {
        var lEvaluation : Evaluation
        var lEleve : Eleve
        var valeur = -1

        init(unEleve : Eleve, uneEval : Evaluation) {
            self.lEleve = unEleve
            self.lEvaluation = uneEval

            super.init()

        }
    }

    class EvalComp: NSObject, Codable {
        var laCompetence : Competence
        var lEleve : Eleve
        var lEvaluation : Evaluation
        var valeur = ""

        init(uneEvaluation : Evaluation, unEleve : Eleve, uneCompetence : Competence) {
            self.laCompetence = uneCompetence
            self.lEvaluation = uneEvaluation
            self.lEleve = unEleve

            super.init()
        }
    }

Si je crée un Eleve, sans qu'il n'y ait d'évaluations, pas de soucis. Si je crée un Eleve alors qu'il existe des évaluations, il y aura une référence à ces évaluations dans l'instance de la classe Eleve, et une référence à l'élève en question dans l'instance de la classe Evaluation, et alors impossible d'encoder (référence "circulaire" non gérée).

J'imagine qu'il y a derrière ça la notion de "clef" des bases de données, et j'aimerais des conseils de "bonne pratique" avant de me lancer ! Si la gestion du problème s'avère très chronophage, je retournerai sur le protocole NSCoding...

Mots clés:

Réponses

  • tl;dr: Utilse CoreData.

    Faisons simple : Codable est utilisé en grande partie pour coder/décoder les requêtes/réponses des APIs web. Souvent du JSON, dans lequel le concept de référence entre objets n'est pas natif. Tu peux le gérer avec des clés mais c'est une utilisation particulière du protocole par une de ses capacités et donc casse-gueule. C'est pour ça qu'une réponse reçue depuis une API web va contenir toutes les informations pertinentes dont la donnée principale est dépendante, pour éviter de multiplier les appels.

    C'est bien beau tout ça mais qu'est-ce que ça implique ? Prenons un client qui a n adresses. On stocke les clients dans un coins et les adresses dans l'autre dans une DB quelconque. Quand il y a une requête sur la collection client c'est bien d'envoyer la liste de ses adresses avec, c'est cohérent. Pour les requêtes sur les adresses c'est bien de mettre dans la réponse à quel(s) client(s) elle est rattachée. Là je pense que tu commence à voir l'embrouille. Les clients comportent les adresses, qui comportent à leur tour les clients, qui eux comportent les adresses, qui elles... etc... Bref les réponses JSON (XML si t'es encore en 2010) ne reflètent qu'un angle particulier de la base de donnée et ne peuvent donc pas être utilisés pour la modéliser entièrement. Dans le cadre d'une DB relationnelle, si on prend un schéma de données linéaire la chose devient possible. Dans ton cas si tu te borne à avoir une Liste d'élèves qui comportent une liste de notes c'est bon. Par ex :

    struct Note: Codable, Identifiable, Hashable {
        var id: Date { date }
        let date: Date
        let valeur: Double
    }
    
    struct Eleve: Codable {
        let nom: String
        let prenom: String
        let dateNaissance: Date
        var commentaire: String?
    
        var notes = Set<Note>()
    }
    

    Tout ça pour dire que Codable par ses origines et sa construction ne peut pas gérer les références et doit être utilisé pour coder/encoder des structures simple.

    Bref utilise CoreData, c'est un object graph très simple que tu as là et tu n'auras pas de gros soucis à gérer, voir pas de soucis du tout 😉

  • CéroceCéroce Membre, Modérateur
    novembre 2020 modifié #3

    Je n'ai pas énormément travaillé avec Codable, mais ça sert à convertir des dictionnaires et listes en objets/enums/structs. Et vice-versa: Codable = Decodable & Encodable.
    Typiquement ça sert à parser du JSON, qui est un arbre de données; donc sans références circulaires.

    Au niveau des solutions:

    • soit tu élimines les références circulaires, en attribuant des UUID aux objets si nécessaire.
    • soit tu restes sur NSCoding mais tous tes objets seront forcément des classes héritant de NSObject. C'est contraignant et casse-pied avec le typage stricte de Swift.
    • soit tu utilises une base de données.

    Pour la base de données, je préfère largement GRDB à Core Data, mais si tu veux utiliser des documents, alors NSPersistentDocument est clairement plus indiquée.

  • Merci pour ces réponses.

    Pour l'idée générale, j'aimerais que les données se trouvent sous forme de fichiers pour l'instant, et certains "réglages" récurrent dans un XML "préférences". L'appli précédente que j'avais bricolée (et qui fonctionnait !!) avait le défaut de stocker toutes les données sous la forme d'une base (devenant assez importante du coup) contenant toutes les classes de tous les niveaux etc. L'idée est ici d'avoir des fichiers séparés qu'on peut stocker facilement dans les bons dossiers de l'ordi.

    Donc, si je veux stocker les données sous forme de structure (et plus d'objet héritant de NSObject : j'aimerais atteindre cet objectif!) l'idée la plus simple est de virer la réciprocité des relations ? (j'étais dans l'esprit de conception "coreData" avec les relations one to many et réciproquement). Je vais donc essayer de revoir la couche modèle en la rendant "linéaire".

    Si toutefois il m'était indispensable de créer une "UUID", est-ce qu'il y a des recommandations pour ce genre d'attribution ? Un simple Int qu'on incrémente à chaque appel ?

    En tous cas je remercie beaucoup les contributeurs de ce forum de prendre le temps de répondre à des "novices" !

  • @Mick a dit :
    Si je crée un Eleve, sans qu'il n'y ait d'évaluations, pas de soucis. Si je crée un Eleve alors qu'il existe des évaluations, il y aura une référence à ces évaluations dans l'instance de la classe Eleve, et une référence à l'élève en question dans l'instance de la classe Evaluation, et alors impossible d'encoder (référence "circulaire" non gérée).

    Sans avoir la compétence de mes collègues en base de données, je me permet d'ajouter mon grain de sel. Je ne comprend pas pourquoi il y a une référence à l'élève dans la classe Evaluation.

    Une évaluation est la "propriété" d'un élève, pas le contraire. Est-ce pour des raisons techniques, pour faciliter l'affichage et l'édition quelque part dans l'application ?

  • Ce que tu décris ici est assez simple:

    • Application type document based
    • CoreData pour ta structure de donnée
    • Pour les données commune utilise UserDefaults

    C'est intéressant à plus d'un titre ce projet, ça ferait très bon exercice. Je suis sûr qu'entre CoreData, NSBinding, NSUserDefaultsController et Interface Builder on peut le produire sans écrire la moindre ligne de code.

  • Bonjour Draken,

    En fait, chaque élève maintient une relation vers un set de notes, et chaque note maintient une relation vers l'élève et aussi vers l'évaluation (ce qui permet de retrouver des infos sur le coefficient de l'éval, si c'est un bonus etc..), l'évaluation elle-même maintenant un lien vers les notes correspondantes (sur le code, il manque d'ailleurs pour l'instant ce lien). L'avantage de cette structure est qu'on a un accès direct aux notes d'un élèves d'une part, et on peut aussi d'autre part retrouver les notes liée à une évaluation. Si par exemple je supprime un élève, je peux retrouver toutes ses notes et les supprimer, de même si je supprime une évaluation. Avec Core Data, j'avais construit ainsi le modèle, et ça marchait pas mal.

    Je vais essayer de penser autrement le modèle pour le "linéariser" et peut-être le rendre plus simple au final. La difficulté sera de retrouver les liens entre les objets : s'il faut refaire tout un travail pour relier les bons objets entres eux au décodage du fichier, c'est effectivement peu judicieux d'utiliser ce protocole Codable.

  • @Pyroh,

    Oui je pense que je vais retourner à Core Data. Je voulais écrire "en vrai" ma couche modèle pour éviter la "boite noire" core Data, mais finalement c'est peut-être le plus efficace.

  • CéroceCéroce Membre, Modérateur
    novembre 2020 modifié #9

    @Mick a dit :
    Merci pour ces réponses.

    Pour l'idée générale, j'aimerais que les données se trouvent sous forme de fichiers pour l'instant, et certains "réglages" récurrent dans un XML "préférences". L'appli précédente que j'avais bricolée (et qui fonctionnait !!) avait le défaut de stocker toutes les données sous la forme d'une base (devenant assez importante du coup) contenant toutes les classes de tous les niveaux etc. L'idée est ici d'avoir des fichiers séparés qu'on peut stocker facilement dans les bons dossiers de l'ordi.

    Tu pourrais créer une base de données SQLite pour chaque document, mais en pratique c'est chiant à cause de la gestion des version des documents introduite dans macOS 10.7. NSPersistentDocument le fait, mais au prix d'un tas de bidouilles que seule Apple peut se permettre de faire. Moi, j'avais fini par désactiver la gestion des versions, mais tout de même il y avait encore plein de gestion à faire.

    On peut même imaginer enregistrer en JSON et utiliser une base de données SQLite 'in-memory'. Ce ne sont pas les solutions qui manquent, plutôt le temps de les implémenter. (je précise que je ne te conseille pas particulièrement cette approche).

    Donc, si je veux stocker les données sous forme de structure (et plus d'objet héritant de NSObject : j'aimerais atteindre cet objectif!) l'idée la plus simple est de virer la réciprocité des relations ? (j'étais dans l'esprit de conception "coreData" avec les relations one to many et réciproquement). Je vais donc essayer de revoir la couche modèle en la rendant "linéaire".

    Oui et non. Dans le schéma Core Data, on crée des relations, mais au final, c'est bien une base de données SQLite dessous. Aussi, chaque Entité aura sa propre table, et il y aura aussi des tables pour faire des jointures entre les tables d'entités. Tu peux ouvrir ton fichier Core Data avec un éditeur SQLite pour t'en convaincre.

    Il se trouve que Core Data est une ORM (Object-Relational database Mapping), et fait le boulot pour toi afin que tu puisses travailler avec des objets. Mais au final, chaque instance de l'entité a un id (clef primaire) qui l'identifie de façon unique.

    Mais évidemment, avec une base de données on peut faire des relations one-to-many ou même many-to-many. C'est juste qu'il faudra lancer des requêtes sur la base, et de fait, ça apparait moins magique — dans le bon et le mauvais sens du terme.

    Si toutefois il m'était indispensable de créer une "UUID", est-ce qu'il y a des recommandations pour ce genre d'attribution ? Un simple Int qu'on incrémente à chaque appel ?

    Un UUID (Universal Unique Id) est typiquement créé par la classe (NS)UUID. C'est une chaîne de caractère suffisamment longue pour que les risques de collision soient infimes.
    Mais pour mieux répondre à ta question, pour identifier les 'lignes' de la base de données il nous faut des clefs primaires, qui sont habituellement des entiers incrémentés automatiquement à l'insertion. On utilise plutôt les UUID quand on doit échanger des données avec une autre machine, parce qu'elle a sa propre base de données. Par exemple, un élève peut être l'enregistrement 18 sur ta machine mais l'enregistrement 804 sur le serveur, en utilisant un UUID, on est sûr que c'est le même élève.

    En tous cas je remercie beaucoup les contributeurs de ce forum de prendre le temps de répondre à des "novices" !

    Je n'appellerais pas ça des questions ne novice. Nous sommes en plein dans les choix architecturaux. C'est toujours une affaire de compromis.

    J'ai beau détester Core Data, je dirais que c'est l'outil le plus adapté ici, parce que NSPersistentDocument va faire une grosse partie du travail pour toi. Et c'est important pour un projet sur lequel tu vas travailler en dilettante.

  • Bon, finalement, je me suis rabattu sur le protocole NSCoding : j'ai rendu les classes modèles conformes en ajoutant les fonctions init (coder aDecoder:NSCoder) et encode(with aCoder:NSCoder) à chaque classe. Par contre, c'est super lourd d'écrire ces bouts de code quasi-identiques pour chaque classe. (D'où peut-être l'utilité de CoreData...) Dans la sous-classe NSDocument, j'utilise la classe NSKeyedArchiver/NSKeyedUnarchiver pour encoder le "rootObject".

    Ca fonctionne très bien ainsi, et je crois que je vais rester là-dessus. Je voulais repartir sur des structures swifts "natives" et pas sur des NSObjects, mais ce n'est pas vraiment possible.

    J'ai quand même évité CoreData, même si j'aurais effectivement pu l'utiliser, pour éviter de reprendre tout le projet.

    Merci pour ces réponses en tous cas !

    Voici la partie Model

        //
        //  Model.swift
        //  VallComp
        //
        //  Created by Mickaël VALLIER on 28/10/2020.
        //  Copyright © 2020 Mickaël VALLIER. All rights reserved.
        //
    
        import Foundation
    
        class Content: NSObject, NSCoding {
            var lesEleves = Set<Eleve>()
            var lesEvaluations = Set<Evaluation>()
            var lesCompetences = Set<Competence>()
            var lesPeriodes : Set<Periode>
    
            override init() {
                lesPeriodes = Set()
                let defaultPeriode = Periode()
                defaultPeriode.nom = "Periode 1"
                lesPeriodes.insert(defaultPeriode)
            }
            required init?(coder aDecoder: NSCoder) {
                lesEleves = aDecoder.decodeObject(forKey: "lesEleves") as! Set
                lesEvaluations = aDecoder.decodeObject(forKey: "lesEvaluations") as! Set
                lesCompetences = aDecoder.decodeObject(forKey: "lesCompetences") as! Set
                lesPeriodes = aDecoder.decodeObject(forKey: "lesPeriodes") as! Set
            }
            func encode(with aCoder: NSCoder) {
                aCoder.encode(lesPeriodes, forKey:"lesPeriodes")
                aCoder.encode(lesEvaluations, forKey:"lesEvaluations")
                aCoder.encode(lesEleves, forKey:"lesEleves")
                aCoder.encode(lesCompetences, forKey:"lesCompetences")
            }
        }
    
        class Evaluation: NSObject, NSCoding {
            var nom = ""
            var date = Date.init()
            var sur : Int = 20
            var coefficient : Float = 1.0
            var laPeriode : Periode
            var ramenerSur20 = false
            var bonus = false
            init(unePeriode : Periode) {
                self.laPeriode = unePeriode
                super.init()
            }
            required init?(coder aDecoder: NSCoder) {
                nom = aDecoder.decodeObject(forKey: "nom") as! String
                date = aDecoder.decodeObject(forKey: "date") as! Date
                sur = aDecoder.decodeInteger(forKey: "sur")
                coefficient = aDecoder.decodeFloat(forKey: "coefficient")
                laPeriode = aDecoder.decodeObject(forKey: "laPeriode") as! Periode
                ramenerSur20 = aDecoder.decodeBool(forKey: "ramenerSur20")
                bonus = aDecoder.decodeBool(forKey: "bonus")
            }
            func encode(with aCoder: NSCoder) {
                aCoder.encode(nom, forKey: "nom")
                aCoder.encode(date, forKey: "date")
                aCoder.encode(sur, forKey: "sur")
                aCoder.encode(coefficient, forKey: "coefficient")
                aCoder.encode(laPeriode, forKey: "laPeriode")
                aCoder.encode(ramenerSur20, forKey: "ramenerSur20")
                aCoder.encode(bonus, forKey: "bonus")
            }
        }
    
        class Eleve: NSObject, NSCoding {
            var nom = ""
            var prenom = ""
            var commentaire : String?
            var lesNotes = Set<Note>()
            var lesEvalsComps = Set<EvalComp>()
    
            override init() {
                super.init()
            }
    
            required init?(coder: NSCoder) {
                nom = coder.decodeObject(forKey: "nom") as! String
                prenom = coder.decodeObject(forKey: "prenom") as! String
                commentaire = coder.decodeObject(forKey: "commentaire") as? String
                lesNotes = coder.decodeObject(forKey: "lesNotes") as! Set
                lesEvalsComps = coder.decodeObject(forKey: "lesEvalsComps") as! Set
            }
    
            func encode(with coder: NSCoder) {
                coder.encode(nom, forKey: "nom")
                coder.encode(prenom, forKey: "prenom")
                coder.encode(commentaire, forKey: "commentaire")
                coder.encode(lesNotes, forKey: "lesNotes")
                coder.encode(lesEvalsComps, forKey: "lesEvalsComps")
    
            }
        }
    
        class Periode : NSObject, NSCoding {
            var nom = "Periode"
            var dateDebut : Date = Date.init()
            var dateFin : Date?
            var lesEvaluations = Set<Evaluation>()
    
            override init() {
                super.init()
            }
    
            required init?(coder aDecoder: NSCoder) {
                nom = aDecoder.decodeObject(forKey: "nom") as! String
                dateDebut = aDecoder.decodeObject(forKey: "dateDebut") as! Date
                dateFin = aDecoder.decodeObject(forKey: "dateFin") as? Date
                lesEvaluations = aDecoder.decodeObject(forKey: "lesEvaluations") as! Set
            }
            func encode(with aCoder: NSCoder) {
                aCoder.encode(nom, forKey: "nom")
                aCoder.encode(dateDebut, forKey: "dateDebut")
                aCoder.encode(dateFin, forKey: "dateFin")
                aCoder.encode(lesEvaluations, forKey: "lesEvaluations")
            }
        }
    
        class Competence: NSObject, NSCoding {
            var nom = ""
            var exemples = ""
            required init?(coder aDecoder: NSCoder) {
                nom = aDecoder.decodeObject(forKey: "nom") as! String
                exemples = aDecoder.decodeObject(forKey: "exemples") as! String
            }
            func encode(with aCoder: NSCoder) {
                aCoder.encode(nom, forKey: "nom")
                aCoder.encode(exemples, forKey: "exemples")
            }
        }
    
        class Note: NSObject, NSCoding {
            var lEvaluation : Evaluation
            var lEleve : Eleve
            var valeur = -1
    
            init(unEleve : Eleve, uneEval : Evaluation) {
                self.lEleve = unEleve
                self.lEvaluation = uneEval
    
                super.init()
    
            }
            required init?(coder aDecoder: NSCoder) {
                lEvaluation = aDecoder.decodeObject(forKey: "lEvaluation") as! Evaluation
                lEleve = aDecoder.decodeObject(forKey: "lEleve") as! Eleve
                valeur = aDecoder.decodeInteger(forKey: "valeur")
            }
            func encode(with aCoder: NSCoder) {
                aCoder.encode(lEvaluation, forKey: "lEvaluation")
                aCoder.encode(lEleve, forKey: "lEleve")
                aCoder.encode(valeur, forKey: "valeur")
            }
        }
    
        class EvalComp: NSObject, NSCoding {
            var laCompetence : Competence
            var lEleve : Eleve
            var lEvaluation : Evaluation
            var valeur = ""
    
            init(uneEvaluation : Evaluation, unEleve : Eleve, uneCompetence : Competence) {
                self.laCompetence = uneCompetence
                self.lEvaluation = uneEvaluation
                self.lEleve = unEleve
    
                super.init()
            }
    
            required init?(coder: NSCoder) {
                laCompetence = coder.decodeObject(forKey: "laCompetence") as! Competence
                lEleve = coder.decodeObject(forKey: "lEleve") as! Eleve
                lEvaluation = coder.decodeObject(forKey: "lEvaluation") as! Evaluation
            }
    
            func encode(with coder: NSCoder) {
                coder.encode(laCompetence, forKey: "laCompetence")
                coder.encode(lEleve, forKey: "lEleve")
                coder.encode(lEvaluation, forKey: "lEvaluation")
            }
        }
    
  • CéroceCéroce Membre, Modérateur
    novembre 2020 modifié #11

    En fait, quand on rend une classe conforme à Codable, le compilateur génère secrètement du code pour accéder aux champs.

    Il y a des gens qui utilisent un générateur de code (comme Sourcery) pour implémenter NSCoding de la même façon. Évidemment, pour un petit projet ça n'en vaut pas la peine.

Connectez-vous ou Inscrivez-vous pour répondre.