Swift: Codable — извлечение одного ключа кодирования

У меня есть следующий код для извлечения JSON, содержащегося в ключе кодирования:

let value = try! decoder.decode([String:Applmusic].self, from: $0["applmusic"])

Это успешно обрабатывает следующие JSON:

{
  "applmusic":{
    "code":"AAPL",
    "quality":"good",
    "line":"She told me don't worry",
}

Однако не удается извлечь JSON с ключом кодирования applmusic из следующего:

{
  "applmusic":{
    "code":"AAPL",
    "quality":"good",
    "line":"She told me don't worry",
  },
  "spotify":{
    "differentcode":"SPOT",
    "music_quality":"good",
    "spotify_specific_code":"absent in apple"
  },
  "amazon":{
    "amzncode":"SPOT",
    "music_quality":"good",
    "stanley":"absent in apple"
  }
}

Модели данных для applmusic, spotify и amazon различаются. Однако мне нужно только извлечь applmusic и опустить другие ключи кодирования.

Моя модель данных Swift выглядит следующим образом:

public struct Applmusic: Codable {
    public let code: String
    public let quality: String
    public let line: String
}

API отвечает полным JSON, и я не могу попросить его предоставить мне только необходимые поля.

Как декодировать только определенную часть json? Кажется, что Decodable требует, чтобы я сначала десериализовал весь json, поэтому я должен знать для него полную модель данных.

Очевидно, одним из решений было бы создание отдельной модели Response только для того, чтобы содержать параметр applmusic, но это похоже на хак:

public struct Response: Codable {
    public struct Applmusic: Codable {
        public let code: String
        public let quality: String
        public let line: String
    }
    // The only parameter is `applmusic`, ignoring the other parts - works fine
    public let applmusic: Applmusic
}

Не могли бы вы предложить лучший способ работы с такими структурами JSON?

Еще немного информации

Я использую следующую технику в универсальном расширении, которое автоматически декодирует для меня ответы API. Поэтому я бы предпочел обобщить способ обработки таких случаев без необходимости создавать структуру Root. Что, если ключ, который мне нужен, находится на трех уровнях в структуре JSON?

Вот расширение, которое выполняет декодирование для меня:

extension Endpoint where Response: Swift.Decodable {
  convenience init(method: Method = .get,
                   path: Path,
                   codingKey: String? = nil,
                   parameters: Parameters? = nil) {
    self.init(method: method, path: path, parameters: parameters, codingKey: codingKey) {
      if let key = codingKey {
        guard let value = try decoder.decode([String:Response].self, from: $0)[key] else {
          throw RestClientError.valueNotFound(codingKey: key)
        }
        return value
      }

      return try decoder.decode(Response.self, from: $0)
    }
  }
}

API определяется следующим образом:

extension API {
  static func getMusic() -> Endpoint<[Applmusic]> {
    return Endpoint(method: .get,
                    path: "/api/music",
                    codingKey: "applmusic")
  }
}

person Richard Topchii    schedule 17.05.2018    source источник
comment
одним из решений является правильный способ извлечения только одного ключа.   -  person vadian    schedule 17.05.2018
comment
Ваш Response все еще правильный способ сделать это. Попробуйте получить applmusic в отдельном объекте и попробуйте декодировать, возможно, это сработает.   -  person Prashant Tukadiya    schedule 17.05.2018
comment
Вы можете сделать это через nestedContainer во время init(decoder) без создания обертки Response   -  person Tj3n    schedule 17.05.2018
comment
@vadian, не могли бы вы рассказать, как правильно извлечь только один ключ? Спасибо.   -  person Richard Topchii    schedule 17.05.2018
comment
@ Tj3n, не могли бы вы опубликовать здесь краткий пример своей идеи?   -  person Richard Topchii    schedule 17.05.2018
comment
Ваш пример hack отлично подходит. Это меньше усилий, чем сглаживание структуры с помощью nestedContainer.   -  person vadian    schedule 17.05.2018
comment
@vadian Согласен, это работает, но я использую одну и ту же структуру JSON во многих местах приложения. Было бы здорово обобщить поиск значений. Пожалуйста, взгляните на раздел «Еще немного информации», который я добавил.   -  person Richard Topchii    schedule 17.05.2018


Ответы (3)


Обновлено: я сделал расширение JSONDecoder из этого ответа, вы можете проверить его здесь: https://github.com/aunnnn/NestedDecodable, он позволяет декодировать вложенную модель любой глубины с ключевым путем.

Вы можете использовать его следующим образом:

let post = try decoder.decode(Post.self, from: data, keyPath: "nested.post")

You can make a Decodable wrapper (e.g., ModelResponse here), and put all the logic to extract nested model with a key inside that:

struct DecodingHelper {

    /// Dynamic key
    private struct Key: CodingKey {
        let stringValue: String
        init?(stringValue: String) {
            self.stringValue = stringValue
            self.intValue = nil
        }

        let intValue: Int?
        init?(intValue: Int) {
            return nil
        }
    }

    /// Dummy model that handles model extracting logic from a key
    private struct ModelResponse<NestedModel: Decodable>: Decodable {
        let nested: NestedModel

        public init(from decoder: Decoder) throws {
            let key = Key(stringValue: decoder.userInfo[CodingUserInfoKey(rawValue: "my_model_key")!]! as! String)!
            let values = try decoder.container(keyedBy: Key.self)
            nested = try values.decode(NestedModel.self, forKey: key)
        }
    }

    static func decode<T: Decodable>(modelType: T.Type, fromKey key: String) throws -> T {
        // mock data, replace with network response
        let path = Bundle.main.path(forResource: "test", ofType: "json")!
        let data = try Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe)

        let decoder = JSONDecoder()

        // ***Pass in our key through `userInfo`
        decoder.userInfo[CodingUserInfoKey(rawValue: "my_model_key")!] = key
        let model = try decoder.decode(ModelResponse<T>.self, from: data).nested
        return model
    }
}

