Объединение Core Data с Дженериками

31 января 2022

Объединение Core Data с Дженериками

Если вы столкнулись с багом в Combine, скорее всего, вы только начали использовать фреймворк для всех видов асинхронной логики в своей кодовой базе. Одной из таких областей, с которой я в последнее время много взаимодействую в этом отношении, является Core Data.

Когда Apple в iOS 13 представила Combine, выпуск включал в себя несколько удобных publisher API-шек для NSManagedObject, в основном, в виде publishers(-ов) для наблюдения за ключом и значением и соответствия протоколу ObservableObject. Однако, несмотря на это, есть еще много чего желать.

Извлечение, добавление и удаление объектов - асинхронны по своей природе, и подходили бы идеально к миру publisher в Combine. К счастью, с Deferred Futures мы можем с минимальными усилиями прикрепить функциональность publisher к нашим взаимодействиям с Core Data:

func addTodo(context: NSManagedObjectContext, title: String) -> AnyPublisher {
    Deferred { [context] in
        Future { promise in
            context.perform {
                let todo = Todo(context: context)
                todo.title = title
                do {
                    try context.save()
                    promise(.success(todo))
                } catch {
                    promise(.failure(error))
                }
            }
        }
    }
    .receive(on: DispatchQueue.main)
    .eraseToAnyPublisher()
}

Однако в проекте, который охватывает несколько сущностей Core Data, обертывание такого рода логики может привести к большому количеству повторяющегося кода. Давайте узнаем, как мы можем использовать обобщения для прикрепления функциональности publisher к любому объекту Core Data.

Выявление общих взаимодействий

Если бы мы ознакомились с каким-либо проектом Core Data, когда речь идет о его управляемых объектах, скорее всего мы бы определили следующие общие взаимодействия:

  • Получение списка объектов с возможностью фильтрации или сортировки
  • Получение одного существующего объекта
  • Добавление нового объекта
  • Обновление существующего объекта
  • Удаление существующего объекта

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

class CoreDataRepository {
    private let context: NSManagedObjectContext

    init(context: NSManagedObjectContext) {
        self.context = context
    }
}

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

Мы также требуем, чтобы наш класс был создан с помощью NSManagedObjectContext. Следуя шаблону инверсии управления (IoC) с нашим контекстом, мы предоставляем потребителям класса гибкость в определении контекста в зависимости от обстоятельства (т.е. визуального или фонового контекста).

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

Получение списка объектов

Получение объектов в Core Data обычно включает в себя создание запроса на выборку и, при необходимости, предоставление дополнительных фильтров или спецификаций сортировки в виде NSPredicates или NSSortDescriptors:

let request: NSFetchRequest = Todo.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \Todo.title, ascending: true)]
do {
    let todos = try context.fetch(request)
    return todos
} catch {
    debugPrint("an error occurred \(error.localizedDescription)")
}

Если бы мы сделали эту функциональность более многоразовой и скорректировали ее для работы с нашей новой универсальной реализацией publisher, мы могли бы определить метод следующим образом:

class CoreDataRepository {
    //...

    func fetch(sortDescriptors: [NSSortDescriptor] = [],
               predicate: NSPredicate? = nil) -> AnyPublisher {
        Deferred { [context] in
            Future { promise in
                context.perform {
                    let request = Entity.fetchRequest()
                                        request.sortDescriptors = sortDescriptors
                    request.predicate = predicate
                    do {
                        let results = try context.fetch(request) as! [Entity]
                        promise(.success(results))
                    } catch {
                        promise(.failure(error))
                    }
                }
            }
        }
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
    }
    //..
}

Запрос конкретного объекта

Фреймворк Core Data предоставляет несколько методов для запроса существующего объекта. Для нашего класса репозитория мы собираемся предположить, что хотели бы получить объект, только если он существует, а в противном случае - выдать ошибку. Здесь мы можем использовать метод existsObject в классе NSManagedObjectContext:

guard let todo = try? context.existingObject(with: id) as? Todo else {
    return
}

Сделав вышесказанное более многоразовым, мы можем применить его к нашему общему классу с помощью следующего:

enum RepositoryError: Error {
    case objectNotFound
}

class CoreDataRepository {
    // ..

