Дисклеймер
Реализация субъективна и, как обычно, не идеальна, рассчитана больше для новичков, для моих задач в самый раз, открыт к критике и предложениям.
Задача
Реализовать сервис, который будет сохранять разные модели данных.
В iOS не то что бы много вариантов реализаций БД, в основном это CoreData и Realm, есть много сторонних, но в проде чаще всего встречаются они, потому чувак который посмотрит твое тестовое ожидает увидеть один из вариантов.
План
Меня устраивает Realm, потому что проще настраивать стек, по перфомансу он быстрее нативной Core Data, модели проще писать, плюс избегаешь всяких стрёмных NSManagedObject, NSManagedObjectContext, координаторы и т.д. - создал модель и все, а еще это mongoDB и NoSQL (так и скажи на собесе, делай вид, что шаришь (собственно я так и делаю)).
Опционально, но я любитель реактивщины и Realm поддерживает RxSwift - еще один плюс в его сторону, но субъективный (можно почитать отдельно про freeze на досуге, полезная фича от realm в контексте RxSwift).
В плане архитектуры все просто - пишем основу CRUD и юзаем ее инстанс в репозиториях для разделения сохраняемых моделей, например: есть модель аэропорта и полета - на каждую модель свой репозиторий.
Плюс такого подхода в том, что будет достаточно просто разделять ответственность за разные данные и не запутаться в условном god object, ну и если ВДРУГ мы захотим подменить Realm на что-то еще (ну если только в проде фичетогглами и то не факт...) - мы сделаем это безболезненней, потому что логика репозитория останется неизменной, изменению подвергентся только основа.
Нам потребуются модели для персистентного слоя и для подсистем приложения, чтобы не рулить сырыми данными и подготовить их для UI, да и не работать с первыми напрямую, чтобы не усложнять код.
Итого к имплементации:
Модели для персистентного слоя
DTO модели
CRUD сервис
Репозитории
Реализация
Шаг первый - модели персистентного слоя
Для менеджмента хранимых данных в 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()
}
}
Итог
В целом получили незамысловатый код, который просто читать, открыт к критике, всем добра!