Codable: Советы и Примеры +5


Хотел бы поделиться с вами некоторыми советами и трюками, которые я использовал на этом примере.

Скачайте Swift Playground со всем кодом из этой статьи:

image

Codable представлен в Swift 4 с целью заменить старый NSCoding API. В отличие от NSCoding у Codable есть поддержка JSON первого класса, что делает его перспективным вариантом для использования API JSON.

Codable отлично подходит в качестве NSCoding. Если имеется необходимость кодировать или декодировать некоторые локальные данные, которые вы должны контролировать в полной мере, вы можете использовать преимущество автоматического кодирования и декодирования.

В реальном мире все становится достаточно сложным и быстрым. Попытка построить отказоустойчивую систему, которая будет управлять JSON и моделировать все требования к продукту, является проблемой.

Одним из основных недостатков Codable является то, что как только вам понадобится пользовательская логика декодирования — даже для одного ключа — вы должны предоставить все: вручную определить все ключи кодирования и полностью вручную реализовать init(from decoder: Decoder) throws. Это не идеально. Но это по крайней мере так же хорошо (или плохо), как использование сторонних библиотек JSON в Swift. Наличие таковой в строенной библиотеке уже является победой.

Поэтому, если вы хотите начать использовать Codable в своем приложении (и вы уже знакомы со всеми его основными особенностями), вот несколько советов и примеров, которые могут оказаться полезными:

  • Безопасное декодирование массивов
  • Тип идентификатора и контейнер с одним значением
  • Безопасное декодирование Перечислений
  • Немногословное ручное декодирование
  • Избавление от определенных типов параметров
  • Использование отдельной схемы декодирования
  • Параметры патча кодирования

Безопасное декодирование массивов


Предположим, вы хотите загрузить и отобразить коллекцию сообщений (Posts) в своем приложении. Каждый Post имеет id (обязательно), title (обязательно) и subtitle (необязательно).

final class Post: Decodable {
    let id: Id<Post> // More about this type later.
    let title: String
    let subtitle: String?
}

Класс Post хорошо моделирует требования. Он уже принимает протокол Decodable, поэтому мы готовы декодировать некоторые данные:

[
    {
        "id": "pos_1",
        "title": "Codable: Tips and Tricks"
    },
    {
        "id": "pos_2"
    }
]

do {
    let posts = try JSONDecoder().decode([Post].self, from: json.data(using: .utf8)!)
} catch {
    print(error)
    //prints "No value associated with key title (\"title\")."
}

Как и ожидалось, мы получили ошибку .keyNotFound, потому что второй объект пост не имеет title.

Когда данные не соответствуют ожидаемому формату (например, это может быть результатом недопонимания, регрессии или неожиданного ввода пользователем), система должна автоматически сообщать об ошибке, чтобы дать разработчикам возможность исправить ее. Swift предоставляет подробный отчет об ошибке в виде DecodingError в любое время, когда декодирование выполняется с ошибкой, что чрезвычайно полезно.

В большинстве случаев никто не хочет, чтобы один поврежденный пост помешал вам отобразить целую страницу других совершенно корректных постов. Чтобы этого не произошло, я использую специальный тип Safe<T>, который позволяет мне безопасно декодировать объект. Если он обнаруживает ошибку при декодировании, он перейдет в безопасный режим обнаружения ошибки и отправит отчет:

public struct Safe<Base: Decodable>: Decodable {
    public let value: Base?

    public init(from decoder: Decoder) throws {
        do {
            let container = try decoder.singleValueContainer()
            self.value = try container.decode(Base.self)
        } catch {
            assertionFailure("ERROR: \(error)")
            // TODO: automatically send a report about a corrupted data
            self.value = nil
        }
    }
}

Теперь, когда я декодирую массив, я могу указать, что я не хочу останавливать декодирование в случае одного поврежденного элемента:

do {
    let posts = try JSONDecoder().decode([Safe<Post>].self, from: json.data(using: .utf8)!)
    print(posts[0].value!.title)    // prints "Codable: Tips and Tricks"
    print(posts[1].value)           // prints "nil"
} catch {
    print(error)
}

Имейте в виду, что декодирование decode([Safe<Post>].self, from:... вызывает ошибку, если данные не содержат массив. В общем случае такие ошибки следует улавливать на более высоком уровне. Общий контракт API заключается в том, чтобы всегда возвращать пустой массив, если нет элементов для возврата.

Тип идентификатора и контейнер с одним значением


В предыдущем примере я использовал специальный идентификатор Id<Post>. Тип Id получает параметры с помощью общего параметра Entity, который фактически не используется самим Id, а используется компилятором при сравнении разных типов Id. Таким образом, компилятор гарантирует, что я не могу случайно передать Id<Media>, где ожидается Id<Image>.

Я так же использовал фантомные типы для обеспечения безопасности при использовании типов, в статье API клиент в Swift.

Сам тип Id очень прост, это всего лишь оболочка поверх исходной строки String:

public struct Id<Entity>: Hashable {
    public let raw: String
    public init(_ raw: String) {
        self.raw = raw
    }
        
    public var hashValue: Int {
        return raw.hashValue
    }
    
    public static func ==(lhs: Id, rhs: Id) -> Bool {
        return lhs.raw == rhs.raw
    }
}

Добавление к нему Codable является определенно сложным заданием. Для этого требуется специальный тип SingleValueEncodingContainer:

Контейнер, который может поддерживать хранение и прямое кодирование одного не блокированного значения.

extension Id: Codable {
    public init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let raw = try container.decode(String.self)
        if raw.isEmpty {
            throw DecodingError.dataCorruptedError(
                in: container,
                debugDescription: "Cannot initialize Id from an empty string"
            )
        }
        self.init(raw)
    }

    public func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(raw)
    }
}

Как видно из кода выше, Id также имеет специальное правило, которое предотвращает его инициализацию из пустой String.

Безопасное декодирование Перечислений


Swift имеет отличную поддержку для декодирования (и кодирования) перечислений. Во многих случаях все, что вам нужно сделать, это просто объявить соответствие Decodable, которое автоматически синтезируется компилятором (необработанный тип перечисления должен быть либо String, либо Int ).

Предположим, вы создаете систему, которая отображает все ваши устройства на карте. Устройство имеет location (обязательно) и system (обязательно), которую оно запускает.

enum System: String, Decodable {
    case ios, macos, tvos, watchos
}

struct Location: Decodable {
    let latitude: Double
    let longitude: Double
}

final class Device: Decodable {
    let location: Location
    let system: System
}

Теперь имеется вопрос. Что делать, если в будущем будет добавлено больше систем? Решение продукта может состоять в том, чтобы по-прежнему отображать эти устройства, но каким-то образом указывать на то, что система «неизвестна». Как вы должны это моделировать в приложении?

По умолчанию Swift будет вызывать ошибку .dataCorrupted, если встречается неизвестное значение enum:

{
    "location": {
        "latitude": 37.3317,
        "longitude": 122.0302
    },
    "system": "caros"
}

do {
    let device = try JSONDecoder().decode(Device.self, from: json.data(using: .utf8)!)
} catch {
    print(error)
    // Prints "Cannot initialize System from invalid String value caros"
}

Как система может быть смоделирована и декодирована безопасным способом? Один из способов — это сделать свойство system опционалом, что будет означать «Unknown». И самый простой способ безопасно декодировать систему — это реализовать пользовательский init (from decoder: Decoder) throws инициализатор:

final class Device: Decodable {
    let location: Location
    let system: System?

    init(from decoder: Decoder) throws {
        let map = try decoder.container(keyedBy: CodingKeys.self)
        self.location = try map.decode(Location.self, forKey: .location)
        self.system = try? map.decode(System.self, forKey: .system)
    }

    private enum CodingKeys: CodingKey {
        case location
        case system
    }
} 

Имейте в виду, что эта версия просто игнорирует все возможные проблемы со значением system. Это означает, что даже «поврежденные» данные (например, отсутствующая ключевая system, число 123, null, пустой объект {} — в зависимости от того, какой контракт API), декодируются как значение nil («Unknown»). Более точный способ сказать «декодировать неизвестные строки как nil»:

self.system = System(rawValue: try map.decode(String.self, forKey: .system))

Немногословное ручное декодирование


В предыдущем примере нам пришлось реализовать пользовательский инициализатор init (from decoder: Decoder) throws, в котором оказалось довольно много кода. К счастью, есть несколько способов сделать это более лаконично.

Избавление от определенных типов параметров


Один из вариантов — избавиться от явных параметров типа:

extension KeyedDecodingContainer {
    public func decode<T: Decodable>(_ key: Key, as type: T.Type = T.self) throws -> T {
        return try self.decode(T.self, forKey: key)
    }

    public func decodeIfPresent<T: Decodable>(_ key: KeyedDecodingContainer.Key) throws -> T? {
        return try decodeIfPresent(T.self, forKey: key)
    }
}

Вернемся к нашему примеру Post и расширим его с помощью свойства webURL (опционал). Если мы попытаемся декодировать данные, опубликованные ниже, мы получим ошибку .dataCorrupted вместе с основной ошибкой:

struct PatchParameters: Swift.Encodable {
    let name: Parameter<String>?
}

func encoded(_ params: PatchParameters) -> String {
    let data = try! JSONEncoder().encode(params)
    return String(data: data, encoding: .utf8)!
}

encoded(PatchParameters(name: nil))
// prints "{}"

encoded(PatchParameters(name: .null))
//print "{"name":null}"

encoded(PatchParameters(name: .value("Alex")))
//print "{"name":"Alex"}"




К сожалению, не доступен сервер mySQL