Jak zdekodować zagnieżdżoną strukturę JSON za pomocą protokołu Swift Decodable?

90

Oto mój JSON

{
    "id": 1,
    "user": {
        "user_name": "Tester",
        "real_info": {
            "full_name":"Jon Doe"
        }
    },
    "reviews_count": [
        {
            "count": 4
        }
    ]
}

Oto struktura, w której chcę, aby została zapisana (niekompletna)

struct ServerResponse: Decodable {
    var id: String
    var username: String
    var fullName: String
    var reviewCount: Int

    enum CodingKeys: String, CodingKey {
       case id, 
       // How do i get nested values?
    }
}

Spojrzałem na dokumentację Apple dotyczącą dekodowania zagnieżdżonych struktur, ale nadal nie rozumiem, jak poprawnie wykonać różne poziomy JSON. Każda pomoc będzie mile widziana.

FlowUI. SimpleUITesting.com
źródło

Odpowiedzi:

109

Innym podejściem jest utworzenie modelu pośredniego, który ściśle pasuje do JSON (za pomocą narzędzia takiego jak quicktype.io ), pozwolenie Swiftowi wygenerować metody dekodowania go, a następnie wybranie elementów, które chcesz w ostatecznym modelu danych:

// snake_case to match the JSON and hence no need to write CodingKey enums / struct
fileprivate struct RawServerResponse: Decodable {
    struct User: Decodable {
        var user_name: String
        var real_info: UserRealInfo
    }

    struct UserRealInfo: Decodable {
        var full_name: String
    }

    struct Review: Decodable {
        var count: Int
    }

    var id: Int
    var user: User
    var reviews_count: [Review]
}

struct ServerResponse: Decodable {
    var id: String
    var username: String
    var fullName: String
    var reviewCount: Int

    init(from decoder: Decoder) throws {
        let rawResponse = try RawServerResponse(from: decoder)

        // Now you can pick items that are important to your data model,
        // conveniently decoded into a Swift structure
        id = String(rawResponse.id)
        username = rawResponse.user.user_name
        fullName = rawResponse.user.real_info.full_name
        reviewCount = rawResponse.reviews_count.first!.count
    }
}

Pozwala to również na łatwe iterowanie reviews_count, jeśli w przyszłości będzie zawierał więcej niż 1 wartość.

Kod inny
źródło
Ok. to podejście wygląda bardzo czysto. Myślę, że w moim przypadku
użyję
Tak, zdecydowanie przemyślałem to - @JTAppleCalendarforiOSSwift powinieneś to zaakceptować, ponieważ jest to lepsze rozwiązanie.
Hamish
@Hamish ok. Zmieniłem to, ale Twoja odpowiedź była bardzo szczegółowa. Wiele się z tego nauczyłem.
FlowUI. SimpleUITesting.com
Jestem ciekawy, jak można zaimplementować Encodablew ServerResponsestrukturze, stosując to samo podejście. Czy to w ogóle możliwe?
nayem
1
@nayem problem polega na tym, że ServerResponsema mniej danych niż RawServerResponse. Możesz przechwycić RawServerResponseinstancję, zaktualizować ją za pomocą właściwości z ServerResponse, a następnie wygenerować na jej podstawie JSON. Możesz uzyskać lepszą pomoc, publikując nowe pytanie dotyczące konkretnego problemu, z którym się borykasz.
Code Different
95

Aby rozwiązać problem, możesz podzielić swoją RawServerResponseimplementację na kilka części logicznych (używając Swift 5).


# 1. Zaimplementuj właściwości i wymagane klucze kodowania

import Foundation

struct RawServerResponse {

    enum RootKeys: String, CodingKey {
        case id, user, reviewCount = "reviews_count"
    }

    enum UserKeys: String, CodingKey {
        case userName = "user_name", realInfo = "real_info"
    }

    enum RealInfoKeys: String, CodingKey {
        case fullName = "full_name"
    }

    enum ReviewCountKeys: String, CodingKey {
        case count
    }

    let id: Int
    let userName: String
    let fullName: String
    let reviewCount: Int

}

