全鏈路追蹤技術(shù)的兩個核心要素分別是 全鏈路信息獲取 和 全鏈路信息存儲展示。
一、Node.js 應(yīng)用全鏈路追蹤系統(tǒng)
目前行業(yè)內(nèi), 不考慮 Serverless 的情況下,主流的 Node.js 架構(gòu)設(shè)計主要有以下兩種方案:
通用架構(gòu):只做 ssr 和 bff,不做服務(wù)器和微服務(wù);
全場景架構(gòu):包含 ssr、bff、服務(wù)器、微服務(wù)。
上述兩種方案對應(yīng)的架構(gòu)說明圖如下圖所示:
在上述兩種通用架構(gòu)中,nodejs 都會面臨一個問題,那就是:
在請求鏈路越來越長,調(diào)用服務(wù)越來越多,其中還包含各種微服務(wù)調(diào)用的情況下,出現(xiàn)了以下訴求:
如何在請求發(fā)生異常時快速定義問題所在;
如何在請求響應(yīng)慢的時候快速找出慢的原因;
如何通過日志文件快速定位問題的根本原因。
我們要解決上述訴求,就需要有一種技術(shù),將每個請求的關(guān)鍵信息聚合起來,并且將所有請求鏈路串聯(lián)起來。讓我們可以知道一個請求中包含了幾次服務(wù)、微服務(wù)請求的調(diào)用,某次服務(wù)、微服務(wù)調(diào)用在哪個請求的上下文。
這種技術(shù),就是Node.js應(yīng)用全鏈路追蹤。它是 Node.js 在涉及到復(fù)雜服務(wù)端業(yè)務(wù)場景中,必不可少的技術(shù)保障。
綜上,我們需要Node.js應(yīng)用全鏈路追蹤,說完為什么需要后,下面將介紹如何做Node.js應(yīng)用的全鏈路信息獲取。
二、全鏈路信息獲取
全鏈路信息獲取,是全鏈路追蹤技術(shù)中最重要的一環(huán)。只有打通了全鏈路信息獲取,才會有后續(xù)的存儲展示流程。
對于多線程語言如 Java 、 Python 來說,做全鏈路信息獲取有線程上下文如 ThreadLocal 這種利器相助。而對于Node.js來說,由于單線程和基于IO回調(diào)的方式來完成異步操作,所以在全鏈路信息獲取上存在天然獲取難度大的問題。那么如何解決這個問題呢?
三、業(yè)界方案
由于 Node.js 單線程,非阻塞 IO 的設(shè)計思想。在全鏈路信息獲取上,到目前為止,主要有以下 4 種方案:
domain: node api;
zone.js: Angular 社區(qū)產(chǎn)物;
顯式傳遞:手動傳遞、中間件掛載;
Async Hooks:node api;
而上述 4 個方案中, domain 由于存在嚴(yán)重的內(nèi)存泄漏,已經(jīng)被廢棄了;zone.js 實現(xiàn)方式非常暴力、API比較晦澀、最關(guān)鍵的缺點是 monkey patch 只能 mock api ,不能 mock language;顯式傳遞又過于繁瑣和具有侵入性;綜合比較下來,效果最好的方案就是第四種方案,這種方案有如下優(yōu)點:
node 8.x 新加的一個核心模塊,Node 官方維護者也在使用,不存在內(nèi)存泄漏;
非常適合實現(xiàn)隱式的鏈路跟蹤,入侵小,目前隱式跟蹤的最優(yōu)解;
提供了 API 來追蹤 node 中異步資源的生命周期;
借助 async_hook 實現(xiàn)上下文的關(guān)聯(lián)關(guān)系;
優(yōu)點說完了,下面我們就來介紹如何通過 Async Hooks 來獲取全鏈路信息。
四、Async Hooks【異步鉤子】
4.1 Async Hooks 概念
Async Hooks 是 Node.js v8.x 版本新增加的一個核心模塊,它提供了 API 用來追蹤 Node.js 中異步資源的生命周期,可幫助我們正確追蹤異步調(diào)用的處理邏輯及關(guān)系。在代碼中,只需要寫 import asyncHook from 'async_hooks' 即可引入 async_hooks 模塊。
一句話概括:async_hooks 用來追蹤 Node.js 中異步資源的生命周期。
目前 Node.js 的穩(wěn)定版本是 v14.17.0 。我們通過一張圖看下 Async Hooks 不同版本的 api 差異。如下圖所示:
從圖中可以看到該 api 變動較大。這是因為從 8 版本到 14 版本,async_hooks 依舊還是 Stability: 1 - Experimental
Stability: 1 - Experimental :該特性仍處于開發(fā)中,且未來改變時不做向后兼容,甚至可能被移除。不建議在生產(chǎn)環(huán)境中使用該特性。
但是沒關(guān)系,要相信官方團隊,這里我們的全鏈路信息獲取方案是基于 Node v9.x 版本 api 實現(xiàn)的。對于 Async Hooks api 介紹和基本使用, 大家可以閱讀官方文檔,下文會闡述對核心知識的理解。
下面我們將系統(tǒng)介紹基于 Async Hooks 的全鏈路信息獲取方案的設(shè)計和實現(xiàn),下文統(tǒng)稱為 zone-context 。
4.2 理解 async_hooks 核心知識
在介紹 zone-context 之前,要對 async_hooks 的核心知識有正確的理解,這里做了一個總結(jié),有如下6點:
每一個函數(shù)(不論異步還是同步)都會提供一個上下文, 我們稱之為 async scope ,這個認(rèn)知對理解 async_hooks 非常重要;
每一個 async scope 中都有一個 asyncId ,它是當(dāng)前 async scope 的標(biāo)志,同一個的 async scope 中 asyncId 必然相同,每個異步資源在創(chuàng)建時, asyncId 自動遞增,全局唯一;
每一個 async scope 中都有一個 triggerAsyncId ,用來表示當(dāng)前函數(shù)是由哪個 async scope 觸發(fā)生成的;
通過 asyncId 和 triggerAsyncId 我們可以追蹤整個異步的調(diào)用關(guān)系及鏈路,這個是全鏈路追蹤的核心;
通過 async_hooks.createHook 函數(shù)來注冊關(guān)于每個異步資源在生命周期中發(fā)生的 init 等相關(guān)事件的監(jiān)聽函數(shù);
同一個 async scope 可能會被調(diào)用及執(zhí)行多次,不管執(zhí)行多少次,其 asyncId 必然相同,通過監(jiān)聽函數(shù),我們很方便追蹤其執(zhí)行的次數(shù)、時間以及上下文關(guān)系。
上述6點知識對于理解 async_hooks 是非常重要的。正是因為這些特性,才使得 async_hooks 能夠優(yōu)秀的完成Node.js 應(yīng)用全鏈路信息獲取。
到這里,下面就要介紹 zone-context 的設(shè)計和實現(xiàn)了,請和我一起往下看。
五、zone-context
5.1 架構(gòu)設(shè)計
整體架構(gòu)設(shè)計如下圖所示:
核心邏輯如下:異步資源(調(diào)用)創(chuàng)建后,會被 async_hooks 監(jiān)聽到。監(jiān)聽到后,對獲取到的異步資源信息進行處理加工,整合成需要的數(shù)據(jù)結(jié)構(gòu),整合后,將數(shù)據(jù)存儲到 invoke tree 中。在異步資源結(jié)束時,觸發(fā) gc 操作,對 invoke tree 中不再有用的數(shù)據(jù)進行刪除回收。
從上述核心邏輯中,我們可以知道,此架構(gòu)設(shè)計需要實現(xiàn)以下三個功能:
異步資源(調(diào)用)監(jiān)聽
invoke tree
gc
下面開始逐個介紹上述三個功能的實現(xiàn)。
5.2 異步資源(調(diào)用)監(jiān)聽
如何做到監(jiān)聽異步調(diào)用呢?
這里用到了 async_hooks (追蹤 Node.js 異步資源的生命周期)代碼實現(xiàn)如下:
asyncHook .createHook({ init(asyncId, type, triggerAsyncId) { // 異步資源創(chuàng)建(調(diào)用)時觸發(fā)該事件 }, }) .enable()
是不是發(fā)現(xiàn)此功能實現(xiàn)非常簡單,是的哦,就可以對所有異步操作進行追蹤了。
在理解 async_hooks 核心知識中,我們提到了通過 asyncId 和 triggerAsyncId 可以追蹤整個異步的調(diào)用關(guān)系及鏈路。現(xiàn)在大家看 init 中的參數(shù),會發(fā)現(xiàn), asyncId 和triggerAsyncId 都存在,而且是隱式傳遞,不需要手動傳入。這樣,我們在每次異步調(diào)用時,都能在 init 事件中,拿到這兩個值。invoke tree 功能的實現(xiàn),離不開這兩個參數(shù)。
介紹完異步調(diào)用監(jiān)聽,下面將介紹 invoke tree 的實現(xiàn)。
5.3 invoke tree 設(shè)計和異步調(diào)用監(jiān)聽結(jié)合
5.3.1 設(shè)計
invoke tree 整體設(shè)計思路如下圖所示:
具體代碼如下:
interface ITree { [key: string]: { // 調(diào)用鏈路上第一個異步資源asyncId rootId: number // 異步資源的triggerAsyncId pid: number // 異步資源中所包含的異步資源asyncId children: Array} } const invokeTree: ITree = {}
創(chuàng)建一個大的對象 invokeTree, 每一個屬性代表一個異步資源的完整調(diào)用鏈路。屬性的key和value代表含義如下:
屬性的 key 是代表這個異步資源的 asyncId。
屬性的 value 是代表這個異步資源經(jīng)過的所有鏈路信息聚合對象,該對象中的各屬性含義請看上面代碼中的注釋進行理解。
通過這種設(shè)計,就能拿到任何一個異步資源在整個請求鏈路中的關(guān)鍵信息。收集根節(jié)點上下文。
5.3.2和異步調(diào)用監(jiān)聽結(jié)合
雖然 invoke tree 設(shè)計好了。但是如何在 異步調(diào)用監(jiān)聽的 init 事件中,將 asyncId 、 triggerAsyncId 和 invokeTree 關(guān)聯(lián)起來呢?
代碼如下:
asyncHook .createHook({ init(asyncId, type, triggerAsyncId) { // 尋找父節(jié)點 const parent = invokeTree[triggerAsyncId] if (parent) { invokeTree[asyncId] = { pid: triggerAsyncId, rootId: parent.rootId, children: [], } // 將當(dāng)前節(jié)點asyncId值保存到父節(jié)點的children數(shù)組中 invokeTree[triggerAsyncId].children.push(asyncId) } } }) .enable()
大家看上面代碼,整個代碼大致有以下幾個步驟:
當(dāng)監(jiān)聽到異步調(diào)用的時候,會先去 invokeTree 對象中查找是否含有 key 為 triggerAsyncId 的屬性;
有的話,說明該異步調(diào)用在該追蹤鏈路中,則進行存儲操作,將 asyncId 當(dāng)成 key , 屬性值是一個對象,包含三個屬性,分別是 pid、rootId、children , 具體含義上文已說過;
沒有的話,說明該異步調(diào)用不在該追蹤鏈路中。則不進行任何操作,如把數(shù)據(jù)存入 invokeTree 對象;
將當(dāng)前異步調(diào)用 asyncId 存入到 invokeTree 中 key 為 triggerAsyncId 的 children 屬性中。
至此,invoke tree 的設(shè)計、和異步調(diào)用監(jiān)聽如何結(jié)合,已經(jīng)介紹完了。下面將介紹 gc 功能的設(shè)計和實現(xiàn)。
5.4gc
5.4.1 目的
我們知道,異步調(diào)用次數(shù)是非常多的,如果不做 gc 操作,那么 invoke tree 會越來越大,node應(yīng)用的內(nèi)存會被這些數(shù)據(jù)慢慢占滿,所以需要對 invoke tree 進行垃圾回收。
5.4.2設(shè)計
gc 的設(shè)計思想主要如下:當(dāng)異步資源結(jié)束的時候,觸發(fā)垃圾回收,尋找此異步資源觸發(fā)的所有異步資源,然后按照此邏輯遞歸查找,直到找出所有可回收的異步資源。
話不多說,直接上代碼, gc 代碼如下:
interface IRoot { [key: string]: Object } // 收集根節(jié)點上下文 const root: IRoot = {} function gc(rootId: number) { if (!root[rootId]) { return } // 遞歸收集所有節(jié)點id const collectionAllNodeId = (rootId: number) => { const {children} = invokeTree[rootId] let allNodeId = [...children] for (let id of children) { // 去重 allNodeId = [...allNodeId, ...collectionAllNodeId(id)] } return allNodeId } const allNodes = collectionAllNodeId(rootId) for (let id of allNodes) { delete invokeTree[id] } delete invokeTree[rootId] delete root[rootId] }
gc 核心邏輯:用 collectionAllNodeId 遞歸查找所有可回收的異步資源( id )。然后再刪除 invokeTree 中以這些 id 為 key 的屬性。最后刪除根節(jié)點。
大家看到了聲明對象 root ,這個是什么呢?
root 其實是我們對某個異步調(diào)用進行監(jiān)聽時,設(shè)置的一個根節(jié)點對象,這個節(jié)點對象可以手動傳入一些鏈路信息,這樣可以為全鏈路追蹤增加其他追蹤信息,如錯誤信息、耗時時間等。
5.5萬事具備,只欠東風(fēng)
我們的異步事件監(jiān)聽設(shè)計好了, invoke tree 設(shè)計好了,gc 也設(shè)計好了。那么如何將他們串聯(lián)起來呢?比如我們要監(jiān)聽某一個異步資源,那么我們要怎樣才能把 invoke tree 和異步資源結(jié)合起來呢?
這里需要三個函數(shù)來完成結(jié)合,分別是 ZoneContext 、 setZoneContext 、 getZoneContext。下面來一一介紹下這三個函數(shù):
5.5.1 ZoneContext
這是一個工廠函數(shù),用來創(chuàng)建異步資源實例的,代碼如下所示:
// 工廠函數(shù) async function ZoneContext(fn: Function) { // 初始化異步資源實例 const asyncResource = new asyncHook.AsyncResource('ZoneContext') let rootId = -1 return asyncResource.runInAsyncScope(async () => { try { rootId = asyncHook.executionAsyncId() // 保存 rootId 上下文 root[rootId] = {} // 初始化 invokeTree invokeTree[rootId] = { pid: -1, // rootId 的 triggerAsyncId 默認(rèn)是 -1 rootId, children: [], } // 執(zhí)行異步調(diào)用 await fn() } finally { gc(rootId) } }) }
大家會發(fā)現(xiàn),在此函數(shù)中,有這樣一行代碼:
const asyncResource = new asyncHook.AsyncResource('ZoneContext')
這行代碼是什么含義呢?
它是指我們創(chuàng)建了一個名為 ZoneContext 的異步資源實例,可以通過該實例的屬性方法來更加精細(xì)的控制異步資源。
執(zhí)行 asyncResource.runInAsyncScope 方法有什么用處呢?
調(diào)用該實例的 runInAsyncScope方法,在runInAsyncScope 方法中包裹要傳入的異步調(diào)用??梢员WC在這個資源( fn )的異步作用域下,所執(zhí)行的代碼都是可追蹤到我們設(shè)置的 invokeTree 中,達(dá)到更加精細(xì)控制異步調(diào)用的目的。在執(zhí)行完后,進行g(shù)c調(diào)用,完成內(nèi)存回收。
5.5.2setZoneContext
用來給異步調(diào)用設(shè)置額外的跟蹤信息。代碼如下:
function setZoneContext(obj: Object) { const curId = asyncHook.executionAsyncId() let root = findRootVal(curId) Object.assign(root, obj) }
通過 Object.assign(root, obj) 將傳入的 obj 賦值給 root 對象中, key 為 curId 的屬性。這樣就可以給我們想跟蹤的異步調(diào)用設(shè)置想要跟蹤的信息。
5.5.3 getZoneContext
用來拿到異步調(diào)的 rootId 的屬性值。代碼如下:
function findRootVal(asyncId: number) { const node = invokeTree[asyncId] return node ? root[node.rootId] : null } function getZoneContext() { const curId = asyncHook.executionAsyncId() return findRootVal(curId) }
通過給 findRootVal 函數(shù)傳入 asyncId 來拿到 root 對象中 key 為 rootId 的屬性值。這樣就可以拿到當(dāng)初我們設(shè)置的想要跟蹤的信息了,完成一個閉環(huán)。
至此,我們將 Node.js應(yīng)用全鏈路信息獲取的核心設(shè)計和實現(xiàn)闡述完了。邏輯上有點抽象,需要多去思考和理解,才能對全鏈路追蹤信息獲取有一個更加深刻的掌握。
最后,我們使用本次全鏈路追蹤的設(shè)計實現(xiàn)來展示一個追蹤 demo 。
5.6 使用 zone-context
5.6.1 確定異步調(diào)用嵌套關(guān)系
為了更好的闡述異步調(diào)用嵌套關(guān)系,這里進行了簡化,沒有輸出 invoke tree 。例子代碼如下:
// 對異步調(diào)用A函數(shù)進行追蹤 ZoneContext(async () => { await A() }) // 異步調(diào)用A函數(shù)中執(zhí)行異步調(diào)用B函數(shù) async function A() { // 輸出 A 函數(shù)的 asyncId fs.writeSync(1, `A 函數(shù)的 asyncId -> ${asyncHook.executionAsyncId()} `) Promise.resolve().then(() => { // 輸出 A 函數(shù)中執(zhí)行異步調(diào)用時的 asyncId fs.writeSync(1, `A 執(zhí)行異步 promiseC 時 asyncId 為 -> ${asyncHook.executionAsyncId()} `) B() }) } // 異步調(diào)用B函數(shù)中執(zhí)行異步調(diào)用C函數(shù) async function B() { // 輸出 B 函數(shù)的 asyncId fs.writeSync(1, `B 函數(shù)的 asyncId -> ${asyncHook.executionAsyncId()} `) Promise.resolve().then(() => { // 輸出 B 函數(shù)中執(zhí)行異步調(diào)用時的 asyncId fs.writeSync(1, `B 執(zhí)行異步 promiseC 時 asyncId 為 -> ${asyncHook.executionAsyncId()} `) C() }) } // 異步調(diào)用C函數(shù) function C() { const obj = getZoneContext() // 輸出 C 函數(shù)的 asyncId fs.writeSync(1, `C 函數(shù)的 asyncId -> ${asyncHook.executionAsyncId()} `) Promise.resolve().then(() => { // 輸出 C 函數(shù)中執(zhí)行異步調(diào)用時的 asyncId fs.writeSync(1, `C 執(zhí)行異步 promiseC 時 asyncId 為 -> ${asyncHook.executionAsyncId()} `) }) }
輸出結(jié)果為:
A 函數(shù)的 asyncId -> 3 A 執(zhí)行異步 promiseA 時 asyncId 為 -> 8 B 函數(shù)的 asyncId -> 8 B 執(zhí)行異步 promiseB 時 asyncId 為 -> 13 C 函數(shù)的 asyncId -> 13 C 執(zhí)行異步 promiseC 時 asyncId 為 -> 16
只看輸出結(jié)果就可以推出以下信息:
A 函數(shù)執(zhí)行異步調(diào)用后, asyncId 為 8 ,而 B 函數(shù)的 asyncId 是 8 ,這說明, B 函數(shù)是被 A 函數(shù) 調(diào)用;
B 函數(shù)執(zhí)行異步調(diào)用后, asyncId 為 13 ,而 C 函數(shù)的 asyncId 是 13 ,這說明, C 函數(shù)是被 B 函數(shù) 調(diào)用;
C 函數(shù)執(zhí)行異步調(diào)用后, asyncId 為 16 , 不再有其他函數(shù)的 asyncId 是 16 ,這說明, C 函數(shù)中沒有調(diào)用其他函數(shù);
綜合上面三點,可以知道,此鏈路的異步調(diào)用嵌套關(guān)系為:A —> B -> C;
至此,我們可以清晰快速的知道誰被誰調(diào)用,誰又調(diào)用了誰。
5.6.2 額外設(shè)置追蹤信息
在上面例子代碼的基礎(chǔ)下,增加以下代碼:
ZoneContext(async () => { const ctx = { msg: '全鏈路追蹤信息', code: 1 } setZoneContext(ctx) await A() }) function A() { // 代碼同上個demo } function B() { // 代碼同上個demo D() } // 異步調(diào)用C函數(shù) function C() { const obj = getZoneContext() Promise.resolve().then(() => { fs.writeSync(1, `getZoneContext in C -> ${JSON.stringify(obj)} `) }) } // 同步調(diào)用函數(shù)D function D() { const obj = getZoneContext() fs.writeSync(1, `getZoneContext in D -> ${JSON.stringify(obj)} `) }
輸出以下內(nèi)容:
呈現(xiàn)代碼宏出錯:參數(shù)
'com.atlassian.confluence.ext.code.render.InvalidValueException'的值無效。
getZoneContext in D -> {"msg":"全鏈路追蹤信息","code":1}
getZoneContext in C-> {"msg":"全鏈路追蹤信息","code":1}
可以發(fā)現(xiàn), 執(zhí)行 A 函數(shù)前設(shè)置的追蹤信息后,調(diào)用 A 函數(shù), A 函數(shù)中調(diào)用 B 函數(shù), B 函數(shù)中調(diào)用 C 函數(shù)和 D 函數(shù)。在 C 函數(shù)和 D 函數(shù)中,都能訪問到設(shè)置的追蹤信息。
這說明,在定位分析嵌套的異步調(diào)用問題時,通過 getZoneContext 拿到頂層設(shè)置的關(guān)鍵追蹤信息??梢院芸旎厮莩?,某個嵌套異步調(diào)用出現(xiàn)的異常,
是由頂層的某個異步調(diào)用異常所導(dǎo)致的。
5.6.3 追蹤信息大而全的 invoke tree
例子代碼如下:
ZoneContext(async () => { await A() }) async function A() { Promise.resolve().then(() => { fs.writeSync(1, `A 函數(shù)執(zhí)行異步調(diào)用時的 invokeTree -> ${JSON.stringify(invokeTree)} `) B() }) } async function B() { Promise.resolve().then(() => { fs.writeSync(1, `B 函數(shù)執(zhí)行時的 invokeTree -> ${JSON.stringify(invokeTree)} `) }) }
輸出結(jié)果如下:
A 函數(shù)執(zhí)行異步調(diào)用時的 invokeTree -> {"3":{"pid":-1,"rootId":3,"children":[5,6,7]},"5":{"pid":3,"rootId":3,"children":[10]},"6":{"pid":3,"rootId":3,"children":[9]},"7":{"pid":3,"rootId":3,"children":[8]},"8":{"pid":7,"rootId":3,"children":[]},"9":{"pid":6,"rootId":3,"children":[]},"10":{"pid":5,"rootId":3,"children":[]}} B 函數(shù)執(zhí)行異步調(diào)用時的 invokeTree -> {"3":{"pid":-1,"rootId":3,"children":[5,6,7]},"5":{"pid":3,"rootId":3,"children":[10]},"6":{"pid":3,"rootId":3,"children":[9]},"7":{"pid":3,"rootId":3,"children":[8]},"8":{"pid":7,"rootId":3,"children":[11,12]},"9":{"pid":6,"rootId":3,"children":[]},"10":{"pid":5,"rootId":3,"children":[]},"11":{"pid":8,"rootId":3,"children":[]},"12":{"pid":8,"rootId":3,"children":[13]},"13":{"pid":12,"rootId":3,"children":[]}}
根據(jù)輸出結(jié)果可以推出以下信息:
1、此異步調(diào)用鏈路的 rootId (初始 asyncId ,也是頂層節(jié)點值) 是 3
2、函數(shù)執(zhí)行異步調(diào)用時,其調(diào)用鏈路如下圖所示:
3、函數(shù)執(zhí)行異步調(diào)用時,其調(diào)用鏈路如下圖所示:
從調(diào)用鏈路圖就可以清晰看出所有異步調(diào)用之間的相互關(guān)系和順序。為異步調(diào)用的各種問題排查和性能分析提供了強有力的技術(shù)支持。
六、總結(jié)
到這,關(guān)于Node.js 應(yīng)用全鏈路信息獲取的設(shè)計、實現(xiàn)和案例演示就介紹完了。全鏈路信息獲取是全鏈路追蹤系統(tǒng)中最重要的一環(huán),當(dāng)信息獲取搞定后,下一步就是全鏈路信息存儲展示。
審核編輯:劉清
-
SSR
+關(guān)注
關(guān)注
0文章
82瀏覽量
17750 -
JAVA語言
+關(guān)注
關(guān)注
0文章
138瀏覽量
20090
原文標(biāo)題:Node.js應(yīng)用全鏈路追蹤技術(shù)——[全鏈路信息獲取]
文章出處:【微信號:OSC開源社區(qū),微信公眾號:OSC開源社區(qū)】歡迎添加關(guān)注!文章轉(zhuǎn)載請注明出處。
發(fā)布評論請先 登錄
相關(guān)推薦
評論