iOS 覆蓋率檢測原理與增量代碼測試覆蓋率工具實現(xiàn)

背景

對蘋果開發(fā)者而言,由于平臺審核周期較長,客戶端代碼導致的線上問題影響時間往往比較久。如果在開發(fā)、測試階段能夠提前暴露問題,就有助于避免線上事故的發(fā)生。代碼覆蓋率檢測正是幫助開發(fā)、測試同學提前發(fā)現(xiàn)問題,保證代碼質(zhì)量的好幫手。

對于開發(fā)者而言,代碼覆蓋率可以反饋兩方面信息:

  1. 自測的充分程度。
  2. 代碼設計的冗余程度。

盡管代碼覆蓋率對代碼質(zhì)量有著上述好處,但在 iOS 開發(fā)中卻使用的不多。我們調(diào)研了市場上常用的 iOS 覆蓋率檢測工具,這些工具主要存在以下四個問題:

  1. 第三方工具有時生成的檢測報告文件會出錯甚至會失敗,開發(fā)者對覆蓋率生成原理不了解,遇到這類問題容易棄用工具。
  2. 第三方工具每次展示全量的覆蓋率報告,會分散開發(fā)者的很多精力在未修改部分。而在絕大多數(shù)情況下,開發(fā)者的關注重點在本次新增和修改的部分。
  3. Xcode 自帶的覆蓋率檢測只適用于單元測試場景,由于需求變更頻繁,業(yè)務團隊開發(fā)單元測試的成本很高
  4. 已有工具很難和現(xiàn)有開發(fā)流程結(jié)合起來,需要額外進行測試,運行覆蓋率腳本才能獲取報告文件。

為了解決上述問題,我們深入調(diào)研了覆蓋率報告的生成邏輯,并結(jié)合團隊的開發(fā)流程,開發(fā)了一套嵌入在代碼提交流程中、基于單次代碼提交(git commit)生成報告、對開發(fā)者透明的增量代碼測試覆蓋率工具。開發(fā)者只需要正常開發(fā),通過模擬器測試開發(fā)代碼,commit 本次代碼(commit 和測試順序可交換),推送(git push)到遠端,就可以在本地看到這次提交代碼的詳細覆蓋率報告了。

本文分為兩部分,先從介紹通用覆蓋率檢測的原理出發(fā),讓讀者對覆蓋率的收集、解析有直觀的認識。之后介紹我們增量代碼測試覆蓋率工具的實現(xiàn)。

覆蓋率檢測原理

生成覆蓋率報告,首先需要在 Xcode 中配置編譯選項,編譯后會為每個可執(zhí)行文件生成對應的 .gcno 文件;之后在代碼中調(diào)用覆蓋率分發(fā)函數(shù),會生成對應的 .gcda 文件。

其中,.gcno 包含了代碼計數(shù)器和源碼的映射關系, .gcda 記錄了每段代碼具體的執(zhí)行次數(shù)。覆蓋率解析工具需要結(jié)合這兩個文件給出最后的檢測報表。接下來先看看 .gcno 的生成邏輯。

.gcno

利用 Clang 分別生成源文件的 AST 和 IR 文件,對比發(fā)現(xiàn),AST 中不存在計數(shù)指令,而 IR 中存在用來記錄執(zhí)行次數(shù)的代碼。搜索 LLVM 源碼可以找到覆蓋率映射關系生成源碼。覆蓋率映射關系生成源碼是 LLVM 的一個 Pass,(下文簡稱 GCOVPass)用來向 IR 中插入計數(shù)代碼并生成 .gcno 文件(關聯(lián)計數(shù)指令和源文件)。

下面分別介紹IR插樁邏輯和 .gcno 文件結(jié)構(gòu)。

IR 插樁邏輯

代碼行是否執(zhí)行到,需要在運行中統(tǒng)計,這就需要對代碼本身做一些修改,LLVM 通過修改 IR 插入了計數(shù)代碼,因此我們不需要改動任何源文件,僅需在編譯階段增加編譯器選項,就能實現(xiàn)覆蓋率檢測了。

從編譯器角度看,基本塊(Basic Block,下文簡稱 BB)是代碼執(zhí)行的基本單元,LLVM 基于 BB 進行覆蓋率計數(shù)指令的插入,BB 的特點是:

  1. 只有一個入口。
  2. 只有一個出口。
  3. 只要基本塊中第一條指令被執(zhí)行,那么基本塊內(nèi)所有指令都會順序執(zhí)行一次

覆蓋率計數(shù)指令的插入會進行兩次循環(huán),外層循環(huán)遍歷編譯單元中的函數(shù),內(nèi)層循環(huán)遍歷函數(shù)的基本塊。函數(shù)遍歷僅用來向 .gcno 中寫入函數(shù)位置信息,這里不再贅述。

一個函數(shù)中基本塊的插樁方法如下:

  1. 統(tǒng)計所有 BB 的后繼數(shù) n,創(chuàng)建和后繼數(shù)大小相同的數(shù)組 ctr[n]。
  2. 以后繼數(shù)編號為序號將執(zhí)行次數(shù)依次記錄在 ctr[i] 位置,對于多后繼情況根據(jù)條件判斷插入。

舉個例子,下面是一段猜數(shù)字的游戲代碼,當玩家猜中了我們預設的數(shù)字10的時候會輸出Bingo,否則輸出You guessed wrong!。這段代碼的控制流程圖如圖1所示。

- (void)guessNumberGame:(NSInteger)guessNumber
{
    NSLog(@"Welcome to the game");
    if (guessNumber == 10) {
        NSLog(@"Bingo!");
    } else {
        NSLog(@"You guess is wrong!");
    }
}

例1 猜數(shù)字游戲

這段代碼如果開啟了覆蓋率檢測,會生成一個長度為 6 的 64 位數(shù)組,對照插樁位置,方括號中標記了樁點序號,圖 1 中代碼前數(shù)字為所在行數(shù)。

image

圖 1 樁點位置

.gcno計數(shù)符號和文件位置關聯(lián)

.gcno 是用來保存計數(shù)插樁位置和源文件之間關系的文件。GCOVPass 在通過兩層循環(huán)插入計數(shù)指令的同時,會將文件及 BB 的信息寫入 .gcno 文件。寫入步驟如下:

  1. 創(chuàng)建 .gcno 文件,寫入 Magic number(oncg+version)。
  2. 隨著函數(shù)遍歷寫入文件地址、函數(shù)名和函數(shù)在源文件中的起止行數(shù)(標記文件名,函數(shù)在源文件對應行數(shù))。
  3. 隨著 BB 遍歷,寫入 BB 編號、BB 起止范圍、BB 的后繼節(jié)點編號(標記基本塊跳轉(zhuǎn)關系)。
  4. 寫入函數(shù)中BB對應行號信息(標注基本塊與源碼行數(shù)關系)。

從上面的寫入步驟可以看出,.gcno 文件結(jié)構(gòu)由四部分組成:

  • 文件結(jié)構(gòu)
  • 函數(shù)結(jié)構(gòu)
  • BB 結(jié)構(gòu)
  • BB 行結(jié)構(gòu)

通過這四部分結(jié)構(gòu)可以完全還原插樁代碼和源碼的關聯(lián),我們以 BB 結(jié)構(gòu) / BB 行結(jié)構(gòu)為例,給出結(jié)構(gòu)圖 2 (a) BB 結(jié)構(gòu),(b) BB 行信息結(jié)構(gòu),在本章末尾覆蓋率解析部分,我們利用這個結(jié)構(gòu)圖還原代碼執(zhí)行次數(shù)(每行等高格代表 64bit):

image

圖2 BB 結(jié)構(gòu)和 BB 行信息結(jié)構(gòu)

.gcda

入口函數(shù)

關于 .gcda 的生成邏輯,可參考覆蓋率數(shù)據(jù)分發(fā)源碼。這個文件中包含了 __gcov_flush() 函數(shù),這個函數(shù)正是分發(fā)邏輯的入口。接下來看看 __gcov_flush() 如何生成 .gcda 文件。

通過閱讀代碼和調(diào)試,我們發(fā)現(xiàn)在二進制代碼加載時,調(diào)用了llvm_gcov_init(writeout_fn wfn, flush_fn ffn)函數(shù),傳入了_llvm_gcov_writeout(寫 gcov 文件),_llvm_gcov_flush(gcov 節(jié)點分發(fā))兩個函數(shù),并且根據(jù)調(diào)用順序,分別建立了以文件為節(jié)點的鏈表結(jié)構(gòu)。(flush_fn_node * ,writeout_fn_node *

__gcov_flush() 代碼如下所示,當我們手動調(diào)用__gcov_flush()進行覆蓋率分發(fā)時,會遍歷flush_fn_node *這個鏈表(即遍歷所有文件節(jié)點),并調(diào)用分發(fā)函數(shù)_llvm_gcov_flush(curr->fn 正是__llvm_gcov_flush函數(shù)類型)。

void __gcov_flush() {
    struct flush_fn_node *curr = flush_fn_head;

    while (curr) {
        curr->fn();
        curr = curr->next;
    }
}

具體的分發(fā)邏輯

觀察__llvm_gcov_flush的 IR 代碼,可以看到:

image

圖3 __llvm_gcov_flush 代碼示例

  1. __llvm_gcov_flush先調(diào)用了__llvm_gcov_writeout,來向 .gcda 寫入覆蓋率信息。
  2. 最后將計數(shù)數(shù)組清零__llvm_gcov_ctr.xx

__llvm_gcov_writeout邏輯為:

  1. 生成對應源文件的 .gcda 文件,寫入 Magic number。

  2. 循環(huán)執(zhí)行
    llvm_gcda_emit_function: 向 .gcda 文件寫入函數(shù)信息。

    llvm_gcda_emit_arcs: 向 .gcda 文件寫入BB執(zhí)行信息,如果已經(jīng)存在 .gcda 文件,會和之前的執(zhí)行次數(shù)進行合并

  3. 調(diào)用llvm_gcda_summary_info,寫入校驗信息。

  4. 調(diào)用llvm_gcda_end_file,寫結(jié)束符。

感興趣的同學可以自己生成 IR 文件查看更多細節(jié),這里不再贅述。

.gcda 的文件/函數(shù)結(jié)構(gòu)和 .gcno 基本一致,這里不再贅述,統(tǒng)計插樁信息結(jié)構(gòu)如圖 4 所示。定制化的輸出也可以通過修改上述函數(shù)完成。我們的增量代碼測試覆蓋率工具解決代碼 BB 結(jié)構(gòu)變動后合并到已有 .gcda 文件不兼容的問題,也是修改上述函數(shù)實現(xiàn)的。

image

圖4 計數(shù)樁輸出結(jié)構(gòu)

覆蓋率解析

在了解了如上所述 .gcno ,.gcda 生成邏輯與文件結(jié)構(gòu)之后,我們以例 1 中的代碼為例,來闡述解析算法的實現(xiàn)。

例 1 中基本塊 B0,B1 對應的 .gcno 文件結(jié)構(gòu)如下圖所示,從圖中可以看出,BB 的主結(jié)構(gòu)完全記錄了基本塊之間的跳轉(zhuǎn)關系。

image

圖5 B0,B1 對應跳轉(zhuǎn)信息

B0,B1 的行信息在 .gcno 中表示如下圖所示,B0 塊因為是入口塊,只有一行,對應行號可以從 B1 結(jié)構(gòu)中獲取,而 B1 有兩行代碼,會依次把行號寫入 .gcno 文件。

image

圖6 B0,B1 對應行信息

在輸入數(shù)字 100 的情況下,生成的 .gcda 文件如下:

image

圖7 輸入 100 得到的 .gcda 文件

通過控制流程圖中節(jié)點出邊的執(zhí)行次數(shù)可以計算出 BB 的執(zhí)行次數(shù),核心算法為計算這個 BB 的所有出邊的執(zhí)行次數(shù),不存在出邊的情況下計算所有入邊的執(zhí)行次數(shù)(具體實現(xiàn)可以參考 gcov 工具源碼),對于 B0 來說,即看 index=0 的執(zhí)行次數(shù)。而 B1 的執(zhí)行次數(shù)即 index=1,2 的執(zhí)行次數(shù)的和,對照上圖中 .gcda 文件可以推斷出,B0 的執(zhí)行次數(shù)為 ctr[0]=1,B1 的執(zhí)行次數(shù)是 ctr[1]+ctr[2]=1, B2 的執(zhí)行次數(shù)是 ctr[3]=0,B4 的執(zhí)行次數(shù)為 ctr[4]=1,B5 的執(zhí)行次數(shù)為 ctr[5]=1。

經(jīng)過上述解析,最終生成的 HTML 如下圖所示(利用 lcov):

image

圖8 覆蓋率檢測報告

以上是 Clang 生成覆蓋率信息和解析的過程,下面介紹美團到店餐飲 iOS 團隊基于以上原理做的增量代碼測試覆蓋率工具。

增量代碼覆蓋率檢測原理

方案權衡

由于 gcov 工具(和前面的 .gcov 文件區(qū)分,gcov 是覆蓋率報告生成工具)生成的覆蓋率檢測報告可讀性不佳,如圖 9 所示。我們做的增量代碼測試覆蓋率工具是基于 lcov 的擴展,報告展示如上節(jié)末尾圖 8 所示。

image

圖9 gcov 輸出,行前數(shù)字代表執(zhí)行次數(shù),#### 代表沒執(zhí)行

比 gcov 直接生成報告多了一步,lcov 的處理流程是將 .gcno 和 .gcda 文件解析成一個以 .info 結(jié)尾的中間文件(這個文件已經(jīng)包含全部覆蓋率信息了),之后通過覆蓋率報告生成工具生成可讀性比較好的 HTML 報告。

結(jié)合前兩章內(nèi)容和覆蓋率報告生成步驟,覆蓋率生成流程如下圖所示。考慮到增量代碼覆蓋率檢測中代碼增量部分需要通過 Git 獲取,比較自然的想法是用 git diff 的信息去過濾覆蓋率的內(nèi)容。根據(jù)過濾點的不同,存在以下兩套方案:

  1. 通過 GCOVPass 過濾,只對修改的代碼進行插樁,每次修改后需重新插樁。
  2. 通過 .info 過濾,一次性為所有代碼插樁,獲取全部覆蓋率信息,過濾覆蓋率信息。
image

圖10 覆蓋率生成流程

分析這兩個方案,第一個方案需要自定義 LLVM 的 Pass,進而會引入以下兩個問題:

  • 只能使用開源 Clang 進行編譯,不利于接入正常的開發(fā)流程。
  • 每次重新插樁會丟失之前的覆蓋率信息,多次運行只能得到最后一次的結(jié)果。

而第二個方案相對更加輕量,只需要過濾中間格式文件,不僅可以解決我們在文章開頭提到的問題,也可以避免上述問題:

  • 可以很方便地加入到平常代碼的開發(fā)流程中,甚至對開發(fā)者透明。
  • 未修改文件的覆蓋率可以疊加(有修改的那些控制流程圖結(jié)構(gòu)可能變化,無法疊加)。

因此我們實際開發(fā)選定的過濾點是在 .info 。在選定了方案 2 之后,我們對中間文件 .info 進行了一系列調(diào)研,確定了文件基本格式(函數(shù)/代碼行覆蓋率對應的文件的表示),這里不再贅述,具體可以參考 .info 生成文檔

增量代碼測試覆蓋率工具的實現(xiàn)

前一節(jié)是實現(xiàn)增量代碼覆蓋率檢測的基本方案選擇,為了更好地接入現(xiàn)有開發(fā)流程,我們做了以下幾方面的優(yōu)化。

降低使用成本

在接入方面,接入增量代碼測試覆蓋率工具只需一次接入配置,同步到代碼倉庫后,團隊中成員無需配置即可使用,降低了接入成本。

在使用方面,考慮到插樁在編譯時進行,對全部代碼進行插樁會很大程度降低編譯速度,我們通過解析 Podfile(iOS 開發(fā)中較為常用的包管理工具 CocoaPods 的依賴描述文件),只對 Podfile 中使用本地代碼的倉庫進行插樁(可配置指定倉庫),降低了團隊的開發(fā)成本。

對開發(fā)者透明

接入增量代碼測試覆蓋率工具后,開發(fā)者無需特殊操作,也不需要對工程做任何其他修改,正常的 git commit 代碼,git push 到遠端就會自動生成并上傳這次 commit 的覆蓋率信息了。

為了做到這一點,我們在接入 Pod 的過程中,自動部署了 Git 的 pre-push 腳本。熟悉 Git 的同學知道,Git 的 hooks 是開發(fā)者的本地腳本,不會被納入版本控制,如何通過一次配置就讓這個倉庫的所有使用成員都能開啟,是做好這件事的一個難點。

我們考慮到 Pod 本身會被納入版本控制,因此利用了 CocoaPods 的一個屬性 script_phase,增加了 Pod 編譯后腳本,來幫助我們把 pre-push 插入到本地倉庫。利用 script_phase 插入還帶來了另外一個好處,我們可以直接獲取到工程的緩存文件,也避免了 .gcno / .gcda 文件獲取的不確定性。整個流程如下:

image

圖11 pre-push 分發(fā)流程

覆蓋率累計

在實現(xiàn)了覆蓋率的過濾后,我們在實際開發(fā)中遇到了另外一個問題:修改分支/循環(huán)結(jié)構(gòu)后生成的 .gcda 文件無法和之前的合并。 在這種情況下,__gcov_flush會直接返回,不再寫入 .gcda 文件了導致覆蓋率檢測失敗,這也是市面上已有工具的通用問題

而這個問題在開發(fā)過程中很常見,比如我們給例 1 中的游戲增加一些提示,當輸入比預設數(shù)字大時,我們就提示出來,反之亦然。

- (void)guessNumberGame:(NSInteger)guessNumber
{
    NSInteger targetNumber = 10;
    NSLog(@"Welcome to the game");
    if (guessNumber == targetNumber) {
        NSLog(@"Bingo!");
    } else if (guessNumber > targetNumber) {
        NSLog(@"Input number is larger than the given target!");
    } else {
        NSLog(@"Input number is smaller than the given target!");
    }
}

這個問題困擾了我們很久,也推動了對覆蓋率檢測原理的調(diào)研。結(jié)合前面覆蓋率檢測的原理可以知道,不能合并的原因是生成的控制流程圖比原來多了兩條邊( .gcno 和舊的 .gcda 也不能匹配了),反映在 .gcda 上就是數(shù)組多了兩個數(shù)據(jù)。考慮到代碼變動后,原有的覆蓋率信息已經(jīng)沒有意義了,當發(fā)生邊數(shù)不一致的時候,我們會刪除掉舊的 .gcda 文件,只保留最新 .gcda 文件(有變動情況下 .gcno 會重新生成)。如下圖所示:

image

圖12 覆蓋率沖突解決算法

整體流程圖

結(jié)合上述流程,我們的增量代碼測試覆蓋率工具的整體流程如圖 13 所示。

開發(fā)者只需進行接入配置,再次運行時,工程中那些作為本地倉庫進行開發(fā)的代碼庫會被自動插樁,并在 .git 目錄插入 hooks 信息;當開發(fā)者使用模擬器進行需求自測時,插樁統(tǒng)計結(jié)果會被自動分發(fā)出去;在代碼被推到遠端前,會根據(jù)插樁統(tǒng)計結(jié)果,生成僅包含本次代碼修改的詳細增量代碼測試覆蓋率報告,以及向遠端推送覆蓋率信息;同時如果測試覆蓋率小于 80% 會強制拒絕提交(可配置關閉,百分比可自定義),保證只有經(jīng)過充分自測的代碼才能提交到遠端。

image

圖13 增量代碼測試覆蓋率生成流程圖

總結(jié)

以上是我們在代碼開發(fā)質(zhì)量方面做的一些積累和探索。通過對覆蓋率生成、解析邏輯的探究,我們揭開了覆蓋率檢測的神秘面紗。開發(fā)階段的增量代碼覆蓋率檢測,可以幫助開發(fā)者聚焦變動代碼的邏輯缺陷,從而更好地避免線上問題。

參考資料

如果你有什么建議,可以關注我,直接留言,留言必回。

更多文章

蘋果技術專家:清后臺反而會增加 iPhone 耗電
Swift中C語言指針的訪問和轉(zhuǎn)換方法
開源框架 RSA_Swift
iOS SKStoreProductViewController的應用
CocoaPods開源庫的搭建
CocoaPods搭建私有庫
CocoaPods搭建私有庫遇到問題
CocoaPods私有庫的升級維護
SKStoreReviewController之程序內(nèi)評價
App應用程序圖標的動態(tài)更換
開源框架 MGJRouter_Swift
iOS的MVP設計模式
iOS插件化
iOS FMDB的使用
Swift之ReactiveSwift
OC之ReactiveCocoa
OC之ReactiveCocoa進階
iOS 性能考慮

原文鏈接

最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,936評論 6 535
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,744評論 3 421
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,879評論 0 381
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,181評論 1 315
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,935評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,325評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,384評論 3 443
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,534評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,084評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 40,892評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,067評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,623評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,322評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,735評論 0 27
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,990評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,800評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,084評論 2 375

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