Просто о важном: миграция баз данных в iPhone-приложениях

IT-копирайтер
Время чтения: 5 минут
Задача контроля версий баз данных и миграции с одной версии на другую встречается очень часто, особенно, у разработчиков популярных приложений, регулярно выпускающих обновления.
Предположим, мы работаем над приложением, в котором используется список друзей. Для первой версии было достаточно хранить информацию о друге в виде {NSString *fullName; NSUInteger identifier} (рисунок слева). Версия была написана быстро и сразу ушла в AppStore, чтобы понять, заинтересует ли продукт пользователей, и определить дальнейшую стратегию.
Вскоре становится ясно, что хранить identifier в NSUInteger неправильно и имя пользователя нужно хранить по двум строкам (рисунок справа). Все три атрибута являются строками.
Возникает вопрос: как быть пользователям, которые уже пользуются приложением? Автомиграция не сможет даже из NSNumber преобразовать в строку, что же говорить про пропавший fullName.
Выход первый — удалить на устройстве существующий файл БД и создать новый с новой моделью. Выход второй — сделать миграцию с первой версии на вторую. Как это сделать, я сегодня и расскажу.
Для начала необходимо создать Mapping Model (New File > Core Data > Mapping Model). В качестве source-модели выбрать первую версию модель, в качестве destination — вторую.
Создадим класс, являющийся подклассом NSEntityMigrationPolicy. В нем необходимо переопределить метод миграции:
createDestinationInstancesForSourceInstance:entityMapping:manager:error:
В данном примере он выглядит следующим образом:
- (BOOL)createDestinationInstancesForSourceInstance:(NSManagedObject *)sInstance entityMapping:(NSEntityMapping *)mapping manager:(NSMigrationManager *)manager error:(NSError **)error { NSManagedObject *newObject = [NSEntityDescription insertNewObjectForEntityForName:[mapping destinationEntityName] inManagedObjectContext:[manager destinationContext]]; NSString *fullName = [sInstance valueForKey:@"fullName"]; NSArray *nameComponentsArray = [fullName componentsSeparatedByString:@" "]; NSString *lastName = [nameComponentsArray lastObject]; NSString *firstName = nameComponentsArray[0]; [newObject setValue:firstName forKey:@"firstName"]; [newObject setValue:lastName forKey:@"lastName"]; [manager associateSourceInstance:sInstance withDestinationInstance:newObject forEntityMapping:mapping]; return YES; }
Не нужно реализовывать миграцию всех классов, а только тех, которые не смогут мигрировать автоматически. В Mapping Model мы опишем, какие классы нужно мигрировать вручную, а какие могут мигрировать автоматически.
Если ситуация сложнее, чем в приведенном примере: у вас уже есть несколько различных версий модели и мигрировать надо на новую версию, то в выше описанную функцию можно передать идентификаторы версий моделей и в зависимости от них описать различное поведение.
При открытии модели можно использовать:
+ (NSManagedObjectModel *)mergedModelFromBundles:(NSArray *)bundles forStoreMetadata:(NSDictionary *)metadata
Но она подходит только, если у вас всего одна модель в проекте. В случае, если она не одна, поможет метод ниже. Имя модели, для которой мы будем описывать процесс миграции — dataModel.
NSURL *dstStoreURL = [[NSBundle mainBundle] URLForResource:@"dataModel.momd" withExtension:nil]; NSManagedObjectModel *model = [[NSManagedObjectModel alloc] initWithContentsOfURL:dstStoreURL];
Создадим координатор для взаимодействия с хранилищем модели:
NSPersistentStoreCoordinator *coordinator = [[NSPersistentStoreCoordinator alloc] initWithManagedObjectModel:model]; NSError *error = nil;
Далее нужно скопировать старую модель базы данных с новым именем, а исходную удалить:
NSFileManager * fileManager = [[NSFileManager alloc] init]; NSArray *paths = NSSearchPathForDirectoriesInDomains(NSLibraryDirectory,NSUserDomainMask, YES); NSString *documentsDirectory = [paths objectAtIndex:0]; NSString *documentDBFolderPath = [documentsDirectory stringByAppendingPathComponent:@"Application Support/PROJECT_NAME/dataModel.sqlite"]; NSURL *storeFileURL = [NSURL fileURLWithPath:documentDBFolderPath]; NSMutableString *url = [[NSMutableString alloc] initWithString:documentDBFolderPath]; NSRange range = [url rangeOfString:storeFileName]; if (range.location != NSNotFound) { [url replaceCharactersInRange:range withString:@"newDataModel.sqlite"]; } NSURL *newStoreFileURL = [NSURL fileURLWithPath:url]; [url release]; [fileManager moveItemAtURL:storeFileURL toURL:newStoreFileURL error:&error ]; error = nil;
Как и конечную исходную версию модели (т.к. у нас не одна модель в проекте) будем открывать по URL:
NSURL *storeURL = [dstStoreURL URLByAppendingPathComponent:@"dataModel.mom"]; NSManagedObjectModel *sourceModel = [[NSManagedObjectModel alloc] initWithContentsOfURL:storeURL];
В идеале хотелось бы просто открыть Mapping Model с указанием исходной и конечной модели. Но скорее всего у вас это не получится, модель будет нулевой: mappingModel == nil. Поэтому как и ранее необходимо найти адрес карты миграции в NSBundle.
Далее откроем модель миграции:
NSURL *migrationModelUrl = [[NSBundle mainBundle] URLForResource:@"ModelMigration" withExtension:@"cdm"]; NSMappingModel *mappingModel = [[NSMappingModel alloc] initWithContentsOfURL:migrationModelUrl];
Создадим менеджер миграции:
NSMigrationManager *manager = [[NSMigrationManager alloc] initWithSourceModel:sourceModel destinationModel:model];
Есть вероятность, что вы получите ошибку о несовпадении существующего файла базы данных с моделью. Это означает, что автоматическое составление карты миграции не сработало и нужно самим прописать хэши классов от исходной и конечной версий модели.
error = nil; NSArray *newEntityMappings = [NSArray arrayWithArray:mappingModel.entityMappings]; for (NSEntityMapping *entityMapping in newEntityMappings) { [entityMapping setSourceEntityVersionHash:[sourceModel.entityVersionHashesByName valueForKey:entityMapping.sourceEntityName]]; [entityMapping setDestinationEntityVersionHash:[model.entityVersionHashesByName valueForKey:entityMapping.destinationEntityName]]; } mappingModel.entityMappings = newEntityMappings;
И вот сама миграция:
BOOL result = [manager migrateStoreFromURL:newStoreFileURL type:NSSQLiteStoreType options:nil withMappingModel:mappingModel toDestinationURL:storeFileURL destinationType:NSSQLiteStoreType destinationOptions:nil error:&error];
Теперь по URL storeFileUrl лежит актуальный файл базы данных, который можно открыть с конечной версией модели.
В документации Apple описан более простой путь: создаете карту миграции, открываете ее с помощью указания исходной и конечной версий модели, а затем просто вызывается Migration Manager. Но, к сожалению, это не сработает, даже если у вас уже просто несколько версий одной модели из-за несоответствия карты и моделей.
Если вы используете несколько разных моделей для хранения разных данных и храните данные в разных файлах базы данных, то придется идти этим путем.
Комментарии