NodeJS基礎API搭建伺服器詳細過程記錄
前言
在習慣了使用express框架,jade模板引擎等現成工具來寫程式碼之後,很多人對於基本的NodeJS API會慢慢生疏。本文將以一個超小型web專案,來詳細介紹如何使用NodeJS基礎的http, fs, path, url等模組提供的API來搭建一個簡單的web伺服器。當做對NodeJS的一次複習,也為初學NodeJS的開發者提供一個參考。本文所搭建的專案將不會使用express等後端框架,僅使用最基礎的NodeJS API,按照MVC設計模式的思路進行編碼和講解,交流意見。原始碼地址如下,建議下載原始碼邊看部落格邊對照原始碼才能比較快理解整個過程。原始碼倉庫simple-http-server
專案介紹
有一個簡單的食品店網站,它包括一個主頁index和一個詳情頁detail。主頁展示食品店的所有食品,包括食品圖片、名稱、價格3個資訊,如下圖所示。
使用者點選任何一項食品就會跳轉到對應的詳情頁,包括食品圖片、名稱、價格和描述4個資訊,如下圖所示。
專案結構
專案的檔案結構如下所示。
node-example
|--data(存放專案資料的資料夾)
|--detail.json(存放食品詳情資料)
|--foods.json(存放首頁食品資料)
|--model(提供訪問和操作資料服務的資料模型)
| --detail.js(詳情資料訪問模組)
|--foods.js(食品資料訪問模組)
|--public(存放css,js,圖片等靜態檔案)
|--css(存放css檔案的資料夾)
|--img(存放圖片的資料夾)
|--js(存放js檔案的資料夾)
|--route(路由,控制器)
|--api(處理普通請求的路由,或者叫控制器)
|--static(處理靜態檔案請求的路由,或者叫控制器)
|--views(檢視,即使用者介面)
|--index .html(主頁介面)
|--detail.html(詳情頁面)
|--server.js(伺服器啟動檔案)
|--package.json(專案包資訊)
|--README.md(專案資訊以及啟動方法描述)
本文只講解服務端程式設計,因此兩個簡單介面的實現過程這裡就不再囉嗦了。假設你已經能夠自行完成前端的介面程式設計,下面開始講解服務端程式設計。
編寫伺服器
server.js中要完成伺服器的建立和啟動,並將請求轉發給相應的路由去處理。詳細程式碼如下所示(假設我們已經有了能夠正常工作的路由,這裡採用Top-Down的思路,我們一層一層地往下寫,專注於解決每個層次的問題)。程式碼中使用正則表示式來判定客戶端request是否是在請求靜態檔案,如果是,則交給專門處理靜態檔案請求的路由static去處理,否則交給普通請求的路由器api去處理。普通請求根據它的HTTP方法來判斷使用get或者post。最後,設定伺服器監聽3000埠,server.js的程式碼就算完成了。
var http = require('http');
var url = require('url');
var api = require('./route/api');
var static = require('./route/static');
// 匹配靜態資料夾路徑的正則表示式,用於判定請求是否為靜態檔案請求
var staticExp = /\/public\/(img|css|js)\/[a-z]*\.(jpg|png|gif|css|js)/;
http.createServer((req, res) => {
var pathname = url.parse(req.url).pathname;
if (staticExp.test(pathname)) {// 靜態檔案請求交由static處理
static.get(__dirname + pathname, res);
} else if (req.method == 'POST') {// 處理普通post請求
api.post(req, res);
} else {// 處理普通get請求
api.get(req, res);
}
}).listen(3000);
console.log('[Server Info] Start server at http://localhost:3000/');
編寫路由
我從簡單的開始,先寫處理靜態檔案請求的路由static。這個路由的邏輯很簡單,只要客戶端想要請求某個靜態檔案(css/js/圖片),就將被請求的檔案傳送給客戶端即可。程式碼如下所示。有以下幾點需要注意的地方,首先,客戶端請求檔案,需要判斷檔案是否存在,如果存在才將其傳送給客戶端,不存在則作其他處理(這裡我暫時沒做其他處理)。其次,將檔案響應給客戶端的時候,需要設定好http報頭的MIME type,這樣檔案發過去之後客戶端才能識別出文件型別從而正確使用。最後,像圖片、音訊等多媒體檔案需要用二進位制的讀寫方式,所以在響應圖片的時候記得加上“binary”。
var fs = require('fs');
var path = require('path');
var MIME = {};
MIME[".css"] = "text/css";
MIME[".js"] = "text/js";
MIME[".jpg"] = "image/jpeg";
MIME[".jpeg"] = "image/jpeg";
MIME[".png"] = "image/png";
MIME[".gif"] = "image/gif";
function get(pathname, res) {
if (fs.existsSync(pathname)) {
var extname = path.extname(pathname);
res.writeHead(200, {'Content-Type': MIME[extname]});
fs.readFile(pathname, (err, data) => {
if (err) {
console.log(err);
res.end();
} else {
if (isImage(extname)) {
res.end(data, "binary");// 二進位制檔案需要加上binary
} else {
res.end(data.toString());
}
}
});
}
}
// 根據拓展名判斷是否為圖片
function isImage(extname) {
if (extname === '.jpg' || extname === '.jpeg' ||
extname === '.png' || extname === '.gif') {
return true;
}
return false;
}
// 提供給其他模組使用的介面
module.exports = {
get: get
};
static寫完了,下面來繼續寫api。api需要根據請求的URL來響應對應的內容。例如客戶端請求“/”,就響應它網站的主頁,請求“/detail?id=0”就響應它id為0的食品的詳情頁面。如果客戶端請求了不存在的URL,則給回一個404響應,表示沒有找到。程式碼如下所示。這裡我分了兩個handler,本專案沒有post操作,所以只有getHandler會使用到。給出postHanlder的目的是為了簡單說明如何寫處理客戶端post請求的路由。
以getHanlder[‘/’]為例,當客戶端請求“/”的時候,不是簡單地把index.html響應給伺服器這麼簡單,想象一下,一家食品店,每天提供的菜式可能會有所不同,或者因為季節問題而導致每個季節的特色菜都有所不同,所以我們網站主頁展示的菜式也可能隨之而變化。因此,我們需要根據資料庫中儲存的主頁資料來動態渲染主頁的內容。我把idnex.html寫成模板,為了不適用jade等模板引擎,我在html裡面使用如同“{{foodMenu}}”這種形式的標記,當讀取完模板之後,利用簡單的字串操作將標記替換成我們需要動態渲染的內容,即可實現動態渲染HTML的目的。
靜態檔案之外的其他路由,或者叫控制器(controller),一般都會包含業務邏輯,即業務邏輯一般是在這一層完成的。像上面的根據資料庫內容動態渲染出首頁,或者你在其他場景下面會見到的如登入註冊的資料檢驗,成功登入之後將客戶端重定向到對應的使用者介面等等業務邏輯都是在這一層實現。
var fs = require('fs');
var url = require('url');
var querystring = require('querystring');
var foods = require('../model/foods')();
var detail = require('../model/detail')();
var getHandler = {};
var postHandler = {};
// 處理對主頁的請求
getHandler['/'] = function(req, res) {
var foodMenu = "";
// 拼裝首頁資料
var food = foods.getAllFoods();
for (var i = 0; i < food.length; ++i) {
foodMenu += '<div class="food-card" id="' + food[i].id + '"><img src="';
foodMenu += food[i].image + '"><h1>' + food[i].name + '</h1><h2>' + food[i].price + '</h2></div>';
}
res.writeHead(200, {"Content-Type": "text/html"});
fs.readFile(__dirname + '/../views/index.html', (err, data) => {
if (err) {
console.log(err);
res.end();
} else {
// 動態渲染模板
res.end(data.toString().replace('{{foodMenu}}', foodMenu));
}
});
};
// 處理對詳情頁面的請求
getHandler['/detail'] = function(req, res) {
var query = querystring.parse(url.parse(req.url).query);
var foodDetail = detail.getDetail(query.id);
res.writeHead(200, {"Content-Type": "text/html"});
fs.readFile(__dirname + '/../views/detail.html', (err, data) => {
// 動態渲染模板
res.end(data.toString().replace('{{image}}', foodDetail.image)
.replace('{{name}}', foodDetail.name)
.replace('{{description}}', foodDetail.description)
.replace('{{price}}', foodDetail.price));
});
};
// 404響應,告知客戶端資源未找到
getHandler['/404'] = function(req, res) {
res.writeHead(404, {"Content-Type": "text/plain"});
res.end("404 Not Found");
};
// post請求的處理方法示例
postHandler['/'] = function(res, data) {
// do something
};
// get請求
function get(req, res) {
var reqUrl = url.parse(req.url);
if (typeof getHandler[reqUrl.pathname] === "function") {
getHandler[reqUrl.pathname](req, res);
} else {
getHandler["/404"](req, res);
}
}
// post請求(示例)
function post(req, res) {
var reqUrl = url.parse(req.url);
if (typeof postHandler[reqUrl.pathname] === "function") {
var postData = "";
req.on('data', (data) => {
postData += data;
});
req.on('end', () => {
postData = querystring.parse(postData);
postHandler[reqUrl.pathname](res, postData);
});
} else {
getHandler["/404"](req, res);
}
}
// 提供給其他模組使用的介面
module.exports = {
get: get,
post: post
};
最後,講一下post方法的處理過程,雖然本專案中沒有使用到post。post方法跟get方法最主要的不同之處在於post方法除了傳送http頭部資訊之外還帶有客戶端提交的資料。在接收到post請求的時候,需要將資料讀取出來,讀取資料的方式也挺簡單,只要給request設定監聽器就行了。當request物件收到資料的時候會觸發“data”事件,因此,給這個事件設定監聽器,讓它收到資料的時候就把資料儲存起來。在接收完一個請求全部的post資料之後會觸發“end”事件,因此,給這個事件設定監聽器,使得在接收完全部資料之後才開始對提交的資料進行相關的操作。
編寫資料模型
先拿主頁來講吧。通過前面的截圖,我們可以知道,主頁上的資料包括展示菜品的圖片、名稱、價格,另外需要根據不同的菜品跳轉到對應的詳情頁,因此還需要一個id來用作識別符號。最後,可以得到如下的資料模型(下面的模型我使用json描述,你也可以採取其他辦法)。這個資料模型描述了主頁的資料模型,即首頁有很多個食品foods,用陣列表示,每個資料元素代表一個食品。每個食品包括四項資訊,id,image,name,price。id的值是一個數字,作為唯一識別符號。image是一個字串,用來指明圖片地址。name的值是字串,表示食品的名字,price的值是一個字串,表示食品的價格。
{
"foods": [{
"id": "number",
"image": "string",
"name": "string",
"price": "string"
}]
}
設計好資料模型的目的是方便我們設計偽資料,也方便我們對資料進行操作,一般在開始程式設計之前要做的事情就是設計好資料模型(資料結構),這樣寫程式時候才會更加順利,很多接口才能規範下來。雖然我這裡把model這一步放在了最後,但我這裡model裡面只是寫了資料訪問模組,不代表資料模型是最後才設計的,只是因為我這裡講解的思路是自定向下,剛好講到model就順帶提一提資料模型設計。
下面以foods.js為例來講解如何編寫model。程式碼如下所示。這裡由於沒有資料庫(涉及資料庫的話對於新手來說比較麻煩,為了講清楚過程本文將不採用資料庫儲存資料),我將所有資料使用json檔案儲存,例如foods.json中儲存了主頁的所有食品的資料。foods model將對外提供介面,用於支援訪問主頁的食品資料,修改食品資料等操作(資料庫常說的增刪查改CRUD四個操作)。本專案只需要用到查詢所有視訊的操作,所以我這裡簡單實現了一個獲取所有食品的方法,另外附帶一個根據id獲取單個食品的方法(這個方法僅是示例,沒有用到)。
var fs = require('fs');
module.exports = function() {
// 讀取檔案中的資料,將其轉成一個物件方便使用
var data = JSON.parse(fs.readFileSync(__dirname + '/../data/foods.json'));
var foods = {
getAllFoods: getAllFoods,
getFood: getFood
};
// 獲取所有食品
function getAllFoods() {
return data.foods;
}
// 根據id獲取單個食品
function getFood(id) {
for (var i = 0; i < data.foods.length; ++i) {
if (data.foods[i].id == id)
return data.foods[i];
}
}
return foods;
};
model裡面的模組一般提供資料操作的服務供控制器使用,所以在這一層就主要關注實現資料CRUD操作即可,基本沒有什麼業務邏輯了。
照著寫foods的思路,我們再把detail寫完,整個專案就完成了。是不是挺簡單的。進到專案目錄下面,使用node server.js啟動伺服器跑一跑吧。
最後,看完整個專案,你大概可以發現整個編寫過程,或者說每個模組的劃分,都好像遵照某種特定的模式在進行,其實我是按照MVC的模式來編寫這個專案的,最近在另外一門學課的學習中也經常用到MVC,覺得還是挺不錯的一種設計模式,有興趣可以研究一下。當然,我不能說我寫的程式碼完全符合MVC的規範,畢竟每個人的理解都可能有那麼一些出入。本文僅供參考,歡迎交流建議,謝謝!