Всем привет! Меня зовут Игорь Сорокин. В этой статье я поделюсь историей о том, куда нас завёл очередной рефакторинг, как мы оттуда выбрались, попутно разработав слой хранения данных. Также приведу практические примеры реализации, покажу подход, который удалось разработать, и расскажу об особенностях, с которыми мы столкнулись. Но обо всём по порядку.
Статья будет полезна, если вы задумались о внедрении базы данных в своё приложение или уже используете её, но без единого подхода. Если же вы не используете базу данных, то, скорее всего, вы счастливый человек.
Предыстория
Исторически так сложилось, что для работы с сетью и сохранения ответов в базу данных в Юле использовался 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
}
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/