Тишина должна быть в библиотеке! Как мы рефачили библиотеку для работы с API и создали свой Repository

Всем привет! Меня зовут Игорь Сорокин. В этой статье я поделюсь историей о том, куда нас завёл очередной рефакторинг, как мы оттуда выбрались, попутно разработав слой хранения данных. Также приведу практические примеры реализации, покажу подход, который удалось разработать, и расскажу об особенностях, с которыми мы столкнулись. Но обо всём по порядку.

Статья будет полезна, если вы задумались о внедрении базы данных в своё приложение или уже используете её, но без единого подхода. Если же вы не используете базу данных, то, скорее всего, вы счастливый человек.

f57c15b8e793be5d8c5dca49240f9d83.png

Предыстория

Исторически так сложилось, что для работы с сетью и сохранения ответов в базу данных в Юле использовался RestKit — мощная библиотека с большим функционалом, написанная ещё на старом добром Objective-C. Она позволяет делать api-запросы, декодить JSON в NSManagedObject сущности и тут же сохранять их в CoreData. Однако библиотека неумолимо постарела, а её поддержка и вовсе остановлена. Да и ребята в iOS-команде неохотно ею пользовались.

Так, однажды на пятничной встрече в офисе за круглым столом и со вкусной пиццей, мы вместе с командой платформы окончательно решили выпилить RestKit и заменить его на Alamofire. Почему именно Alamofire? Все просто: это современная библиотека, написанная на Swift. У неё большое комьюнити и хорошая поддержка. К тому же, большинство iOS-разработчиков с ней знакомы. Одним словом, profit!

Написать новый api-клиент — задача несложная, но перед нами встал вопрос: как быть с сохранением ответов в базу? Работа с CoreData была размазана по всему проекту, отсутствовал единый подход, и это порождало некоторые проблемы: запутанность кода, сложности онбординга, трудности с многопоточностью.

66ba88c4382315b02c4165ccfbba3c4b.jpeg

Мы решили, что если уж переписывать api-менеджеры, то качественно! Так, перед нами встала новая задача — спроектировать слой хранения данных. Вот какие особенности реализации мы учли в целях:

  • цель №1: описать общий интерфейс так, чтобы при необходимости можно было заменить CoreData на другое хранилище;

  • цель №2: скрыть детали реализации — разработчик должен оперировать доменными моделями, а не сущностями и контекстами;

  • цель №3: иметь возможность в одну и ту же сущность сохранять разные модели.

Мы запаслись терпением, пиццей и смузи, и пошли работать…

Проектирование интерфейса

Отталкиваясь от наших целей, мы накидали желаемый интерфейс и назвали это Repository. Ниже представлены его методы:

class Repository {
    func persist(_ model: Model,
                                       mapper: PersistMapper,
                                       completion: ((Result) -> Void)?) where PersistMapper: PersistableMapper,
			                                                                                            PersistMapper.FromModel == Model,
		                                                                                              PersistMapper.ToModel == DBEntity { ... }
    func persist(_ models: [Model],
                                       mapper: PersistMapper,
                                       completion: ((Result<[DomainModel], Error>) -> Void)?) where PersistMapper: PersistableMapper,
			                                                                                              PersistMapper.FromModel == Model,
		                                                                                                PersistMapper.ToModel == DBEntity { ... }

    func fetch(searchQuery: Query, completion: @escaping (Result<[DomainModel], Error>) -> Void) where Query: SortedDatabaseQuery, Query.DBEntity == DBEntity { ... }

    func fetchAll(completion: @escaping (Result<[DomainModel], Error>) -> Void) { ... }

    func removeAll(matching query: Query, completion: ((Result) -> Void)?) where Query: DatabaseQuery, Query.DBEntity == DBEntity { ... }

    func removeAll(completion: ((Result) -> Void)?) { ... }

    func count(matching query: Query) -> Int where Query: DatabaseQuery, Query.DBEntity == DBEntity { ... }

    func count() -> Int { ... }

    func first(matching query: Query) -> DomainModel? where Query: SortedDatabaseQuery, Query.DBEntity == DBEntity { ... }

    func first() -> DomainModel? { ... }
}

Так как Repository дженериковый, мы решили использовать абстрактный класс, чтобы избежать танцев с бубнами при размывании типов. DomainModel — тип доменной модели, с которой работает репозиторий, а DBEntity — сущность, которая ассоциируется с доменной моделью. Repository содержит основные CRUD-методы, такие как сохранение/обновление, выборка, удаление, а также метод для запроса на количество элементов.

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

В общем виде работу с репозиторием мы представляли так:

Проектирование интерфейсаПроектирование интерфейса

Сохранение

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

protocol PersistableMapper {
    associatedtype FromModel
    associatedtype ToModel

    func createEntity(from model: FromModel) -> ToModel
    func updateEntity(_ entity: inout ToModel, from model: FromModel)
    func keyPathsForPrimaryKeys() -> [PrimaryKeysPaths]
}

PersistableMapper содержит два дженериковых типа:

  1. FromModel — модель, которую нужно конвертировать;

  2. ToModel — сущность, в которую нужно конвертировать. 

