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

автор Nikolay July

16 мая 2013

Задача контроля версий баз данных и миграции с одной версии на другую встречается очень часто, особенно, у разработчиков популярных приложений, регулярно выпускающих обновления.

Предположим, мы работаем над приложением, в котором используется список друзей. Для первой версии было достаточно хранить информацию о друге в виде {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. Но, к сожалению, это не сработает, даже если у вас уже просто несколько версий одной модели из-за несоответствия карты и моделей.

Если вы используете несколько разных моделей для хранения разных данных и храните данные в разных файлах базы данных, то придется идти этим путем.

  • 0 Репосты

Комментарии

Фильтр

Закрыть

Технологии

Индустрии