NodeJs async_hooks詳解

nodejs從版本8以上提供async_hooks奄容,async_hooks很有意思雕擂,可以完成許多高級(jí)的功能啡邑,但使用起來有點(diǎn)費(fèi)解。

一井赌、背景

要了解asnyc_hooks谤逼,我們需要先從nodejs的異步IO談起

nodejs是一種單線程的語言,單線程并不是說nodejs進(jìn)程中真的只包含一個(gè)線程仇穗,而是nodejs只有一個(gè)主線程用于處理業(yè)務(wù)邏輯流部。余下的線程是為支持nodejs的異步io功能,被叫做io線程纹坐,io線程主要用于接收主線程的io事件請求枝冀,然后發(fā)起io調(diào)用,并將io調(diào)用的結(jié)果傳遞到主線程耘子。如下圖所示:


nodejs異步IO(圖片來自于《深入淺出Node.js》)

nodejs中進(jìn)行異步io調(diào)用通常類似于:

function callback(err, data){

}
obj.ioMethod(arg, callback);

例如異步讀取文件的代碼示例如下所示:

var fs = require("fs");

fs.readFile("a.txt", function(err, data){
    if(err){
        console.log(err);
    } else {
        console.log(data);
    }
})

從上面異步io的流程中可以看出果漾, readFile方法完成(返回)時(shí),數(shù)據(jù)并沒有真正讀回來谷誓,等io線程完成文件讀取之后跨晴,主線程檢測到此事件之后,才會(huì)執(zhí)行callback處理讀取回來的數(shù)據(jù)(此處只是直接輸出到console)片林。由此可知端盆,readFile和callback雖然同樣是主線程執(zhí)行的,但其實(shí)并沒有多大的關(guān)聯(lián)费封,也就是說焕妙,我們無法從主線程的執(zhí)行順序來判定這兩個(gè)操作是否是同一個(gè)調(diào)用鏈。所以當(dāng)callback拋出異常時(shí)弓摘,我們其實(shí)得不到一個(gè)完整的調(diào)用棧焚鹊。或者韧献,我們需要追蹤一個(gè)調(diào)用鏈末患,并且分別統(tǒng)計(jì)每個(gè)調(diào)用環(huán)節(jié)的耗時(shí)研叫,因?yàn)楫惒絠o的原因,就無法完成了璧针。

當(dāng)然嚷炉,上述問題,還是有一些解決方法的探橱,下面介紹一些可行的方案申屹。

二. AsyncListener + Continuation-Local Storage

AsyncListener提供了在異步調(diào)用時(shí)添加listener功能的機(jī)制,并且隧膏,AsyncListener能保證調(diào)用鏈的完整哗讥。
Continuation-Local Storage(簡稱CLS),在AsyncListener的基礎(chǔ)上胞枕,提供了更宜于使用的api杆煞。

三. async_hooks模塊介紹

從nodejs8開始,原生提供了類似于AsyncListener的功能腐泻,官方API文檔 索绪。async_hooks,模塊提供了一個(gè)用于注冊回調(diào)函數(shù)的 API贫悄,這些回調(diào)函數(shù)可追蹤在 Node.js 應(yīng)用中創(chuàng)建的異步資源的生命周期瑞驱。

async_hooks提供了有限的幾個(gè)api,createHook窄坦,enable唤反,disableexecutionAsyncId鸭津,triggerAsyncId彤侍。這幾個(gè)api即可為調(diào)用鏈的追蹤提供可能。其中的enable和disable非常容易理解逆趋,即開啟或者停止此功能盏阶。createHook比較復(fù)雜,在理解了此方法之后闻书,executionAsyncIdtriggerAsyncId就很好理解了名斟。createHook方法定義如下:

async_hooks.createHook(callbacks)

Added in: v8.1.0

  • callbacks <Object> the callbacks to register
  • Returns: {AsyncHook} instance used for disabling and enabling hooks
    createHook方法主要用于注冊一系列回調(diào)函數(shù),這些回調(diào)函數(shù)會(huì)在異步操作的各個(gè)生命周期的事件中被調(diào)用魄眉∨檠危回調(diào)函數(shù)包含4個(gè),init()/before()/after()/destroy() 坑律,當(dāng)然視業(yè)務(wù)不同岩梳,有時(shí)候并不需要提供完整的4個(gè)回調(diào)函數(shù)。

在開始說明這些回調(diào)函數(shù)的作用之前,先說明一件事件冀值,我們可能試圖在回調(diào)函數(shù)中調(diào)用console.log來打印出一些信息也物,但是console.log本身就是一個(gè)異步操作, 這會(huì)導(dǎo)致另一個(gè)異步事件的產(chǎn)生列疗,而另一個(gè)異步事件又會(huì)試圖調(diào)用console.log打印信息滑蚯,所以會(huì)得到一個(gè)死循環(huán)。為了解決這個(gè)問題作彤,官方給出了方法,可以使用fs的writeSync方法來輸出打印數(shù)據(jù)乌逐,例如:

fs.writeSync(1, `${type}(${asyncId}): trigger: ${triggerAsyncId} execution: ${eid}\n`);

下面我們依次來看幾個(gè)回調(diào)函數(shù)的定義竭讳,以及參數(shù)的含義:

  1. init(asyncId, type, triggerAsyncId, resource)
  • asyncId <number> 異步資源的唯一ID
  • type <string> 異步資源的類型,這是nodejs內(nèi)部定義好的浙踢,當(dāng)然也提供了自定義的方案
  • triggerAsyncId <number> 創(chuàng)建此異步資源的執(zhí)行上下文的唯一ID绢慢,即此資源調(diào)用鏈上的父ID
  • resource <Object> reference to the resource representing the async operation, needs to be released during destroy

asyncId: 當(dāng)一個(gè)可能觸發(fā)異步事件的類初始化的時(shí)候,init方法將會(huì)被調(diào)用洛波。然而隨后的before()/after()/destroy()并不是一會(huì)被調(diào)用胰舆。每個(gè)異步資源,會(huì)被分配一個(gè)唯一的id蹬挤,即: asyncId缚窿。

type: 一個(gè)表示引發(fā)init被調(diào)用的異步資源的類型,目前內(nèi)置了部分焰扳,也可以自定義倦零,詳見官方api文檔。

triggerAsyncId: 表示創(chuàng)建引發(fā)init回調(diào)的異步資源的異步資源id吨悍,可以理解為目前異步資源的父id扫茅。

resource : 一個(gè)代表異步資源的對(duì)象,可以從此對(duì)象中獲得一些異步資源相關(guān)的數(shù)據(jù)育瓜。比如: GETADDRINFOREQWRAP 類型的異步資源對(duì)象葫隙,提供了hostname。

  1. before(asyncId)

asyncId <number>

當(dāng)一個(gè)異步操作初始化(例如 TCP服務(wù)端接收到一個(gè)新的連接)或者完成時(shí)(例如寫數(shù)據(jù)到磁盤)其對(duì)應(yīng)的回調(diào)函數(shù)將被調(diào)用躏仇。before回調(diào)將會(huì)在異步操作的回調(diào)執(zhí)行之前被調(diào)用恋脚。asyncId表示執(zhí)行回調(diào)函數(shù)的異步資源的唯一ID。理論上異步資源的回調(diào)將會(huì)被執(zhí)行0或者多次焰手,因此before回調(diào)也可能被執(zhí)行0到多次慧起。

  1. after(asyncId)
    asyncId <number>
    after回調(diào),會(huì)在異步資源的回調(diào)被執(zhí)行之后立即調(diào)用册倒。

  2. destroy(asyncId)
    asyncId <number>
    當(dāng)asyncId代表的資源被銷毀的時(shí)候蚓挤,destrory回調(diào)被調(diào)用。或者當(dāng)通過內(nèi)置的api emitDestroy()進(jìn)行異步調(diào)用時(shí)灿意。

四估灿、async_hooks使用示例

知道了async_hooks模塊中的主要方法,以及方法的參數(shù)之后缤剧,我們以讀取文件為例馅袁,將其中的調(diào)用鏈顯示出來如下:

'use strict';
const async_hooks = require('async_hooks');
const fs = require('fs');

let indent = 0;
async_hooks.createHook({
  init(asyncId, type, triggerAsyncId) {
    const eid = async_hooks.executionAsyncId();
    const indentStr = ' '.repeat(indent);
    fs.writeSync(
      1,
      `${indentStr}${type}(${asyncId}):` +
      ` trigger: ${triggerAsyncId} execution: ${eid}\n`);
  },
  before(asyncId) {
    const indentStr = ' '.repeat(indent);
    fs.writeSync(1, `${indentStr}before:  ${asyncId}\n`);
    indent += 2;
  },
  after(asyncId) {
    indent -= 2;
    const indentStr = ' '.repeat(indent);
    fs.writeSync(1, `${indentStr}after:   ${asyncId}\n`);
  },
  destroy(asyncId) {
    const indentStr = ' '.repeat(indent);
    fs.writeSync(1, `${indentStr}destroy: ${asyncId}\n`);
  },
}).enable();

fs.readFile("a.txt", 'utf8', function(err, data){
});

fs.readFile("b.txt", 'utf8', function(err, data){
});

上面的代碼,并行讀取a.txt和b.txt荒辕,從nodejs的異步特性來看汗销,a.txt和b.txt應(yīng)該是兩個(gè)不同的調(diào)用鏈,運(yùn)行上面代碼抵窒,輸出的結(jié)果如下:

peachcat@peachcat:~/nodejs $ node async_hooks.js 
FSREQWRAP(2): trigger: 1 execution: 1
FSREQWRAP(3): trigger: 1 execution: 1
before:  2
  FSREQWRAP(4): trigger: 2 execution: 2
after:   2
before:  3
  FSREQWRAP(5): trigger: 3 execution: 3
after:   3
destroy: 2
destroy: 3
before:  4
  FSREQWRAP(6): trigger: 4 execution: 4
after:   4
before:  5
  FSREQWRAP(7): trigger: 5 execution: 5
after:   5
destroy: 4
destroy: 5
before:  6
  FSREQWRAP(8): trigger: 6 execution: 6
after:   6
before:  7
  FSREQWRAP(9): trigger: 7 execution: 7
after:   7
destroy: 6
destroy: 7
before:  8
after:   8
before:  9
after:   9
destroy: 8
destroy: 9

從輸出結(jié)果弛针,可以看出,a.txt的調(diào)用鏈路為:

1 -> 2 -> 4 -> 6 -> 8 李皇,

而b.txt的調(diào)用鏈路為:

1 -> 3 -> 5 -> 7 -> 9削茁,

可以看出,根結(jié)構(gòu)是一樣的掉房,從第二個(gè)操作開始茧跋,就是各自不同的鏈路了。我們將a和b的操作卓囚,修改為在a.txt的回調(diào)中去讀取文件b.txt瘾杭,相應(yīng)的修改代碼為:

fs.readFile("a.txt", 'utf8', function(err, data){
  fs.readFile("b.txt", 'utf8', function(err, data){
  }); 
});

調(diào)用輸出結(jié)果如下:

FSREQWRAP(2): trigger: 1 execution: 1
before:  2
  FSREQWRAP(3): trigger: 2 execution: 2
after:   2
destroy: 2
before:  3
  FSREQWRAP(4): trigger: 3 execution: 3
after:   3
destroy: 3
before:  4
  FSREQWRAP(5): trigger: 4 execution: 4
after:   4
destroy: 4
before:  5
  FSREQWRAP(6): trigger: 5 execution: 5
after:   5
destroy: 5
before:  6
  FSREQWRAP(7): trigger: 6 execution: 6
after:   6
destroy: 6
before:  7
  FSREQWRAP(8): trigger: 7 execution: 7
after:   7
destroy: 7
before:  8
  FSREQWRAP(9): trigger: 8 execution: 8
after:   8
destroy: 8
before:  9
after:   9
destroy: 9

由上面輸出可以得知,此次調(diào)用的鏈路為:
1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9

五哪亿、使用async_hooks傳遞上下文信息

async_hooks提供的這些功能富寿,可以擴(kuò)展出一些非常實(shí)用的應(yīng)用場景。比如可以將原來由于異步回調(diào)锣夹,導(dǎo)致異常錯(cuò)誤棧無法顯示完全页徐,可以使用async_hooks功能進(jìn)行修復(fù)。更進(jìn)一步银萍,我們可以使用async_hooks完成類似于java中的ThreadLocal的功能”溆拢現(xiàn)在已經(jīng)有現(xiàn)成的npm包使用async_hooks,提供了在調(diào)用上下文中保存一些數(shù)據(jù)的功能贴唇。比如:asyncctx搀绣,其核心代碼非常少,我們來分析一下實(shí)現(xiàn)原理戳气。

class ContinuationLocalStorage {
    constructor() {
        this.initMap();
        this.hookFuncs = {
            init: (id, type, triggerId) => {
                // a new async handle gets initialized:
                const oriTriggerId = triggerId;
                if (triggerId == null) {
                    triggerId = this._currId;
                }
                let triggerHook = this.idHookMap.get(triggerId);
                if (!triggerHook) {
                    triggerId = ROOT_ID;
                    triggerHook = this.idHookMap.get(triggerId);
                }
                else {
                    while (triggerHook.type === 'PROMISE' && !triggerHook.activated &&
                        this.idHookMap.has(triggerHook.triggerId)) {
                        triggerId = triggerHook.triggerId;
                        triggerHook = this.idHookMap.get(triggerId);
                    }
                }
                this.idHookMap.set(id, { id, type, triggerId, oriTriggerId, triggerHook, activated: false });
            },
            before: (id) => {
                // an async handle starts
                this._currId = id;
                let hi = this.idHookMap.get(id);
                if (hi) {
                    if (!hi.activated) {
                        hi.data = hi.triggerHook ? hi.triggerHook.data : undefined;
                    }
                    hi.activated = true;
                }
                else {
                    this._currId = ROOT_ID;
                }
            },
            after: (id) => {
                // an async handle ends
                if (id === this._currId) {
                    this._currId = ROOT_ID;
                }
            },
            destroy: (id) => {
                // an async handle gets destroyed
                if (this.idHookMap.has(id)) {
                    if (id === this._currId) {
                        nodeproc._rawDebug(`asyncctx: destroy hook called for current context (id: ${this.currId})!`);
                    }
                    this.idHookMap.delete(id);
                }
            }
        };
        this.hookInstance = asyncHooks.createHook(this.hookFuncs);
        this.enable();
    }
    get currId() { return this._currId; }
    getContext() {
        let hi = this.idHookMap.get(this.currId);
        return hi ? hi.data : undefined;
    }
    
    setContext(value) {
        let hi = this.idHookMap.get(this.currId);
        if (!hi) {
            throw new Error('setContext must be called in an async context!');
        }
        hi.data = value;
        return value;
    }
    
    getRootContext() {
        let hi = this.idHookMap.get(ROOT_ID);
        if (!hi) {
            throw new Error('internal error: root node not found (1)!');
        }
        return hi ? hi.data : undefined;
    }
    
    setRootContext(value) {
        let hi = this.idHookMap.get(ROOT_ID);
        if (!hi) {
            throw new Error('internal error: root node not found (2)!');
        }
        hi.data = value;
        return value;
    }
    
    initMap(value) {
        this.idHookMap = new Map();
        this.idHookMap.set(ROOT_ID, { id: ROOT_ID, type: 'C++', triggerId: 0, activated: true });
        this._currId = ROOT_ID;
        if (value) {
            this.setRootContext(value);
        }
    }
}

上面的代碼链患,使用了一個(gè)Map來保存每個(gè)調(diào)用鏈的上下文數(shù)據(jù),由initMap方法進(jìn)行初始化瓶您,并且生成ROOT_ID(值為1)麻捻,保存在Map中的數(shù)據(jù)纲仍,以當(dāng)前操作的id為key,值為一個(gè)對(duì)象贸毕,其中triggerId表示其父操作的id郑叠,由此,可以在Map中記錄一個(gè)樹形結(jié)構(gòu)的上下文鏈明棍。

六乡革、使用asyncctx,實(shí)現(xiàn)調(diào)用鏈時(shí)間統(tǒng)計(jì)

通過上面的async_hooks原理和擴(kuò)展的npm包的說明摊腋,我們已經(jīng)可以考慮使用async_hooks功能沸版,來完成一些實(shí)際的事情的。下面我們使用asyncctx來完成一個(gè)統(tǒng)計(jì)調(diào)用鏈中兴蒸,統(tǒng)計(jì)各個(gè)異步資源耗時(shí)的功能视粮。

  1. 首先,我們需要定義一個(gè)數(shù)據(jù)結(jié)構(gòu)类咧,用于保存調(diào)用鏈中各節(jié)點(diǎn)的耗時(shí)和節(jié)點(diǎn)的父子關(guān)系馒铃,以及調(diào)用的開始和結(jié)束時(shí)間蟹腾,為此我們定義一個(gè)Span類:
'use strict';

const {ContinuationLocalStorage} = require('asyncctx');
const fs = require('fs');
const cls = new ContinuationLocalStorage();

class Span {
  constructor(operationName, parent) {
    this.operationName = operationName;
    this.parent = parent;
    this.start  = (new Date()).getTime();
    this.end    = 0;
  }

  finish(){
    if(this.parent){
      this.parent.finish();
    }
    this.end = (new Date()).getTime();
  }

  toString() {
    let str = `Operation: ${this.operationName}, ${this.end - this.start}ms`;
    if(this.parent){
      str += `, Parent: ${this.parent.operationName}\n`;
      str += this.parent.toString();
    }
    return str;
  }
}

Span的構(gòu)造方法中痕惋,需要傳遞一個(gè)操作名稱,以及其父節(jié)點(diǎn)娃殖,我們可以想像到值戳,父節(jié)點(diǎn)的獲取需要使用asyncctx來完成,因?yàn)椴僮髦g是異步的炉爆,我們必須要使用asynccxt來傳遞父子關(guān)系堕虹。初始化Span對(duì)象時(shí),記錄當(dāng)前的時(shí)間芬首。其中的finish方法赴捞,用于設(shè)置結(jié)束時(shí)間,并且同時(shí)結(jié)束父節(jié)點(diǎn)的耗時(shí)統(tǒng)計(jì)(我們演示的例子郁稍,為單一嵌套的赦政,若有并行請求的時(shí)候,此做法是不可行的)耀怜。toString()方法用于輸出節(jié)點(diǎn)統(tǒng)計(jì)的耗時(shí)恢着,以及父節(jié)點(diǎn)的信息。

我們打算繼續(xù)使用fs.readFile來做為演示的例子财破,為了更接近于真實(shí)的使用場景掰派,我們對(duì)fs.readFile做一下改造。

  1. 改造fs.readFile
let originReadFile = fs.readFile;

fs.readFile = function(file, callback){
  let ctx = cls.getContext();
  let span = new Span(`Read ${file}`, ctx);
  cls.setContext(span);
  originReadFile(file, function(err, data){
    callback(err, data);
    span.finish();
  });
}

為了使用fs.readFile時(shí)左痢,無代碼入侵靡羡,我們重寫了readFile方法系洛,在調(diào)用原始方法之前,開啟統(tǒng)計(jì)亿眠,調(diào)用結(jié)束之后碎罚,結(jié)束統(tǒng)計(jì)。由代碼中可知纳像,span的父節(jié)點(diǎn)是直接通過ctx.getContext()獲取的荆烈。

  1. 調(diào)用代碼
let rootSpan = new Span('root', null);  //開啟一個(gè)rootSpan做為根節(jié)點(diǎn)
cls.setRootContext(rootSpan);

fs.readFile("a.txt", function(err, data){
  fs.readFile("b.txt", function(err, data){
    //完成所有的讀取操作,獲取最后一個(gè)span竟趾,結(jié)束掉憔购,并且打印出調(diào)用鏈結(jié)果
    let span = cls.getContext();
    span.finish();
    console.log(span.toString());
  });
});

運(yùn)行代碼,輸出的結(jié)果如下:

peachcat@peachcat:~/nodejs $ node async.js 
Operation: Read b.txt, 1ms, Parent: Read a.txt
Operation: Read a.txt, 2ms, Parent: root
Operation: root, 2ms

由上面輸出結(jié)果可以看出來岔帽,同我們預(yù)想的一樣玫鸟,root -> a.txt -> b.txt的調(diào)用順序,并且root的耗時(shí)犀勒,包含了后面兩個(gè)操作的時(shí)間屎飘,a.txt的讀取時(shí)間,包含了b.txt的時(shí)間贾费。

七钦购、參考文檔

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市褂萧,隨后出現(xiàn)的幾起案子押桃,更是在濱河造成了極大的恐慌,老刑警劉巖导犹,帶你破解...
    沈念sama閱讀 216,372評(píng)論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件唱凯,死亡現(xiàn)場離奇詭異,居然都是意外死亡谎痢,警方通過查閱死者的電腦和手機(jī)磕昼,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,368評(píng)論 3 392
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來节猿,“玉大人票从,你說我怎么就攤上這事°迮” “怎么了纫骑?”我有些...
    開封第一講書人閱讀 162,415評(píng)論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長九孩。 經(jīng)常有香客問我先馆,道長,這世上最難降的妖魔是什么躺彬? 我笑而不...
    開封第一講書人閱讀 58,157評(píng)論 1 292
  • 正文 為了忘掉前任煤墙,我火速辦了婚禮梅惯,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘仿野。我一直安慰自己铣减,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,171評(píng)論 6 388
  • 文/花漫 我一把揭開白布脚作。 她就那樣靜靜地躺著葫哗,像睡著了一般。 火紅的嫁衣襯著肌膚如雪球涛。 梳的紋絲不亂的頭發(fā)上劣针,一...
    開封第一講書人閱讀 51,125評(píng)論 1 297
  • 那天,我揣著相機(jī)與錄音亿扁,去河邊找鬼捺典。 笑死,一個(gè)胖子當(dāng)著我的面吹牛从祝,可吹牛的內(nèi)容都是我干的襟己。 我是一名探鬼主播,決...
    沈念sama閱讀 40,028評(píng)論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼牍陌,長吁一口氣:“原來是場噩夢啊……” “哼擎浴!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起呐赡,我...
    開封第一講書人閱讀 38,887評(píng)論 0 274
  • 序言:老撾萬榮一對(duì)情侶失蹤退客,失蹤者是張志新(化名)和其女友劉穎骏融,沒想到半個(gè)月后链嘀,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,310評(píng)論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡档玻,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,533評(píng)論 2 332
  • 正文 我和宋清朗相戀三年怀泊,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片误趴。...
    茶點(diǎn)故事閱讀 39,690評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡霹琼,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出凉当,到底是詐尸還是另有隱情枣申,我是刑警寧澤,帶...
    沈念sama閱讀 35,411評(píng)論 5 343
  • 正文 年R本政府宣布看杭,位于F島的核電站忠藤,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏楼雹。R本人自食惡果不足惜模孩,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,004評(píng)論 3 325
  • 文/蒙蒙 一尖阔、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧榨咐,春花似錦介却、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至数焊,卻和暖如春胃夏,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背昌跌。 一陣腳步聲響...
    開封第一講書人閱讀 32,812評(píng)論 1 268
  • 我被黑心中介騙來泰國打工仰禀, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人蚕愤。 一個(gè)月前我還...
    沈念sama閱讀 47,693評(píng)論 2 368
  • 正文 我出身青樓答恶,卻偏偏與公主長得像,于是被迫代替她去往敵國和親萍诱。 傳聞我的和親對(duì)象是個(gè)殘疾皇子悬嗓,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,577評(píng)論 2 353