Русский
Русский
English
Статистика
Реклама

Core Data Repository pattern. Детали реализации

Всем привет! Данный пост рассчитан на людей, которые имеют представление о Core Data. Если вы не один из них, прочитайте краткую информацию тут и скорее присоединяйтесь. Прежде всего, мне хотелось бы поделиться своим взглядом на некоторые подходы к организации работы с данными в IOS приложениях, и в этом вопросе мне пришелся по душе паттерн репозиторий (далее - репозиторий). Поехали!

Немного про репозиторий

Основная идея репозитория заключается в том, чтобы абстрагироваться от источников данных, скрыть детали реализации, ведь, c точки зрения доступа к данным, эта самая реализация - не играет роли. Предоставить доступ к основным операциям над данными: сохранить, загрузить или удалить - главная задача репозитория.

Таким образом, к его преимуществам можно отнести:

- отсутствие зависимостей от реализации репозитория. Под капотом может быть все, что угодно: коллекция в оперативной памяти, UserDefaults, KeyChain, Core Data, Realm, URLCache, отдельный файл в tmp и т.п.;

- разделение зон ответственности. Репозиторий выступает прослойкой между бизнес-логикой и способом хранения данных, отделяя одно от другого;

- формирование единого, более структурированного подхода в работе с данными.

В конечном итоге, все это благоприятно сказывается на скорости разработки, возможностях масштабирования и тестируемости проектов.

Ближе к деталям

Рассмотрим самый неблагоприятный сценарий использования Core Data.

Для общего понимания будем использовать класс DataProvider (далее - DP) - его задача получать данные откуда - либо (сеть, UI) и класть их в Repository . Также, при необходимости, DP может достать данные из репозитория, выступая для него фасадом. Под данными будем подразумевать массив доменных объектов. Именно ими оперирует репозиторий.

1. Core Data как один большой репозиторий с NSManagamentObject

Рисунок 1Рисунок 1

Идея оперировать NSManagedObject в качестве доменного объекта самая простая, но не самая удачная. При таком подходе перед нами встают сразу несколько проблем:

  1. Сore Data разрастается по всему проекту, вешая ненужные зависимости и нарушая зоны ответственности. Раскрываются детали реализации. Знать на чем завязана реализация репозитория - должен только репозиторий;

  2. Используя единый репозиторий для всех Data Provider, он будет разрастаться с появлением новых доменных объектов;

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

2. Core Data + DB Client

Первое, что приходит на ум, для решения проблем из предыдущего примера, это вынести логику работы с объектами в отдельный класс (назовем его DB Client), тогда наш Repository будет только сохранять и доставать объекты из хранилища, в то время, как вся логика по работе с объектами ляжет в DB Client. На выходе должно получиться что-то такое:

Рисунок 2Рисунок 2

Обе схемы решают проблему 1. (Core Data ограничивается DB Client и Repository), и частично могут решить проблему 2 и 3 на небольших проектах, но не исключают их полностью. Продолжая мысль дальше, возможно придти к следующей схеме:

Рисунок 3Рисунок 3
  1. Core Data можно ограничить только репозиторием. DB Client конвертирует доменные объекты в NSManagedObject и обратно;

  2. Repository больше не единый и он не разрастается;

  3. Логика работы с данными более структурирована и консолидирована

Здесь, стоит отметить, что вариантов композиции и декомпозиции классов, а также способов организации взаимодействия между ними - множество. Тем не менее, выше-описанная схема показывает еще одну, на мой взгляд, важную проблему: на каждый новый доменный объект, в лучшем случае, требуется заводить X2 объектов (DB Client и Repository). Поэтому, предлагаю рассмотреть еще один способ реализации. Для удобства, проект лежит тут.

Подготовка к реализации

В первую очередь, таким я вижу репозиторий:

protocol AccessableRepository {   //1   associatedtype DomainModel   //2   var actualSearchedData: Observable<[DomainModel]>? {get}      //3   func save(_ objects: [DomainModel], completion: @escaping ((Result<Void>) -> Void))   //4   func save(_ objects: [DomainModel], clearBeforeSaving: RepositorySearchRequest, completion: @escaping ((Result<Void>) -> Void))      //5   func present(by request: RepositorySearchRequest, completion: @escaping ((Result<[DomainModel]>) -> Void))      //6   func delete(by request: RepositorySearchRequest, completion: @escaping ((Result<Void>) -> Void))   //7   func eraseAllData(completion: @escaping ((Result<Void>) -> Void))}
  1. Доменный объект, которым оперирует репозиторий;

  2. Возможность подписаться на отслеживание изменений в репозитории;

  3. Сохранение объектов в репозиторий;

  4. Сохранение объектов с возможностью очистки старых данных в рамках одного контекста;

  5. Загрузка данных из репозитория;

  6. Удаление объектов из репозитория;

  7. Удаление всех данных из репозитория.

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

К сожалению, возможность работать с репозиторием через AccessableRepository отсутствует, о чем свидетельствует ошибка на рисунке 4:

Рисунок 4Рисунок 4

В таком случае, хорошо подходит Generic-реализация репозитория, которая выглядит следующим образом:

class Repository<DomainModel>: NSObject, AccessableRepository {   typealias DomainModel = DomainModel      var actualSearchedData: Observable<[DomainModel]>? {      fatalError("actualSearchedData must be overrided")   }      func save(_ objects: [DomainModel], completion: @escaping ((Result<Void>) -> Void)) {      fatalError("save(_ objects: must be overrided")   }   func save(_ objects: [DomainModel], clearBeforeSaving: RepositorySearchRequest, completion: @escaping ((Result<Void>) -> Void)) {      fatalError("save(_ objects vs clearBeforeSaving: must be overrided")   }      func present(by request: RepositorySearchRequest, completion: @escaping ((Result<[DomainModel]>) -> Void)) {      fatalError("present(by request: must be overrided")   }      func delete(by request: RepositorySearchRequest, completion: @escaping ((Result<Void>) -> Void)) {      fatalError("delete(by request: must be overrided")   }   func eraseAllData(completion: @escaping ((Result<Void>) -> Void)) {      fatalError("eraseAllData(completion: must be overrided")   }}
  1. NSObject нужен для взаимодействия с NSFetchResultController;

  2. AccessableRepository - для наглядности, прозрачности и порядка;

  3. FatalError играет роль предохранителя, чтобы всяк сюда входящий не использовал то, что не реализовано;

  4. Данное решение позволяет не привязываться к конкретной реализации, а также обойти предыдущую проблему:

Рисунок 5Рисунок 5

Для работы с выборкой объектов потребуются объект с двумя свойствами:

protocol RepositorySearchRequest {   /* NSPredicate = nil,  apply for all records    for deletion sortDescriptor is not Used    */   //1   var predicate: NSPredicate? {get}   //2   var sortDescriptors: [NSSortDescriptor] {get}}
  1. Условие для выборки, если условие отсуствует - выборка применяется ко всему набору данных;

  2. Условие для сортировки - может как отсуствтвоать так и присутствовать.

Поскольку, в дальнейшем может возникнуть необходимость использовать отдельный NSPersistentContainer (например для тестирования), который, в свою очередь, выступает источником контекста - нужно закрыть его протоколом:

protocol DBContextProviding {   //1   func mainQueueContext() -> NSManagedObjectContext   //2   func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void)}
  1. Контекст, с которым работает main Queue, необходим для использования NSFetchedResultsController;

  2. Требуется для выполнения операций с данными в фоновом потоке. Можно заменить на newBackgroundContext(). Про различия в работе этих двух методов можно прочитать тут.

Также, потребуются объекты, которые будут осуществлять конвертацию (мапинг) доменных моделей в объекты репозитория (NSManagedObject) и обратно:

class DBEntityMapper<DomainModel, Entity> {  //1   func convert(_ entity: Entity) -> DomainModel? {      fatalError("convert(_ entity: Entity: must be overrided")   }  //2   func update(_ entity: Entity, by model: DomainModel) {      fatalError("supdate(_ entity: Entity: must be overrided")   }  //3   func entityAccessorKey(_ entity: Entity) -> String {      fatalError("entityAccessorKey must be overrided")   }  //4   func entityAccessorKey(_ object: DomainModel) -> String {      fatalError("entityAccessorKey must be overrided")   }}
  1. Позволяет конвертировать NSManagedObject в доменную модель;

  2. Позволяет обновить NSManagedObject с помощью доменной модели;

