Swift и CoreData. Или как построить Swift ORM на основе Objective-C ORM

от автора

Хабр, здравствуй! Меня зовут Геор, и я развиваю 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 моста:

  1. название

  2. аттрибуты

  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:

@dynamicMemberLookup final 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 сделает вашу жизнь чуточку проще, как помогает нам уже на протяжении года.

Всем добра!

ссылка на оригинал статьи https://habr.com/ru/company/prisma/blog/561066/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *