Тишина должна быть в библиотеке! Как мы рефачили библиотеку для работы с 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 {
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 содержит два дженериковых типа:
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: 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)
}
}
Модели готовы, мапперы написаны. Пристегнитесь, мы взлетаем!