  3. 4. - используется для связи между собой NSManagedObject и доменным объектом.

Когда-то, я использовал доменный объект, в качестве инициализатора NSManagedObject. С одной стороны, это было удобно, с другой стороны накладывало ряд ограничений. Например, когда использовались связи между объектами и один NSManagedObject создавал несколько других NSManagedObject. Такой подход размывал зоны ответственности и негативно сказывался на общей логике работы с данными.

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

enum DBRepositoryErrors: Error {   case entityTypeError   case noChangesInRepository}

Здесь абстрактная часть репозитория подходит к концу, осталось самои интересное - реализация.

Реализация

Для данного примера, подойдет простая реализация DBContextProvider (без каких-либо дополнительных параметров):

final class DBContextProvider {   //1   private lazy var persistentContainer: NSPersistentContainer = {      let container = NSPersistentContainer(name: "DataStorageModel")           container.loadPersistentStores(completionHandler: { (_, error) in         if let error = error as NSError? {            fatalError("Unresolved error \(error),\(error.userInfo)")         }         container.viewContext.automaticallyMergesChangesFromParent = true      })      return container   }()   //2   private lazy var mainContext = persistentContainer.viewContext}//3//MARK:- DBContextProviding implementationextension DBContextProvider: DBContextProviding {   func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void) {      persistentContainer.performBackgroundTask(block)   }   func mainQueueContext() -> NSManagedObjectContext {      self.mainContext   }}
  1. Инициализация NSPersistentContainer;

  2. Раньше такой подход избавлял от утечек памяти;

  3. Реализация DBContextProviding.

Основная составляющая репозитория выглядит следующим образом:

final class DBRepository<DomainModel, DBEntity>: Repository<DomainModel>, NSFetchedResultsControllerDelegate {      //1   private let associatedEntityName: String   //2   private let contextSource: DBContextProviding   //3   private var fetchedResultsController: NSFetchedResultsController<NSFetchRequestResult>?   //4   private var searchedData: Observable<[DomainModel]>?   //5   private let entityMapper: DBEntityMapper<DomainModel, DBEntity>      //6   init(contextSource: DBContextProviding, autoUpdateSearchRequest: RepositorySearchRequest?, entityMapper: DBEntityMapper<DomainModel, DBEntity>) {      self.contextSource = contextSource      self.associatedEntityName = String(describing: DBEntity.self)      self.entityMapper = entityMapper            super.init()     //7      guard let request = autoUpdateSearchRequest else { return }      self.searchedData  = .init(value: [])      //self.fetchedResultsController = configureactualSearchedDataUpdating(request)   }}
  1. Cвойство, которое будет использовать при работе с NSFetchRequest;

  2. DBContextProviding - для доступа к контексту, требуется для выполнения операций сохранения, загрузки, удаления;

  3. fetchedResultsController - необходим, когда требуется отслеживать изменения в NSPersistentStore (проще говоря, изменение объектов в базе);

  4. searchedData - зависит от fetchedResultsController и служит оберткой для fetchedResultsController, скрывая детали реализации и уведомляя подписчиков об изменениях в данных;

  5. entityMapper - конвертирует доменные объекты в NSManagedObject и обратно;

  6. Иницализатор;

  7. В случае если autoUpdateSearchReques != nil, выполняем конфигурацию fetchedResultsController для отслеживания изменениы в базе ;

Чтобы не порождать однотипный код для работы с контекстом, потребуется вспомогательный метод:

   private func applyChanges(context: NSManagedObjectContext, mergePolicy: Any = NSMergeByPropertyObjectTrumpMergePolicy, completion: ((Result<Void>) -> Void)? = nil) {      //1      context.mergePolicy = mergePolicy      switch context.hasChanges {      case true:         do {            //2            try context.save()         } catch {            ConsoleLog.logEvent(object: "DBRepository \(DBEntity.self)", method: "saveIn", "Error: \(error)")            completion?(Result.error(error))         }         ConsoleLog.logEvent(object: "DBRepository \(DBEntity.self)", method: "saveIn", "Saving Complete")         completion?(Result(value: ()))      case false:         //3         ConsoleLog.logEvent(object: "DBRepository \(DBEntity.self)", method: "saveIn", "No changes in context")         completion?(Result(error: DBRepositoryErrors.noChangesInRepository))      }   }
  1. mergePolicy - отвечает за то, как решаются конфликты при работе с контекстом. В данном случае, по умолчанию используется политика, которая отдает приоритет изменённым объектам, находящимся в памяти, а не в persistent store;

  2. Сохранение изменений в persistent store;

  3. Если изменения объектов отсутствуют, в completion-блок передается соответствующая ошибка.

Сохранение объектов реализовано следующим образом:

   private func saveIn(data: [DomainModel], clearBeforeSaving: RepositorySearchRequest?, completion: @escaping ((Result<Void>) -> Void))  {      contextSource.performBackgroundTask() { context in                  //1         if let clearBeforeSaving = clearBeforeSaving {            let clearFetchRequest = NSFetchRequest<NSManagedObject>(entityName: self.associatedEntityName)            clearFetchRequest.predicate = clearBeforeSaving.predicate            clearFetchRequest.includesPropertyValues = false            (try? context.fetch(clearFetchRequest))?.forEach({ context.delete($0) })         }                  //2         var existingObjects: [String: DBEntity] = [:]         let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: self.associatedEntityName)                  (try? context.fetch(fetchRequest) as? [DBEntity])?.forEach({            let accessor = self.entityMapper.entityAccessorKey($0)            existingObjects[accessor] = $0         })                  data.forEach({            let accessor = self.entityMapper.entityAccessorKey($0)            //3            let entityForUpdate: DBEntity? = existingObjects[accessor] ??  NSEntityDescription.insertNewObject(forEntityName: self.associatedEntityName, into: context) as? DBEntity            //4            guard let entity = entityForUpdate else { return }            self.entityMapper.update(entity, by: $0)         })         //5         self.applyChanges(context: context, completion: completion)      }   }
  1. Используется при необходимости удалить объекты, перед сохранением новых (в рамках текущего контекста);

  2. Выполняется выгрузка объектов, которые существуют в репозитории, для их дальнейшего изменения;

  3. Если объект с нужным entityAccessorKey отсутствует, создается новый экземпляр NSManagedObject;

  4. Выполнение мапинга свойств из доменного объекта в NSManagedObject;

  5. Применение выполненных изменений.

Важно: Возможно вас смутил п.2., данное решение оптимально на небольших наборах данных. Я выполнял замеры (ExampleCase3 в демо-проекте) на 10 000 записей, iPhone 6s Plus IOS 12.4.1 и получил следующие результаты:

  • время записи/перезаписи данных от 0,9 до 1.8 сек, cкачок потребления оперативной памяти в пике до 33 мб;

  • если убрать код в п2 и оставить только вставку новых объектов, то время записи/перезаписи данных +- 20 сек, cкачок потребления оперативной памяти в пике до 50 мб.

Для больших наборов данных я бы рекомендовал разделять их на части, использовать batchUpdate и batchDelete, а начиная с IOS 13 появился batchInsert.

Таким образом, реализация методов save cводится к вызову метода saveIn:

  override func save(_ objects: [DomainModel], completion: @escaping ((Result<Void>) -> Void)) {      saveIn(data: objects, clearBeforeSaving: nil, completion: completion)   }   override func save(_ objects: [DomainModel], clearBeforeSaving: RepositorySearchRequest, completion: @escaping ((Result<Void>) -> Void)) {      saveIn(data: objects, clearBeforeSaving: clearBeforeSaving, completion: completion)   }

Методы present, delete, eraseAllData завязаны на работе с NSFetchRequest. В их реализации нет ничего особенного, поэтому не вижу смысла заострять на них внимание:

 override func present(by request: RepositorySearchRequest, completion: @escaping ((Result<[DomainModel]>) -> Void)) {      //1      let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: associatedEntityName)      fetchRequest.predicate = request.predicate      fetchRequest.sortDescriptors = request.sortDescriptors      contextSource.performBackgroundTask() { context in         do {            //2            let rawData = try context.fetch(fetchRequest)            guard rawData.isEmpty == false else {return completion(Result(value: [])) }            guard let results = rawData as? [DBEntity] else {               completion(Result(value: []))               assertionFailure(DBRepositoryErrors.entityTypeError.localizedDescription)               return            }            //3            let converted = results.compactMap({ return self.entityMapper.convert($0) })            completion(Result(value: converted))         } catch {            completion(Result(error: error))         }      }   }      override func delete(by request: RepositorySearchRequest, completion: @escaping ((Result<Void>) -> Void)) {      //1      let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: associatedEntityName)      fetchRequest.predicate = request.predicate      fetchRequest.includesPropertyValues = false      contextSource.performBackgroundTask() { context in         //2         let results = try? context.fetch(fetchRequest)         results?.forEach({ context.delete($0) })         //3         self.applyChanges(context: context, completion: completion)      }   }   override func eraseAllData(completion: @escaping ((Result<Void>) -> Void)) {      //1      let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: associatedEntityName)      let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)            batchDeleteRequest.resultType = .resultTypeObjectIDs      contextSource.performBackgroundTask({ context in         do {            //2            let result = try context.execute(batchDeleteRequest)            guard let deleteResult = result as? NSBatchDeleteResult,                  let ids = deleteResult.result as? [NSManagedObjectID]            else {               completion(Result.error(DBRepositoryErrors.noChangesInRepository))               return            }                        let changes = [NSDeletedObjectsKey: ids]            NSManagedObjectContext.mergeChanges(               fromRemoteContextSave: changes,               into: [self.contextSource.mainQueueContext()]            )            //3            completion(Result(value: ()))            return         } catch {            ConsoleLog.logEvent(object: "DBRepository \(DBEntity.self)", method: "eraseAllData", "Error: \(error)")            completion(Result.error(error))         }      })
  1. Создание запроса;

  2. Выборка объектов и их обработка;

  3. Вовзрат результата операции.

Для реализации возможности отслеживания изменений данных в реальном времени, потребуется FetchedResultsController. Для его конфигурации используется следующий метод:

 private func configureactualSearchedDataUpdating(_ request: RepositorySearchRequest) -> NSFetchedResultsController<NSFetchRequestResult> {      //1      let fetchRequest: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: associatedEntityName)            fetchRequest.predicate = request.predicate      fetchRequest.sortDescriptors = request.sortDescriptors            //2      let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest,                                                                managedObjectContext: contextSource.mainQueueContext(), sectionNameKeyPath: nil,                                                                cacheName: nil)      //3      fetchedResultsController.delegate = self      try? fetchedResultsController.performFetch()      if let content = fetchedResultsController.fetchedObjects as? [DBEntity] {         updateObservableContent(content)      }      return fetchedResultsController   } func updateObservableContent(_ content: [DBEntity]) {      let converted = content.compactMap({ return self.entityMapper.convert($0) })   //4      searchedData?.value = converted   }
  1. Формирование запроса, на основании которого будут отслеживаться изменения;

  2. Создание экземпляра класса NSFetchedResultsController;

  3. performFetch() позволяет выполнить запрос и получить данные, не дожидаясь изменений в базе. Например, это может быть полезно при реализации Ofline First;

  4. Изменение свойства searchedData, в свою очередь уведомляет подписчиков (если такие имеются) об изменении.

Заключение

На этом этапе реализация всех основных методов для работы с репозиторием подходит к концу. Для меня основными преимуществами данного подхода стало следующее:

  • логика работы репозитория с Core Data стала везде единая;

  • для добавления новых объектов в репозиторий, достаточно создать только EntityMapper (новое Entity требуется создать в любом случае). Вся логика по мапингу свойств также собрана в одном месте;

  • Data слой стал более структурированным. Теперь можно точно гарантировать, что репозиторий не выполняет 100500 запросов в методе сохранения, чтобы проставить связи между объектами;

  • репозиторий легко можно подменить, например для тестов, или для отладки.

Подобный подход может подойти не всем, и это основной его минус. Часто логика работы с данными зависит в т.ч и от бэк-энда. Основная задача этого поста - донести один из концептов по работе с Core Data. Смотрите демо-проект, допиливайте его под себя, пользуйтесь!

Спасибо за внимание! Легкого кодинга, поменьше багов, побольше фич!

Источник: habr.com
К списку статей
Опубликовано: 10.05.2021 20:18:52
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Разработка под ios

Swift

Coredata

Core data

Repository

Ios development

Хранение данных

Данные приложения

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru