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

от автора

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

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

Предыстория

Исторически так сложилось, что для работы с сетью и сохранения ответов в базу данных в Юле использовался 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 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 + репозиторий. И уже видим улучшения, ради которых это всё затевали. А именно:

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

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

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

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/678120/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *