nodeJS 菜鳥入門

從一個簡單的 HTTP 服務開始旅程……

建立一個 server.js 文件,寫入:php

//最簡單的 http 服務例子
var http = require("http");
http.createServer(function(request, response) {
    response.writeHead(200, {"Content-Type": "text/html"});
    response.write("<h1>Hi NodeJs</h1>");
    response.end();
}).listen(8080);
console.log("成功的提示:httpd start @8080");

打開 http://localhost:8080/ 你會看到驚喜~html

tipsnode

執行: node server.js啓動服務。
Ctrl + c 結束 剛剛建立的服務。git

分析該HTTP服務

  1. http服務器: Node.JS 自帶的, http 模塊
  2. createServer: 調用該返回的對象中的 listen 方法,對服務端口進行監聽
  3. 複習下 匿名函數
匿名函數的變化
//自帶的http 模塊
var http = require("http");

function onRequest(request, response) {
  //console.log("請求來了,事件響應");
  response.writeHead(200, {"Content-Type": "text/plain"});
  response.write("<h1>Hi NodeJs</h1>");
  response.end();
}

http.createServer(onRequest).listen(8080);
console.log("成功的提示:httpd start @8080");
擴展 事件驅動

Felix Geisendörfer 的 Understanding node.js (理解NodeJS)github

  • php: 任什麼時候候當有請求進入的時候,網頁服務器(一般是Apache)就爲這一請求新建一個進程,而且開始從頭至尾執行相應的PHP腳本;
  • Node.js: 事件驅動設計
  • 證實 NodeJS 的事件驅動設計: 去掉以上代碼 這個註釋
    //console.log("請求來了,事件響應"); 啓動 server.js
  • 結果: 啓動服務時,輸出 成功…… 執行網頁請求時,輸出 請求……
  • 一次http請求輸出倆次事件是由於:大部分服務器都會在你訪問 http://localhost:8080 /時嘗試讀取 http://localhost:8080/favicon.ico

模塊化

把 server.js 變成一個模塊:
var http = require("http");

function start() {
  function onRequest(request, response) {
    //console.log("請求來了,事件響應");
    response.writeHead(200, {"Content-Type": "text/plain"});
    response.write("Hi NodeJS");
    response.end();
  }

  http.createServer(onRequest).listen(8080);
  console.log("成功的提示:httpd start @8080");
}
//nodejs中exports對象,理解 **module.exports** 和 **exports**
exports.start = start;
理解 module.exports 和 exports :

exports 獲取的全部的屬性和方法,都會傳遞給 Module.exports
可是 Module.exports 自己不具有任何屬性和方法。若是, Module.exports 已經具有某些屬性或方法,那麼 exports 傳遞的屬性或者方法會被忽略(失敗)。shell

代碼舉例
// a.js
exports.words = function() {
    console.log('Hi');
};

// b.js
var say = require('./a.js');
say.words(); // 'Hi'

//----分割線----

// aa.js
module.exports = 'Wellcome';
exports.words = function() {
    console.log('Hi');
};

// bb.js
var say = require('./aa.js');
say.words(); // TypeError: Object Wellcome has no method 'words'
調用

建立 index.js 寫入:npm

var server = require("./server");

server.start();

啓動: node index.js 看看吧!編程

牛刀小試 路由選擇模塊

做爲 ThinkPHP 的玩家,確定能想到 TP 的路由: 經過實例化對象來實現路由選擇;
如今來看看 node 是怎麼來實現的:瀏覽器

1 、提取出請求的URL以及GET/POST參數,這裏須要額外的NodeJS模塊:URLquerystring服務器

仔細看下圖:

url.parse(string).query
                                           |
           url.parse(string).pathname      |
                       |                   |
                       |                   |
                     -----   ------------------
http://localhost:8080/start?foo=bar&hello=world
                                ---       -----
                                 |          |
                                 |          |
              querystring(string)["foo"]    |
                                            |
                         querystring(string)["hello"]

2 、幫助 onRequest()函數 找出瀏覽器請求的URL路徑

先新建一個 router.js 寫入,可以輸出當前請求的路徑名稱

function route(pathname) {
    console.log("請求路徑是:" + pathname);
}
exports.route = route;

再擴展 server.js

var http = require("http"),
    //URL模塊能夠讀取URL、分析諸如hostname、port之類的信息
    url  = require("url");

//傳入 route (回調函數)
function start(route) {
    function onRequest(request, response) {
        var pathname = url.parse(request.url).pathname;
        //console.log("請求 "+pathname+" ,事件");

        route(pathname);

        response.writeHead(200, {"Content-Type": "text/plain"});
        response.write("Hi NodeJS");
        response.end();
    }

    http.createServer(onRequest).listen(8080);
    console.log("成功的提示:httpd start @8080");
}

exports.start = start;

最後擴展 index.js

var server = require("./server");
var router = require("./router");

server.start(router.route);

啓動,輸入 http://localhost:8080/a 結果

$ node index.js
成功的提示:httpd start @8080
請求路徑是:/a
請求路徑是:/favicon.ico

擴展:

Martin Fowlers 關於依賴注入的大做

函數編程

注重:數學本質、抽象本質。
重要的概念:循環能夠沒有(描述如何解決問題),遞歸(描述這個問題的定義)是不可或缺;
面向對象編程是 傳遞對象;而在函數式編程中,傳遞的是函數(更專業的叫:叫作高階函數)
高階函數:a、接受一個或多個函數輸入;b、輸出一個函數

其餘, 行爲驅動執行 (BDD) 和 測試驅動開發(TDD)

函數編程擴展閱讀:

函數式編程掃盲篇
Steve Yegge 名詞王國中的死刑

路由處理函數

示例,建立一個 requestHandlers.js 模塊

function start() {
    console.log("處理請求 'start' 開啓.");
}

function upload() {
    console.log("處理請求 'upload' 開啓.");
}

exports.start = start;
exports.upload = upload;
對象傳遞

將一系列請求處理程序經過一個對象來傳遞,而且須要使用鬆耦合的方式將這個對象注入到 route() 函數中

一、先將這個對象引入到主文件 index.js

var server = require("./server"),
    router = require("./router"),
    requestHandlers = require("./requestHandlers");

var handle = {};
    handle["/"] = requestHandlers.start;
    handle["/start"] = requestHandlers.start;
    handle["/upload"] = requestHandlers.upload;

server.start(router.route, handle);

二、把額外的傳遞參數 handle 給服務器 server.js

var http = require("http"),
    url  = require("url");

function start(route, handle) {
    function onRequest(request, response) {
        var pathname = url.parse(request.url).pathname;
        console.log("請求 "+pathname+" 響應");

        route(handle, pathname);

        response.writeHead(200, {"Content-Type": "text/plain"});
        response.write("Hi NodeJS");
        response.end();
    }

    http.createServer(onRequest).listen(8080);
    console.log("成功的提示:httpd start @8080");
}

exports.start = start;

三、修改 router.js

function route(handle, pathname) {
    console.log("route 請求路徑:" + pathname);
    if (typeof handle[pathname] === 'function') {
        handle[pathname]();
    } else {
        console.log("找不到路徑 " + pathname);
    }
}

exports.route = route;

四、運行結果: http://localhost:8080/start

$ node index.js
成功的提示:httpd start @8080
請求 /start 響應
route 請求路徑:/start
處理請求 'start' 開啓.
請求 /favicon.ico 響應
route 請求路徑:/favicon.ico
找不到路徑 /favicon.ico

和瀏覽器的互動

瀏覽器須要對請求做出響應。

很差的實現方式

一、將 requestHandler.js 修改成

function start() {
    console.log("處理請求 'start' 開啓.");
    return "Hello Start";
}

function upload() {
    console.log("處理請求 'upload' 開啓.");
    return "Hello Upload";
}

exports.start = start;
exports.upload = upload;

二、將 router.js 修改成

function route(handle, pathname) {
    console.log("route 請求路徑:" + pathname);
    if (typeof handle[pathname] === 'function') {
        return handle[pathname]();
    } else {
        console.log("找不到路徑 " + pathname);
        return '404 Not Found'
    }
}

exports.route = route;

三、須要對 server.js 進行重構,以使得它可以將請求處理程序經過請求路由返回的內容 響應 給瀏覽器

var http = require("http"),
    url  = require("url");

function start(route, handle) {
    function onRequest(request, response) {
        var pathname = url.parse(request.url).pathname;
        console.log("請求 "+pathname+" 響應");
        response.writeHead(200, {"Content-Type": "text/plain"});

        var content = route(handle, pathname);

        response.write(content);
        response.end();
    }
    http.createServer(onRequest).listen(8080);
    console.log("成功的提示:httpd start @8080");
}

exports.start = start;

四、運行結果:當輸入 /start 是瀏覽器顯示 hello start ……

缺點:

當將來有請求處理程序須要進行 非阻塞 的操做的時候,咱們的應用就「掛」了。

阻塞與非阻塞

在請求處理程序中加入阻塞操做的用例 requestHandlers.js

function start() {
    console.log("處理請求 'start' 開啓.");

    function sleep (milliSeconds) {
        var startTime = new Date().getTime();
        while (new Date().getTime() < startTime + milliSeconds);
    }

    sleep(10000);
    return "Hello Start";
}

function upload() {
    console.log("處理請求 'upload' 開啓.");
    return "Hello Upload";
}

exports.start = start;
exports.upload = upload;

這樣,當 start() 被調用的時候,Node.js會先等待10秒,以後纔會返回 「Hello Start」 。當調用 upload() 的時候,會和此前同樣當即返回。

可是,當你打開兩個瀏覽器窗口或者標籤頁,
第一個輸入 http://localhost:8080/start 可是先不「回車」;
第二個輸入 http://localhost:8080/upload 一樣先不「回車」
接下來在第一個窗口中 /start 按下回車,快速的切到第二個窗口再按下回車……
會看到: /start 花了10s加載url, upload 竟然也是!
緣由就是 start() 包含了阻塞操做。形象的說就是「它阻塞了全部其餘的處理工做」。

tips

一、Node.js是單線程的。它經過事件輪詢(event loop)來實現並行操做
二、避免阻塞操做,多使用非阻塞操做————回調( callbackFunction()

一種錯誤的使用非阻塞操做的方式

修改 requestHandlers.js 中的 start 請求處理程序

//child_process 能夠建立多進程,利用多核計算資源。
var exec = require("child_process").exec;

function start() {
    console.log("處理請求 'start' 開啓.");
    var content = "empty";

    exec("ls -lah", function (error, stdout, stderr) {
        content = stdout;
      });

    return content;
}

function upload() {
    console.log("處理請求 'upload' 開啓.");
    return "Hello Upload";
}

exports.start = start;
exports.upload = upload;

上述代碼建立了一個新的變量content(初始值爲「empty」),執行 「ls -lah」 命令,將結果賦值給 content ,最後將content返回。
接下來 重啓服務 訪問 http://localhost:8080/start 結果是 empty ,
這時,exec() 在非阻塞這塊發揮了做用。它能夠執行很是耗時的 shell 操做而無需網頁應用等待該操做。

可是代碼是同步執行的,這就意味着在調用 exec() 以後,Node.js會當即執行 return content; 在這個時候,content仍然是 「empty」 ,由於傳遞給 exec() 的回調函數還未執行到————由於 exec() 的操做是異步的……

tips:
child_process 模塊提供四個建立子進程的函數: spawn,exec,execFile和fork
其中 spawn 是最原始的建立子進程的函數,其餘三個都是對 spawn 不一樣程度的封裝。 spawn 只能運行指定的程序,參數須要在列表中給出,至關於 execvp 系統函數,而 exec 能夠直接運行復雜的命令
例子:運行ls -lh /usr

//使用 spawn
spawn('ls', ['-lh', '/usr'])
//使用 exec
exec('ls -lh /usr')

exec 是啓動了一個系統 shell 命令來解析參數,能夠執行復雜的命令,包括管道和重定向。
exec 還能夠直接接受一個回調函數做爲參數,回調函數有三個參數,分別是 err, stdout, stderr

以非阻塞操做進行請求響應(正確的方式)

實現方案: 函數編程(函數傳遞)

目前的結果:經過應用各層之間傳遞值的方式(請求處理程序 -> 請求路由 -> 服務器)將請求處理程序返回的內容(請求處理程序最終要顯示給用戶的內容)傳遞給HTTP服務器。

新實現方式:將服務器「傳遞」給內容的方式。從實踐角度來講,就是將response對象(從服務器的回調函數 onRequest() 獲取)經過請求路由傳遞給請求處理程序。 隨後,處理程序就能夠採用該對象上的函數來對請求做出響應。

一、 server.js

var http = require("http"),
    url  = require("url");

function start(route, handle) {
    function onRequest(request, response) {
        var pathname = url.parse(request.url).pathname;
        console.log("請求 "+pathname+" 響應");

        route(handle, pathname, response);
        
    }
    http.createServer(onRequest).listen(8080);
    console.log("成功的提示:httpd start @8080");
}

exports.start = start;

再也不從 route() 函數獲取返回值,而將 response 對象做爲第三個參數傳遞給 route() 函數,而且,移除 onRequest() 中有關 response 的函數調用,這部分讓 route() 函數完成。

二、 router.js

function route(handle, pathname, response) {
    console.log("route 請求路徑:" + pathname);
    if (typeof handle[pathname] === 'function') {
        handle[pathname](response);
    } else {
        console.log("找不到路徑 " + pathname);
        response.writeHead(404, {"Content-Type": "text/plain"});
        response.write("404 Not found");
        response.end();
    }
}

exports.route = route;

相對此前從請求處理程序中獲取返回值,此次取而代之的是直接傳遞 response 對象。

三、 requestHandler.js

var exec = require("child_process").exec;

function start(response) {
    console.log("處理請求 'start' 開啓.");
    
    exec("ls -lah", function (error, stdout, stderr) {
        response.writeHead(200, {"Content-Type": "text/plain"});
        response.write(stdout);
        response.end();
    });
}

function upload(response) {
    console.log("處理請求 'upload' 開啓.");
    response.writeHead(200, {"Content-Type": "text/plain"});
    response.write("Hello Upload");
    response.end();
}

exports.start = start;
exports.upload = upload;

start 處理程序在 exec() 的匿名回調函數中作請求響應的操做
upload 處理程序此次是使用 response 對象。

四、運行結果: 運行很好

如何 證實 /start 處理程序中耗時的操做不會阻塞對 /upload 請求做出當即響應?
修改 requestHandlers.js

var exec = require("child_process").exec;

function start(response) {
    console.log("處理請求 'start' 開啓.");
    
    exec("find /",
        { timeout: 10000, maxBuffer: 2000*1024},
        function (error, stdout, stderr) {
            response.writeHead(200, {"Content-Type": "text/plain"});
            response.write(stdout);
            response.end();
        }
    );
}

function upload(response) {
    console.log("處理請求 'upload' 開啓.");
    response.writeHead(200, {"Content-Type": "text/plain"});
    response.write("Hello Upload");
    response.end();
}

exports.start = start;
exports.upload = upload;

結果: /upload 時會當即響應,無論 /start 是否還在處理中

場景實戰:一個圖片上傳並在瀏覽器中顯示的功能

簡單的例子: 用 post 請求提交給服務器 文本區 中的內容; requestHandlers.js

function start(response) {
    console.log("處理請求 'start' 開啓.");
    
    var body = '<html>'+
        '<head>'+
        '<meta http-equiv="Content-Type" content="text/html; '+
        'charset=UTF-8" />'+
        '</head>'+
        '<body>'+
        '<form action="/upload" method="post">'+
        '<textarea name="text" rows="20" cols="60"></textarea>'+
        '<input type="submit" value=" 提 交 " />'+
        '</form>'+
        '</body>'+
        '</html>';

    response.writeHead(200, {"Content-Type": "text/html"});
    response.write(body);
    response.end();

}

function upload(response) {
    console.log("處理請求 'upload' 開啓.");
    response.writeHead(200, {"Content-Type": "text/plain"});
    response.write("Hello Upload");
    response.end();
}

exports.start = start;
exports.upload = upload;

由於 POST 請求會比較大(用戶可能會填大量內容),爲了使整個過程非阻塞, NodeJS 會將數據拆分紅不少小數據塊,而後經過觸發特定的事件,將這些小數據塊傳遞給回調函數。這裏的特定的事件有 data 事件(表示新的小數據塊到達了)以及 end事件(表示全部的數據都已經接收完畢)。

經過在 request 對象上註冊監聽器(listener)來實現這些事件的觸發,以及回調;request對象是每次接收到HTTP請求時候,都會把該對象傳遞給 onRequest 回調函數。

request.addListener("data", function(chunk) {
  // called when a new chunk of data was received
});

request.addListener("end", function() {
  // called when all chunks of data have been received
});

這部分的邏輯應該寫在哪裏?
由於獲取全部來自請求的數據,而後將這些數據給應用層處理,應該是HTTP服務器要作的事情,因此能夠在服務器中處理 POST 數據,而後將最終的數據傳遞給請求路由和請求處理器,讓他們來進行進一步的處理。

一、 server.js

var http = require("http"),
    url  = require("url");

function start(route, handle) {
    function onRequest(request, response) {
        var postData = '',
            pathname = url.parse(request.url).pathname;
        console.log("請求 "+pathname+" 響應");

        request.setEncoding('UTF-8');

        request.addListener('data', function(postDataChunk){
            postData += postDataChunk;
            console.log("收到數據塊 ‘" + postDataChunk + "’.")
        })

        request.addListener('end', function(){
            route(handle, pathname, response, postData);
        });

    }
    
    http.createServer(onRequest).listen(8080);
    console.log("成功的提示:httpd start @8080");
}

exports.start = start;

以上代碼作了三件事:

  • 設置接收數據的編碼 utf-8
  • 註冊 data 事件,收集每次接收到的新數據塊並賦值給 postData
  • 當全部數據接收完成在 end 事件中調用路由,傳遞給請求程序

二、接下來須要在 /upload 頁面展現用戶輸入的內容,將 postData 傳遞給請求處理程序 router.js

function route(handle, pathname, response, postData) {
    console.log("route 請求路徑:" + pathname);
    if (typeof handle[pathname] === 'function') {
        handle[pathname](response, postData);
    } else {
        console.log("找不到路徑 " + pathname);
        response.writeHead(404, {"Content-Type": "text/plain"});
        response.write("404 Not found");
        response.end();
    }
}

exports.route = route;

三、 requestHandlers.js

function start(response, postData) {
    console.log("處理請求 'start' 開啓.");
    
    var body = '<html>'+
        '<head>'+
        '<meta http-equiv="Content-Type" content="text/html; '+
        'charset=UTF-8" />'+
        '</head>'+
        '<body>'+
        '<form action="/upload" method="post">'+
        '<textarea name="text" rows="20" cols="60"></textarea>'+
        '<input type="submit" value=" 提 交 " />'+
        '</form>'+
        '</body>'+
        '</html>';

    response.writeHead(200, {"Content-Type": "text/html"});
    response.write(body);
    response.end();

}

function upload(response, postData) {
    console.log("處理請求 'upload' 開啓.");
    response.writeHead(200, {"Content-Type": "text/plain"});
    response.write("你寫的:" + decodeURIComponent(postData));
    response.end();
}

exports.start = start;
exports.upload = upload;

四、運行結果:

在 /start 中輸入「 你好 」 ,在 /upload 輸出 你寫的:text=你好

缺點: 其實通常狀況下須要的只是 text 字段;那麼,方法是調用 querystring 模塊
修改 requestHandlers.js

//原生自帶,包括4個方法,看 tips
var querystring = require('querystring');

function start(response, postData) {
    console.log("處理請求 'start' 開啓.");
    
    var body = '<html>'+
        '<head>'+
        '<meta http-equiv="Content-Type" content="text/html; '+
        'charset=UTF-8" />'+
        '</head>'+
        '<body>'+
        '<form action="/upload" method="post">'+
        '<textarea name="text" rows="20" cols="60"></textarea>'+
        '<input type="submit" value=" 提 交 " />'+
        '</form>'+
        '</body>'+
        '</html>';

    response.writeHead(200, {"Content-Type": "text/html"});
    response.write(body);
    response.end();

}

function upload(response, postData) {
    console.log("處理請求 'upload' 開啓.");
    response.writeHead(200, {"Content-Type": "text/plain"});
    response.write("你寫的:<br>" + querystring.parse(decodeURIComponent(postData).text));
    response.end();
}

exports.start = start;
exports.upload = upload;

tips

querystring 類包含4個方法

一、querystring.stringify(obj, [sep], [eq]) 將對象轉換成字符串
二、querystring.parse(str, [sep], [eq], [options]) 將字符串轉換成對象
三、querystring.escape 參數編碼
四、querystring.unescape 參數解碼

處理文件上傳

效果:容許用戶上傳圖片,並將該圖片在瀏覽器中顯示出來。

須要用到外部模塊: Felix Geisendörfer 開發的 node-formidable 模塊

用 NPM 包管理器安裝

npm install formidable

完成後

一、 requestHandlers.js 添加圖片展現模塊,修改上傳模塊

var querystring = require('querystring'),
    //可將文件讀取到服務器中
    fs = require('fs');
    //解析上傳文件數據
    formidable = require("formidable");

function start(response) {
    console.log("處理請求 'start' 開啓.");
    
    var body = '<html>'+
        '<head>'+
        '<meta http-equiv="Content-Type" content="text/html; '+
        'charset=UTF-8" />'+
        '</head>'+
        '<body>'+
        '<form action="/upload" enctype="multipart/form-data" '+
        'method="post">'+
        '<input type="file" name="upload">'+
        '<input type="submit" value=" 上 傳 " />'+
        '</form>'+
        '</body>'+
        '</html>';

    response.writeHead(200, {"Content-Type": "text/html"});
    response.write(body);
    response.end();

}

function upload(response, request) {
    console.log("處理請求 'upload' 開啓.");

    var form = new formidable.IncomingForm();
    console.log("加載解析");
    form.parse(request, function(error, fields, files) {
        console.log("解析完成");

        var is = fs.createReadStream(files.upload.path);
        var os = fs.createWriteStream("./tmp/test.png");
        is.pipe(os);
        is.on('end',function(){
            fs.unlinkSync(files.upload.path);
        });
        
        //fs.renameSync(files.upload.path, "./tmp/test.png");
        response.writeHead(200, {"Content-Type": "text/html"});
        response.write("收到圖片:<br/>");
        response.write("<img src='/show' />");
        response.end();
    });

}


function show(response) {
    console.log("處理請求 'show' 開啓.");
    fs.readFile("./tmp/test.png", "binary", function(error, file) {
        if(error) {
            response.writeHead(500, {"Content-Type": "text/plain"});
            response.write(error + "\n");
            response.end();
        } else {
            response.writeHead(200, {"Content-Type": "image/png"});
            response.write(file, "binary");
            response.end();
        }
    });
}

exports.start = start;
exports.upload = upload;
exports.show = show;

二、 index.js 添加新的請求處理到 路由映射表

var server = require("./server"),
    router = require("./router"),
    requestHandlers = require("./requestHandlers");

var handle = {};
    handle["/"] = requestHandlers.start;
    handle["/start"] = requestHandlers.start;
    handle["/upload"] = requestHandlers.upload;
    handle["/show"] = requestHandlers.show; 

server.start(router.route, handle);

三、 server.js 移除對postData的處理以及request.setEncoding (這部分node-formidable自身會處理),轉而採用將request對象傳遞給請求路由的方式:

var http = require("http"),
    url  = require("url");

function start(route, handle) {
    function onRequest(request, response) {
        var pathname = url.parse(request.url).pathname;
        console.log("請求 "+pathname+" 響應");
        route(handle, pathname, response, request);
    }

    http.createServer(onRequest).listen(8080);
    console.log("成功的提示:httpd start @8080");
}

exports.start = start;

四、 router.js 不須要傳遞postData了,而要傳遞request對象

function route(handle, pathname, response, request) {
    console.log("route 請求路徑:" + pathname);
    if (typeof handle[pathname] === 'function') {
        handle[pathname](response, request);
    } else {
        console.log("找不到路徑 " + pathname);
        response.writeHead(404, {"Content-Type": "text/plain"});
        response.write("404 Not found");
        response.end();
    }
}

exports.route = route;

小坑:

//fs.renameSync(files.upload.path, "./tmp/test.png")
//報錯以下
fs.js:543
  return binding.rename(pathModule._makeLong(oldPath),
                 ^
Error: EXDEV, cross-device link not permitted 'C:\User……
……

修改爲以下代碼便可:

var is = fs.createReadStream(files.upload.path);
var os = fs.createWriteStream("./tmp/test.png");
is.pipe(os);
is.on('end',function(){
    fs.unlinkSync(files.upload.path);
});

//fs.renameSync(files.upload.path, "./tmp/test.png");

五、效果演示:(樓主以 .gif爲例)

最終效果

注:本文筆記來自於 Manuel Kiessling寫的 Node入門 一本全面的Node.js教程
其餘: Node.js community wiki——NodeJS社區
本次擴展: NodeJS+Mongodb+Express作CMS博客系統(符合MVC)

相關文章
相關標籤/搜索