You can pass your desired key through userInfo of JSONDecoder ("my_model_key"). It is then converted to our dynamic Key inside ModelResponse to actually extract the model.

Затем вы можете использовать его следующим образом:

let appl = try DecodingHelper.decode(modelType: Applmusic.self, fromKey: "applmusic")
let amazon = try DecodingHelper.decode(modelType: Amazon.self, fromKey: "amazon")
let spotify = try DecodingHelper.decode(modelType: Spotify.self, fromKey: "spotify")
print(appl, amazon, spotify)

Full code: https://gist.github.com/aunnnn/2d6bb20b9dfab41189a2411247d04904


Бонус: глубоко вложенный ключ

Поиграв еще, я обнаружил, что вы можете легко декодировать ключ произвольной глубины с помощью этого модифицированного ModelResponse:

private struct ModelResponse<NestedModel: Decodable>: Decodable {
    let nested: NestedModel

    public init(from decoder: Decoder) throws {
        // Split nested paths with '.'
        var keyPaths = (decoder.userInfo[CodingUserInfoKey(rawValue: "my_model_key")!]! as! String).split(separator: ".")

        // Get last key to extract in the end
        let lastKey = String(keyPaths.popLast()!)

        // Loop getting container until reach final one
        var targetContainer = try decoder.container(keyedBy: Key.self)
        for k in keyPaths {
            let key = Key(stringValue: String(k))!
            targetContainer = try targetContainer.nestedContainer(keyedBy: Key.self, forKey: key)
        }
        nested = try targetContainer.decode(NestedModel.self, forKey: Key(stringValue: lastKey)!)
    }

Then you can use it like this:

let deeplyNestedModel = try DecodingHelper.decode(modelType: Amazon.self, fromKey: "nest1.nest2.nest3")

From this json:

{
    "apple": { ... },
    "amazon": {
        "amzncode": "SPOT",
        "music_quality": "good",
        "stanley": "absent in apple"
    },
    "nest1": {
        "nest2": {
            "amzncode": "Nest works",
            "music_quality": "Great",
            "stanley": "Oh yes",

            "nest3": {
                "amzncode": "Nest works, again!!!",
                "music_quality": "Great",
                "stanley": "Oh yes"
            }
        }
    }
}

Полный код: https://gist.github.com/aunnnn/9a6b4608ae49fe1594dbcabd9e607834

person aunnnn    schedule 17.05.2018
comment
удивительно, просто нужно немного изменить структуру init, чтобы иметь возможность передавать пользовательские decoder и принимать данные, как исходный декодер, тогда было бы идеально - person Tj3n; 18.05.2018
comment
Спасибо. Кажется, это немного более общий подход, я обязательно поиграю с ним... - person Richard Topchii; 19.05.2018

Вам действительно не нужна вложенная структура Applmusic внутри Response. Это сделает работу:

import Foundation

let json = """
{
    "applmusic":{
        "code":"AAPL",
        "quality":"good",
        "line":"She told me don't worry"
    },
    "I don't want this":"potatoe",
}
"""

public struct Applmusic: Codable {
    public let code: String
    public let quality: String
    public let line: String
}

public struct Response: Codable {
    public let applmusic: Applmusic
}

if let data = json.data(using: .utf8) {
    let value = try! JSONDecoder().decode(Response.self, from: data).applmusic
    print(value) // Applmusic(code: "AAPL", quality: "good", line: "She told me don\'t worry")
}

Изменить: ответ на ваш последний комментарий

Если ответ JSON изменится таким образом, что тег applmusic будет вложенным, вам нужно будет только правильно изменить свой тип Response. Пример:

Новый JSON (обратите внимание, что applmusic теперь вложен в новый тег responseData):

{
    "responseData":{
        "applmusic":{
            "code":"AAPL",
            "quality":"good",
            "line":"She told me don't worry"
        },
        "I don't want this":"potatoe",
    }   
}

Единственное необходимое изменение будет в Response:

public struct Response: Decodable {

