背景
很久以前就遇到過資料庫版本升級的引用場景,當時的做法是簡單的刪除舊的資料庫檔案,重建資料庫和表結構,這種暴力升級的方式會導致舊的資料的丟失,考慮到資料升級和資料遷移這個問題以後還會遇到,這算是一個常用的場景吧,所以發點時間把這部分做了一個簡單重構,實現了一個簡單的方案。
結果
一番努力之後,終於有了結果
專案的開源地址:YTBaseDBManager
使用 Pod 匯入,因為是開發庫,所以需要指定 :path 引數
pod 'YTBaseDBManager', :path => '../'
複製程式碼
客戶端使用的DEMO程式碼如下
- 客戶端使用方法
[self setDBFilePath:DBPath newDBVersion:DB_Version];
設定資料庫路徑 - 客戶端重寫模板方法
initTables
執行建立表的邏輯 - 底層庫會自動分析新表和舊錶,自動進行資料遷移的操作
/** 資料庫儲存的快取目錄 */
static NSString* kDBCache = @"DBCache";
/** 資料庫檔名稱 */
static NSString* DB_NAME = @"YTDB.sqlite";
/** 當前使用的資料庫版本,程式會根據版本號的改變升級資料庫以及遷移舊的資料 */
static NSString* DB_Version = @"1.0.0";
@implementation YTBusinessDBManager
- (instancetype)init {
self = [super init];
if (self) {
// 建立資料庫檔案
NSString* cachePath = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) objectAtIndex:0];
NSString *DBDir = [cachePath stringByAppendingPathComponent:kDBCache];
BOOL isDir = NO;
if (!([[NSFileManager defaultManager] fileExistsAtPath:DBDir isDirectory:&isDir] && isDir)) {
[[NSFileManager defaultManager] createDirectoryAtPath:DBDir withIntermediateDirectories :YES attributes :nil error :nil];
}
NSString* DBPath = [DBDir stringByAppendingPathComponent:DB_NAME];
// 設定資料庫路徑,包含了資料庫升級的邏輯
[self setDBFilePath:DBPath newDBVersion:DB_Version];
}
return self;
}
// 初始化資料表
- (void)initTables {
[VideoUploadModel createTableIfNotExists];
}
複製程式碼
問題分析
理想的情況是:資料庫升級,表結構、主鍵和約束有變化,新的表結構建立之後會自動的從舊的表檢索資料,相同的欄位進行對映遷移資料,而絕大多數的業務場景下的資料庫版本升級是隻涉及到欄位的增減、修改主鍵約束,所以下面要實現的方案也是從最基本的、最常用的業務場景去做一個實現,至於更加複雜的場景,可以在此基礎上進行擴充套件,達到符合自己的預期的。
網上搜尋了下,並沒有資料庫升級資料遷移簡單完整的解決方案,找到了一些思路
- 清除舊的資料,重建表
優點:簡單
缺點:資料丟失 - 在已有表的基礎上對錶結構進行修改
優點:能夠保留資料
缺點:規則比較繁瑣,要建立一個資料庫的欄位配置檔案,然後讀取配置檔案,執行SQL修改表結構、約束和主鍵等等,涉及到跨多個版本的資料庫升級就變得繁瑣並且麻煩了 - 建立臨時表,把舊的資料拷貝到臨時表,然後刪除舊的資料表並且把臨時表設定為資料表。
優點:能夠保留資料,支援表結構的修改,約束、主鍵的變更,實現起來比較簡單
缺點:實現的步驟比較多
綜合考慮,第三種方法是一個比較靠譜的方案。
方案的主要步驟
根據這個思路,分析了一下資料庫升級了主要步驟大概如下:
- 獲取資料庫中舊的表
- 修改表名,新增字尾“_bak”,把舊的表當做備份表
- 建立新的表
- 獲取新建立的表
- 遍歷舊的表和新表,對比取出需要遷移的表的欄位
- 資料遷移處理
- 刪除備份表
使用到的SQL語句分析
這些操作都是和資料庫操作有關係的,所以問題的關鍵是對應步驟的SQL語句了,下面分析下用到的主要的SQL語句:
- 獲取資料庫中舊的表
SELECT * from sqlite_master WHERE type='table'
複製程式碼
結果如下,可以看到有type | name | tbl_name | rootpage | sql 這些資料庫欄位,我們只要用到name
也就是資料庫名稱這個欄位就行了
sqlite> SELECT * from sqlite_master WHERE type='table'
...> ;
+-------+---------------+---------------+----------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| type | name | tbl_name | rootpage | sql |
+-------+---------------+---------------+----------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| table | t_message_bak | t_message_bak | 2 | CREATE TABLE "t_message_bak" (messageID TEXT, messageType INTEGER, messageJsonContent TEXT, retriveTimeString INTEGER, postTimeString INTEGER, readState INTEGER, PRIMARY KEY(messageID)) |
| table | t_message | t_message | 4 | CREATE TABLE t_message (
messageID TEXT,
messageType INTEGER,
messageJsonContent TEXT,
retriveTimeString INTEGER,
postTimeString INTEGER,
readState INTEGER,
addColumn INTEGER,
PRIMARY KEY(messageID)
) |
+-------+---------------+---------------+----------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
2 行於資料集 (0.03 秒)
複製程式碼
- 修改表名,新增字尾“_bak”,把舊的表當做備份表
-- 把t_message表修改為t_message_bak表
ALTER TABLE t_message RENAME TO t_message_bak
複製程式碼
- 獲取表欄位資訊
-- 獲取t_message_bak表的欄位資訊
PRAGMA table_info('t_message_bak')
複製程式碼
獲取到的表欄位資訊如下,可以看到有| cid | name | type | notnull | dflt_value | pk | 這些資料庫欄位,我們只要用到name
也就是欄位名稱這個欄位就行了
sqlite> PRAGMA table_info('t_message_bak');
+------+--------------------+---------+---------+------------+------+
| cid | name | type | notnull | dflt_value | pk |
+------+--------------------+---------+---------+------------+------+
| 0 | messageID | TEXT | 0 | NULL | 1 |
| 1 | messageType | INTEGER | 0 | NULL | 0 |
| 2 | messageJsonContent | TEXT | 0 | NULL | 0 |
| 3 | retriveTimeString | INTEGER | 0 | NULL | 0 |
| 4 | postTimeString | INTEGER | 0 | NULL | 0 |
| 5 | readState | INTEGER | 0 | NULL | 0 |
+------+--------------------+---------+---------+------------+------+
6 行於資料集 (0.01 秒)
複製程式碼
- 使用子查詢進行資料遷移處理
INSERT INTO t_message(messageID, messageType, messageJsonContent, retriveTimeString, postTimeString, readState) SELECT messageID, messageType, messageJsonContent, retriveTimeString, postTimeString, readState FROM t_message_bak
複製程式碼
把t_message_bak
表中的messageID, messageType, messageJsonContent, retriveTimeString, postTimeString, readState這些欄位的值複製到t_message
表中
程式碼實現
有了以上的分析,接下來的程式碼的實現就很簡單了
主要方法
// 資料庫版本控制主要方法
- (void)versionControlWithNewDBVersion:(NSString*)newDBVersion {
if (nil == _DBFilePath) {
return;
}
// 獲取新舊版本
NSString * version_old = YTBaseDBManager_ValueOrEmpty([self DBVersion]);
NSString * version_new = [NSString stringWithFormat:@"%@", newDBVersion];
NSLog(@"dbVersionControl before: %@ after: %@",version_old,version_new);
// 資料庫版本升級
if (version_old != nil && ![version_new isEqualToString:version_old]) {
// 獲取資料庫中舊的表
NSArray* existsTables = [self sqliteExistsTables];
NSMutableArray* tmpExistsTables = [NSMutableArray array];
// 修改表名,新增字尾“_bak”,把舊的表當做備份表
for (NSString* tablename in existsTables) {
[tmpExistsTables addObject:[NSString stringWithFormat:@"%@_bak", tablename]];
[self.databaseQueue inDatabase:^(FMDatabase *db) {
NSString* sql = [NSString stringWithFormat:@"ALTER TABLE %@ RENAME TO %@_bak", tablename, tablename];
[db executeUpdate:sql];
}];
}
existsTables = tmpExistsTables;
// 建立新的表
[self initTables];
// 獲取新建立的表
NSArray* newAddedTables = [self sqliteNewAddedTables];
// 遍歷舊的表和新表,對比取出需要遷移的表的欄位
NSDictionary* migrationInfos = [self generateMigrationInfosWithOldTables:existsTables newTables:newAddedTables];
// 資料遷移處理
[migrationInfos enumerateKeysAndObjectsUsingBlock:^(NSString* newTableName, NSArray* publicColumns, BOOL * _Nonnull stop) {
NSMutableString* colunmsString = [NSMutableString new];
for (int i = 0; i<publicColumns.count; i++) {
[colunmsString appendString:publicColumns[i]];
if (i != publicColumns.count-1) {
[colunmsString appendString:@", "];
}
}
NSMutableString* sql = [NSMutableString new];
[sql appendString:@"INSERT INTO "];
[sql appendString:newTableName];
[sql appendString:@"("];
[sql appendString:colunmsString];
[sql appendString:@")"];
[sql appendString:@" SELECT "];
[sql appendString:colunmsString];
[sql appendString:@" FROM "];
[sql appendFormat:@"%@_bak", newTableName];
[self.databaseQueue inDatabase:^(FMDatabase *db) {
[db executeUpdate:sql];
}];
}];
// 刪除備份表
[self.databaseQueue inDatabase:^(FMDatabase *db) {
[db beginTransaction];
for (NSString* oldTableName in existsTables) {
NSString* sql = [NSString stringWithFormat:@"DROP TABLE IF EXISTS %@", oldTableName];
[db executeUpdate:sql];
}
[db commit];
}];
[self setDBVersion:version_new];
} else {
[self setDBVersion:version_new];
}
}
複製程式碼
提取資料遷移的列
// 遍歷舊的表和新表,對比取出需要遷移的表的欄位
- (NSDictionary*)generateMigrationInfosWithOldTables:(NSArray*)oldTables newTables:(NSArray*)newTables {
NSMutableDictionary<NSString*, NSArray* >* migrationInfos = [NSMutableDictionary dictionary];
for (NSString* newTableName in newTables) {
NSString* oldTableName = [NSString stringWithFormat:@"%@_bak", newTableName];
if ([oldTables containsObject:oldTableName]) {
// 獲取表資料庫欄位資訊
NSArray* oldTableColumns = [self sqliteTableColumnsWithTableName:oldTableName];
NSArray* newTableColumns = [self sqliteTableColumnsWithTableName:newTableName];
NSArray* publicColumns = [self publicColumnsWithOldTableColumns:oldTableColumns newTableColumns:newTableColumns];
if (publicColumns.count > 0) {
[migrationInfos setObject:publicColumns forKey:newTableName];
}
}
}
return migrationInfos;
}
// 提取新表和舊錶的共同表欄位,表欄位相同列的才需要進行資料遷移處理
- (NSArray*)publicColumnsWithOldTableColumns:(NSArray*)oldTableColumns newTableColumns:(NSArray*)newTableColumns {
NSMutableArray* publicColumns = [NSMutableArray array];
for (NSString* oldTableColumn in oldTableColumns) {
if ([newTableColumns containsObject:oldTableColumn]) {
[publicColumns addObject:oldTableColumn];
}
}
return publicColumns;
}
複製程式碼
獲取資料庫表的所有列
// 獲取資料庫表的所有的表欄位名
- (NSArray*)sqliteTableColumnsWithTableName:(NSString*)tableName {
__block NSMutableArray<NSString*>* tableColumes = [NSMutableArray array];
[self.databaseQueue inDatabase:^(FMDatabase *db) {
NSString* sql = [NSString stringWithFormat:@"PRAGMA table_info('%@')", tableName];
FMResultSet *rs = [db executeQuery:sql];
while ([rs next]) {
NSString* columnName = [rs stringForColumn:@"name"];
[tableColumes addObject:columnName];
}
}];
return tableColumes;
}
複製程式碼
獲取資料庫中的表
// 獲取資料庫中舊的表
- (NSArray*)sqliteExistsTables {
__block NSMutableArray<NSString*>* existsTables = [NSMutableArray array];
[self.databaseQueue inDatabase:^(FMDatabase *db) {
NSString* sql = @"SELECT * from sqlite_master WHERE type='table'";
FMResultSet *rs = [db executeQuery:sql];
while ([rs next]) {
NSString* tablename = [rs stringForColumn:@"name"];
[existsTables addObject:tablename];
}
}];
return existsTables;
}
// 獲取新建立的表
- (NSArray*)sqliteNewAddedTables {
__block NSMutableArray<NSString*>* newAddedTables = [NSMutableArray array];
[self.databaseQueue inDatabase:^(FMDatabase *db) {
NSString* sql = @"SELECT * from sqlite_master WHERE type='table' AND name NOT LIKE '%_bak'";
FMResultSet *rs = [db executeQuery:sql];
while ([rs next]) {
NSString* tablename = [rs stringForColumn:@"name"];
[newAddedTables addObject:tablename];
}
}];
return newAddedTables;
}
複製程式碼
方案通用化
上面是資料庫升級資料遷移解決方案
的核心內容,在此基礎上新增點東西就可以讓這個方案可以通用了。
對於客戶端來說,客戶端關心的問題有以下:
- 資料庫檔案的路徑設定
- 觸發資料庫升級邏輯
- 資料表的建立
這些內容在不同的業務場景中都是不可缺少的必要部分,所以對可以對共同的部分做作一個封裝。
注入
對於資料庫檔案的路徑設定和觸發資料庫升級邏輯,底層庫只關心對應的引數,客戶端傳遞引數給底層庫,底層庫會進行處理,這也就是注入的部分,可以採用構造注入或者設定注入的方式來解耦這部分。設定注入靈活性更好一些,所以採用設定注入的方式,實現起來很簡單,就是新增一個設定資料庫路徑和資料庫新版本的方法就行了。
設定資料庫檔案路徑和版本號的方法,該方法除了設定資料庫檔案路徑,還進行了資料庫升級邏輯的操作,這部分對客戶端是隱藏的。
// !!!設定資料庫檔案路徑和版本號
- (void)setDBFilePath:(NSString *)DBFilePath newDBVersion:(NSString*)newDBVersion {
// 設定資料庫檔案路徑
_DBFilePath = DBFilePath;
[[NSFileManager defaultManager] setAttributes:[NSDictionary dictionaryWithObject:NSFileProtectionNone forKey:NSFileProtectionKey] ofItemAtPath:_DBFilePath error:NULL];
// 資料庫版本控制
// 當前的方法如果是放在初始化方法中
// versionControlWithNewDBVersion 方法呼叫 initTables 方法 會使用到當前單例物件
// 因為初始化未完成,所以會造成死鎖的問題,versionControlWithNewDBVersion 方法呼叫採用延遲的策略
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self versionControlWithNewDBVersion:newDBVersion];
});
}
複製程式碼
模板方法
對於資料表的建立,底層庫不關心具體的表建立邏輯,而只需要用到建立之後的表的名稱和表的欄位名稱這些資料,也就是建立表的結果,可以可以把這些內容延遲放置到子類中處理,所以這裡用到了模板方法模式。
#pragma mark - ......::::::: 模板方法,子類重寫 :::::::......
// 初始化資料表
- (void)initTables;
複製程式碼
子類重寫該方法執行表建立的邏輯
// 初始化資料表
- (void)initTables {
// 建立視訊上傳記錄表
[VideoUploadModel createTableIfNotExists];
}
複製程式碼
單例
資料庫操作是資源密集型的操作,建立多個物件會導致資源消耗嚴重,此外多個物件操作同一個資料庫檔案也會引入資料不一致等問題,所以這裡使用單例模式。
OC中標準的單例是不支援繼承的,這裡使用標準的方式,所以還是把單例放在子類中進行建立。
多說一句,OC可以使用Runtime的方式達到單例可繼承的目的,但是出於簡單和謹慎考慮沒有這麼做。
.h
// 子類的單例
+ (instancetype)sharedInstance;
.m
// 子類的單例
+ (instancetype)sharedInstance{
static id instance;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[self alloc] init];
});
return instance;
}
複製程式碼
One More Thing
專案的開源地址: YTBaseDBManager
TODO
專案依賴於FMDB,庫的公有屬性暴露給客戶端的是一個FMDatabaseQueue
類的物件,所以這裡存在耦合,暫時沒有想到好的辦法解除這個耦合。