Для конвертации мы добавили два метода:

  1. updateEntity (: from:) — для обновления полей, когда в базе уже есть нужная сущность;

  2. createEntity (from:) — для создания сущности и заполнения её данными.

Помимо этого, протокол требует реализовать метод keyPathsForPrimaryKeys (), который возвращает массив PrimaryKeysPaths. Это структура, содержащая PartialKeyPath для модели и сущности. По сути, это первичные ключи, по которым мы будем понимать, есть ли у нас соответствующая сущность в базе или нет. Если значения всех первичных ключей равны, то считается, что модель и сущность описывают один и тот же объект.

Фильтрация и сортировка

Для выборки и удаления нужно было придумать способ фильтрования и сортировки элементов. Мы не хотели напрямую передавать NSPredicate и NSSortDescriptor, поскольку есть вероятность ошибиться — например, передать NSPredicate, предназначенный для другой сущности. Поэтому решили добавить новый уровень абстракции. Создали специальные протоколы: DatabaseQuery — для выборки, SortedDatabaseQuery — для выборки и сортировки.

protocol SortedDatabaseQuery: DatabaseQuery {
    var sortDescriptors: [NSSortDescriptor] { get }
}

protocol DatabaseQuery {
    associatedtype DBEntity

    var predicate: NSPredicate? { get }
}

Данные протоколы дженериковые — чтобы знать, для какой сущности предназначен Query-объект. Благодаря этому не получится передать Query, созданную для работы с одной сущностью, в Repository, работающий с другой. Это своеобразная защита, и мы получим ошибку на этапе компиляции.

На следующем этапе нужно было реализовать новый протокол, используя CoreData.

Реализация репозитория

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

protocol DomainModelCoreDataMapper {
    associatedtype DomainModel
    associatedtype DBEntity: NSManagedObject

    func model(from entity: DBEntity) -> DomainModel?
}

Заметьте, что помимо метода конвертации, у протокола есть требование, что DBEntity должен быть NSManagedObject.

CoreDataRepository тоже дженериковый, но в отличие от Repository ему нужно указать не тип модели и сущности, а сразу маппер, который реализует протокол DomainModelCoreDataMapper, за счёт чего сущность всегда будет NSManagedObject или его наследником.

final class CoreDataRepository: Repository {
    typealias DomainModel = DomainModelMapper.DomainModel
    typealias EntityMO = DomainModelMapper.DBEntity
	
		init(domainModelMapper: DomainModelMapper, contextProvider: CoreDataContextProvider) {
        self.contextProvider = contextProvider
        self.domainModelMapper = domainModelMapper
    }
		...
}

При создании также передаётся протокол CoreDataContextProvider. Его реализует YoulaCoreDataContextProvider. Это синглтон-объект, предоставляющий настроенный контекст для CoreDataRepository.

При сохранении модели мы должны проверить, есть ли такая сущность в базе. Если нет, то создать её. В рамках CoreData эту обязанность мы возложили на PersistableMapper, так как он знает первичные ключи и способ обновления сущности. Кроме того, мы могли бы добавить вложенный маппер, чтобы иметь возможность обновлять связи (relations) сущности. Наше расширение выглядит так:

extension PersistableMapper where ToModel: NSManagedObject {
    typealias Model = FromModel
    typealias DBEntity = ToModel

    // Вспомогательные методы для получения значений по PartialKeyPath
    private func modelPrimaryKey(_ model: Model, primaryKeyPath: PartialKeyPath) -> Any {
        return model[keyPath: primaryKeyPath]
    }

    private func entityPrimaryKey(_ entity: DBEntity, primaryKeyPath: PartialKeyPath) -> Any {
        return entity[keyPath: primaryKeyPath]
    }

    // Реализуем обязательный метод протокола	
    func createEntity(from model: FromModel) -> ToModel {
        return DBEntity(entity: DBEntity.entity(), insertInto: nil)
    }

    // Вспомогательные методы для создания/нахождения сущности и обновления полей
    //
    // Алгоритм: 
    // Запрашиваем/создаем сущность
    // Если создаем сущность через метод createEntity(from:), то ее нужно вставить в контекст
    // Обновляем поля с помощью метода протокола updateEntity(:from:)
    // Возвращаем сущность
    @discardableResult
    func entity(from model: Model, in context: NSManagedObjectContext) -> DBEntity { ... }

    @discardableResult
    func entities(from models: [Model], in context: NSManagedObjectContext) -> [DBEntity] { ... }

}

Теперь можно приступить к реализации методов репозитория. В качестве примера я привёл реализацию сохранения и выборки. Остальные методы реализованы похожим образом:

private func persistModel(_ model: Model,
                                                    mapper: PersistMapper,
                                                    completion: ((Result) -> Void)?) where DomainModelMapper.DBEntity == PersistMapper.ToModel,
		                                                                                                                             Model == PersistMapper.FromModel,
		                                                                                                                             PersistMapper: PersistableMapper {
        // Получаем worker context
        let context = contextProvider.workerContext()
        context.perform { [weak self] in
            guard let self = self else {
                return
            }

            // Маппер найдет в базе нужную сущность, если такой нет, то создаст ее
            // Заполнит ее значениями из model
            // Вернет NSManagedObject
            let entity = mapper.entity(from: model, in: context)

            // Конвертируем полученную сущность в доменную модель
            guard let domainModel = self.domainModelMapper.model(from: entity) else {
                // Вызываем completion на main потоке с ошибкой маппинга
                DispatchQueue.main.asyncCompletion(completion: completion, with: .failure(CoreDataError.modelMapping))
                return
            }

            // сохраняем контекст
            self.safelySaveToPersistentStore(context: context, completion: { error in
                if let error = error {
                    completion?(.failure(error))
                } else {
                    completion?(.success(domainModel))
                }
            })
        }
    }

		private func fetchModels(predicate: NSPredicate?,
                             sortDescriptors: [NSSortDescriptor]?,
                             completion: @escaping (Result<[DomainModel], Error>) -> Void) {

        // Получаем worker context
        let context = contextProvider.workerContext()

        context.perform { [weak self] in
            guard let self = self else {
                return
            }

            // Создаем NSFetchRequest
            let entityName = EntityMO.entity().name ?? ""
            let fetchRequest = NSFetchRequest(entityName: entityName)
            fetchRequest.sortDescriptors = sortDescriptors
            fetchRequest.predicate = predicate

            do {
                // Получаем все сущности
                let entities = try context.fetch(fetchRequest)
                var domainModels: [DomainModel] = []

                // Конвертируем сущности в доменные модели
                for entity in entities {
                    guard let domainModel = self.domainModelMapper.model(from: entity) else {
										// Вызываем completion на main потоке с ошибкой маппинга
												DispatchQueue.main.asyncCompletion(completion: completion, with: .failure(CoreDataError.modelMapping))
                        return
                    }
                    domainModels.append(domainModel)
                }

                // Вызываем completion на main потоке с переданным результатом
                DispatchQueue.main.asyncCompletion(completion: completion, with: .success(domainModels))
            } catch {
                DispatchQueue.main.asyncCompletion(completion: completion, with: .failure(error))
            }
        }
    }

Попробуем?

Посмотрим, что у нас получилось. Допустим, перед нами стоит задача: сделать запрос на пользователя и сохранить его в базу. Вместо использования CoreData напрямую попробуем использовать репозиторий. Для начала проверим, какие модели у нас есть.

// Модель ответа с сервера
struct UserResponse: Decodable {
    let identifier: String
    let name: String
    let type: String
    let image: ImageResponse
    let isOnline: Bool
}

// Доменная модель
struct User {
    let identifier: String
    let name: String
    let type: UserType
    let image: Image
    let isOnline: Bool
}

// Сущность в CoreData
final class UserMO: NSManagedObject {
    @NSManaged var identifier: String?
    @NSManaged var name: String?
    @NSManaged var type: String?
    @NSManaged var image: ImageMO?
    @NSManaged var isOnline: NSNumber?
}

Для сохранения моделей нужно реализовать Persistable-мапперы. Реализуем маппер из UserResponse в UserMO. Маппер из User в UserMO будет выглядеть аналогично:

struct UserResponsePersistableMapper: PersistableMapper {
    typealias FromModel = UserResponse
    typealias ToModel = UserMO

    // Вложенный маппер, отвечающий за конвертацию ImageResponse в ImageMO
    private let imageResponseMapper = ImageResponsePersistableMapper()

    func updateEntity(_ entity: inout UserMO, from model: UserResponse) {
        // Обновляем поля
        entity.name = model.name
        entity.type = model.type
        entity.isOnline = model.isOnline as NSNumber

        guard let context = entity.managedObjectContext else {
            return
        }

        // Для обновления связи используем метод из нашего расширения
        entity.image = imageResponseMapper.entity(from: model.image, in: context)
    }

    func keyPathsForPrimaryKeys() -> [PrimaryKeysPaths] {
        // Указываем первичные ключи
        return [PrimaryKeysPaths(modelKeyPath: \UserResponse.identifier,
                                 entityKeyPath: \UserMO.identifier)]
    }
}

Также нужно написать CoreDataMapper для конвертации UserMO в доменный User.

final class UserCoreDataMapper: DomainModelCoreDataMapper {
    typealias DomainModel = User
    typealias DBEntity = UserMO

    // Вложенный маппер, отвечающий за конвертацию ImageMO в Image
    private let imageMapper = ImageCoreDataMapper()

    func model(from entity: UserMO) -> User? {
        guard
            let identifier = entity.identifier,
            let name = entity.name,
            let type = UserType(rawValue: entity.type ?? ""),
            let imageEntity = entity.image,
            let image = imageMapper.model(from: imageEntity),
            let isOnline = entity.isOnline?.boolValue
        else {
            return
        }

        return User(identifier: identifier,
                    name: name,
                    type: type,
                    image: image,
                    isOnline: isOnline)
    }

}

Модели готовы, мапперы написаны. Пристегнитесь, мы взлетаем!

© Habrahabr.ru