Дисклеймер

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

Задача

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

В 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()
    }
}

Итог

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

Комментарии (7)


  1. anomaly86
    10.01.2023 14:26

    Спасибо, что поделились. На мой взгляд в такой реализации есть проблема. Например, ваш продуктовод наверняка захочет прикрутить запросы "показать ближайшие к пользователю аэропорты, начиная от ближайшего", "аэропорты в выбранной стране" или "поиск аэропортов по названию". И таких специфических запросов может быть не мало даже в небольшом приложении. Как в таком случае будет выглядеть код? Предполагаю, что это будет двойное пробрасывание параметров запроса в Realm через AirportRepository и StorageService (что мне кажется будет избыточным). Нет?


    1. EndlessResonance Автор
      10.01.2023 14:35

      Привет, спасибо за мнение!
      А почему подобные запросы должны быть именно в репозитория?
      Я понимаю, что можно сразу ограничить объем рекордов полученных из БД, реализовав запросы в репозитории, что сразу позволяет получить их из разных модулей приложения, но в целом, на мой взгляд, такие запросы можно инкапсулировать в условную ViewModel в контексте MVVM, ViewModel получает сырые данные из репозитория, а затем подготавливает в нужный вид для UI слоя, отбирая их по нужному предикату, так и ограничивается кол-во модулей, которые будут перформить запросы.


      1. anomaly86
        10.01.2023 15:20

        Да, это решает вопрос с параметрами, если я вас правильно понял. Но в этом случае у вас будет производительность падать, если во ViewModel (по сути уровень бизнес логики) придет много сырых данных. И вам придется запросы уже самому оптимизировать, т е реализовывать то, что realm умеет из коробки. Например, realm найдет вам ближайшие объекты без прохода по всем записям в БД.


        1. EndlessResonance Автор
          10.01.2023 16:13

          Резонно, и тогда мы возвращаемся к поиску по предикату и пробрасыванию его сквозь репозиторий и сервис. А почему это избыточно? Может быть у Вас есть идеи для более оптимальной реализации?


          1. anomaly86
            10.01.2023 17:01

            Избыточно, потому что ваши репозитории наполнятся шаблонным кодом типа:

            func request1(param1, param2, param3) {
              storage.request1(param1, param2, param3)
            }
            

            Ну, давайте подумаем вместе. Первое, что можно сделать на мой взгляд, это в fetch возвращать Results.

            func fetch<T: Object>(by type: T.Type) -> Results<T> {
              realm.objects(T.self)
            }
            

            Таким образом вам будут доступны плюшки realm уже в репозитории.


        1. EndlessResonance Автор
          10.01.2023 16:39

          Как вариант можно рассмотреть добавление запросов в репозиторий и заменить пробрасывание предиката на API посвежее - Query + сопутствующие изменения в StorageService в виде возвращаемого типа метода fetch

          То есть в методе будет что-то вроде такого
          let cache = storage.fetch(by: AirportObject.self).where { $0.id == "" }


          1. anomaly86
            10.01.2023 21:35

            Да, это похоже на улучшение и решение, имхо.