Pull to refresh

Swift 2 в быту. Еще один парсер JSON

Reading time13 min
Views17K
Пару месяцев назад Apple выпустила мажорный апдейт своего нового детища — Swift 2. Выпустило оно его, что называется apple-way, причем не тем way, который «все очень хорошо и вам не нужно ни о чем думать, просто пользуйтесь», а другим. «Мы знаем, что так лучше, а раньше было хуже, поэтому бросайте все ваше раньше и начинайте пользоваться» — то есть язык с заметными проблемами с обратной совместимостью — начиная от того, что штатный инструмент миграции работает довольно таки нестабильно, и заканчивая, собственно, тем, что вы, определенно, не сможете разрабатывать на новой версии языка, не обновя весь инструментарий разработчика до пока еще не очень стабильного последнего — и, что самое страшное, в обратную стророну тоже. Но речь пойдет не об этом. Речь пойдет о том, что мне нравится Swift 2. К сожалению, так как язык все еще не признан сообществом как production-ready, то подавляющее большинство материалов о нем можно отнести к двум категориям — документация Apple и «я вот тут поигрался вечерком и у меня получилось прикольно». Исключения, конечно, есть, но их не хватает, поэтому я и попробую немного осветить этот язык именно с позиции работы с ним и на нем.
В этой статье, мне бы хотелось поговорить о стандартной ORM задаче десериализации JSONа — то есть о том, как из объекта NSDictionary словаря [String: AnyObject] получить некоторую десериализованную структуру. Что изменилось с появлением Swift 2? Как оно было раньше? Кроме того, мы будем рассматривать эту задачу с точки зрения около-функционального подхода, что налагает определенные ограничения — такие, как иммутабельность единожды созданных данных, например. Поэтому рассматриваемое решение может быть несколько сложнее других — но ну и ладно.
image



Итак, какие требования предъявляются к десериализатору?
  • Он должен быть. Не очень здорово и удобно контролировать изменения JSON-схемы, когда работаешь с простым AnyObject-словарем. Хочется иметь подход, который на выходе будет давать строго описанную и именованную структуру
  • Он не должен использовать рефлексию. Почему? For greater justice, разумеется. Пользуясь рефлексией, мы перечекиваем такие преимущества языка — как, например, статическая типизация. Да и вообще, если говорить про чистый свифт — в рамках которого мы и хотим остаться — она пока недостаточно хорошо покрывает язык и, например, плохо справляется с enum
  • Код при этом должен быть компактным и поддерживать такие кейсы, как опциональные поля, различия в именовании соответствующих полей структуры и десериализуемого словаря
  • Десериализатор должен предоставлять достаточно подробную информацию о несоответствии десериализируемого объекта ожидаемой схеме, для того, чтобы можно было быстро локализовать это самое несоответствие.

На Swift 1 есть следующие подходы к работы с JSON объектами:

Итак, начнем с того, с чем мы работаем:
Для начала введем протокол, соответствие которому у нас будет обозначать возможность получить этот объект из некоторого JSON-объекта

protocol Deserializeable {
    func decode() -> Self?
}

Теперь хотелось бы сделать еще одно небольшое отступление:
Мы хотим на работать с type-safe кодом и вообще не очень экономим на типах
А в реальном коде было бы неплохо различать Int, соответствующий возрасту человека
и Int, соответствующий температуре за бортом самолета — поэтому для того, чтобы избежать («одержимости примитивами»)
и иметь возможность на уровне типа вводить доменные ограничения, такие как, например — возраст не может быть отрицательным.
Поэтому в коде появляются в том или ином виде обертки для скалярных типов

struct NamedInt {
    let value: Int
}
struct NamedString {
    let value: String
}

struct LeafObject {
    let BunchOfInts: [NamedInt]
    let optionalString: NamedString?
    let someDouble: Double
}

struct RootObject {
    let leaves: [LeafObject]
}

И для этого мы будем десериализовать следующий словарь:

[«childs» :
    [
        [
            «ints»: [1, 2 ,3],
            «string»: «superString»,
            «double» : 1.25
        ],
        [
            «ints»: [],
            «double» : 2.5
        ]
    ]
]

Полный код, обсуждаемый дальше, можно найти по ссылке Github: JSON_1.playground. Для того, чтобы он у вас скомпилировался и работал — его стоит открывать все-таки в XCode 7, но написан он целиком и полностью в идеологии Swift 1.2 — с ним мы и будем работать.

Итак, разберем основные аспекты этого кода.

Каррирование


Зачем: В приведенном примере код есть одно, упомянутое раньше, ограничение — все структуры, с которыми мы работаем в рамках этой статьи — иммутабельны, что на уровне языка решает за нас проблемы о том, что кто-то из пользователей структуры может, потенциально, работать не с теми данными, которые пришли из модели из-за того, что они были кем-то заменены. Кому-то такой подход может показаться излишним. И, в общем, справедливо — структуры, в отличии от классов передаются «по значению» — то есть при передачи этой структуры от модели — она будет скопирована — что приводит к дополнительным расходам памяти и процессорного времени. Изменение одного поля в структуре данных также, очевидно, быстрее, чем создание нового экземпляра, но тем не менее долговременно такой компромисс считается оправданынм — потому что он дает нам такие преимущества как
  • абсолютная гарантия отсутствия race condition при работе с этой сущностью модели — невозможно, чтобы кто-то изменил то, с чем мы в данный момент работаем.
  • дешевый параллелизм, который вызван, опять же, априрорным отсутствием race condition
  • легкость тестирования (хотя, она в большей степени обусловлена тем, что иммутабельные структуры данных используются в функциональном стиле)

Так или иначе — это осознанный выбор, поэтому мы строим нашу инфраструктуру десериализации в этом предположении.
Итак, у нас есть иммутабельный объект. Какие операции нам предлагает Swift для работы с ним? Помимо, собственно, геттеров полей, Swift предлагает конструктор по умолчанию, в который мы передаем все поля создаваемой структуры.
Каждое поле имеет четкий и строгий тип — и это, с одной стороны очень хорошо — потому что мы физически не можем даже попытаться поместить не подходящий объект в неподходящее поля, но с другой стороны — если на каком-то из этапов десериализация не получилась(например, в исходном словаре нет поля с нужным ключом) — нам нужно прервать процесс.

И здесь на помощь приходит каррирование (curry).

func curry<A, B, C, R>(f: (A, B, C) -> R) -> A -> B -> C -> R {
    return { a in { b in {c in f(a, b, c) } } }
}

Оно превращает конструктор LeafObject(BunchOfInts: [NamedInt], optionalString: NamedString?, someDouble: Double) в цепочку функций [NamedInt] -> (NamedString? -> (Double -> LeafObject)) к которой мы можем последовательно применять десериализованные поля.

Optional partial application


Собственно вторая часть десериализации, это прерывание процесса в том случае, если у нас не получился конкретный этап. Здесь мы воспользуемся оператором <*>, определяемого следующим образом:

func <*><A, B>(f: (A -> B)?, x: A?) -> B? {
    if let f1 = f, x1 = x {
        return f1(x1)
    }
    return nil
}

Что он делает? Он всего-лишь говорит о том, что запись f <*> x в некотором смысле эквивалентна записи f(x). Почему в некотором смысле? Потому что в отличии от f(x) — если x (или f) по той или иной причине равно nil — то результат всего выражения тоже будет nil — при том, функция f вызвана не будет.
То есть если у нас есть некоторая функция f(x,y,z) то ее можно преобразовать каррирование в x -> y -> z -> f и вычислить как
curry(f) <*> x <*> y <*> z. Что это нам дает? Например то, что если на каком-то этапе — например, при вычислении y — у нас случилась ошибка — и y равно nil — то оставшиеся части выражения (z) не будут вычисляться и, более того, все это вместе не вызовет, собственно, саму функцию f.

Аналогичным образом можно определить оператор <?> для которого nil будет валидным значением

Apply


Это можно отнести уже к синтаксическому сахару, нужному для большей декларативности описанного, а так же, опять же, для того, чтобы упростить работу с тем, что называется монадным биндингом — единого правила для работы группой функций.
Оператор apply >>>= делает почти равносильными конструкции f(x) и x >>>= f. При чем тут биндинг и монады? А для этого можно прочитать про монаду Maybe, или про чуть более широкую ее трактовку — Result, про которую можно почитать, например, тут
Фактически этот оператор позволяет каждой функции работать с простыми входными параметрами, при этом выдавая информации больше, чем необходимо для следующей функции в цепочке(ошибку). И в случае наступления этой ошибки — она поднимается на верх без необходимости вычислять оставшиеся части выражения

Соответственно, посмотрев на пример заполнения одной конкретной структуры

return curriedConstructor
    <*> dict >>>= objectForKey(«ints») >>>= asArray >>>= decodeArray
    <?> dict >>>= objectForKey(«string») >>>= NamedString.decode
    <*> dict >>>= objectForKey(«double») >>>= Double.decode


Мы видим последовательное применения вычисляемых аргументов и теперь знаем, что если какой-то аргумент не смог быть вычисленным — то вся цепочка прервется.

В этом коде есть еще несколько неразобранных моментов, но думаю, они вполне понятны из кода, который на гитхабе, и потому не буду на них сильно останавливаться.
Итак, таким образом мы имеем пример десериализации в терминах Swift 1.2
В чем его достоинства?
  • Он декларативен — каждое преобразование, через которое проходит значение — описано в виде читабельного и простого пайплайна
  • После некоторой доработки он способен обнаруживать ошибки на любом этапе и делать так, чтобы эта ошибка поднялась до результата всей функции
  • Он в должной мере общий — таким подходом десериализуются необходимые нам объекты модели, независимо от их структуры и сложности.

Итак, почему же его собственно хочется переделать?
  • Данный формат очень плохо воспринимается компилятором: по сути это выражение есть лишь длинная цепочка вызванных одной в другой функций, многие из которых generic — соответственно при достижении определенного размера — время компиляции возрастает в разы, а иногда и просто крэшит компилятор.
  • Формат ошибок — как и в предыдущем пункте — так как это по сути лишь последовательно примененные функции — компилятор не может нормально указать тебе на место, в котором у тебя ошибка в этом коде. Он просто говорит, что ошибка во всем этом выражении — что значительно затрудняет ее поиск
  • Такой подход вводит аж 3 новых ненативных оператора, что значительно повышает общую сложность проекта

Попробуем последовательно избавиться от этих недостатков

Итак, Swift 2


Итоговый файл после переписывания этого кода на Swift 2 можно найти тут Github: JSON_2.playground. Как вы понимаете, его уже совершенно необходимо открывать, как минимум, на Xcode 7 beta 6 для корректной работы. (именно в beta 6 была дополнена сигнатура функции map для тогО, чтобы она могла работать с throws параметрами. Поэтому для того, чтобы запустить этот код на beta 5 — нужно будет написать эту функцию самому)

Первое и одно из основных изменений, которые принес нам swift 2 — это exception-like обработка ошибок. (Для тех, кто еще не нырял в эту тему, прочитать можно, например, тут). Какими полезными свойствами этот подход обладает?
Если вкратце — то это крайне простая в использовании монада. А если чуть подробнее, то вот несколько следствий из тех операций, которые нам предлагает язык:

Chaining


если у нас есть

func foo(a: Int) throws -> Int
func bar(b: Int) throws -> Int

то вместо кода

do {
    let q = try foo(1)
    let b = try bar(q)
} catch {}

мы вполне можем написать следующий

do {
    let q = try bar(foo(1))
} catch {}

Более того, мы можем использовать ровно 1 try в рамках одной операции вне зависимости от ее сложности

Rethrow


Более того, нам даже не всегда нужен сахар do…catch — если функция не предполагает обработку ошибки, а только лишь передает дальше наверх ошибки внутренних функций и еще кидает какие-то свои

func foo(a: Int) throws -> Int {
    return try bar(a + 1)
}

Function flow with errors


throw имеет примерно такой же смысл, как и return, то есть мы вполне можем писать функции такого плана:

func foo(a: Int) throws -> Int {
    if a > 0 {
        return a
    } else {
        throw someError
    }
}

То есть функции в которых есть пути выполнения не проходящие через return

Да, это монада, которую очень просто использовать.


Работа с ней происходит ровно как на картинке
image
То есть, сцепливая вызовы функций в цепочку, как следствие из 1 и 2 — мы можем не заботиться об обработке ошибок на каждом промежуточном этапе — мы можем иметь дело только с результатом последнего. Более того, переход с зеленого трека success на красный трек error осуществляется с минимальными затратами труда — достаточно, чтобы где-то(возможно, глубоко глубоко внутри) сработал throw

Итак. Пользуясь этими преимуществами, попробуем сформулировать, как бы хотелось производить десериализацию в дальнейшем:

У нас есть конструктор LeafObject(BunchOfInts: [NamedInt], optionalString: NamedString?, someDouble: Double)
Так почему бы его не заполнять throws сущностями?

LeafObject(
    BunchOfInts: try getInts,
    optionalString: try getString,
    someDouble: try getDouble
)

Кроме того, как писалось выше — такой try можно вынести наружу

try LeafObject(
    BunchOfInts: getInts,
    optionalString: getString,
    someDouble: getDouble
)