    func object(_ id: NSManagedObjectID) -> AnyPublisher {
        Deferred { [context] in
            Future { promise in
                context.perform {
                    guard let entity = try? context.existingObject(with: id) as? Entity else {
                        promise(.failure(RepositoryError.objectNotFound))
                        return
                    }
                    promise(.success(entity))
                }
            }
        }
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
    }
    // ..
}

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

Добавление нового объекта

Когда дело доходит до создания или добавления новых объектов, составление общей реализации может быть немного сложным. Это связано с тем, что каждая сущность будет иметь свои собственные соответствующие атрибуты, которые собираются до сохранения вновь добавленного объекта:

let todo = Todo(context: context)
todo.title = title
todo.createdAt = Date()
do {
    try context.save()
} catch {
        debugPrint("an error occurred \(error.localizedDescription)")
}

Как можно собрать функцию, которая предоставляет вызывающей стороне возможность применять изменения к объекту до его сохранения? Здесь мы можем использовать удобный in-out параметр Swift:

class CoreDataRepository {
    // ..
    func add(_ body: @escaping (inout Entity) -> Void) -> AnyPublisher {
        Deferred { [context] in
            Future  { promise in
                context.perform {
                    var entity = Entity(context: context)
                    body(&entity)
                    do {
                        try context.save()
                        promise(.success(entity))
                    } catch {
                        promise(.failure(error))
                    }
                }
            }
        }
        .eraseToAnyPublisher()
    }
        //..
}

Обновление или удаление существующего объекта

Обновление или удаление объекта немного проще, особенно если ожидается немедленное сохранение изменений:

class CoreDataRepository {
    // ..

    func update(_ entity: Entity) -> AnyPublisher {
        Deferred { [context] in
            Future { promise in
                context.perform {
                    do {
                        try context.save()
                        promise(.success(()))
                    } catch {
                        promise(.failure(error))
                    }
                }
            }
        }
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
    }

    func delete(_ entity: Entity) -> AnyPublisher {
        Deferred { [context] in
            Future { promise in
                context.perform {
                    do {
                        context.delete(entity)
                        try context.save()
                        promise(.success(()))
                    } catch {
                        promise(.failure(error))
                    }
                }
            }
        }
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
    }

    // ..
}

Собираем все это вместе

Теперь, когда мы определили функциональность реализации нашего общего репозитория, мы можем прикрепить функциональность CRUD (Create, Read, Update, Delete) publisher к любому объекту, который наследуется от класса NSManagedObject:

// create a repo for the relevant entity with the relevant context
let repo = CoreDataRepository(context: context)

// add an entity
repo.add { todo in
    todo.title = "Hello Generics"
}
.sink { completion in
    switch completion {
    case .failure(let error):
        debugPrint("an error occurred \(error.localizedDescription)")
    case .finished:
        break
    }
} receiveValue: { todo in
    debugPrint("todo has been added")
}

// fetch entities
repo.fetch(sortDescriptors: [NSSortDescriptor(keyPath: \Todo.title, ascending: true)])
    .replaceError(with: [])
    .sink { todos in
        debugPrint("\(todos.count) todos fetched")
    }

// get an existing object
repo.object(todoId)
    .sink { completion in
        switch completion {
        case .failure(let error):
            debugPrint("an error occurred \(error.localizedDescription)")
        case .finished:
            break
        }
    } receiveValue: { todo in
        debugPrint("hello \(todo.title) object")
    }

// update an entity
todo.title = "updated title"
repo.update(todo)
    .sink { completion in
        switch completion {
        case .failure(let error):
            debugPrint("an error occurred \(error.localizedDescription)")
        case .finished:
            break
        }
    } receiveValue: { _ in
        debugPrint("todo updated")
    }

// delete an entity
repo.delete(todo)
    .sink { completion in
        switch completion {
        case .failure(let error):
            debugPrint("an error occurred \(error.localizedDescription)")
        case .finished:
            break
        }
    } receiveValue: { _ in
        debugPrint("todo deleted")
    }

Вывод

Обобщения позволяют нам писать гибкие, повторно используемые функции, которые могут работать с любым типом, выступая в качестве отличного инструмента для предотвращения дублирования или повторения кода. В этой статье мы узнали, как можно задействовать их для присоединения многократно используемой функциональности publisher к любому объекту Core Data.

Оригинал статьи

Содержание