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

Предыстория
Исторически так сложилось, что для работы с сетью и сохранения ответов в базу данных в Юле использовался 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 содержит два дженериковых типа:
FromModel — модель, которую нужно конвертировать;
ToModel — сущность, в которую нужно конвертировать.
Для конвертации мы добавили два метода:
updateEntity(:from:) — для обновления полей, когда в базе уже есть нужная сущность;
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 nil } 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 + репозиторий. И уже видим улучшения, ради которых это всё затевали. А именно:
постепенно уменьшаем зависимость от RestKit и скоро сможем его выпилить;
писать и понимать код стало проще, выработался единый подход для работы с базой;
нет нужды заморачиваться с деталями CoreData — можно оперировать доменными моделями.
Велком в комменты — обсудить нашу реализацию, поделиться болью или предложить рекомендации.
PS: При разработке вдохновлялись статьей https://habr.com/ru/post/542752/
