Avec JSONDecoder dans Swift 4, les clés manquantes peuvent-elles utiliser une valeur par défaut au lieu d'avoir à être des propriétés optionnelles?

114

Swift 4 a ajouté le nouveau Codableprotocole. Lorsque je l'utilise, JSONDecoderil semble que toutes les propriétés non facultatives de ma Codableclasse aient des clés dans le JSON ou cela génère une erreur.

Rendre chaque propriété de ma classe facultative semble être un problème inutile car ce que je veux vraiment, c'est utiliser la valeur dans le json ou une valeur par défaut. (Je ne veux pas que la propriété soit nulle.)

Y a-t-il un moyen de faire cela?

class MyCodable: Codable {
    var name: String = "Default Appleseed"
}

func load(input: String) {
    do {
        if let data = input.data(using: .utf8) {
            let result = try JSONDecoder().decode(MyCodable.self, from: data)
            print("name: \(result.name)")
        }
    } catch  {
        print("error: \(error)")
        // `Error message: "Key not found when expecting non-optional type
        // String for coding key \"name\""`
    }
}

let goodInput = "{\"name\": \"Jonny Appleseed\" }"
let badInput = "{}"
load(input: goodInput) // works, `name` is Jonny Applessed
load(input: badInput) // breaks, `name` required since property is non-optional
Zekel
la source
Une autre question: que puis-je faire si j'ai plusieurs clés dans mon json et que je veux écrire une méthode générique pour mapper json pour créer un objet au lieu de donner nil, il devrait donner au moins une valeur par défaut.
Aditya Sharma

Réponses:

22

L'approche que je préfère utilise ce qu'on appelle les DTO - objet de transfert de données. Il s'agit d'une structure conforme à Codable et représentant l'objet souhaité.

struct MyClassDTO: Codable {
    let items: [String]?
    let otherVar: Int?
}

Ensuite, vous lancez simplement l'objet que vous souhaitez utiliser dans l'application avec ce DTO.

 class MyClass {
    let items: [String]
    var otherVar = 3
    init(_ dto: MyClassDTO) {
        items = dto.items ?? [String]()
        otherVar = dto.otherVar ?? 3
    }

    var dto: MyClassDTO {
        return MyClassDTO(items: items, otherVar: otherVar)
    }
}

Cette approche est également bonne car vous pouvez renommer et modifier l'objet final comme vous le souhaitez. Il est clair et nécessite moins de code que le décodage manuel. De plus, avec cette approche, vous pouvez séparer la couche réseau des autres applications.

Leonid Silver
la source
Certaines des autres approches ont bien fonctionné, mais en fin de compte, je pense que quelque chose de ce genre est la meilleure approche.
zekel
bon à savoir, mais il y a trop de duplication de code. Je préfère la réponse de Martin R
Kamen Dobrev
136

Vous pouvez implémenter la init(from decoder: Decoder)méthode dans votre type au lieu d'utiliser l'implémentation par défaut:

class MyCodable: Codable {
    var name: String = "Default Appleseed"

    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        if let name = try container.decodeIfPresent(String.self, forKey: .name) {
            self.name = name
        }
    }
}

Vous pouvez également créer nameune propriété constante (si vous le souhaitez):

class MyCodable: Codable {
    let name: String

    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        if let name = try container.decodeIfPresent(String.self, forKey: .name) {
            self.name = name
        } else {
            self.name = "Default Appleseed"
        }
    }
}

ou

required init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? "Default Appleseed"
}

À propos de votre commentaire: avec une extension personnalisée

extension KeyedDecodingContainer {
    func decodeWrapper<T>(key: K, defaultValue: T) throws -> T
        where T : Decodable {
        return try decodeIfPresent(T.self, forKey: key) ?? defaultValue
    }
}

vous pouvez implémenter la méthode init comme

required init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    self.name = try container.decodeWrapper(key: .name, defaultValue: "Default Appleseed")
}

mais ce n'est pas beaucoup plus court que

    self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? "Default Appleseed"
Martin R
la source
Notez également que dans ce cas particulier, vous pouvez utiliser l' CodingKeysénumération générée automatiquement (vous pouvez donc supprimer la définition personnalisée) :)
Hamish
@Hamish: Il ne s'est pas compilé quand je l'ai essayé pour la première fois, mais maintenant ça marche :)
Martin R
Oui, c'est actuellement un peu irrégulier, mais sera corrigé ( bugs.swift.org/browse/SR-5215 )
Hamish
54
Il est toujours ridicule que les méthodes générées automatiquement ne puissent pas lire les valeurs par défaut des non-optionnels. J'ai 8 optionnels et 1 non optionnel, donc maintenant écrire manuellement les méthodes Encoder et Decoder apporterait beaucoup de passe-partout. ObjectMappergère cela très bien.
Legoless
1
@LeoDabus Serait-ce que vous vous conformez Decodableet fournissez également votre propre implémentation de init(from:)? Dans ce cas, le compilateur suppose que vous souhaitez gérer vous-même le décodage manuellement et ne synthétise donc pas d' CodingKeysénumération pour vous. Comme vous le dites, se conformer à la Codableplace fonctionne parce que maintenant le compilateur synthétise encode(to:)pour vous et synthétise donc également CodingKeys. Si vous fournissez également votre propre implémentation de encode(to:), CodingKeysne sera plus synthétisé.
Hamish
37

Une solution serait d'utiliser une propriété calculée qui prend par défaut la valeur souhaitée si la clé JSON n'est pas trouvée. Cela ajoute un peu de verbosité supplémentaire car vous devrez déclarer une autre propriété et nécessitera l'ajout de l' CodingKeysénumération (si ce n'est déjà fait). L'avantage est que vous n'avez pas besoin d'écrire de code de décodage / encodage personnalisé.

Par exemple:

class MyCodable: Codable {
    var name: String { return _name ?? "Default Appleseed" }
    var age: Int?

    private var _name: String?

    enum CodingKeys: String, CodingKey {
        case _name = "name"
        case age
    }
}
Cristik
la source
Approche intéressante. Cela ajoute un peu de code mais c'est très clair et inspectable après la création de l'objet.
zekel
Ma réponse préférée à ce problème. Cela me permet d'utiliser toujours le JSONDecoder par défaut et de faire facilement une exception pour une variable. Merci.
iOS_Mouse
Remarque: en utilisant cette approche, votre propriété devient en lecture seule, vous ne pouvez pas attribuer de valeur directement à cette propriété.
Ganpat
8

Vous pouvez mettre en œuvre.

struct Source : Codable {

    let id : String?
    let name : String?

    enum CodingKeys: String, CodingKey {
        case id = "id"
        case name = "name"
    }

    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        id = try values.decodeIfPresent(String.self, forKey: .id) ?? ""
        name = try values.decodeIfPresent(String.self, forKey: .name)
    }
}
Ankit
la source
oui c'est la réponse la plus claire, mais elle reçoit quand même beaucoup de code lorsque vous avez de gros objets!
Ashkan Ghodrat
1

Si vous ne souhaitez pas implémenter vos méthodes d'encodage et de décodage, il existe une solution quelque peu sale autour des valeurs par défaut.

Vous pouvez déclarer votre nouveau champ comme optionnel implicitement déballé et vérifier s'il est nul après le décodage et définir une valeur par défaut.

J'ai testé cela uniquement avec PropertyListEncoder, mais je pense que JSONDecoder fonctionne de la même manière.

Kirill Kuzyk
la source
1

Je suis tombé sur cette question en cherchant exactement la même chose. Les réponses que j'ai trouvées n'étaient pas très satisfaisantes même si j'avais peur que les solutions ici ne soient la seule option.

Dans mon cas, la création d'un décodeur personnalisé nécessiterait une tonne de passe-partout qui serait difficile à entretenir, alors j'ai continué à chercher d'autres réponses.

Je suis tombé sur cet article qui montre un moyen intéressant de surmonter cela dans des cas simples en utilisant un @propertyWrapper. La chose la plus importante pour moi, c'était qu'il était réutilisable et nécessitait une refactorisation minimale du code existant.

L'article suppose un cas où vous voudriez qu'une propriété booléenne manquante soit par défaut false sans échouer, mais montre également d'autres variantes différentes. Vous pouvez le lire plus en détail mais je vais vous montrer ce que j'ai fait pour mon cas d'utilisation.

Dans mon cas, j'avais un arrayque je voulais initialiser comme vide si la clé manquait.

Donc, j'ai déclaré les @propertyWrapperextensions suivantes et supplémentaires:

@propertyWrapper
struct DefaultEmptyArray<T:Codable> {
    var wrappedValue: [T] = []
}

//codable extension to encode/decode the wrapped value
extension DefaultEmptyArray: Codable {
    
    func encode(to encoder: Encoder) throws {
        try wrappedValue.encode(to: encoder)
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        wrappedValue = try container.decode([T].self)
    }
    
}

extension KeyedDecodingContainer {
    func decode<T:Decodable>(_ type: DefaultEmptyArray<T>.Type,
                forKey key: Key) throws -> DefaultEmptyArray<T> {
        try decodeIfPresent(type, forKey: key) ?? .init()
    }
}

L'avantage de cette méthode est que vous pouvez facilement résoudre le problème dans le code existant en ajoutant simplement le @propertyWrapperà la propriété. Dans mon cas:

@DefaultEmptyArray var items: [String] = []

J'espère que cela aidera quelqu'un à faire face au même problème.


METTRE À JOUR:

Après avoir publié cette réponse tout en continuant à examiner la question, j'ai trouvé cet autre article, mais surtout la bibliothèque respective qui contient des éléments communs faciles à utiliser @propertyWrapperpour ce type de cas:

https://github.com/marksands/BetterCodable

lbarbosa
la source
0

Si vous pensez qu'écrire votre propre version de init(from decoder: Decoder) est écrasant, je vous conseillerais d'implémenter une méthode qui vérifiera l'entrée avant de l'envoyer au décodeur. De cette façon, vous aurez un endroit où vous pourrez vérifier l'absence de champs et définir vos propres valeurs par défaut.

Par exemple:

final class CodableModel: Codable
{
    static func customDecode(_ obj: [String: Any]) -> CodableModel?
    {
        var validatedDict = obj
        let someField = validatedDict[CodingKeys.someField.stringValue] ?? false
        validatedDict[CodingKeys.someField.stringValue] = someField

        guard
            let data = try? JSONSerialization.data(withJSONObject: validatedDict, options: .prettyPrinted),
            let model = try? CodableModel.decoder.decode(CodableModel.self, from: data) else {
                return nil
        }

        return model
    }

    //your coding keys, properties, etc.
}

Et pour initier un objet depuis json, au lieu de:

do {
    let data = try JSONSerialization.data(withJSONObject: json, options: .prettyPrinted)
    let model = try CodableModel.decoder.decode(CodableModel.self, from: data)                        
} catch {
    assertionFailure(error.localizedDescription)
}

Init ressemblera à ceci:

if let vuvVideoFile = PublicVideoFile.customDecode($0) {
    videos.append(vuvVideoFile)
}

Dans cette situation particulière, je préfère traiter les options mais si vous avez un avis différent, vous pouvez rendre votre méthode customDecode (:) jetable

Eugène Alexeev
la source