iOS 數(shù)據(jù)庫升級數(shù)據(jù)遷移解決方案

背景

很久以前就遇到過數(shù)據(jù)庫版本升級的引用場景,當(dāng)時的做法是簡單的刪除舊的數(shù)據(jù)庫文件,重建數(shù)據(jù)庫和表結(jié)構(gòu)郑趁,這種暴力升級的方式會導(dǎo)致舊的數(shù)據(jù)的丟失,考慮到數(shù)據(jù)升級和數(shù)據(jù)遷移這個問題以后還會遇到捐寥,這算是一個常用的場景吧,所以發(fā)點時間把這部分做了一個簡單重構(gòu)祖驱,實現(xiàn)了一個簡單的方案握恳。

結(jié)果

一番努力之后,終于有了結(jié)果
項目的開源地址:YTBaseDBManager
使用 Pod 導(dǎo)入捺僻,因為是開發(fā)庫乡洼,所以需要指定 :path 參數(shù)

pod 'YTBaseDBManager', :path => '../'

客戶端使用的DEMO代碼如下

  1. 客戶端使用方法 [self setDBFilePath:DBPath newDBVersion:DB_Version]; 設(shè)置數(shù)據(jù)庫路徑
  2. 客戶端重寫模板方法 initTables 執(zhí)行創(chuàng)建表的邏輯
  3. 底層庫會自動分析新表和舊表,自動進行數(shù)據(jù)遷移的操作
/** 數(shù)據(jù)庫保存的緩存目錄 */
static NSString* kDBCache = @"DBCache";
/** 數(shù)據(jù)庫文件名稱 */
static NSString* DB_NAME = @"YTDB.sqlite";
/** 當(dāng)前使用的數(shù)據(jù)庫版本匕坯,程序會根據(jù)版本號的改變升級數(shù)據(jù)庫以及遷移舊的數(shù)據(jù) */
static NSString* DB_Version = @"1.0.0";

@implementation YTBusinessDBManager

- (instancetype)init {
    self = [super init];
    if (self) {
        // 創(chuàng)建數(shù)據(jù)庫文件
        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];
        
        // 設(shè)置數(shù)據(jù)庫路徑束昵,包含了數(shù)據(jù)庫升級的邏輯
        [self setDBFilePath:DBPath newDBVersion:DB_Version];
    }
    return self;
}

// 初始化數(shù)據(jù)表
- (void)initTables {
    [VideoUploadModel createTableIfNotExists];
}

問題分析

理想的情況是:數(shù)據(jù)庫升級,表結(jié)構(gòu)葛峻、主鍵和約束有變化锹雏,新的表結(jié)構(gòu)建立之后會自動的從舊的表檢索數(shù)據(jù),相同的字段進行映射遷移數(shù)據(jù)术奖,而絕大多數(shù)的業(yè)務(wù)場景下的數(shù)據(jù)庫版本升級是只涉及到字段的增減礁遵、修改主鍵約束,所以下面要實現(xiàn)的方案也是從最基本的采记、最常用的業(yè)務(wù)場景去做一個實現(xiàn)佣耐,至于更加復(fù)雜的場景,可以在此基礎(chǔ)上進行擴展唧龄,達到符合自己的預(yù)期的兼砖。

網(wǎng)上搜索了下,并沒有數(shù)據(jù)庫升級數(shù)據(jù)遷移簡單完整的解決方案选侨,找到了一些思路

  1. 清除舊的數(shù)據(jù)掖鱼,重建表
    優(yōu)點:簡單
    缺點:數(shù)據(jù)丟失
  2. 在已有表的基礎(chǔ)上對表結(jié)構(gòu)進行修改
    優(yōu)點:能夠保留數(shù)據(jù)
    缺點:規(guī)則比較繁瑣然走,要建立一個數(shù)據(jù)庫的字段配置文件援制,然后讀取配置文件,執(zhí)行SQL修改表結(jié)構(gòu)芍瑞、約束和主鍵等等晨仑,涉及到跨多個版本的數(shù)據(jù)庫升級就變得繁瑣并且麻煩了
  3. 創(chuàng)建臨時表,把舊的數(shù)據(jù)拷貝到臨時表,然后刪除舊的數(shù)據(jù)表并且把臨時表設(shè)置為數(shù)據(jù)表洪己。
    優(yōu)點:能夠保留數(shù)據(jù)妥凳,支持表結(jié)構(gòu)的修改,約束答捕、主鍵的變更逝钥,實現(xiàn)起來比較簡單
    缺點:實現(xiàn)的步驟比較多

綜合考慮,第三種方法是一個比較靠譜的方案拱镐。

方案的主要步驟

根據(jù)這個思路艘款,分析了一下數(shù)據(jù)庫升級了主要步驟大概如下:

  • 獲取數(shù)據(jù)庫中舊的表
  • 修改表名,添加后綴“_bak”,把舊的表當(dāng)做備份表
  • 創(chuàng)建新的表
  • 獲取新創(chuàng)建的表
  • 遍歷舊的表和新表沃琅,對比取出需要遷移的表的字段
  • 數(shù)據(jù)遷移處理
  • 刪除備份表

使用到的SQL語句分析

這些操作都是和數(shù)據(jù)庫操作有關(guān)系的哗咆,所以問題的關(guān)鍵是對應(yīng)步驟的SQL語句了,下面分析下用到的主要的SQL語句:

  • 獲取數(shù)據(jù)庫中舊的表
SELECT * from sqlite_master WHERE type='table' 

結(jié)果如下益眉,可以看到有type | name | tbl_name | rootpage | sql 這些數(shù)據(jù)庫字段晌柬,我們只要用到name也就是數(shù)據(jù)庫名稱這個字段就行了

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 行于數(shù)據(jù)集 (0.03 秒)
  • 修改表名,添加后綴“_bak”,把舊的表當(dāng)做備份表
-- 把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 | 這些數(shù)據(jù)庫字段年碘,我們只要用到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 行于數(shù)據(jù)集 (0.01 秒)
  • 使用子查詢進行數(shù)據(jù)遷移處理
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這些字段的值復(fù)制到t_message表中

代碼實現(xiàn)

有了以上的分析,接下來的代碼的實現(xiàn)就很簡單了

主要方法

// 數(shù)據(jù)庫版本控制主要方法
- (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);
    
    // 數(shù)據(jù)庫版本升級
    if (version_old != nil && ![version_new isEqualToString:version_old]) {
        
        // 獲取數(shù)據(jù)庫中舊的表
        NSArray* existsTables = [self sqliteExistsTables];
        NSMutableArray* tmpExistsTables = [NSMutableArray array];
        
        // 修改表名,添加后綴“_bak”展鸡,把舊的表當(dāng)做備份表
        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;
        
        // 創(chuàng)建新的表
        [self initTables];
        
        // 獲取新創(chuàng)建的表
        NSArray* newAddedTables = [self sqliteNewAddedTables];
        
        // 遍歷舊的表和新表盛泡,對比取出需要遷移的表的字段
        NSDictionary* migrationInfos = [self generateMigrationInfosWithOldTables:existsTables newTables:newAddedTables];
        
        // 數(shù)據(jù)遷移處理
        [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];
    }
}

提取數(shù)據(jù)遷移的列

// 遍歷舊的表和新表,對比取出需要遷移的表的字段
- (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]) {
            // 獲取表數(shù)據(jù)庫字段信息
            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;
}

// 提取新表和舊表的共同表字段娱颊,表字段相同列的才需要進行數(shù)據(jù)遷移處理
- (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;
}

獲取數(shù)據(jù)庫表的所有列

// 獲取數(shù)據(jù)庫表的所有的表字段名
- (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;
}

獲取數(shù)據(jù)庫中的表

// 獲取數(shù)據(jù)庫中舊的表
- (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;
}

// 獲取新創(chuàng)建的表
- (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;
}

方案通用化

上面是數(shù)據(jù)庫升級數(shù)據(jù)遷移解決方案的核心內(nèi)容傲诵,在此基礎(chǔ)上添加點東西就可以讓這個方案可以通用了。
對于客戶端來說箱硕,客戶端關(guān)心的問題有以下:

  • 數(shù)據(jù)庫文件的路徑設(shè)置
  • 觸發(fā)數(shù)據(jù)庫升級邏輯
  • 數(shù)據(jù)表的創(chuàng)建

這些內(nèi)容在不同的業(yè)務(wù)場景中都是不可缺少的必要部分拴竹,所以對可以對共同的部分做作一個封裝。

注入

對于數(shù)據(jù)庫文件的路徑設(shè)置觸發(fā)數(shù)據(jù)庫升級邏輯剧罩,底層庫只關(guān)心對應(yīng)的參數(shù)栓拜,客戶端傳遞參數(shù)給底層庫,底層庫會進行處理惠昔,這也就是注入的部分幕与,可以采用構(gòu)造注入或者設(shè)置注入的方式來解耦這部分。設(shè)置注入靈活性更好一些镇防,所以采用設(shè)置注入的方式啦鸣,實現(xiàn)起來很簡單,就是添加一個設(shè)置數(shù)據(jù)庫路徑和數(shù)據(jù)庫新版本的方法就行了来氧。

設(shè)置數(shù)據(jù)庫文件路徑和版本號的方法诫给,該方法除了設(shè)置數(shù)據(jù)庫文件路徑香拉,還進行了數(shù)據(jù)庫升級邏輯的操作,這部分對客戶端是隱藏的中狂。

// Y炻怠!胃榕!設(shè)置數(shù)據(jù)庫文件路徑和版本號
- (void)setDBFilePath:(NSString *)DBFilePath newDBVersion:(NSString*)newDBVersion {
    // 設(shè)置數(shù)據(jù)庫文件路徑
    _DBFilePath = DBFilePath;
    [[NSFileManager defaultManager] setAttributes:[NSDictionary dictionaryWithObject:NSFileProtectionNone forKey:NSFileProtectionKey] ofItemAtPath:_DBFilePath error:NULL];
    
    // 數(shù)據(jù)庫版本控制
    // 當(dāng)前的方法如果是放在初始化方法中
    // versionControlWithNewDBVersion 方法調(diào)用 initTables 方法 會使用到當(dāng)前單例對象
    // 因為初始化未完成盛险,所以會造成死鎖的問題,versionControlWithNewDBVersion 方法調(diào)用采用延遲的策略
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        [self versionControlWithNewDBVersion:newDBVersion];
    });
}

模板方法

對于數(shù)據(jù)表的創(chuàng)建勋又,底層庫不關(guān)心具體的表創(chuàng)建邏輯枉层,而只需要用到創(chuàng)建之后的表的名稱和表的字段名稱這些數(shù)據(jù),也就是創(chuàng)建表的結(jié)果赐写,可以可以把這些內(nèi)容延遲放置到子類中處理鸟蜡,所以這里用到了模板方法模式。

#pragma mark - ......::::::: 模板方法挺邀,子類重寫 :::::::......

// 初始化數(shù)據(jù)表
- (void)initTables;

子類重寫該方法執(zhí)行表創(chuàng)建的邏輯

// 初始化數(shù)據(jù)表
- (void)initTables {
    // 創(chuàng)建視頻上傳記錄表
    [VideoUploadModel createTableIfNotExists];
}

單例

數(shù)據(jù)庫操作是資源密集型的操作揉忘,創(chuàng)建多個對象會導(dǎo)致資源消耗嚴重,此外多個對象操作同一個數(shù)據(jù)庫文件也會引入數(shù)據(jù)不一致等問題端铛,所以這里使用單例模式泣矛。
OC中標(biāo)準(zhǔn)的單例是不支持繼承的,這里使用標(biāo)準(zhǔn)的方式禾蚕,所以還是把單例放在子類中進行創(chuàng)建您朽。
多說一句,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類的對象倍试,所以這里存在耦合讯屈,暫時沒有想到好的辦法解除這個耦合。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末县习,一起剝皮案震驚了整個濱河市涮母,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌躁愿,老刑警劉巖叛本,帶你破解...
    沈念sama閱讀 212,718評論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異彤钟,居然都是意外死亡来候,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,683評論 3 385
  • 文/潘曉璐 我一進店門样勃,熙熙樓的掌柜王于貴愁眉苦臉地迎上來吠勘,“玉大人性芬,你說我怎么就攤上這事峡眶【绶溃” “怎么了?”我有些...
    開封第一講書人閱讀 158,207評論 0 348
  • 文/不壞的土叔 我叫張陵辫樱,是天一觀的道長峭拘。 經(jīng)常有香客問我,道長狮暑,這世上最難降的妖魔是什么鸡挠? 我笑而不...
    開封第一講書人閱讀 56,755評論 1 284
  • 正文 為了忘掉前任,我火速辦了婚禮搬男,結(jié)果婚禮上拣展,老公的妹妹穿的比我還像新娘。我一直安慰自己缔逛,他們只是感情好备埃,可當(dāng)我...
    茶點故事閱讀 65,862評論 6 386
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著褐奴,像睡著了一般按脚。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上敦冬,一...
    開封第一講書人閱讀 50,050評論 1 291
  • 那天辅搬,我揣著相機與錄音,去河邊找鬼脖旱。 笑死堪遂,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的萌庆。 我是一名探鬼主播蚤氏,決...
    沈念sama閱讀 39,136評論 3 410
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼踊兜!你這毒婦竟也來了竿滨?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,882評論 0 268
  • 序言:老撾萬榮一對情侶失蹤捏境,失蹤者是張志新(化名)和其女友劉穎于游,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體垫言,經(jīng)...
    沈念sama閱讀 44,330評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡贰剥,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,651評論 2 327
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了筷频。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片蚌成。...
    茶點故事閱讀 38,789評論 1 341
  • 序言:一個原本活蹦亂跳的男人離奇死亡前痘,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出担忧,到底是詐尸還是另有隱情芹缔,我是刑警寧澤,帶...
    沈念sama閱讀 34,477評論 4 333
  • 正文 年R本政府宣布瓶盛,位于F島的核電站最欠,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏惩猫。R本人自食惡果不足惜芝硬,卻給世界環(huán)境...
    茶點故事閱讀 40,135評論 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望轧房。 院中可真熱鬧拌阴,春花似錦、人聲如沸奶镶。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,864評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽实辑。三九已至捺氢,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間剪撬,已是汗流浹背摄乒。 一陣腳步聲響...
    開封第一講書人閱讀 32,099評論 1 267
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留残黑,地道東北人馍佑。 一個月前我還...
    沈念sama閱讀 46,598評論 2 362
  • 正文 我出身青樓,卻偏偏與公主長得像梨水,于是被迫代替她去往敵國和親拭荤。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 43,697評論 2 351

推薦閱讀更多精彩內(nèi)容