Хабр, здравствуй! Меня зовут Геор, и я развиваю iOS проекты в компании Prisma Labs. Как вы наверняка поняли, речь сегодня пойдет про кордату и многим из вас стало скучно уже на этом моменте. Но не спешите отчаиваться, так как говорить мы будет по большей части о магии свифта и про метал. Шутка - про метал в другой раз. Рассказ будет про то, как мы победили NSManaged-бойлерплейт, переизобрели миграции и сделали кордату great again.
Разработчики, пройдемте.
Пару слов о мотивации
Работать с кордатой сложно. Особенно в наше свифт-время. Это очень старый фреймворк, который был создан в качестве дата-слоя с акцентом на оптимизацию I/O, что по умолчанию сделало его сложнее других способов хранения данных. Но производительность железа со временем перестала быть узким горлышком, а сложность кордаты, увы, никуда не делась. В современных приложениях многие предпочитают кордате другие фреймворки: Realm, GRDB (топ), etc. Или просто используют файлы (почему бы и нет). Даже Apple в новых туториалах использует Codable сериализацию/десериализацию для персистенса.
Несмотря на то, что АПИ кордаты периодически пополнялся различными удобными абстракциями (напр. NSPersistentContainer), разработчики по-прежнему должны следить за жизненным циклом NSManaged объектов, не забывать выполнять чтение/запись на очереди контекста, к которому эти объекты привязаны и разумеется ругаться каждый раз, когда что-то пойдет не так. И наверняка во многих проектах есть дублирующий набор моделей доменного уровня и код для конвертации между ними и их NSManaged-парами.
Но у кордаты есть и немало плюсов - мощный визуальный редактор схемы данных, автоматические миграции, упрощенная (по сравнению с SQL) система запросов, безопасный мультипоточный доступ к данным и так далее.
У себя в Призме мы написали простой и мощный фреймворк, который позволяет забыть о недостатках кордаты и при этом использовать всю силу ее светлой стороны.
Встречайте - Sworm.
Я не буду рассказывать полностью про устройство фреймворка - для этого есть репозиторий, а сосредоточусь на основных моментах и хитростях имплементации.
Отказ от NSManagedObject-наследования и встроенной CoreData-кодогенерации
Вместо этого NSManagedObject'ы используются напрямую как key-value контейнеры. Идея не нова, а сложность заключается в том, как автоматизировать конвертацию между KV-контейнером и доменной моделью. Чтобы решить эту задачу нужно навести 3 моста:
-
название
-
аттрибуты
-
отношения
С названием все просто - указав в типе вашей модели строчку с названием можно однозначно связать ее с сущностью в модели:
struct Foo { static let entityName: String = "FooEntity"}
"Мост" отношений - уже более сложная техническая конструкция. В случае названия, статическое поле указанное внутри типа автоматически с ним связано:
Foo.entityName
Но чтобы определить отношение, помимо названия этого отношения нам нужен еще тип destination-модели, внутри которой так же должно быть название соответствующей сущности. Это наводит на две мысли. Во-первых, все модели, конвертируемые в NSManageObject должны следовать единому набору правил, то есть пришло время протокола, и, во-вторых, нам нужен дополнительный тип данных Relation<T: протокол>(name: String), который будет связывать название отношения в модели данных с типом, соответствующей ей доменной модели. Пока опустим детали, что отношения бывают разные - это на данном этапе неважно. Итак, протокол версия 1:
protocol ManagedObjectConvertible { static var entityName: String { get }}
и тип для отношений:
Relation<T: ManageObjectConvertible>(name: String)
Применяем:
struct Foo: ManageObjectConvertible { static var entityName: String = "FooEntity" static let relation1 = Relation<Bar1>(name: "bar1") static let relation2 = Relation<Bar2>(name: "bar2")}
Сразу напрашивается идея зафиксировать наличие связей (отношений) в нашем протоколе, но как это сделать, если количество связей всегда разное? Сделать коллекцию отношений не получится по нескольким причинам. Во-первых, в свифте дженерики инвариантны, во-вторых, рано или поздно нам придется вспомнить, что Relation распадется на несколько типов - one/many/orderedmany, и это автоматически приведет к мысли о гомогенности через стирание типов, что нас не устраивает. Но на самом деле, нас не интересуют сами отношения и мы можем даже не думать об их количестве. Поэтому мы добавим в протокол не конкретный тип отношений, а ассоциацию с типом отношений. Звучит странно и на первый взгляд непонятно, но подержите мое пиво - протокол версия 2:
protocol ManagedObjectConvertible { associatedtype Relations static var entityName: String { get } static var relations: Relations { get }}
Все еще странно, продолжаем держать пиво:
struct Foo: ManageObjectConvertible { static let entityName: String = "FooEntity" struct Relations { let relation1 = Relation<Bar1>(name: "bar1") let relation2 = Relation<Bar2>(name: "bar2") } static let relations = Relations()}
И вот сейчас станет понятно - с помощью такой имплементации можно легко достать название отношения:
extension ManagedObjectConvertible { func relationName<T: ManagedObjectConvertible>( keyPath: KeyPath<Self.Relations, Relation<T>> ) -> String { Self.relations[keyPath: keyPath].name }}
Пиво-то верни, что стоишь :)
Финальный мост - атрибуты
Как известно у любого босса есть слабые места и этот не исключение.
На первый взгляд, задача выглядит аналогичной "мосту" отношений, но в отличие от них, нам необходимо знать о всех доступных атрибутах, и обойтись ассоциированным типом не получится. Нужна полноценная коллекция атрибутов, каждый из которых должен уметь делать две вещи: заэнкодить значение из модели в контейнер и задекодить из контейнера обратно в модель. Очевидно, что это связь WritableKeyPath + String key. Но, как и в случае отношений, нам понадобится решить задачу - как сохранить информацию о типах, учитывая инвариантность дженериков и необходимость иметь гомогенную коллекцию атрибутов.
Пусть в роли атрибута выступит специальный объект типа Attribute<T>, где T - доменная модель. Тогда коллекцией атрибутов будет `[Attribute<T>]` и для нашего протокола заменим T на Self. Итак, протокол - версия 3:
public protocol ManagedObjectConvertible { associatedtype Relations static var entityName: String { get } static var attributes: [Attribute<Self>] { get } static var relations: Relations { get }}
И теперь попробуем реализовать непосредственно класс Attribute. Напомню, что в зону его ответственности входит сериализация/десериализация поля между моделью и KV-контейнером. Сперва, попробуем ненадолго забыть про ограничения на гомогенность типов и сделаем в лоб:
final class Attribute<T: ManagedObjectConvertible, V> { let keyPath: WritableKeyPath<T, V> let key: String ... func update(container: NSManagedObject, model: T) { container.setValue(model[keyPath: keyPath], forKey: key) } func update(model: inout T, container: NSManagedObject) { model[keyPath: keyPath] = container.value(forKey: key) as! V }}
Имплементация атрибута могла бы выглядеть так, но [Attribute<T, V>] - не наш случай. Как можно избавиться от V в сигнатуре класса, сохранив информацию об этом типе? Не все знают, но в свифте можно добавлять дженерики в сигнатуру инициализатора:
final class Attribute<T: ManagedObjectConvertible> { ... init<V>( keyPath: WritableKeyPath<T, V>, key: String ) { ... } ...}
Теперь у нас есть информация о V в момент инициализации атрибута. А для того, чтобы не потерять ее и дальше, расчехлим свифт аналог BFG - кложуры:
final class Attribute<T: ManagedObjectConvertible> { let encode: (T, NSManagedObject) -> Void let decode: (inout T, NSManagedObject) -> Void init<V>(keyPath: WritableKeyPath<T, V>, key: String) { self.encode = { $1.setValue($0[keyPath: keyPath], forKey: key) } self.decode = { $0[keyPath: keyPath] = $1.value(forKey: key) as! V } }}
В нашем протоколе осталось еще одно пустое место. Мы знаем как создать NSManagedObject и заполнить его данными из модели, знаем как заполнить модель из NSManagedObject'а, но НЕ знаем, как создать инстанс нашей модели при необходимости.
Протокол - версия 4, финальная:
protocol ManagedObjectConvertible { associatedtype Relations static var entityName: String { get } static var attributes: Set<Attribute<Self>> { get } static var relations: Relations { get } init()}
Все - мы победили наследование от NSManagedObject'ов, заменив его на имплементацию протокола.
Далее рассмотрим как можно сделать систему атрибутов гибче и шире.
Гибкая система атрибутов
Кордата поддерживает набор примитивных аттрибутов - bool, int, double, string, data, etc. Но помимо них есть малоиспользуемый Transformable, который позволяет сохранять в кордате данные различных типов. Идея отличная и мы решили вдохнуть в нее новую жизнь с помощью системы типов свифта.
Определим следующий набор атрибутов-примитивов:
Bool, Int, Int16, Int32, Int64, Float, Double, Decimal, Date, String, Data, UUID, URL
И утвердим правило: тип атрибута валиден, если данные можно сериализовать в один из примитивов и десериализовать обратно.
Это легко выразить в виде двух протоколов:
protocol PrimitiveAttributeType {}protocol SupportedAttributeType { associatedtype P: PrimitiveAttributeType func encodePrimitive() -> P static func decode(primitive: P) -> Self}
Применив SupportedAttributeType в нашей имплементации Attribute
final class Attribute<T: ManagedObjectConvertible> { let encode: (T, NSManagedObject) -> Void let decode: (inout T, NSManagedObject) -> Void init<V: SupportedAttributeType>(keyPath: WritableKeyPath<T, V>, key: String) { self.encode = { $1.setValue($0[keyPath: keyPath].encodePrimitive(), forKey: key) } self.decode = { $0[keyPath: keyPath] = V.decode(primitive: $1.value(forKey: key) as! V.P) } }}
мы получим возможность хранить в кордате данные любых типов по аналогии с Transformable, но без objc-легаси.
Комбинируя гибкие атрибуты и заменяя NSManagedObject-наследование имплементацией протокола можно очень сильно сократить кодовую базу - убрать много бойлерплейта, связанного с дублированием моделей, копированием кода сериализации композитных атрибутов и так далее.
Благодаря ManagedObjectConvertible мы однозначно связали типы наших моделей и информмацию о схеме данных. Но для того, чтобы на основе этой информации мы могли выполнять операции с данными нам потребуется слой data access объектов или DAO, поскольку доменные модели обычно выступают в роли DTO - data transfer объектов.
Скрываем NSManaged под капот
Если рассматривать NSManaged-слой в терминах DAO и DTO, то контекст+объекты это DAO+DTO, причем равны суммы, но не компоненты по отдельности, так как NSManagedObject помимо трансфера данных еще может их обновлять, но с участием контекста. Попробуем перераспределить функциональность между NSManaged-сущностями и нашими доменными моделями. Наши модели это DTO + метаинформация о схеме данных (имплементация ManagedObjectConvertible). Составим псевдоуравнение:
доменные модели + raw NSManaged- объекты + X = DAO+DTO
я пометил NSManaged как raw - так как с точки зрения компилятора мы забрали от них информацию о схеме данных и передали ее во владение доменным моделям.
А X - это тот недостающий фрагмент, который свяжет информацию о схеме данных, информацию о типах моделей с NSManaged-слоем.
Решением нашего псевдоуравнения будет выступать новая сущность:
final class ManagedObject<T: ManagedObjectConvertible> { let instance: NSManagedObject ...}
Этот класс будет служить фасадом для NSManaged слоя, используя дженерик в сигнатуре типа для доступа к схеме данных.
Я не буду вдаваться в подробности конечной имплементации из-за масштабности фреймворка, но хотел бы на примере отношений между моделями продемонстрировать мощь dynamicMemberLookup в Swift.
Если мы вспомним ManagedObjectConvertible предоставляет информацию о названи сущности в схеме данных, о атрибутах-конвертерах и отношениях между моделями. Я специально заострил тогда внимание на том, как с помощью Keypaths можно получить название отношения. Адаптируем тот код под нужды ManagedObject:
final class ManagedObject<T: ManagedObjectConvertible> { ... subscript<D: ManagedObjectConvertible>( keyPath: KeyPath<T.Relations, Relation<D>> ) -> ManagedObject<D> { let destinationName = T.relations[keyPath: keyPath] // получаем объект отношения через NSManaged API return .init(instance: ...) }}
И, соответственно, использование:
managedObject[keyPath: \.someRelation]
Достаточно просто, но мы можем применить спец заклинание в
свифте - dynamicMemberLookup
:
@dynamicMemberLookupfinal class ManagedObject<T: ManagedObjectConvertible> { ... subscript<D: ManagedObjectConvertible>( dynamicMember keyPath: KeyPath<T.Relations, Relation<D>> ) -> ManagedObject<D> { ... }}
и сделать наш код проще и более читаемым:
managedObject.someRelation
Последний момент, который я бы хотел вам сегодня показать - это как с помощью наших атрибутов можно сделать удобную систему предикатов в запросах.
Типизированные предикаты
Идея заключается в том, чтобы заменить строковые запросы кордаты типизированными свифт выражениями:
Вместо "foo.x > 9 AND foo.y = 10"
написать
\Foo.x > 9 && \Foo.y == 10
и из этого
выражения получить обратно "foo.x > 9 AND foo.y =
10"
Сделать это имея на руках информацию из сущности Attribute и протоколов Equatable и Comparable достаточно просто. От нас понадобится заимплементировать набор операторов сравнения и логических операторов.
Разберем для примера логический оператор >. В левой части у него находится KeyPath нужного атрибута, а в правой - значение соответствующего типа. Наша задача превратить выражение \Foo.x > 9 в строку "x > 9". Самое простое - это знак оператора. Просто в имплементации функции оператора зашиваем литерал ">". Чтобы из кипаса получить название обратимся к имплементации нашего протокола ManagedObjectConvertible у сущности Foo и попытаемся найти в списке атрибутов тот, что соответствует нашему кипасу. Сейчас мы не храним кипас и ключ контейнера внутри объекта атрибута, но ничего не мешает нам это сделать:
final class Attribute<T: ManagedObjectConvertible> { let key: String let keyPath: PartialKeyPath<T> let encode: (T, NSManagedObject) -> Void let decode: (inout T, NSManagedObject) -> Void ...}
Обратите внимание, что WritableKeyPath стал PartialKeyPath. И самое важное, что мы можем в рантайме сравнивать кипасы межды собой, так как они имплементируют Hashable. Это крайне интересный момент, который говорит о том, что кипасы играют важную роль не только в комплайл тайме, но и в рантайме.
Исходя из новой имплементации, обращайся к списку атрибутов мы можем зная кипас, легко вытащить соответствующий ему ключ в KV-контейнере.
Также нам нужно понимать, к каким атрибутам можно применять операции сравнения. Очевидно, что не все типы имплементируют Equatable и/или Comparable. Но на самом деле, нас интересует не сам тип атрибута, а тип его конечного примитива (см. SupportedAttributeType).
Поскольку в кордате мы оперируем именно примитивами, нам будут подходить те атрибуты, чьи примитивы имплементируют Equatable и/или Comparable:
func == <T: ManagedObjectConvertible, V: SupportedAttributeType>( keyPath: KeyPath<T, V>, value: V) -> Predicate where V.PrimitiveAttributeType: Equatable { return .init( key: T.attributes.first(where: { $0.keyPath == keyPath })!.key, value: value.encodePrimitiveValue(), operator: "=" )}
где Predicate - это специальный тип, который нужен для абстракции отдельных фрагментов целого выражения.
И для полноты картины не хватает логического оператора. Например
AND. Его имплементация по сути дела является склейкой двух
фрагментов в выражении и верхнеуровнево его можно представить как
"(\(left)) AND (\(right))"
.
Таким образом основная идея заключается в том, что зная связь между типами и схемой данных, можно из типизированных swift выражений составить правильную строку запроса и за счет этого меньше ошибаться.
Заключение
Я постарался осветить основные и наиболее интересеные моменты в имплементации, не пытаясь рассказать про кажду строчку нашего фреймворка. Я не затронул некоторые важные темы, например - прогрессивные миграции, но про это и многое другое подробно написано в репозитории.
Надеюсь, что Sworm сделает вашу жизнь чуточку проще, как помогает нам уже на протяжении года.
Всем добра!