KTV. Грабли на пути к маршалингу +5


Я писал про KTV, но одно дело — придумать что-то непонятное, другое — попробовать это использовать. Помимо стилевой системы S2 я планирую использовать KTV для работы с сервером вместо JSON. Планов завоевать мир у меня нет, но разобраться, удобнее получилось или нет, хочется. Для того, чтобы общаться было легко, нужно уметь парсить объекты из ktv-файлов, и сериализовывать обратно в них же.

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

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

Если кто видит, как можно проще или лучше решить указанные задачи, пишите. С удовольствием узнаю ещё варианты, так как все, что перечислены ниже, в той или иной степени — костыли. Вдруг есть что-то более приятное.

Как бы получить структуру объекта?


Первая задача, которая возникает, если мы хотим преобразовать то, что пришло по сети, в родной объект (в моём случае языка Swift) — это разобраться со структурой объекта. Вспомнив опыт Java (где на каждый чих уже написано уже двести библиотек), я разобрал несколько способов.

Интроспекция объектов языка


Рефлекшн, рантайм или хоть что-то похожее. В Swift'е есть два направления, которые развиваются в эту сторону:

  • Класс Mirror. Это штука, которой пользуется отладчик или Playgrounds для отображения информации об объектах. Соответственно, и информация та, что им нужна: тип, имя поля, значение, дженерик. Параметров доступа (private/public) нет, аннотаций нет, и сам метод определён не для всех объектов.
  • функция reflect, которая возвращает ровно те же данные немножко в другом формате.

Скорее всего, оба эти метода сходятся где-то в один, поэтому и результат похожий. Этот способ самый «крутой», если можно им пользоваться. Увы, она сейчас работает только для чтения, записи никакой нет ни в каком виде. Ждём расширения «зеркал», переходим к следующему способу.

Парсинг исходного кода. SourceKit


Следующий способ — парсить самому исходники, в которых, очевидно, указано всё, что можно. Это было бы крайне непросто, если бы Apple не предоставил SourceKit. Что такое SourceKit? Это фреймворк (sourcekitd.framework), который умеет выполнять запросы вида «пропарси, пожалуйста, этот файл, и расскажи, что ты там видишь, в виде синтаксического дерева (AST, Abstract Syntax Tree)». Крайне полезно, что SourceKit ещё и умеет парсить документацию к элементам языка, а не только идентификаторы и типы.

Кроме самого фреймворка, на Гитхабе живёт SourceKitten, который предоставляет интерфейс на Swift к sourcekitd.framework. Использовать его очень просто:

let sourceKitFile = File(path:classFileName) // получаем файл
let structure = Structure(file:sourceKitFile!).dictionary // вытаскиваем AST

Правда, чтобы подключить и sourcekitd.framework и SourceKitten, пришлось постраться. Получилось как-то так:

  • скачать, подключить исходниками SourceKitten
  • не забыть зависимость, SWXMLHash.swift
  • подключить sourcekitd.framework и libclang.dylyb
  • написать Bridging-Header, в который включить необходимые заголовки:
    #import "Index.h"
    #import "Documentation.h"
    #import "CXCompilationDatabase.h"
    #import "sourcekitd.h"

После этого будут работать строки выше. После получения AST, работа состоит лишь в том, чтобы правильно его интерпретировать (для этого я написал класс и структуру с разными полями и запускал, проверял, что мне выдаст SourceKit. Удалось вытащить и типы объектов (правда, только если они прописаны явно, type inferrence не поддерживается), и модификаторы доступа, и понять, где константы, где нет. И дженерики забрать у обычных и ассоциативных массивов.

Кроме этого, SourceKit также выдаёт документацию к элементу кода. Тут, правда, тоже не без граблей:

  • почему-то у меня Structure(file:String) не показывает документацию (а внутри структуры с документацией недостаёт типов), чтобы её увидеть, нужно выполнить другую команду:
    let structureWithDocs = SwiftDocs(file:sourceKitFile!, arguments:[classFileName])?.docsDictionary
  • поведение не очень детерминировано. Например, если в комментарии прописать @name, то он пропадает из AST. Может, есть и ещё какие-то секретные слова, не знаю.

Хорошо, получили структуру, что делать дальше?

Работа со структурой


Работать со структурой можно тремя способами:

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

Второй способ отпадает потому, что зеркала в Swift не умеют записывать ничего в объекты. Третий способ очень крутой, но с ним вообще без шансов (в отличие от Java, где байткод можно генерировать на лету), остаётся только первый. То есть нам по структуре нужно создать методы, которые будут получать на вход KTV или JSON, выдавая нужный, заполненный объект, или наоборот, из объекта получать текст в форматах KTV или JSON.

Генерировать код нужно не абы-как (просто взял и присвоил полям значения):

  • нужно работать со всевозможными проверками (учитывая Optional-типы, например),
  • уметь работать с вложенными объектами (иерархиями классов)
  • иметь возможность настройки (хотя бы примаппить пропертю на поле с другим именем)
  • иметь возможность кастомных парсеров/сериализаторов (например, если попадётся дата в нестандартном формате)
  • уметь генерировать код, который доступен из Objective-C, а не только из Swift.

Задач много, и чтобы их все решить, пришлось серьёзно заморочиться.

Этап 1. Просто присваивания


Перед разбором кода поглядим, что, собственно, делаем. У нас есть модельный класс, который должен создать кучу модельных объектов, используя данные из KTV-файла. На схеме представлены участники. Схему по мере работы немного усложним.
image

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

Итак, нужно написать функцию (или функции), которые бы вытаскивали по ключу значение из KTV, после чего присваивали его проперте. Единственная сложность получилась в том, что значения бывают Optional, и типов много разных. Разберем по шагам.

Сначала напишем метод, который вытаскивает по произвольному ключу KTV-значение. При этом резолвятся ссылки, учитываются миксины. У меня получилось что-то такое. Я использую этот метод ещё и для того, чтобы получить текст ссылки в S2, поэтому возвращаем tuple.

private func valueAndReferenceForKey(key:String, resolveReferences:Bool = true) 
        throws -> (value:KTVValue?, reference:String?) {
    var result:KTVValue? = properties[key]
    var reference:String? = nil

    if let result_ = result {
        switch result_ {
            case .reference(let link):
                if resolveReferences || link.hasPrefix("~") {
                    result = try findObjectByReference(link)
                } else {
                    result = nil
                    reference = link
                        .stringByReplacingOccurrencesOfString("@", withString:"")
                }
            default:
                break
        }
    }

    return (result, reference)
}

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

private func specificValueForKey<T>(key:String, defaultValue:T?, 
        resolveReferences:Bool, valueResolver:(value:KTVValue) throws -> T?) 
        throws -> (value:T?, reference:String?) {
    let (resultValue, reference) = try valueAndReferenceForKey(key,
            resolveReferences:resolveReferences)
    var result = defaultValue

    if let result_ = resultValue {
        result = try valueResolver(value:result_)
    }

    return (result, reference)
}

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

public func string(key key:String, defaultValue:String? = "") 
        throws -> String? {
    return try specificValueForKey(key, defaultValue:defaultValue,
            resolveReferences:true, valueResolver:KTVValue.stringResolver).value
}

Еще один генерализованный метод я использую, чтобы единообразно работать с Optional-типами.

private func deoptionizeValue<T>(value:T?, defaultValue:T) -> T {
    if let value_ = value {
        return value_
    } else {
        return defaultValue
    }
}

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

stringProperty = deoptionizeValue(value:string(key:"key"), defaultValue:"")

С объектами приходится разбираться чуть менее удобно, но с точки зрения хитростей, вполне очевидно. У меня получилось как-то так:

if let object_ = getGeneralObject(name:"object", ktv:ktv) {
    object = ChildObject(ktvLenient:object_)
} else {
    object = nil
}

Этап 2. Даты, исследование


Следующая задача, которая требовала решения — форматтеры дат. Решений здесь может быть также несколько.

Использовать специальные методы модельного класса для форматирования полей. Я этот вариант использовал в Objective-C варианте этой же либы, и оно работало неплохо. Проблема лишь в разделении объявления поля и его параметров. Неудобно, постоянно забываешь, или забываешь поправить, если поменялось имя проперти. Плюс, есть ещё проблема со структурами

Дело в том, что у структур есть автоматически создающийся инициализатор с именами всех полей. Это удобно, и, учитывая семантику этого типа, экономно. Если же мы хотим использовать какие-то методы объекта или класса, то необходимо использовать self, который требует инициализации перед вызовом. Следовательно, у структуры появляется необходимость в (пустом) умолчальном инициализаторе init(). Это лишний код, который потребуется писать в каждом модельном классе/в каждой структуре (сгенерить его нельзя, он должен быть именно в основном классе) и который также всегда будут забывать.

Использовать специальные классы, или туплы, в качестве типов пропертей модельного класса. То есть, не String, а MappedString. Это позволит их настраивать (прямо при создании), например, так: var property:MappedDate = MappedDate(format:'dd-MM-yyyy'), и можно будет пользоваться их методами для сериализации/парсинга значений. Можно вместо класса использовать туплы, это выглядит совершенно монструозно, но также работает. Минусов у этого решения много, главный — для доступа к пропертям будет нужно как-то изворачиваться (object.property.value, например). Ну и запись диковатая.

Использовать мапперы. Попробовав вышеперечисленные варианты, я пришёл именно к этому.

Этап 3. Кастомные мапперы


Маппером я называю класс, который знает, как определенный тип преобразуется из/в KTV. В него можно отправить KTV-value, чтобы он вернул настоящий тип (String, NSDate, ...), и можно наоборот, отдать тип, чтобы он вернул KTV-value (для сериализации).

Обычный маппер мы уже реализовали пару разделов назад, для строки. В принципе, можно этот код обернуть в класс, после чего выбирать классы в зависимости от типа проперти. В этом месте выясняется интересная особенность Swift.

Дело в том, что в конце-концов, нам нужна какая-то фабрика, которая по типу (по строке или по классу) будет выдавать нужный объект. Хорошо бы, чтобы эта фабрика выдавала генерализованный объект. Тогда не будет никаких приведений, и логика вызова получится максимально простой, как-то так

let stringMapper:Mapper<String> = mapper.getFor("String")
var value:String = stringMapper.valueFromKTV(ktvValue)

Проблема в том, как сделать этот метод func getFor<T>(className:String) -> Mapper<T>. Из-за особенностей дженериков в протоколах (точнее, их отсутствия, вместо них используются ассоциативные типы), например, нельзя сделать массив генерализованных объектов. То есть, так нельзя (не важно, указывать дженерик-тип или нет) var mappers:\[Mapper] = \[]. В массиве должны быть уже конкретные типы.

В результате пришлось немного схитрить. Массив мапперов внутри фабрики пришлось сделать каким-то общим негенерализованным протоколом-родителем всех мапперов. Можно было бы просто сделать массив AnyObject, но это как-то совсем нехорошо получается.

public protocol KTVModelAnyMapper {}

public class KTVModelMapper<MappingType>: KTVModelAnyMapper { ... }

public class KTVModelMapperFactory {
    private var _mappersByType = [String:KTVModelAnyMapper]()

    public func mapper<T>(type:String, propertyName:String) 
            throws -> KTVModelMapper<T> {
        if let mapper = _mappersByType[propertyName] as? KTVModelMapper<T> {
            return mapper
        } else {
            throw KTVModelObjectParseableError.CantFindTypeMapper
        }
    }
}

Решения без приведения типов я не придумал. Может, кто-то из читателей подскажет?

В результате код парсера получился такой:

let mappers = KTVModelMapperFactory()

do { 
    _stringOrNil = try mappers
        .mapper("String", propertyName:mappers.ktvNameFor("stringOrNil"))
        .parseOptionalValue(ktv["stringOrNil"], defaultValue:_stringOrNil)
} catch { 
    errors["stringOrNil"] = error 
}

Прелесть этого решения в том, что для каждого модельного класса можно подставить свою фабрику, и таким образом контролировать, как маппятся ktv-значения в объект.
image

Остаётся вопрос, каким образом задать эту кастомную фабрику для модельного объекта? Я придумал два варианта:

  • Использовать кастомный же протокол. В имени протокола закодировать название кастомной фабрики, и в процессе создания расширения-парсера, вытаскивать SourceKit'ом это название, оттуда название фабрики, и подключать при парсинге. Решение рабочее, я проверил, но страшно кривое.
  • Не менее кривое решение (хороших я вообще не знаю), но хотя бы красивое — использование комментариев.

Этап 4. Аннотации в комментариях


Дело в том, что SourceKit помимо информации о классах и структурах выдаёт ещё и привязанные к элементам комментарии. Те самые, из которых потом получается документация, начинающиеся с /// или /** */. Таким образом, туда можно запихнуть что угодно, парсить это что угодно как угодно, и делать что хотим. Понятно, что никакой типизацией тут и не пахнет, написание — исключительно на совести разработчика, но, попробовав все вышеперечисленные методы (и ещё парочку совсем уж кошмарных), выходит, что это — самый адекватный.

Выглядит комментарий с кастомной фабрикой для модельного класса так:

/// @mapper = RootObjectMapperFactory
public struct RootObject: KTVModelObject { ... }

А так, например, можно проконтролировать наименование проперти в KTV:

/// @ktv = __date__
var date:NSDate

Впрочем, в этом месте возможности безграничны, так как мы уходим от Swift'а и начинаем просто парсить произвольный текст. Можно задавать форматы дат, можно — произвольный код, который потом впишется в парсер или сериализатор.

Результат, выводы


Swift — пока плохой язык для «волшебных» библиотек. То есть таких, в которые что-то кладёшь простое, оно там варится и выдаётся другое простое и красивое. При этом со стороны разработчика «ничего делать не надо» (якобы). Этот тип библиотек всегда самый сложный, но именно он показывает мощность платформы. Это Hibernate, это Rails, это CoreData и так далее. На Swift'е такое писать сейчас — безумно тяжело и только SourceKit снижает сложность до приемлемой, не было бы его, пришлось бы парсить классы руками, что, мягко говоря, неблагодарное занятие.

Впрочем, как зарядка для ума, этот код оказался великолепен. Столько граблей в одном месте я не находил давненько. То, что получилось, можно потрогать вот тут: https://github.com/bealex/KTV Это очень живой, не-продакшн код, на котором я изучаю работу со Свифтом, поэтому, пожалуйста, относитесь к нему также.

Надеюсь, и вам будет интересно. Если вдруг остались вопросы — спрашивайте!




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