# 2. Ustaw strategię dekodowania dla idwłaściwości

extension RawServerResponse: Decodable {

    init(from decoder: Decoder) throws {
        // id
        let container = try decoder.container(keyedBy: RootKeys.self)
        id = try container.decode(Int.self, forKey: .id)

        /* ... */                 
    }

}

# 3. Ustaw strategię dekodowania dla userNamewłaściwości

extension RawServerResponse: Decodable {

    init(from decoder: Decoder) throws {
        /* ... */

        // userName
        let userContainer = try container.nestedContainer(keyedBy: UserKeys.self, forKey: .user)
        userName = try userContainer.decode(String.self, forKey: .userName)

        /* ... */
    }

}

# 4. Ustaw strategię dekodowania dla fullNamewłaściwości

extension RawServerResponse: Decodable {

    init(from decoder: Decoder) throws {
        /* ... */

        // fullName
        let realInfoKeysContainer = try userContainer.nestedContainer(keyedBy: RealInfoKeys.self, forKey: .realInfo)
        fullName = try realInfoKeysContainer.decode(String.self, forKey: .fullName)

        /* ... */
    }

}

# 5. Ustaw strategię dekodowania dla reviewCountwłaściwości

extension RawServerResponse: Decodable {

    init(from decoder: Decoder) throws {
        /* ...*/        

        // reviewCount
        var reviewUnkeyedContainer = try container.nestedUnkeyedContainer(forKey: .reviewCount)
        var reviewCountArray = [Int]()
        while !reviewUnkeyedContainer.isAtEnd {
            let reviewCountContainer = try reviewUnkeyedContainer.nestedContainer(keyedBy: ReviewCountKeys.self)
            reviewCountArray.append(try reviewCountContainer.decode(Int.self, forKey: .count))
        }
        guard let reviewCount = reviewCountArray.first else {
            throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: container.codingPath + [RootKeys.reviewCount], debugDescription: "reviews_count cannot be empty"))
        }
        self.reviewCount = reviewCount
    }

}

Pełne wdrożenie

import Foundation

struct RawServerResponse {

    enum RootKeys: String, CodingKey {
        case id, user, reviewCount = "reviews_count"
    }

    enum UserKeys: String, CodingKey {
        case userName = "user_name", realInfo = "real_info"
    }

    enum RealInfoKeys: String, CodingKey {
        case fullName = "full_name"
    }

    enum ReviewCountKeys: String, CodingKey {
        case count
    }

    let id: Int
    let userName: String
    let fullName: String
    let reviewCount: Int

}
extension RawServerResponse: Decodable {

    init(from decoder: Decoder) throws {
        // id
        let container = try decoder.container(keyedBy: RootKeys.self)
        id = try container.decode(Int.self, forKey: .id)

        // userName
        let userContainer = try container.nestedContainer(keyedBy: UserKeys.self, forKey: .user)
        userName = try userContainer.decode(String.self, forKey: .userName)

        // fullName
        let realInfoKeysContainer = try userContainer.nestedContainer(keyedBy: RealInfoKeys.self, forKey: .realInfo)
        fullName = try realInfoKeysContainer.decode(String.self, forKey: .fullName)

        // reviewCount
        var reviewUnkeyedContainer = try container.nestedUnkeyedContainer(forKey: .reviewCount)
        var reviewCountArray = [Int]()
        while !reviewUnkeyedContainer.isAtEnd {
            let reviewCountContainer = try reviewUnkeyedContainer.nestedContainer(keyedBy: ReviewCountKeys.self)
            reviewCountArray.append(try reviewCountContainer.decode(Int.self, forKey: .count))
        }
        guard let reviewCount = reviewCountArray.first else {
            throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: container.codingPath + [RootKeys.reviewCount], debugDescription: "reviews_count cannot be empty"))
        }
        self.reviewCount = reviewCount
    }

}

Stosowanie

let jsonString = """
{
    "id": 1,
    "user": {
        "user_name": "Tester",
        "real_info": {
            "full_name":"Jon Doe"
        }
    },
    "reviews_count": [
    {
    "count": 4
    }
    ]
}
"""

let jsonData = jsonString.data(using: .utf8)!
let decoder = JSONDecoder()
let serverResponse = try! decoder.decode(RawServerResponse.self, from: jsonData)
dump(serverResponse)

/*
prints:
▿ RawServerResponse #1 in __lldb_expr_389
  - id: 1
  - user: "Tester"
  - fullName: "Jon Doe"
  - reviewCount: 4
*/
Imanou Petit
źródło
13
Bardzo dedykowana odpowiedź.
Hexfire
3
Zamiast structużywać enumz kluczami. co jest o wiele bardziej eleganckie 👍
Jack
1
Ogromne podziękowania za poświęcenie czasu na tak dobre udokumentowanie tego. Po przejrzeniu tak dużej ilości dokumentacji na temat możliwości dekodowania i przeanalizowania JSON, twoja odpowiedź naprawdę wyjaśniła wiele pytań, które miałem.
Marcy,
30

Zamiast mieć jedno duże CodingKeyswyliczenie ze wszystkimi kluczami potrzebnymi do dekodowania JSON, radziłbym podzielić klucze dla każdego z zagnieżdżonych obiektów JSON, używając zagnieżdżonych wyliczeń, aby zachować hierarchię:

// top-level JSON object keys
private enum CodingKeys : String, CodingKey {

    // using camelCase case names, with snake_case raw values where necessary.
    // the raw values are what's used as the actual keys for the JSON object,
    // and default to the case name unless otherwise specified.
    case id, user, reviewsCount = "reviews_count"

    // "user" JSON object keys
    enum User : String, CodingKey {
        case username = "user_name", realInfo = "real_info"

        // "real_info" JSON object keys
        enum RealInfo : String, CodingKey {
            case fullName = "full_name"
        }
    }

    // nested JSON objects in "reviews" keys
    enum ReviewsCount : String, CodingKey {
        case count
    }
}

Ułatwi to śledzenie kluczy na każdym poziomie w JSON.

Mając na uwadze, że:

  • Wpust Pojemnik służy do dekodowania obiektów JSON i jest dekodowany z CodingKeyrodzaju odpowiadającego (na przykład te, które zostały zdefiniowane powyżej).

  • Unkeyed pojemnik służy do dekodowania tablicę JSON i jest dekodowany sekwencyjnie (czyli za każdym razem można nazwać dekodowania lub zagnieżdżonych metody pojemnik na to, że przejście do następnego elementu w tablicy). Zobacz drugą część odpowiedzi, aby dowiedzieć się, jak przejść przez jedną z nich.

Po pobraniu kontenera z kluczem najwyższego poziomu z dekodera za pomocą container(keyedBy:)(ponieważ masz obiekt JSON na najwyższym poziomie), możesz wielokrotnie używać tych metod:

Na przykład:

struct ServerResponse : Decodable {

    var id: Int, username: String, fullName: String, reviewCount: Int

    private enum CodingKeys : String, CodingKey { /* see above definition in answer */ }

    init(from decoder: Decoder) throws {

        // top-level container
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decode(Int.self, forKey: .id)

        // container for { "user_name": "Tester", "real_info": { "full_name": "Jon Doe" } }
        let userContainer =
            try container.nestedContainer(keyedBy: CodingKeys.User.self, forKey: .user)

        self.username = try userContainer.decode(String.self, forKey: .username)

        // container for { "full_name": "Jon Doe" }
        let realInfoContainer =
            try userContainer.nestedContainer(keyedBy: CodingKeys.User.RealInfo.self,
                                              forKey: .realInfo)

        self.fullName = try realInfoContainer.decode(String.self, forKey: .fullName)

        // container for [{ "count": 4 }] – must be a var, as calling a nested container
        // method on it advances it to the next element.
        var reviewCountContainer =
            try container.nestedUnkeyedContainer(forKey: .reviewsCount)

        // container for { "count" : 4 }
        // (note that we're only considering the first element of the array)
        let firstReviewCountContainer =
            try reviewCountContainer.nestedContainer(keyedBy: CodingKeys.ReviewsCount.self)

        self.reviewCount = try firstReviewCountContainer.decode(Int.self, forKey: .count)
    }
}

