Как стать автором
Обновить
58.32
Рейтинг
Prisma Labs
Обработка фото и видео на основе Machine Learning

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

Блог компании Prisma LabsРазработка под iOSSwift

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

Всем добра!

Теги:iosswiftcoredataorm
Хабы: Блог компании Prisma Labs Разработка под iOS Swift
Всего голосов 8: ↑8 и ↓0+8
Просмотры2.5K
Комментарии Комментарии 2

Лучшие публикации за сутки