Con JSONDecoder en Swift 4, ¿las claves faltantes pueden usar un valor predeterminado en lugar de tener que ser propiedades opcionales?

114

Swift 4 agregó el nuevo Codableprotocolo. Cuando lo uso JSONDecoder, parece requerir que todas las propiedades no opcionales de mi Codableclase tengan claves en el JSON o arroja un error.

Hacer que cada propiedad de mi clase sea opcional parece una molestia innecesaria ya que lo que realmente quiero es usar el valor en el json o un valor predeterminado. (No quiero que la propiedad sea nula).

¿Hay alguna forma de hacer esto?

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
fuente
Una consulta más, ¿qué puedo hacer si tengo varias claves en mi json y quiero escribir un método genérico para mapear json para crear un objeto en lugar de dar nulo? Debería dar el valor predeterminado al menos.
Aditya Sharma

Respuestas:

22

El enfoque que prefiero es el uso de los llamados DTO: objeto de transferencia de datos. Es una estructura que se ajusta a Codable y representa el objeto deseado.

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

Luego, simplemente inicie el objeto que desea usar en la aplicación con ese 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)
    }
}

Este enfoque también es bueno ya que puede renombrar y cambiar el objeto final como desee. Es claro y requiere menos código que la decodificación manual. Además, con este enfoque, puede separar la capa de red de otra aplicación.

Leonid Silver
fuente
Algunos de los otros enfoques funcionaron bien, pero en última instancia, creo que algo en este sentido es el mejor enfoque.
zekel
Es bueno saberlo, pero hay demasiada duplicación de código. Prefiero la respuesta de Martin R
Kamen Dobrev
136

Puede implementar el init(from decoder: Decoder)método en su tipo en lugar de usar la implementación predeterminada:

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
        }
    }
}

También puede hacer nameuna propiedad constante (si lo desea):

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"
        }
    }
}

o

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"
}

Re tu comentario: con una extensión personalizada

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

podría implementar el método init como

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

pero eso no es mucho más corto que

    self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? "Default Appleseed"
Martín R
fuente
También tenga en cuenta que en este caso particular, puede usar la CodingKeysenumeración generada automáticamente (por lo que puede eliminar la definición personalizada) :)
Hamish
@Hamish: No se compiló cuando lo probé por primera vez, pero ahora funciona :)
Martin R
Sí, actualmente es un poco irregular, pero se solucionará ( bugs.swift.org/browse/SR-5215 )
Hamish
54
Sigue siendo ridículo que los métodos generados automáticamente no puedan leer los valores predeterminados de los no opcionales. Tengo 8 opcionales y 1 no opcional, por lo que ahora escribir manualmente los métodos de codificador y decodificador traería una gran cantidad de texto estándar. ObjectMappermaneja esto muy bien.
Legoless
1
@LeoDabus ¿Podría ser que se está ajustando Decodabley también está proporcionando su propia implementación init(from:)? En ese caso, el compilador asume que desea manejar la decodificación manualmente usted mismo y, por lo tanto, no sintetiza una CodingKeysenumeración por usted. Como usted dice, en Codablecambio , conformarse a funciona porque ahora el compilador está sintetizando encode(to:)para usted y también sintetiza CodingKeys. Si también proporciona su propia implementación de encode(to:), CodingKeysya no se sintetizará.
Hamish
37

Una solución sería utilizar una propiedad calculada que tenga como valor predeterminado el valor deseado si no se encuentra la clave JSON. Esto agrega un poco de verbosidad adicional, ya que deberá declarar otra propiedad y requerirá agregar la CodingKeysenumeración (si no está ya allí). La ventaja es que no es necesario escribir código de decodificación / codificación personalizado.

Por ejemplo:

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
fuente
Enfoque interesante. Agrega un poco de código, pero es muy claro e inspeccionable después de que se crea el objeto.
zekel
Mi respuesta favorita a este problema. Me permite seguir usando el JSONDecoder predeterminado y hacer fácilmente una excepción para una variable. Gracias.
iOS_Mouse
Nota: Con este enfoque, su propiedad se convierte en solo de obtención, no puede asignar un valor directamente a esta propiedad.
Ganpat
8

Puede implementar.

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
fuente
sí, esta es la respuesta más limpia, ¡pero aún recibe mucho código cuando tienes objetos grandes!
Ashkan Ghodrat
1

Si no desea implementar sus métodos de codificación y decodificación, existe una solución algo sucia en torno a los valores predeterminados.

Puede declarar su nuevo campo como opcional sin empaquetar implícitamente y verificar si es nulo después de la decodificación y establecer un valor predeterminado.

Probé esto solo con PropertyListEncoder, pero creo que JSONDecoder funciona de la misma manera.

Kirill Kuzyk
fuente
0

Si cree que escribir su propia versión de init(from decoder: Decoder) es abrumador, le aconsejo que implemente un método que verifique la entrada antes de enviarla al decodificador. De esa manera, tendrá un lugar donde puede verificar la ausencia de campos y establecer sus propios valores predeterminados.

Por ejemplo:

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.
}

Y para iniciar un objeto desde json, en lugar 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 se verá así:

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

En esta situación particular, prefiero tratar con opcionales, pero si tiene una opinión diferente, puede hacer que su método customDecode (:) sea arrojable

Eugene Alexeev
fuente
0

Me encontré con esta pregunta buscando exactamente lo mismo. Las respuestas que encontré no fueron muy satisfactorias aunque temía que las soluciones aquí fueran la única opción.

En mi caso, la creación de un decodificador personalizado requeriría una tonelada de texto estándar que sería difícil de mantener, así que seguí buscando otras respuestas.

Me encontré con este artículo que muestra una forma interesante de superar esto en casos simples usando un archivo @propertyWrapper. Lo más importante para mí fue que era reutilizable y requería una refactorización mínima del código existente.

El artículo asume un caso en el que querría que una propiedad booleana faltante se estableciera de forma predeterminada en falso sin fallar, pero también muestra otras variantes diferentes. Puede leerlo con más detalle, pero mostraré lo que hice para mi caso de uso.

En mi caso, tenía un arraymensaje que quería inicializar como vacío si faltaba la clave.

Entonces, declaré las siguientes @propertyWrapperextensiones adicionales:

@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()
    }
}

La ventaja de este método es que puede solucionar fácilmente el problema en el código existente simplemente agregando el @propertyWrappera la propiedad. En mi caso:

@DefaultEmptyArray var items: [String] = []

Espero que esto ayude a alguien a lidiar con el mismo problema.


ACTUALIZAR:

Después de publicar esta respuesta mientras continuaba investigando el asunto, encontré este otro artículo, pero lo más importante es la biblioteca respectiva que contiene algunos correos electrónicos comunes fáciles de usar @propertyWrapperpara este tipo de casos:

https://github.com/marksands/BetterCodable

lbarbosa
fuente