Dengan JSONDecoder di Swift 4, dapatkah kunci yang hilang menggunakan nilai default daripada harus berupa properti opsional?

114

Swift 4 menambahkan Codableprotokol baru . Ketika saya menggunakannya JSONDecoder, tampaknya memerlukan semua properti non-opsional Codablekelas saya untuk memiliki kunci di JSON atau itu membuat kesalahan.

Membuat setiap properti kelas saya opsional sepertinya merepotkan yang tidak perlu karena yang saya inginkan adalah menggunakan nilai di json atau nilai default. (Saya tidak ingin properti menjadi nihil.)

Apakah ada cara untuk melakukan ini?

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
sumber
Satu pertanyaan lagi apa yang dapat saya lakukan jika saya memiliki beberapa kunci di json saya dan saya ingin menulis metode umum untuk memetakan json untuk membuat objek daripada memberikan nil itu harus memberikan nilai default minimal.
Aditya Sharma

Jawaban:

22

Pendekatan yang saya sukai adalah menggunakan apa yang disebut DTO - objek transfer data. Ini adalah struct, yang sesuai dengan Codable dan mewakili objek yang diinginkan.

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

Kemudian Anda cukup memasukkan objek yang ingin Anda gunakan dalam aplikasi dengan DTO itu.

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

Pendekatan ini juga bagus karena Anda dapat mengganti nama dan mengubah objek akhir sesuka Anda. Jelas dan membutuhkan lebih sedikit kode daripada decoding manual. Selain itu, dengan pendekatan ini Anda dapat memisahkan lapisan jaringan dari aplikasi lain.

Leonid Silver
sumber
Beberapa pendekatan lain bekerja dengan baik tetapi pada akhirnya saya pikir sesuatu di sepanjang garis ini adalah pendekatan terbaik.
zekel
baik untuk diketahui, tetapi ada terlalu banyak duplikasi kode. Saya lebih suka jawaban Martin R
Kamen Dobrev
136

Anda dapat mengimplementasikan init(from decoder: Decoder)metode dalam tipe Anda daripada menggunakan implementasi default:

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

Anda juga dapat membuat nameproperti konstan (jika Anda mau):

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

atau

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 komentar Anda: Dengan ekstensi khusus

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

Anda bisa mengimplementasikan metode init sebagai

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

tapi itu tidak lebih pendek dari

    self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? "Default Appleseed"
Martin R
sumber
Perhatikan juga bahwa dalam kasus khusus ini, Anda dapat menggunakan CodingKeyspencacahan yang dibuat secara otomatis (sehingga dapat menghapus definisi kustom) :)
Hamish
@ Hamish: Ini tidak dikompilasi ketika saya pertama kali mencobanya, tetapi sekarang berhasil :)
Martin R
Ya, saat ini agak tambal sulam, tetapi akan diperbaiki ( bugs.swift.org/browse/SR-5215 )
Hamish
54
Masih konyol bahwa metode yang dibuat secara otomatis tidak dapat membaca nilai default dari non-opsional. Saya memiliki 8 opsional dan 1 non-opsional, jadi sekarang menulis secara manual metode Encoder dan Decoder akan membawa banyak boilerplate. ObjectMappermenangani ini dengan sangat baik.
Tanpa kaki
1
@LeoDabus Mungkinkah Anda menyesuaikan diri Decodabledan juga menyediakan implementasi Anda sendiri init(from:)? Dalam hal ini kompilator menganggap Anda ingin menangani sendiri decoding secara manual dan oleh karena itu tidak mensintesis CodingKeysenum untuk Anda. Seperti yang Anda katakan, menyesuaikan ke Codablemalah berfungsi karena sekarang kompilator sedang melakukan sintesis encode(to:)untuk Anda dan begitu juga sintesis CodingKeys. Jika Anda juga menyediakan implementasi Anda sendiri encode(to:), CodingKeystidak akan lagi disintesis.
Hamish
37

Salah satu solusinya adalah menggunakan properti yang dihitung secara default ke nilai yang diinginkan jika kunci JSON tidak ditemukan. Ini menambahkan beberapa verbositas ekstra karena Anda harus mendeklarasikan properti lain, dan akan memerlukan penambahan CodingKeysenum (jika belum ada). Keuntungannya adalah Anda tidak perlu menulis kode decoding / encoding kustom.

Sebagai contoh:

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
sumber
Pendekatan yang menarik. Itu memang menambahkan sedikit kode tetapi sangat jelas dan dapat diperiksa setelah objek dibuat.
zekel
Jawaban favorit saya untuk masalah ini. Ini memungkinkan saya untuk tetap menggunakan JSONDecoder default dan dengan mudah membuat pengecualian untuk satu variabel. Terima kasih.
iOS_Mouse
Catatan: Dengan menggunakan pendekatan ini, properti Anda menjadi hanya-get, Anda tidak dapat menetapkan nilai secara langsung ke properti ini.
Ganpat
8

Anda bisa menerapkan.

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
sumber
ya ini adalah jawaban terbersih, tetapi masih mendapat banyak kode saat Anda memiliki benda besar!
Ashkan Ghodrat
1

Jika Anda tidak ingin menerapkan metode encoding dan decoding, ada solusi yang agak kotor di sekitar nilai default.

Anda dapat mendeklarasikan kolom baru Anda sebagai opsional yang tidak terbungkus secara implisit dan memeriksa apakah nilainya nihil setelah decoding dan menyetel nilai default.

Saya menguji ini hanya dengan PropertyListEncoder, tetapi saya pikir JSONDecoder bekerja dengan cara yang sama.

Kirill Kuzyk
sumber
1

Saya menemukan pertanyaan ini mencari hal yang persis sama. Jawaban yang saya temukan tidak terlalu memuaskan meskipun saya takut solusi di sini akan menjadi satu-satunya pilihan.

Dalam kasus saya, membuat decoder khusus akan membutuhkan satu ton boilerplate yang akan sulit dipertahankan, jadi saya terus mencari jawaban lain.

Saya menemukan artikel ini yang menunjukkan cara menarik untuk mengatasi hal ini dalam kasus sederhana menggunakan file @propertyWrapper. Hal terpenting bagi saya, adalah dapat digunakan kembali dan memerlukan pemfaktoran ulang kode yang ada.

Artikel ini mengasumsikan kasus di mana Anda ingin properti boolean yang hilang menjadi default ke false tanpa gagal, tetapi juga menampilkan varian lain yang berbeda. Anda dapat membacanya lebih detail tetapi saya akan menunjukkan apa yang saya lakukan untuk kasus penggunaan saya.

Dalam kasus saya, saya memiliki file array yang saya ingin diinisialisasi sebagai kosong jika kuncinya hilang.

Jadi, saya menyatakan @propertyWrapperekstensi berikut dan tambahannya:

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

Keuntungan dari metode ini adalah Anda dapat dengan mudah mengatasi masalah dalam kode yang ada hanya dengan menambahkan @propertyWrapperke properti. Dalam kasus saya:

@DefaultEmptyArray var items: [String] = []

Semoga ini bisa membantu seseorang yang menghadapi masalah yang sama.


MEMPERBARUI:

Setelah memposting jawaban ini sambil terus menyelidiki masalah ini, saya menemukan artikel lain ini tetapi yang paling penting perpustakaan masing-masing yang berisi beberapa umum yang mudah digunakan @propertyWrapperuntuk kasus-kasus seperti ini:

https://github.com/marksands/BetterCodable

lbarbosa.dll
sumber
0

Jika Anda berpikir bahwa menulis versi Anda sendiri init(from decoder: Decoder) terlalu banyak, saya akan menyarankan Anda untuk menerapkan metode yang akan memeriksa input sebelum mengirimkannya ke decoder. Dengan begitu, Anda akan memiliki tempat untuk memeriksa ketidakhadiran bidang dan menetapkan nilai default Anda sendiri.

Sebagai contoh:

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

Dan untuk melakukan init objek dari json, sebagai ganti:

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 akan terlihat seperti ini:

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

Dalam situasi khusus ini saya lebih suka berurusan dengan pilihan tetapi jika Anda memiliki pendapat berbeda, Anda dapat membuat metode customDecode (:) Anda dapat dibuang

Eugene Alexeev
sumber