Как стать автором
Обновить

Пишем персистентный слой на Realm

Время на прочтение5 мин
Количество просмотров3.5K

Дисклеймер

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

Задача

Реализовать сервис, который будет сохранять разные модели данных.

В iOS не то что бы много вариантов реализаций БД, в основном это CoreData и Realm, есть много сторонних, но в проде чаще всего встречаются они, потому чувак который посмотрит твое тестовое ожидает увидеть один из вариантов.

План

Меня устраивает Realm, потому что проще настраивать стек, по перфомансу он быстрее нативной Core Data, модели проще писать, плюс избегаешь всяких стрёмных NSManagedObject, NSManagedObjectContext, координаторы и т.д. - создал модель и все, а еще это mongoDB и NoSQL (так и скажи на собесе, делай вид, что шаришь (собственно я так и делаю)).

Опционально, но я любитель реактивщины и Realm поддерживает RxSwift - еще один плюс в его сторону, но субъективный (можно почитать отдельно про freeze на досуге, полезная фича от realm в контексте RxSwift).

В плане архитектуры все просто - пишем основу CRUD и юзаем ее инстанс в репозиториях для разделения сохраняемых моделей, например: есть модель аэропорта и полета - на каждую модель свой репозиторий.

Плюс такого подхода в том, что будет достаточно просто разделять ответственность за разные данные и не запутаться в условном god object, ну и если ВДРУГ мы захотим подменить Realm на что-то еще (ну если только в проде фичетогглами и то не факт...) - мы сделаем это безболезненней, потому что логика репозитория останется неизменной, изменению подвергентся только основа.

Нам потребуются модели для персистентного слоя и для подсистем приложения, чтобы не рулить сырыми данными и подготовить их для UI, да и не работать с первыми напрямую, чтобы не усложнять код.

Итого к имплементации:

  1. Модели для персистентного слоя

  2. DTO модели

  3. CRUD сервис

  4. Репозитории

Реализация

Шаг первый - модели персистентного слоя

Для менеджмента хранимых данных в Realm есть Object, покопавшись в доке - вместо привычных @objc dynamic var завезли лакончиный проперти враппер Persisted, с которым избегаем этих извращенных в названиях, прости господи, еще у него есть плюс в виде отсутвия нужды в реализации метода primaryKey, который заменили обычной проперти - круто, погнали писать имплементацию.

import RealmSwift

final class AirportObject: Object {
    @Persisted(primaryKey: true) var id: String
    @Persisted var latitude: Double
    @Persisted var longitude: Double
    @Persisted var name: String
    
    convenience init(
        id: String,
        latitude: Double,
        longitude: Double,
        name: String,
    ) {
        self.init()
        self.id = id
        self.latitude = latitude
        self.longitude = longitude
        self.name = name
    }
}

Шаг второй - DTO

Пишем незамысловатую имплементацию

struct AirportDTO {
    let id: String
    let latitude: Double
    let longitude: Double
    let name: String
}

Теперь важный момент - раз у нас есть модели для разных подсистем, то надо как-то перегонять одни в другие и обратно, часто видел реализации типа написания методов а-ля translateToPersistentObject, которые возвращают объект нужного типа, но я решил не париться и тупо расширить доп инициализаторами и в методах репозиториев мапить в предполагаемый тип, зачем нам повторять фунцкионал инициализтора реализуя метод?

extension AirportObject {
    convenience init(_ dto: AirportDTO) {
        self.init()
        id = dto.id
        latitude = dto.latitude
        longitude = dto.longitude
        name = dto.name
    }
}

extension AirportDTO {
    init(object: AirportObject) {
        id = object.id
        latitude = object.latitude
        longitude = object.longitude
        name = object.name
    }
}

Шаг третий - сервис

А теперь пишем мозги. Учитывая то, что я хочу использовать сервис в тестовом и каких-то пет проектах, да и по-хорошему настройки/зависимости стоит передавать извне, чтобы мы могли замокать их для тестов - сетим конфигурацию через конструктор.

Стоит учесть, что при изминении или добавлении нового поля в модели персистенстного слоя случится краш, потому надо выбрать дефолтную стратегию миграции БД.

В целом для моих задач подойдет inMemoryIdentifier, чтобы для тестов данные хранились только в ОЗУ, так нам не придется вычищать БД для каждого теста ожидая асинхронного выполеняния транзакции. Представим, что у нас 20 тестов? А если 50? 100? Было бы долго и дорого, имхо - по дефолту самое оно, при этом всегда сможем поменять.

final class StorageService {
    private let storage: Realm?
    
    init(
        _ configuration: Realm.Configuration = Realm.Configuration(
            inMemoryIdentifier: "inMemory"
        )
    ) {
        self.storage = try? Realm(configuration: configuration)
    }
}

Ну и далее понеслась душа в рай - реализуем CRUD функционал, заострю внимание только на паре методов - сохранение и фетч объектов

Сохранение

Очевидно (очевидно же?) что эта операция важна и трудозатратна, тут встает вопросв о многопоточности, чтобы сохранять асинхронно. В документации есть целый раздел посвященный многопоточности (https://www.mongodb.com/docs/realm/sdk/swift/swift-concurrency/).

Чтобы не писать свои костыли - берем из коробки метод writeAsync и радуемся. Ну и учитываем, что если мы добавляем уже существующие объекты, но с измененными полями то нам не надо создавать их заново, потому явно маркируем update: .all

  func saveOrUpdateObject(object: Object) throws {
    guard let storage else { return }
    storage.writeAsync {
        storage.add(object, update: .all)
    }
  }

Фетч

Далее вытаскиеваем объекты, раз мы будем писать репозитории для разных моделей, то и метод нужен generic, плюс я хочу сразу возвращать массив объектов, а не родной Result<Element> (наверно здесь можно было разобраться, как это сделать покрасивше, но мне лень).

func fetch<T: Object>(by type: T.Type) -> [T] {
    guard let storage else { return [] }
    return storage.objects(T.self).toArray()
}


extension Results {
    func toArray() -> [Element] {
        .init(self)
    }
}

В итоге получаем сервис

import Foundation
import RealmSwift

final class StorageService {
    private let storage: Realm?
    
    init(
        _ configuration: Realm.Configuration = Realm.Configuration(
            inMemoryIdentifier: "inMemory"
        )
    ) {
        self.storage = try? Realm(configuration: configuration)
    }
    
    func saveOrUpdateObject(object: Object) throws {
        guard let storage else { return }
        storage.writeAsync {
            storage.add(object, update: .all)
        }
    }
    
    func saveOrUpdateAllObjects(objects: [Object]) throws {
        try objects.forEach {
            try saveOrUpdateObject(object: $0)
        }
    }
    
    func delete(object: Object) throws {
        guard let storage else { return }
        try storage.write {
            storage.delete(object)
        }
    }
    
    func deleteAll() throws {
        guard let storage else { return }
        try storage.write {
            storage.deleteAll()
        }
    }
    
    func fetch<T: Object>(by type: T.Type) -> [T] {
        guard let storage else { return [] }
        return storage.objects(T.self).toArray()
    }
}

extension Results {
    func toArray() -> [Element] {
        .init(self)
    }
}

Последний шаг - репозиторий

Тут уже все еще примитивней, пишем по классике протокол, чтобы не привязываться к конкретной реализации и в случае чего не менять логику внутри будущих модулей при подмене Realm на что-то другое (да кто так будет делать в тестовом...) и имплементим его.

import Foundation
import RealmSwift

protocol AirportRepository {
    func getAirportList() -> [AirportDTO]
    func saveAirportList(_ data: [AirportDTO])
    func clearAirportList()
}

final class AirportRepositoryImpl: AirportRepository {
    private let storage: StorageService
    
    init(storage: StorageService = StorageService()) {
        self.storage = storage
    }
    
    func getAirportList() -> [AirportDTO] {
        let data = storage.fetch(by: AirportObject.self)
        return data.map(AirportDTO.init)
    }
    
    func saveAirportList(_ data: [AirportDTO]) {
        let objects = data.map(AirportObject.init)
        try? storage.saveOrUpdateAllObjects(objects: objects)
    }
    
    func clearAirportList() {
        try? storage.deleteAll()
    }
}

В итоге мы будем использовать только конкретные репозитории, чтобы рулить данными

final class ViewModel {
    private let airportRepository: AirportRepository
    
    init(airportRepository: AirportRepository = AirportRepositoryImpl()) {
        self.airportRepository = airportRepository
    }
    
    func getData() {
        let cache = airportRepository.getAirportList()
    }
}

Итог

В целом получили незамысловатый код, который просто читать, открыт к критике, всем добра!

Теги:
Хабы:
Всего голосов 6: ↑6 и ↓0+6
Комментарии9

Публикации

Истории

Работа

iOS разработчик
17 вакансий
Swift разработчик
19 вакансий

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань