Русский
Русский
English
Статистика
Реклама

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:

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

Всем добра!

Источник: habr.com
К списку статей
Опубликовано: 04.06.2021 16:20:58
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Блог компании prisma labs

Разработка под ios

Swift

Ios

Coredata

Orm

Категории

Последние комментарии

  • Имя: Murshin
    13.06.2024 | 14:01
    Нейросеть-это мозг вселенной.Если к ней подключиться,то можно получить все знания,накопленные Вселенной,но этому препятствуют аннуннаки.Аннуннаки нас от неё отгородили,установив в головах барьер. Подр Подробнее..
  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru