node.js初探


慚愧慚愧,node.js已經火了好幾年了,最近纔開始接觸……主要是工做中也基本用不到,可是我以爲一個東西火確定自有道理,頗有必要接觸瞭解,甚至深刻學習裏面的精髓~
官網:https://nodejs.org
API查詢:https://nodejs.org/apijavascript

簡介

JavaScript是一種運行在瀏覽器的腳本,它簡單,輕巧,易於編輯,這種腳本一般用於瀏覽器的前端編程,可是一位開發者Ryan有一天發現這種前端式的腳本語言能夠運行在服務器上的時候,一場席捲全球的風暴就開始了。php

Node.js是一個基於Chrome JavaScript運行時創建的平臺, 用於方便地搭建響應速度快、易於擴展的網絡應用。Node.js 使用事件驅動, 非阻塞I/O 模型而得以輕量和高效,很是適合在分佈式設備上運行的數據密集型的實時應用。html

Node是一個Javascript運行環境(runtime)。實際上它是對Google V8引擎進行了封裝。V8引 擎執行Javascript的速度很是快,性能很是好。Node對一些特殊用例進行了優化,提供了替代的API,使得V8在非瀏覽器環境下運行得更好。前端


Node.js中代碼是單進程、單線程執行,可是底層實際上是有線程池的。
NodeJS適合運用在高併發、I/O密集、少許業務邏輯的場景,例如RESTful API,消息推送,聊天服務等java

安裝

因爲我使用的是Ubuntu Kylin,因此安裝方式以下:node

1.curl -sL https://deb.nodesource.com/setup_5.x | sudo -E bash
2.sudo apt-get install -y nodejslinux

順便下載安裝一個Atom編輯器來配合敲代碼~nginx

curl -sL https://atom.io/download/deb -o atom.deb
#因爲atom安裝依賴於git,還要先安裝git
sudo apt-get install git
sudo dpkg –install atom.debgit

安裝完成輸入atom運行便可(插件什麼的自行https://atom.io/packages尋找)。web

Hello World

  1. var http = require('http');
  2. http.createServer(function(request, response){
  3. // 發送 HTTP 頭部
  4. // HTTP 狀態值: 200 : OK
  5. // 內容類型: text/plain
  6. response.writeHead(200,{'Content-Type':'text/plain'});
  7. // 發送響應數據 "Hello World"
  8. response.end('Hello World\n');
  9. }).listen(8888);
  10. // 終端打印以下信息
  11. console.log('Server running at http://127.0.0.1:8888/');

node server.js
瀏覽器瀏覽127.0.0.1:8888

npm

NPM是隨同NodeJS一塊兒安裝的包管理工具,能解決NodeJS代碼部署上的不少問題,常見的使用場景有如下幾種:

  • 容許用戶從NPM服務器下載別人編寫的第三方包到本地使用。
  • 容許用戶從NPM服務器下載並安裝別人編寫的命令行程序到本地使用。
  • 容許用戶將本身編寫的包或命令行程序上傳到NPM服務器供別人使用。

全局安裝與本地安裝

npm 的包安裝分爲本地安裝(local)、全局安裝(global)兩種,從敲的命令行來看,差異只是有沒有-g而已,好比

npm install express # 本地安裝
npm install express -g # 全局安裝

本地安裝

  • 將安裝包放在 ./node_modules 下(運行 npm 命令時所在的目錄),若是沒有 node_modules 目錄,會在當前執行 npm 命令的目錄下生成 node_modules 目錄。
  • 能夠經過 require() 來引入本地安裝的包。

全局安裝

  • 將安裝包放在 /usr/local 下。
  • 能夠直接在命令行裏使用。
  • 不能經過 require() 來引入本地安裝的包。

使用npm help可查看全部命令。

Node.js REPL(交互式解釋器)

Node.js REPL(Read Eval Print Loop:交互式解釋器) 表示一個電腦的環境,相似 Window 系統的終端或 Unix/Linux shell,咱們能夠在終端中輸入命令,並接收系統的響應。

使用變量

你能夠將數據存儲在變量中,並在你須要的使用它。
變量聲明須要使用 var 關鍵字,若是沒有使用 var 關鍵字變量會直接打印出來。
使用 var 關鍵字的變量可使用 console.log() 來輸出變量。

$ node
> x = 10
10
> var y = 10
undefined
> x + y
20
> console.log(「Hello World」)
Hello World
undefined
> console.log(「www.runoob.com」)
www.runoob.com
undefined

多行表達式

Node REPL 支持輸入多行表達式,這就有點相似 JavaScript。接下來讓咱們來執行一個 do-while 循環:

$ node
> var x = 0
undefined
> do {
… x++;
… console.log(「x: 」 + x);
… } while ( x < 5 );
x: 1
x: 2
x: 3
x: 4
x: 5
undefined
>

… 三個點的符號是系統自動生成的,你回車換行後便可。Node 會自動檢測是否爲連續的表達式。

下劃線(_)變量

你可使用下劃線(_)獲取表達式的運算結果:

$ node
> var x = 10
undefined
> var y = 20
undefined
> x + y
30
> var sum = _
undefined
> console.log(sum)
30
undefined
>

Node.js 回調函數

Node.js 異步編程的直接體現就是回調。
異步編程依託於回調來實現,但不能說使用了回調後程序就異步化了。
回調函數在完成任務後就會被調用,Node 使用了大量的回調函數,Node 全部 API 都支持回調函數。
例如,咱們能夠一邊讀取文件,一邊執行其餘命令,在文件讀取完成後,咱們將文件內容做爲回調函數的參數返回。這樣在執行代碼時就沒有阻塞或等待文件 I/O 操做。這就大大提升了 Node.js 的性能,能夠處理大量的併發請求。

  1. var fs = require("fs");
  2. //阻塞代碼實例
  3. var data = fs.readFileSync('input.txt');
  4. 非阻塞代碼實例
  5. fs.readFile('input.txt',function(err, data){
  6. if(err)return console.error(err);
  7. console.log(data.toString());
  8. });

Node.js 事件循環

Node.js 是單進程單線程應用程序,可是經過事件和回調支持併發,因此性能很是高。
Node.js 的每個 API 都是異步的,並做爲一個獨立線程運行,使用異步函數調用,並處理併發。
Node.js 基本上全部的事件機制都是用設計模式中觀察者模式實現。
Node.js 單線程相似進入一個while(true)的事件循環,直到沒有事件觀察者退出,每一個異步事件都生成一個事件觀察者,若是有事件發生就調用該回調函數.

事件驅動程序

Node.js 使用事件驅動模型,當web server接收到請求,就把它關閉而後進行處理,而後去服務下一個web請求。
當這個請求完成,它被放回處理隊列,當到達隊列開頭,這個結果被返回給用戶。
這個模型很是高效可擴展性很是強,由於webserver一直接受請求而不等待任何讀寫操做。(這也被稱之爲非阻塞式IO或者事件驅動IO)
在事件驅動模型中,會生成一個主循環來監聽事件,當檢測到事件時觸發回調函數。

  1. // 引入 events 模塊
  2. var events = require('events');
  3. // 建立 eventEmitter 對象
  4. var eventEmitter =new events.EventEmitter();
  5. // 建立事件處理程序
  6. var connectHandler =function connected(){
  7. console.log('鏈接成功。');
  8. // 觸發 data_received 事件
  9. eventEmitter.emit('data_received');
  10. }
  11. // 綁定 connection 事件處理程序
  12. eventEmitter.on('connection', connectHandler);
  13. // 使用匿名函數綁定 data_received 事件
  14. eventEmitter.on('data_received',function(){
  15. console.log('數據接收成功。');
  16. });
  17. // 觸發 connection 事件
  18. eventEmitter.emit('connection');
  19. console.log("程序執行完畢。");

咱們執行以上代碼:

$ node main.js
鏈接成功。
數據接收成功。
程序執行完畢。

Node.js 全部的非阻塞I/O I/O 操做在完成時都會發送一個事件到事件隊列。
Node.js裏面的許多對象都會分發事件:一個net.Server對象會在每次有新鏈接時分發一個事件, 一個fs.readStream對象會在文件被打開的時候發出一個事件。 全部這些產生事件的對象都是 events.EventEmitter 的實例。
EventEmitter 的核心就是事件觸發與事件監聽器功能的封裝。

EventEmitter 的每一個事件由一個事件名和若干個參數組成,事件名是一個字符串,一般表達必定的語義。對於每一個事件,EventEmitter 支持 若干個事件監聽器。
當事件觸發時,註冊到這個事件的事件監聽器被依次調用,事件參數做爲回調函數參數傳遞。

  1. var events = require('events');
  2. var emitter =new events.EventEmitter();
  3. emitter.on('someEvent',function(arg1, arg2){
  4. console.log('listener1', arg1, arg2);
  5. });
  6. emitter.on('someEvent',function(arg1, arg2){
  7. console.log('listener2', arg1, arg2);
  8. });
  9. emitter.emit('someEvent','arg1 參數','arg2 參數');

error 事件

EventEmitter 定義了一個特殊的事件 error,它包含了錯誤的語義,咱們在遇到 異常的時候一般會觸發 error 事件。
當 error 被觸發時,EventEmitter 規定若是沒有響 應的監聽器,Node.js 會把它看成異常,退出程序並輸出錯誤信息。
咱們通常要爲會觸發 error 事件的對象設置監聽器,避免遇到錯誤後整個程序崩潰。

繼承 EventEmitter

大多數時候咱們不會直接使用 EventEmitter,而是在對象中繼承它。包括 fs、net、 http 在內的,只要是支持事件響應的核心模塊都是 EventEmitter 的子類。
爲何要這樣作呢?緣由有兩點:
首先,具備某個實體功能的對象實現事件符合語義, 事件的監聽和發射應該是一個對象的方法。
其次 JavaScript 的對象機制是基於原型的,支持 部分多重繼承,繼承 EventEmitter 不會打亂對象原有的繼承關係。

Node.js Buffer(緩衝區)

JavaScript 語言自身只有字符串數據類型,沒有二進制數據類型。
但在處理像TCP流或文件流時,必須使用到二進制數據。所以在 Node.js中,定義了一個 Buffer 類,該類用來建立一個專門存放二進制數據的緩存區。
在 Node.js 中,Buffer 類是隨 Node 內核一塊兒發佈的核心庫。Buffer 庫爲 Node.js 帶來了一種存儲原始數據的方法,可讓 Node.js 處理二進制數據,每當須要在 Node.js 中處理I/O操做中移動的數據時,就有可能使用 Buffer 庫。原始數據存儲在 Buffer 類的實例中。一個 Buffer 相似於一個整數數組,但它對應於 V8 堆內存以外的一塊原始內存。

utf-8 是默認的編碼方式,此外它一樣支持如下編碼:」ascii」, 「utf8」, 「utf16le」, 「ucs2」, 「base64」 和 「hex」。

Node.js Stream(流)

  • Stream 是一個抽象接口,Node 中有不少對象實現了這個接口。例如,對http 服務器發起請求的request 對象就是一個 Stream,還有stdout(標準輸出)。
  • Node.js,Stream 有四種流類型:
  • Readable - 可讀操做。
  • Writable - 可寫操做。
  • Duplex - 可讀可寫操做.
  • Transform - 操做被寫入數據,而後讀出結果。

全部的 Stream 對象都是 EventEmitter 的實例。經常使用的事件有:

  • data - 當有數據可讀時觸發。
  • end - 沒有更多的數據可讀時觸發。
  • error - 在接收和寫入過程當中發生錯誤時觸發。
  • finish - 全部數據已被寫入到底層系統時觸發。

Node.js模塊系統

爲了讓Node.js的文件能夠相互調用,Node.js提供了一個簡單的模塊系統。
模塊是Node.js 應用程序的基本組成部分,文件和模塊是一一對應的。換言之,一個 Node.js 文件就是一個模塊,這個文件多是JavaScript 代碼、JSON 或者編譯過的C/C++ 擴展。

Node.js 提供了exports 和 require 兩個對象,其中 exports 是模塊公開的接口,require 用於從外部獲取一個模塊的接口,即所獲取模塊的 exports 對象。
接下來咱們就來建立hello.js文件,代碼以下:

  1. exports.world =function(){
  2. console.log('Hello World');
  3. }

在以上示例中,hello.js 經過 exports 對象把 world 做爲模塊的訪問接口,在 main.js 中經過 require(‘./hello’) 加載這個模塊,而後就能夠直接訪 問 hello.js 中 exports 對象的成員函數了。
有時候咱們只是想把一個對象封裝到模塊中
例如:

  1. //hello.js
  2. functionHello(){
  3. var name;
  4. this.setName =function(thyName){
  5. name = thyName;
  6. };
  7. this.sayHello =function(){
  8. console.log('Hello '+ name);
  9. };
  10. };
  11. module.exports =Hello;

這樣就能夠直接得到這個對象了:

  1. //main.js
  2. varHello= require('./hello');
  3. hello =newHello();
  4. hello.setName('BYVoid');
  5. hello.sayHello();

模塊接口的惟一變化是使用 module.exports = Hello 代替了exports.world = function(){}。 在外部引用該模塊時,其接口對象就是要輸出的 Hello 對象自己,而不是原先的 exports。

因爲Node.js中存在4類模塊(原生模塊和3種文件模塊),儘管require方法極其簡單,可是內部的加載倒是十分複雜的,其加載優先級也各自不一樣。以下圖所示:
nodejs-require

從文件模塊緩存中加載

儘管原生模塊與文件模塊的優先級不一樣,可是都不會優先於從文件模塊的緩存中加載已經存在的模塊。

從原生模塊加載

原生模塊的優先級僅次於文件模塊緩存的優先級。require方法在解析文件名以後,優先檢查模塊是否在原生模塊列表中。以http模塊爲例,儘管在目錄下存在一個http/http.js/http.node/http.json文件,require(「http」)都不會從這些文件中加載,而是從原生模塊中加載。
原生模塊也有一個緩存區,一樣也是優先從緩存區加載。若是緩存區沒有被加載過,則調用原生模塊的加載方式進行加載和執行。

從文件加載

當文件模塊緩存中不存在,並且不是原生模塊的時候,Node.js會解析require方法傳入的參數,並從文件系統中加載實際的文件,加載過程當中的包裝和編譯細節在前一節中已經介紹過,這裏咱們將詳細描述查找文件模塊的過程,其中,也有一些細節值得知曉。
require方法接受如下幾種參數的傳遞:

  • http、fs、path等,原生模塊。
  • ./mod或../mod,相對路徑的文件模塊。
  • /pathtomodule/mod,絕對路徑的文件模塊。
  • mod,非原生模塊的文件模塊。

Node.js 全局對象

JavaScript 中有一個特殊的對象,稱爲全局對象(Global Object),它及其全部屬性均可以在程序的任何地方訪問,即全局變量。
在瀏覽器 JavaScript 中,一般 window 是全局對象, 而 Node.js 中的全局對象是 global,全部全局變量(除了 global 自己之外)都是 global 對象的屬性。
在 Node.js 咱們能夠直接訪問到 global 的屬性,而不須要在應用中包含它。

全局對象與全局變量

global 最根本的做用是做爲全局變量的宿主。按照 ECMAScript 的定義,知足如下條 件的變量是全局變量:

  • 在最外層定義的變量;
  • 全局對象的屬性;
  • 隱式定義的變量(未定義直接賦值的變量)。
    當你定義一個全局變量時,這個變量同時也會成爲全局對象的屬性,反之亦然。須要注 意的是,在 Node.js 中你不可能在最外層定義變量,由於全部用戶代碼都是屬於當前模塊的, 而模塊自己不是最外層上下文。
    注意: 永遠使用 var 定義變量以免引入全局變量,由於全局變量會污染 命名空間,提升代碼的耦合風險。

Node.js 多進程

咱們都知道 Node.js 是以單線程的模式運行的,但它使用的是事件驅動來處理併發,這樣有助於咱們在多核 cpu 的系統上建立多個子進程,從而提升性能。
每一個子進程老是帶有三個流對象:child.stdin, child.stdout 和child.stderr。他們可能會共享父進程的 stdio 流,或者也能夠是獨立的被導流的流對象。
Node 提供了 child_process 模塊來建立子進程,方法有:

  • exec - child_process.exec 使用子進程執行命令,緩存子進程的輸出,並將子進程的輸出以回調函數參數的形式返回。
  • spawn - child_process.spawn 使用指定的命令行參數建立新線程。
  • fork - child_process.fork 是 spawn()的特殊形式,用於在子進程中運行的模塊,如 fork(‘./son.js’) 至關於 spawn(‘node’, [‘./son.js’]) 。與spawn方法不一樣的是,fork會在父進程與子進程之間,創建一個通訊管道,用於進程之間的通訊。

阻塞與非阻塞,同步與異步

咱們聽到Node.js時,咱們經常會聽到異步,非阻塞,回調,事件這些詞語混合在一塊兒。其中,異步與非阻塞聽起來彷佛是同一回事。從實際效果的角度說,異步和非阻塞都達到了咱們並行I/O的目的。可是從計算機內核I/O而言,異步/同步和阻塞/非阻塞實際上時兩回事。

I/O的阻塞與非阻塞
阻塞模式的I/O會形成應用程序等待,直到I/O完成。同時操做系統也支持將I/O操做設置爲非阻塞模式,這時應用程序的調用將可能在沒有拿到真正數據時就當即返回了,爲此應用程序須要屢次調用才能確認I/O操做徹底完成。
I/O的同步與異步
I/O的同步與異步出如今應用程序中。若是作阻塞I/O調用,應用程序等待調用的完成的過程就是一種同步情況。相反,I/O爲非阻塞模式時,應用程序則是異步的。

異步I/O與輪詢技術

當進行非阻塞I/O調用時,要讀到完整的數據,應用程序須要進行屢次輪詢,才能確保讀取數據完成,以進行下一步的操做。
輪詢技術的缺點在於應用程序要主動調用,會形成佔用較多CPU時間片,性能較爲低下。現存的輪詢技術有如下這些:

  • read
  • select
  • poll
  • epoll
  • pselect
  • kqueue

read是性能最低的一種,它經過重複調用來檢查I/O的狀態來完成完整數據讀取。select是一種改進方案,經過對文件描述符上的事件狀態來進行判斷。操做系統還提供了poll、epoll等多路複用技術來提升性能。
輪詢技術知足了異步I/O確保獲取完整數據的保證。可是對於應用程序而言,它仍然只能算時一種同步,由於應用程序仍然須要主動去判斷I/O的狀態,依舊花費了不少CPU時間來等待。

上一種方法重複調用read進行輪詢直到最終成功,用戶程序會佔用較多CPU,性能較爲低下。而實際上操做系統提供了select方法來代替這種重複read輪詢進行狀態判斷。select內部經過檢查文件描述符上的事件狀態來進行判斷數據是否徹底讀取。可是對於應用程序而言它仍然只能算是一種同步,由於應用程序仍然須要主動去判斷I/O的狀態,依舊花費了不少CPU時間等待,select也是一種輪詢。

理想的異步I/O模型

理想的異步I/O應該是應用程序發起異步調用,而不須要進行輪詢,進而處理下一個任務,只需在I/O完成後經過信號或是回調將數據傳遞給應用程序便可。

幸運的是,在Linux下存在一種這種方式,它原生提供了一種異步非阻塞I/O方式(AIO)便是經過信號或回調來傳遞數據的。
不幸的是,只有Linux下有這麼一種支持,並且還有缺陷(AIO僅支持內核I/O中的O_DIRECT方式讀取,致使沒法利用系統緩存。參見:http://forum.nginx.org/read.php?2,113524,113587#msg-113587
以上都是基於非阻塞I/O進行的設定。另外一種理想的異步I/O是採用阻塞I/O,但加入多線程,將I/O操做分到多個線程上,利用線程之間的通訊來模擬異步。Glibc的AIO即是這樣的典型http://www.ibm.com/developerworks/linux/library/l-async/。然而遺憾在於,它存在一些難以忍受的缺陷和bug。能夠簡單的概述爲:Linux平臺下沒有完美的異步I/O支持。
所幸的是,libev的做者Marc Alexander Lehmann從新實現了一個異步I/O的庫:libeio。libeio實質依然是採用線程池與阻塞I/O模擬出來的異步I/O。
那麼在Windows平臺下的情況如何呢?而實際上,Windows有一種獨有的內核異步IO方案:IOCP。IOCP的思路是真正的異步I/O方案,調用異步方法,而後等待I/O完成通知。IOCP內部依舊是經過線程實現,不一樣在於這些線程由系統內核接手管理。IOCP的異步模型與Node.js的異步調用模型已經十分近似。
以上兩種方案則正是Node.js選擇的異步I/O方案。因爲Windows平臺和*nix平臺的差別,Node.js提供了libuv來做爲抽象封裝層,使得全部平臺兼容性的判斷都由這一層次來完成,保證上層的Node.js與下層的libeio/libev及IOCP之間各自獨立。Node.js在編譯期間會判斷平臺條件,選擇性編譯unix目錄或是win目錄下的源文件到目標程序中。

下文咱們將經過解釋Windows下Node.js異步I/O(IOCP)的簡單例子來探尋一下從JavaScript代碼到系統內核之間都發生了什麼。

Node.js的異步I/O模型

不少同窗在碰見Node.js後必然產生過對回調函數究竟如何被調用產生過好奇。在文件I/O這一塊與普通的業務邏輯的回調函數不一樣在於它不是由咱們本身的代碼所觸發,而是系統調用結束後,由系統觸發的。下面咱們以最簡單的fs.open方法來做爲例子,探索Node.js與底層之間是如何執行異步I/O調用和回調函數到底是如何被調用執行的。

  1. fs.open =function(path, flags, mode, callback){
  2. callback = arguments[arguments.length -1];
  3. if(typeof(callback)!=='function'){
  4. callback = noop;
  5. }
  6. mode = modeNum(mode,438/*=0666*/);
  7. binding.open(pathModule._makeLong(path),
  8. stringToFlags(flags),
  9. mode,
  10. callback);
  11. };

fs.open的做用是根據指定路徑和參數,去打開一個文件,從而獲得一個文件描述符,是後續全部I/O操做的初始操做。

在JavaScript層面上調用的fs.open方法最終都透過node_file.cc調用到了libuv中的uv_fs_open方法,這裏libuv做爲封裝層,分別寫了兩個平臺下的代碼實現,編譯以後,只會存在一種實現被調用。

請求對象

在uv_fs_open的調用過程當中,Node.js建立了一個FSReqWrap請求對象。從JavaScript傳入的參數和當前方法都被封裝在這個請求對象中,其中回調函數則被設置在這個對象的oncomplete_sym屬性上。

req_wrap->object_->Set(oncomplete_sym, callback);

對象包裝完畢後,調用QueueUserWorkItem方法將這個FSReqWrap對象推入線程池中等待執行。

QueueUserWorkItem(&uv_fs_thread_proc, req, WT_EXECUTELONGFUNCTION)

QueueUserWorkItem接受三個參數,第一個是要執行的方法,第二個是方法的上下文,第三個是執行的標誌。當線程池中有可用線程的時候調用uv_fs_thread_proc方法執行。該方法會根據傳入的類型調用相應的底層函數,以uv_fs_open爲例,實際會調用到fs__open方法。調用完畢以後,會將獲取的結果設置在req->result上。而後調用PostQueuedCompletionStatus通知咱們的IOCP對象操做已經完成。

PostQueuedCompletionStatus((loop)->iocp, 0, 0, &((req)->overlapped))

PostQueuedCompletionStatus方法的做用是向建立的IOCP上相關的線程通訊,線程根據執行情況和傳入的參數斷定退出。
至此,由JavaScript層面發起的異步調用第一階段就此結束。

事件循環

在調用uv_fs_open方法的過程當中實際上應用到了事件循環。以在Windows平臺下的實現中,啓動Node.js時,便建立了一個基於IOCP的事件循環loop,並一直處於執行狀態。

uv_run(uv_default_loop());

每次循環中,它會調用IOCP相關的GetQueuedCompletionStatus方法檢查是否線程池中有執行完的請求,若是存在,poll操做會將請求對象加入到loop的pending_reqs_tail屬性上。 另外一邊這個循環也會不斷檢查loop對象上的pending_reqs_tail引用,若是有可用的請求對象,就取出請求對象的result屬性做爲結果傳遞給oncomplete_sym執行,以此達到調用JavaScript中傳入的回調函數的目的。 至此,整個異步I/O的流程完成結束。其流程以下:

事件循環和請求對象構成了Node.js的異步I/O模型的兩個基本元素,這也是典型的消費者生產者場景。在Windows下經過IOCP的GetQueuedCompletionStatus、PostQueuedCompletionStatus、QueueUserWorkItem方法與事件循環實。對於*nix平臺下,這個流程的不一樣之處在與實現這些功能的方法是由libeio和libev提供。

參考

http://www.runoob.com/nodejs/nodejs-tutorial.html
http://www.infoq.com/cn/articles/nodejs-asynchronous-io/



相關文章
相關標籤/搜索