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

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

Предыстория

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    func first<Query>(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<FromModel, ToModel>]
}

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<DomainModelMapper: DomainModelCoreDataMapper>: Repository<DomainModelMapper.DomainModel, DomainModelMapper.DBEntity> {
    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<Model>) -> Any {
        return model[keyPath: primaryKeyPath]
    }

    private func entityPrimaryKey(_ entity: DBEntity, primaryKeyPath: PartialKeyPath<DBEntity>) -> 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, PersistMapper>(_ model: Model,
                                                    mapper: PersistMapper,
                                                    completion: ((Result<DomainModel, Error>) -> 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<EntityMO>(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<UserResponse, UserMO>] {
        // Указываем первичные ключи
        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)
    }

}

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

final class ProfileInteractor: ProfileInteractorInput {
    // Репозиторий для работы с базой
    private let userRepository: Repository<User, UserMO>
    // Сервис для работы с апи
    private let userService: UserServiceDescription
    // id пользователя, с которым работаем
    private let userId: String

    init(userId: String) {
        self.userId = userId
        // В качестве хранилища используем CoreData
        userRepository = CoreDataRepository(domainModelMapper: UserCoreDataMapper(), contextProvider: YoulaCoreDataContextProvider.default)
        userService = UserService()
    }

    func obtainUser(id: String, completion: @escaping (Result<User, Error>) -> Void) {
        // Запрашиваем пользователя с сервера
        userService.obtainUser(id: id, completion: { [weak self] result in
            switch result {
            case .success(let userResponse):
                // Пользователь получен (UserResponse)
                // Можем сохранить UserResponse, главное передать маппер, который умеет это делать
                // На выходе репозиторий вернет доменную модель (User), поэтому результат мы сразу возвращаем в completion
                self?.userRepository.persist(userResponse, mapper: UserResponsePersistableMapper(), completion: completion)
            case .failure(let error):
                completion(.failure(error))
            }
        })
    }

    func updateUserName(_ newName: String) {
        // Создаем объект для фильтрации
        // Объект содержит NSPredicate и массив NSSortDescriptor
        let query = UserIdQuery(id: userId)

        // Запрашиваем первого пользователя
        guard var user = userRepository.first(matching: query) else {
            return
        }

        // Меняем имя
        user.name = newName
        // Сохраняем обновленного пользователя, передаем соответствующий маппер
        userRepository.persist(user, mapper: UserPersistableMapper(), completion: nil)
    }

		private struct UserIdQuery: SortedDatabaseQuery {
        typealias DBEntity = UserMO

        let id: String

        var predicate: NSPredicate? {
            return NSPredicate(format: "%K == %@", #keyPath(DBEntity.identifier), id)
        }

        var sortDescriptors: [NSSortDescriptor] {
            return []
        }
    }

}

Круто! Но это не конец

Казалось бы, всё готово! Но мы столкнулись ещё с одним челленджем. На один и тот же запрос наш бэкенд может возвращать разный набор полей. Нужно было добиться следующей логики: если поле пришло в ответе, то его следует перезаписать в сущности при сохранении, если же не пришло, то поле в сущности не трогать, поскольку там могло быть значение, которое приходило в других ответах.

Для декодинга мы собирались использовать Codable, но с ним решить эту проблему невозможно, так как поле, которое пришло со значением null, и поле, которое не пришло вообще, в модели будут представлены как nil. Поэтому при сохранении в базу мы не сможем узнать, надо обновлять поле или нет.

Немного покумекав, мы сделали специальный Property Wrapper, а чтобы его можно было использовать при декодинге, расширили KeyedDecodingContainer. Благодаря враперу можно обращаться к полю как к обычному опциональному полю, а если потребуется дополнительная информация, то обратиться к projectedValue через нотацию — $.

@propertyWrapper
public struct DetailedResponse<Value> {

    public enum Response {

        // поле не пришло
        case notCome 
	
        // поле пришло, но null
        case null 

        // поле пришло со значением 
        case value(Value) 
    }

    public let wrappedValue: Value?
    public let projectedValue: Response

    public init(response: Response) {
        self.projectedValue = response

        if case let .value(value) = response {
            self.wrappedValue = value
        } else {
            self.wrappedValue = nil
        }
    }
}

public extension KeyedDecodingContainer {

    func decodeDetailResponse<T>(_ type: T.Type, forKey key: KeyedDecodingContainer<Key>.Key) throws -> DetailedResponse<T> where T: Decodable {
        guard contains(key) else {
            return DetailedResponse(response: .notCome)
        }

        if let result = try decodeIfPresent(type, forKey: key) {
            return DetailedResponse(response: .value(result))
        } else {
            return DetailedResponse(response: .null)
        }
    }

}

Разработчикам остаётся пометить нужное поле как @DetailedResponse и реализовать инициализатор Decodable. А в маппере при сохранении можно опираться на projectedValue, которое предоставит информацию о том, пришло поле или нет.

Предположим, что поле isOnline в модели нашего юзера иногда может не приходить. В этом случае наш декодинг надо переделать следующим образом:

struct UserResponse: Decodable {
    let identifier: String
    let name: String
    let type: String
    let image: ImageResponse

    // Помечаем враппером 
    @DetailedResponse var isOnline: Bool?

    enum CodingKeys: String, CodingKey { ... }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        identifier = try container.decode(String.self, forKey: .identifier)
        name = try container.decode(String.self, forKey: .name)
        type = try container.decode(String.self, forKey: .type)
        image = try container.decode(ImageResponse.self, forKey: .image)

        // Декодим с помощью специального метода
        _isOnline = try container.decodeDetailResponse(Bool.self, forKey: .isOnline)
}

struct User {
    let identifier: String
    let name: String
    let type: UserType
    let image: Image

    // Теперь поле опциональное
    let isOnline: Bool?
}

При сохранении смотрим на projectedValue.

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

    private let imageResponseMapper = ImageResponsePersistableMapper()

    func updateEntity(_ entity: inout UserMO, from model: UserResponse) {
        entity.name = model.name
        entity.type = model.type

        // Свичимся по DetailedResponse.Response
        switch model.$isOnline {
        case .notCome:
            break
        case .null:
            entity.isOnline = nil
        case .value(let newIsOnline):
            entity.isOnline = newIsOnline as NSNumber
        }

        guard let context = entity.managedObjectContext else {
            return
        }

        entity.image = imageResponseMapper.entity(from: model.image, in: context)
    }

    func keyPathsForPrimaryKeys() -> [PrimaryKeysPaths<UserResponse, UserMO>] {
        return [PrimaryKeysPaths(modelKeyPath: \UserResponse.identifier,
                                 entityKeyPath: \UserMO.identifier)]
    }
}

Вместо итога

На данный момент мы зарефакторили более половины менеджеров с использованием новых подходов: api-сервисы на Alamofire + репозиторий. И уже видим улучшения, ради которых это всё затевали. А именно:

  1. постепенно уменьшаем зависимость от RestKit и скоро сможем его выпилить;

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

  3. нет нужды заморачиваться с деталями CoreData — можно оперировать доменными моделями.

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

PS: При разработке вдохновлялись статьей https://habr.com/ru/post/542752/

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