В таком случае в каком бы из этапов вычисления не произошла ошибка — процесс построения объекта прекратится.
Ну что ж, попробуем к этому придти в коде.
Для этого нам понадобятся два вспомогательных класса — DictionaryDecoder и ArrayDecoder — из названий, в общем-то, понятно, чем они занимаются — этакие Helper Classes для декодинга. Итак, приведем начнем с чего-нибудь совсем простого. Никаких try/catch — просто достанем значение из словаря

struct DictionaryDecoder {
    private let dict: [String: AnyObject]
    init?(_ value: AnyObject) {
        if let dict = value as? [String: AnyObject] {
            self.dict = dict
        } else {
            return nil
        }
    }
    
    func decode<T: Deserializeable>(forKey key: String) -> T? {
        return T.decode(self.resultForKey(key))
    }

    private func resultForKey(key: String) -> AnyObject? {
        return self.dict[key]
    }
}

А теперь посмотрим, что в этом нас не устраивает:
  1. decode вернул объект типа T, или nil, если не получилось задекодить. А что если мы хотим на выходе получить T?
  2. объект не получилось задекодить — почему?
  3. на вход DictionaryDecoder мы подали невалидный словарь. Например, мы ожидаем, что какой-то узел декодируемого словаря окажется словарем — а он оказался массивом. Или скалярным значением.

Это выглядит как явные кандидаты для throw — но для этого нам немножко надо изменить наш протокол Deserializeable:

protocol Deserializeable {
    func decode() throws -> Self
}

И внести соответствующие изменения в DictionaryDecoder

struct DictionaryDecoder {
    private let dict: [String: AnyObject]
    init(_ value: AnyObject) throws {
        guard let dict = value as? [String: AnyObject] else { throw NotADictError(/*какие-то детали*/) }
        self.dict = dict
    }

    func decode<T: Deserializeable>(forKey key: String) throws -> T {
        return try T.decode(self.resultForKey(key))
    }

    func decode<T: Deserializeable>(forKey key: String) throws -> T? {
        return try self.optionalForKey(key).map(T.decodeJSON)
    }

    private func resultForKey(key: String) throws -> AnyObject {
        guard let value = self.dict[key] else { throw KeyMissingError(/*какие-то детали*/) }
        return value
    }

    private func optionalForKey(key: String) -> AnyObject? {
        return self.dict[key]
    }
}

А кроме того и в десериализация атомарных сущностей в том числе

protocol ScalarDeserializeable : Deserializeable { }
extension ScalarDeserializeable {
    static func decode(input: AnyObject) throws -> Self {
        guard let value = input as? Self else { throw UnexpectedTypeError(/<em>какие-то детали</em>/) }
        return value
    }
}

extension Int : ScalarDeserializeable {}
extension String : ScalarDeserializeable {}
extension Double : ScalarDeserializeable {}

Написанного выше нам достаточно уже для того, чтобы десериализовать простенький иерархический словарь, в которым каждое конкретное значение — скалярно.
Теперь давайте обратим внимание на некоторые важные особенности того, что мы сейчас написали:
  1. В последней части — ScalarDeserializeable мы краешком зацепили еще одно важное нововведение Swift 2 — миксины, они же Protocol extensions, которые позволяют предоставлять дефолтную имплементацию методов протокола. То есть, написав такой код — мы автоматом добавили метод decode и соответствие протоколу Deserializeable скалярным классом. Подробнее про это можно прочитать где угодно, где говорят про Swift 2 :-). Но, например, тут
  2. Перед этим — в декодере — засчет throw мы смогли простым образом добавить различие между optional и не-optional типами — теперь если мы хотим парсить optional параметр — для нас различается то, что по этому ключу ничего не найдено и то, что то, что лежит по этому ключе не смогло быть десериализовано. ( использование optionalForKey вместо resultForKey генерирует на одну ошибку меньше )
  3. Как и описывалось выше — мы использовали try chaining
    В конструкции return try T.decode(self.resultForKey(key)) ошибкой могут завершиться оба метода — decode и resultForKey — но если ошибкой завершится внутренний — декодинг даже не начнется.

Итак. Займемся последним этапом и разберемся с ошибками: UnexpectedTypeError, KeyMissingError, NotADictError в предыдущем коде пока еще очень малоинформативны.
Для этого мы заведем два типа для описания ошибки:

  1. enum SchemeMismatchError {
        case NotADict
        case KeyMissing
        case UnexpectedType(expectedTypeName: String)
    }
    

Который хранит в себе то, какая конкретно ошибка случилась
  1. struct DecodingError: ErrorType {
        let error: SchemeMismatchError
        let reason: String
        let path: [String]
    }
    

ErrorType — еще одно нововведение Swift 2 — протокол, наследовавшись от которого — получаешь возможность использовать объекты данного типа в throw (и только их)
В этой структуре мы видим, собственно, ошибку, которая у нас случилась и массив String, в котором мы собираемся хранить путь до конкретной ошибки, который позволяет легче ее локализовать (ради чего все собственно и затевалось). Как мы видим, он тоже let — так как и тут мы не собираемся отступать от взятого курса на иммутабельность данных.

В общем-то довольно очевидно, на что в приведенном выше коде заменятся все throw — на конструкции вроде throw DecodingError(error: .NotADict, reason: «expected dictionary», path: []), или какие-то сокращенные формы
Но теперь нам нужно последовательно заполнить path. Для того, чтобы понять, как нам это получить — давайте выпишем какие шаги нас отделяют от начала десериализации словаря до десериализации конкретного значения — получится что-то вроде

DictionaryDecoder.init -> resultForKey -> decode -> … -> resultForKey -> decode

То есть, фактически, всего три различных шага, которые применяются рекурсивно за один вызов. Соответственно, на каком бы уровне не произошла ошибка — она будет подниматься по приведенной цепочке в обратном направлении — и нам всего лишь нужно добавлять одно значение в path на этапах этой цепочки. На всех? Нет, достаточно, только на уровне decode — потому что на каждый уровень иерархии словаря до нашего конкретного значения приходится один и ровно один decode (чтобы спустить ниже по уровню). Как нам это сделать? Для этого, мы собственно, напишем catch и наш decode станет выглядеть следующим образом:

extension DecodingError {
    func errorByAppendingPathComponent(component: String) -> DecodingError {
        return DecodingError(error: error, reason: reason, path: [component] + path)
    }
}

func decode<T: Deserializeable>(forKey key: String) throws -> T {
    do {
        return try T.decode(self.resultForKey(key))
    } catch let error as DecodingError {
        throw error.errorByAppendingPathComponent(key)
    }
}

Обратите внимание — что в catch мы обрабатываем не все ошибки, а только DecodingError. Почему?
  1. Потому что мы знаем, что наш код не шлет никаких других ошибок.
  2. Потому что даже, если код и шлет другие ошибки — они не потеряются на этом этапе — а просто пройдут дальше.
    На самом деле на данный момент немного огорчает то, что нет возможности строго типизировать выбрасываемые ошибки, но пока что живем с этим.

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

extension LeafObject: Deserializeable {
    static func decode(input: AnyObject) throws -> LeafObject {
        let dict = try JSONDictionary(input)
        return try LeafObject(
            BunchOfInts: dict.decode(forKey: «ints»),
            optionalString: dict.decode(forKey: «string»),
            someDouble: dict.decode(forKey: «double»),
        )
    }
}

Не знаю как вам, а мне нравится _. На этом, наверное, все.
Приведенный в плейграунде код немного отличается от приведенного здесь в сторону расширения, для того, чтобы с ним можно было поиграться с десериализацией более сложных сущностей. В качестве домашнего задания — попробуйте, например, десериализовать enum, состоящий из каких-нибудь сложных структур — например:

enum Graph {
    case Tree(RootObject)
    case Forest([RootObject])
    case SimpleNode(LeafObject)
}

Итак, в качестве заключения к чему мы пришли:
  1. try/catch — это удобно — его действительно сильно не хватало в Swift 1.2 — поэтому постарайтесь на нем больше не писать :-)
  2. Данный код, очевидно, не отвечает жесточайшим требованиям быстродействия — но вполне себе может использоваться в типовом клиент-серверном приложении. Не в последнюю очередь, засчет того, что в нем просто работать с ошибками во время разработки — когда сервер меняется часто и клиент отваливается.
  3. Мне нравится направление в котором движется Swift — потому что при переходе на новую версию из языка из кода ушли за, банально, ненадобностью неочевидные конструкты из первой версии парсинга

P.S. Если вы дочитали до этого места, то, друзья мои, iOS-разработчики — не бойтесь — переходите на Swift. Он действительно заслуживает внимания, как основной язык приложения (пусть даже и домашних наработок). Я все еще жду и не могу дождаться реальной необходимости иметь часть проекта на Objective-C (не third-party зависимостей, от огромного количества которых отказываться просто глупо, а именно значимой части своей внутренней кодовой базы, которую необходимо поддерживать day-by-day). Он лаконичен, компактен и изящен. Ну да и хватит пока на этом.
Tags:
Hubs:
+7
Comments4

Articles

Change theme settings