Przykładowe dekodowanie:

let jsonData = """
{
  "id": 1,
  "user": {
    "user_name": "Tester",
    "real_info": {
    "full_name":"Jon Doe"
  }
  },
  "reviews_count": [
    {
      "count": 4
    }
  ]
}
""".data(using: .utf8)!

do {
    let response = try JSONDecoder().decode(ServerResponse.self, from: jsonData)
    print(response)
} catch {
    print(error)
}

// ServerResponse(id: 1, username: "Tester", fullName: "Jon Doe", reviewCount: 4)

Iterowanie przez kontener bez klucza

Biorąc pod uwagę przypadek, w którym chcesz reviewCountbyć an [Int], gdzie każdy element reprezentuje wartość "count"klucza w zagnieżdżonym formacie JSON:

  "reviews_count": [
    {
      "count": 4
    },
    {
      "count": 5
    }
  ]

Będziesz musiał wykonać iterację przez zagnieżdżony kontener bez klucza, uzyskać zagnieżdżony kontener z kluczem w każdej iteracji i zdekodować wartość "count"klucza. Możesz użyć countwłaściwości kontenera bez klucza, aby wstępnie przydzielić wynikową tablicę, a następnie isAtEndwłaściwość do jej iteracji.

Na przykład:

struct ServerResponse : Decodable {

    var id: Int
    var username: String
    var fullName: String
    var reviewCounts = [Int]()

    // ...

    init(from decoder: Decoder) throws {

        // ...

        // container for [{ "count": 4 }, { "count": 5 }]
        var reviewCountContainer =
            try container.nestedUnkeyedContainer(forKey: .reviewsCount)

        // pre-allocate the reviewCounts array if we can
        if let count = reviewCountContainer.count {
            self.reviewCounts.reserveCapacity(count)
        }

        // iterate through each of the nested keyed containers, getting the
        // value for the "count" key, and appending to the array.
        while !reviewCountContainer.isAtEnd {

            // container for a single nested object in the array, e.g { "count": 4 }
            let nestedReviewCountContainer = try reviewCountContainer.nestedContainer(
                                                 keyedBy: CodingKeys.ReviewsCount.self)

            self.reviewCounts.append(
                try nestedReviewCountContainer.decode(Int.self, forKey: .count)
            )
        }
    }
}
Hamish
źródło
jedna rzecz do wyjaśnienia: co miałeś na myśli I would advise splitting the keys for each of your nested JSON objects up into multiple nested enumerations, thereby making it easier to keep track of the keys at each level in your JSON?
FlowUI. SimpleUITesting.com
@JTAppleCalendarforiOSSwift Mam na myśli to, że zamiast jednego dużego CodingKeyswyliczenia ze wszystkimi kluczami potrzebnymi do zdekodowania obiektu JSON, należy je podzielić na wiele wyliczeń dla każdego obiektu JSON - na przykład w powyższym kodzie, który mamy CodingKeys.Userz kluczami aby zdekodować obiekt JSON użytkownika ( { "user_name": "Tester", "real_info": { "full_name": "Jon Doe" } }), więc tylko klucze "user_name"& "real_info".
Hamish
Dzięki. Bardzo wyraźna odpowiedź. Wciąż go przeglądam, aby w pełni to zrozumieć. Ale to działa.
FlowUI. SimpleUITesting.com
Miałem jedno pytanie, reviews_countktóre jest tablicą słowników. Obecnie kod działa zgodnie z oczekiwaniami. My reviewsCount zawsze ma tylko jedną wartość w tablicy. Ale co, jeśli rzeczywiście chciałbym mieć tablicę review_count, to musiałbym po prostu zadeklarować var reviewCount: Intjako tablicę, prawda? -> var reviewCount: [Int]. A potem musiałbym również edytować ReviewsCountwyliczenie, prawda?
FlowUI. SimpleUITesting.com
1
@JTAppleCalendarforiOSSwift To faktycznie byłoby nieco bardziej skomplikowane, ponieważ to, co opisujesz, to nie tylko tablica Int, ale tablica obiektów JSON, z których każdy ma Intwartość dla danego klucza - więc to, co musisz zrobić, to iterować bezkluczowy kontener i pobierz wszystkie zagnieżdżone kontenery z kluczem, dekodując Intkażdy z nich (a następnie dodając je do swojej tablicy), np. gist.github.com/hamishknight/9b5c202fe6d8289ee2cb9403876a1b41
Hamish
4

Wiele dobrych odpowiedzi zostało już opublikowanych, ale istnieje prostsza metoda, jeszcze nie opisana IMO.

Gdy nazwy pól JSON są zapisywane przy użyciu snake_case_notation, możesz nadal używać camelCaseNotationw swoim pliku Swift.

Musisz tylko ustawić

decoder.keyDecodingStrategy = .convertFromSnakeCase

Po tym ☝️ wierszu Swift automatycznie dopasuje wszystkie snake_casepola z JSON do camelCasepól w modelu Swift.

Na przykład

user_name` -> userName
reviews_count -> `reviewsCount
...

Oto pełny kod

1. Pisanie modelu

struct Response: Codable {

    let id: Int
    let user: User
    let reviewsCount: [ReviewCount]

    struct User: Codable {
        let userName: String

        struct RealInfo: Codable {
            let fullName: String
        }
    }

    struct ReviewCount: Codable {
        let count: Int
    }
}

2. Ustawianie dekodera

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase

3. Dekodowanie

do {
    let response = try? decoder.decode(Response.self, from: data)
    print(response)
} catch {
    debugPrint(error)
}
Luca Angeletti
źródło
2
Nie dotyczy to pierwotnego pytania, jak radzić sobie z różnymi poziomami zagnieżdżenia.
Theo
2
  1. Skopiuj plik json do https://app.quicktype.io
  2. Wybierz Swift (jeśli używasz Swift 5, sprawdź przełącznik zgodności dla Swift 5)
  3. Użyj poniższego kodu, aby zdekodować plik
  4. Voila!
let file = "data.json"

guard let url = Bundle.main.url(forResource: "data", withExtension: "json") else{
    fatalError("Failed to locate \(file) in bundle.")
}

guard let data = try? Data(contentsOf: url) else{
    fatalError("Failed to locate \(file) in bundle.")
}

let yourObject = try? JSONDecoder().decode(YourModel.self, from: data)
simibac
źródło
1
Pracował dla mnie, dziękuję. Ta strona jest złota. Dla widzów, jeśli dekodujesz zmienną łańcuchową json jsonStr, możesz użyć tego zamiast dwóch guard letpowyższych: guard let jsonStrData: Data? = jsonStr.data(using: .utf8)! else { print("error") }następnie przekonwertuj jsonStrDatana swoją strukturę, jak opisano powyżej w let yourObjectlinii
Zapytaj P
To niesamowite narzędzie!
PostCodeism
0

Możesz także skorzystać z przygotowanej przeze mnie biblioteki KeyedCodable . Będzie to wymagało mniej kodu. Daj mi znać, co o tym myślisz.

struct ServerResponse: Decodable, Keyedable {
  var id: String!
  var username: String!
  var fullName: String!
  var reviewCount: Int!

  private struct ReviewsCount: Codable {
    var count: Int
  }

  mutating func map(map: KeyMap) throws {
    var id: Int!
    try id <<- map["id"]
    self.id = String(id)

    try username <<- map["user.user_name"]
    try fullName <<- map["user.real_info.full_name"]

    var reviewCount: [ReviewsCount]!
    try reviewCount <<- map["reviews_count"]
    self.reviewCount = reviewCount[0].count
  }

  init(from decoder: Decoder) throws {
    try KeyedDecoder(with: decoder).decode(to: &self)
  }
}
decybel
źródło