Обновить

Sourcery для автоматического конвертирования в структуры объектов Realm

Разработка под iOSSwift
Из песочницы
В интернете, да и даже на Хабре, есть куча статей о том, как работать с Realm. Эта база данных достаточно удобная и требует минимальных усилий для написания кода, если ей уметь пользоваться. В этой статье будет описан метод работы, к которому пришел я.

Проблемы


Оптимизация кода


Очевидно, что каждый раз писать код инициализации объекта Realm и вызов одних и тех же функций для чтения и записи объектов- неудобно. Можно обернуть абстракцией.

Пример Data Access Object:

struct DAO<O: Object> {
    func persist(with object: O) {
        guard let realm = try? Realm() else { return }
        try? realm.write { realm.add(object, update: .all) }
    }
	
    func read(by key: String) -> O? {
        guard let realm = try? Realm() else { return [] }
        return realm.object(ofType: O.self, forPrimaryKey: key)
    }
}

Использование:

let yourObjectDAO = DAO<YourObject>()
let object = YourObject(key)
yourObjectDAO.persist(with: object)
let allPersisted = yourObjectDAO.read(by: key)

В DAO можно добавить много полезных методов например: для удаления, чтения всех объектов одного типа, сортировки и т.п.. Все они будут работать с любым из объектов Realm-а.

Accessed from incorrect thread


Realm — потокобезопасная база данных. Основное неудобство, которое из этого возникает — невозможность передать объект типа Realm.Object из одного потока в другой.

Код:

DispatchQueue.global(qos: .background).async {
    let objects = yourObjectDAO.read(by: key)
    DispatchQueue.main.sync {
        print(objects)
    }
}

Выдаст ошибку:

Terminating app due to uncaught exception 'RLMException', reason: 'Realm accessed from incorrect thread.'

Конечно, можно все время работать с объектом в одном потоке, но в реальности это создает определенные сложности, которые лучше обойти.

Для решения “удобно” конвертировать Realm.Object в структуры, которые будут спокойно передаваться между разными потоками.

Объек Realm:

final class BirdObj: Object {
    @objc dynamic var id: String = ""
    @objc dynamic var name: String = ""
    override static func primaryKey() -> String? { return "id" }
}

Структура:

struct Bird {
    var id: String
    var name: String
}

Для конвертирования объектов в структуры будем использовать реализации протокола
Translator:

protocol Translator {
    func toObject(with any: Any) -> Object
    func toAny(with object: Object) -> Any
}

Для Bird она будет выглядеть так:

final class BirdTranslator: Translator {
    func toObject(with any: Any) -> Object {
        let any = any as! Bird
        let object = BirdObj()
        object.id = any.id
        object.name = any.name
        return object
    }
    func toAny(with object: Object) -> Any {
        let object = object as! BirdObj
        return Bird(id: object.id,
                    name: object.name)
    }
}

Теперь остается немного поменять DAO для того, чтобы он принимал и возвращал структуры, а не объекты Realm.

struct DAO<O: Object> {
    
    private let translator: Translator
    
    init(translator: Translator) {
        self.translator = translator
    }
    
    func persist(with any: Any) {
        guard let realm = try? Realm() else { return }
        let object = translator.toObject(with: any)
        try? realm.write { realm.add(object, update: .all) }
    }
    
    func read(by key: String) -> Any? {
        guard let realm = try? Realm() else { return nil }
        if let object = realm.object(ofType: O.self, forPrimaryKey: key) {
            return translator.toAny(with: object)
        } else {
            return nil
        }
    }
}

Проблема вроде решена. Теперь DAO будет возвращать структуру Bird, которую можно будет свободно перемещать между потоками.

let birdDAO = DAO<BirdObj>(translator: BirdTranslator())
DispatchQueue.global(qos: .background).async {
    let bird = birdDAO.read(by: key)
    DispatchQueue.main.sync {
        print(bird)
    }
}

Огромное количество однотипного кода.


Решив проблему с передачей объектов между потоками, мы напоролись на новую. Даже в нашем простейшем случае, с классом из двух полей, нам нужно дополнительно написать 18 строк кода. А представьте, если полей не 2 а, к примеру 10, а некоторые из них не примитивные типы, а сущности, которые тоже нужно преобразовать. Все это порождает кучу строк однотипного кода. Тривиальное изменение структуры данных в базе, вынуждает вас лезть в три места.

Код на каждую сущность всегда, по своей сути, один и тот же. Различие в нем зависит только от полей структур.

Можно написать автогенерацию, которая будет парсить наши структуры выдавая Realm.Object и Translator для каждой. В этом может помочь Sourcery. На хабре уже была статья про Mocking с его помощью.

Для того, чтобы на достаточном уровне освоить этот инструмент, мне хватило описания template tags and filters Stencils (на основе которого сделан Sourcery) и докуметации самого Sourcery.

В нашем конкретном примере генерация Realm.Object может выглядеть так:

import Foundation
import RealmSwift
#1
{% for type in types.structs %}
#2
final class {{ type.name }}Obj: Object {
        #3
        {% for variable in type.storedVariables %}
        {% if variable.typeName.name == "String" %}
        @objc dynamic var {{variable.name}}: String = ""
        {% endif %}
        {% endfor %}
        override static func primaryKey() -> String? { return "id" }
}

{% endfor %}

#1 — Проходим по всем структурам.
#2 — Для каждой создаем свой класс- наследник Object.
#3 — Для каждого поля, у которого название типа == String, создаем переменную с таким же названием и типом. Здесь можно добавить код, как для примитивов типа Int, Date, так и более сложных. Думаю суть ясна.

Аналогично выглядит и код для генерации Translator

{% for type in types.structs %}
final class {{ type.name }}Translator: Translator {
    func toObject(with entity: Any) -> Object {
        let entity = entity as! {{ type.name }}
        let object = {{ type.name }}Obj()
        {% for variable in type.storedVariables %}
        object.{{variable.name}} = entity.{{variable.name}}
        {% endfor %}
        return object
    }
    func toAny(with object: Object) -> Any {
        let object = object as! {{ type.name }}Obj
        return Bird(
        {% for variable in type.storedVariables %}
        {{variable.name}}: object.{{variable.name}}{%if not forloop.last%},{%endif%}
        {% endfor %}
        )
    }
}
{% endfor %}

Лучше всего устанавливать Sourcery через менеджер зависимостей, с указанием версии, чтобы то, что вы напишите, работало у всех одинаково и не ломалось.

После установки нам остается написать одну строку bash кода для его запуска в BuildPhase проекта. Генерировать он должен перед тем, как начнут компилироваться файлы вашего проекта.



Заключение


Приведенный мной пример был изрядно упрощен. Понятно, что в больших проектах файлы типа .stencil будут гораздо больше. В моем проекте они занимают чуть меньше 200 строк, при этом генерируя 4000 и добавляя, ко всему прочему, возможность полиморфизма в Realm.
В целом с задержками из-за конвертирования одних объектов в другие я не сталкивался.
Буду рад любым отзывам и критике.

Ссылки


Realm Swift
Sourcery GitHub
Sourcery Documentation
Stencil built-in template tags and filters
Mocking в swift при помощи Sourcery
Создание приложения ToDo с помощью Realm и Swift
Теги:Realmбазы данныхSourceryкодогенерацияiOS developmentswift
Хабы: Разработка под iOS Swift
Рейтинг +7
Количество просмотров 1,6k Добавить в закладки 5
Комментарии
Комментировать

Похожие публикации