    public let applmusic: Applmusic

    enum CodingKeys: String, CodingKey {
        case responseData
    }

    enum ApplmusicKey: String, CodingKey {
        case applmusic
    }

    public init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)

        let applmusicKey = try values.nestedContainer(keyedBy: ApplmusicKey.self, forKey: .responseData)
        applmusic = try applmusicKey.decode(Applmusic.self, forKey: .applmusic)
    }
}

Предыдущие изменения не нарушили бы существующий код, мы только тонко настроили частную реализацию того, как Response анализирует данные JSON для правильного извлечения объекта Applmusic. Все вызовы, такие как JSONDecoder().decode(Response.self, from: data).applmusic, останутся прежними.

Кончик

Наконец, если вы хотите полностью скрыть логику оболочки Response, у вас может быть один общедоступный/открытый метод, который будет выполнять всю работу; такие как:

// (fine-tune this method to your needs)
func decodeAppleMusic(data: Data) throws -> Applmusic {
    return try JSONDecoder().decode(Response.self, from: data).applmusic
}

Сокрытие того факта, что Response вообще существует (сделайте его закрытым/недоступным), позволит вам использовать весь код вашего приложения только для вызова decodeAppleMusic(data:). Например:

if let data = json.data(using: .utf8) {
    let value = try! decodeAppleMusic(data: data)
    print(value) // Applmusic(code: "AAPL", quality: "good", line: "She told me don\'t worry")
}

Рекомендуем прочитать:

Кодирование и декодирование пользовательских типов

https://developer.apple.com/documentation/foundation/archives_and_serialization/encoding_and_decoding_custom_types

person R.B.    schedule 17.05.2018

Интересный вопрос. Я знаю, что это было 2 недели назад, но мне было интересно, как это можно решить с помощью библиотеки KeyedCodable. созданный. Вот мое предложение с общим:

struct Response<Type>: Codable, Keyedable where Type: Codable {

    var responseObject: Type!

    mutating func map(map: KeyMap) throws {
        try responseObject <-> map[map.userInfo.keyPath]
    }

    init(from decoder: Decoder) throws {
        try KeyedDecoder(with: decoder).decode(to: &self)
    }
}

вспомогательное расширение:

private let infoKey = CodingUserInfoKey(rawValue: "keyPath")!
extension Dictionary where Key == CodingUserInfoKey, Value == Any {

   var keyPath: String {
        set { self[infoKey] = newValue }

        get {
            guard let key = self[infoKey] as? String else { return "" }
            return key
        }
    }

использовать:

let decoder = JSONDecoder()
decoder.userInfo.keyPath = "applmusic"
let response = try? decoder.decode(Response<Applmusic>.self, from: jsonData)

Обратите внимание, что keyPath может быть вложен более глубоко, я имею в виду, что это может быть, например. "responseData.services.applemusic".

Кроме того, Response является Codable, поэтому вы можете кодировать его без дополнительной работы.

person decybel    schedule 30.05.2018