1. 構建 Web 應用(服務器端)
1.1. 基礎功能
對象 http.Server 的 'request'
事件發(fā)生于網(wǎng)絡連接建立,客戶端向服務器端發(fā)送報文,服務器段解析報文,發(fā)現(xiàn) HTTP 請求報文的報文頭時。在已出發(fā) 'request'
事件前,http 模塊已準備好 IncomingMessage 和 ServerResponse 對象以對應請求和響應報文的操作。
const http = require('http');
http.createServer( function(req, res) { // 'request' 事件的偵聽器
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end();
}).listen(1337, '127.0.0.1');
對于一個 Web 應用除了上面的業(yè)務還有如下的需求:
- 請求方法的判斷
- URL 的路徑解析
- URL 中的查詢字符串的解析
- Cookie 和 Session 的解析
- Basic 認證
- 表單數(shù)據(jù)的解析
- 任意格式文件的上傳處理
Web 應用可以看成是將上述需求進行線性組合,最終生成 'request'
事件的偵聽器,通過高階函數(shù)將它傳遞給 http.createServer()
方法。
const app = express();
// TODO
http.createServer(app).listen(1337);
1.1.1. HTTP Parser
Node 底層使用 HTTP_Parser 這個 C 語言模塊來解析 HTTP 協(xié)議數(shù)據(jù), 它解析的主要信息有:
- 頭部字段和對應值(Header)
- Content-Length
- 請求方法(Method)
- 響應狀態(tài)碼(Status Code)
- 傳輸編碼
- HTTP 版本
- 請求 URL
- 報文主體
1.1.2. 請求方法
HTTP_Parser 在解析請求報文時,將報文頭抽取出來并將請求方式抽象為 req.method
屬性。
1.1.3. 路徑解析
url 模塊提供了 URL 的解析。URL 是由多個具有意義的字段組成的字符串,具體描述如下:
HTTP_Parser 將請求報文頭的路徑字段解析成名為 req.url
的 URL 字符串, 它可通過 url.parse()
方法解析成 URL 對象,對象中的 urlObject.pathname
屬性反映了 URL 字符串的 path 字段中的 pathname 部分。
1.1.4. 查詢字符串
在 pathname 部分后就是查詢字符串,這部分內容經(jīng)常需要為業(yè)務邏輯所用, Node 提供了 qureystring 模塊來處理這部分數(shù)據(jù)。注意,業(yè)務的判斷一定要檢查值是數(shù)組還是字符串。
1.1.5. Cookie
HTTP 是一個無狀態(tài)協(xié)議,無法區(qū)分用戶之間的身份。如何標識和認證一個用戶,最早的方案就是 Cookie 。
Cookie 的處理分為如下幾步:
- 服務器向客戶端發(fā)送 Cookie
- 瀏覽器將 Cookie 保存
- 之后每次瀏覽器都會將 Cookie 發(fā)送給服務器端
1.1.5.1. 服務器端解析 Cookie
HTTP_Parser 會將請求報文頭的所有字段解析到 req.headers
上,Cookie 就是 req.headers.cookie
。 Cookie 值的格式是鍵值對,Express 的中間件 cookie-parser
將其掛載在 req 對象上,讓業(yè)務代碼可以直接訪問。
function cookieParser (options) {
return function cookieParser (req, res, next) {
if (req.cookies) {
return next()
}
var cookies = req.headers.cookie
req.cookies = Object.create(null)
// no cookies
if (!cookies) {
return next()
}
req.cookies = cookie.parse(cookies, options) // 這里調用了 cookie 模塊 (https://github.com/jshttp/cookie)
next()
}
}
1.1.5.2. 客戶端初始 Cookie
客戶端的 Cookie 最初來自服務器端,服務器端告知客戶端的方式是通過響應報文實現(xiàn)的,響應的 Cookie 值在 Set-Cookie 字段中設置。具體格式如下所示:
Set-Cookie: name=value; Path=/; Expires=Sun, 23-Apr-23 09:01:35 GMT; Domain=.domain.com;
-
path
標識這個 Cookie 影響到的路徑。 -
Expires
和Max-Age
告知瀏覽器這個 Cookie 何時過期。 -
Secure
該屬性為 true 時,表示 Cookie 只能通過 HTTPS 協(xié)議傳遞。
Express 中間件 express-session
處理 Set-Cookie :
function setcookie(res, name, val, secret, options) {
var signed = 's:' + signature.sign(val, secret);
var data = cookie.serialize(name, signed, options);
var prev = res.getHeader('set-cookie') || [];
var header = Array.isArray(prev) ? prev.concat(data) : [prev, data];
res.setHeader('set-cookie', header)
}
1.1.6. Session
Cookie 的缺點是無法保護敏感數(shù)據(jù),因此 Session 應運而生。 Session 的數(shù)據(jù)只保留在服務器端,客戶端是無法更改的。服務器端是如何將每個用戶和 Session 數(shù)據(jù)對應起來的?通常是基于 Cookie 來實現(xiàn)映射關系的,具體步驟如下:
- 服務器端生成 Session 和 sessionID(口令)
- 將 Session 數(shù)據(jù) 和 sessionID 映射存儲在 Store 中(redis、mongodb、memory)
- 通過 Set-Cookie 將 sessionID 作為 Cookie 的鍵值對發(fā)送給客戶端。
- 對于客戶端的請求,服務器端每次檢查 Cookie 中的 sessionID,并對應保留在服務器端 Store 的 Session 數(shù)據(jù)。
- [ 服務器端更新存儲 Session 數(shù)據(jù) ]
Express 的中間件 express-session
將 Session 數(shù)據(jù)掛載在 req.session
,方便業(yè)務邏輯使用。同時 express-session
還提供了多種 Store 。
1.1.7. Basic 認證
Basic 認證是一個通過用戶名和密碼實現(xiàn)的身份認證方式。如果用戶首次訪問網(wǎng)頁, URL 地址中沒有攜帶認證內容,那么瀏覽器會到得一個 401 未授權的響應。
var http = require('http')
var auth = require('basic-auth')
// Create server
var server = http.createServer(function (req, res) {
var credentials = auth(req)
if (!credentials || credentials.name !== 'john' || credentials.pass !== 'secret') {
res.statusCode = 401
res.setHeader('WWW-Authenticate', 'Basic realm="example"')
res.end('Access denied')
} else {
res.end('Access granted')
}
})
// Listen
server.listen(3000)
響應頭中的
WWW-Authenticate
字段告知瀏覽器采用什么樣的認證和加密方式。
瀏覽器在后續(xù)請求中都攜帶上 Authorization 信息,服務器會檢查請求報文頭中的 Authorization 字段的內容,該字段有認證方式和加密值構成。
function auth (req) {
// get header
var header = req.headers.authorization
// parse header
var match = CREDENTIALS_REGEXP.exec(string) // CREDENTIALS_REGEXP = /^ *(?:[Bb][Aa][Ss][Ii][Cc]) +([A-Za-z0-9._~+/-]+=*) *$/
if (!match) {
return undefined
}
// decode user pass
var userPass = USER_PASS_REGEXP.exec(decodeBase64(match[1])) // USER_PASS_REGEXP = /^([^:]*):(.*)$/
if (!userPass) {
return undefined
}
// return credentials object
return new Credentials(userPass[1], userPass[2])
}
function decodeBase64 (str) {
return new Buffer(str, 'base64').toString()
}
function Credentials (name, pass) {
this.name = name
this.pass = pass
}
1.2. 數(shù)據(jù)上傳
報文頭部中的內容已經(jīng)能夠讓服務器端進行大多數(shù)業(yè)務邏輯操作了,但是單純的報文頭部無法攜帶大量的數(shù)據(jù),請求報文中還有攜帶內容的報文體,這部分需要用戶自行接收和解析。通過報文頭部的 Transfer-Encoding
或 Content-Length
字段即可判斷請求中是否帶有報文體。
function hasbody (req) {
return req.headers['transfer-encoding'] !== undefined ||
!isNaN(req.headers['content-length'])
}
HTTP_Parser 模塊通過觸發(fā) 'data'
事件獲取 req.rawBody
,然后針對不同類型的報文體進行相應的解析。 Express 中間件 body-parser
針對 JSON 的解析如下:
function parse (body) {
if (body.length === 0) {
// special-case empty json body, as it's a common client-side mistake
// TODO: maybe make this configurable or part of "strict" option
return {}
}
if (strict) {
var first = firstchar(body) // FIRST_CHAR_REGEXP = /^[\x20\x09\x0a\x0d]*(.)/ // eslint-disable-line no-control-regex
if (first !== '{' && first !== '[') {
throw new SyntaxError('Unexpected token ' + first)
}
}
return JSON.parse(body)
}
1.2.1. 表單數(shù)據(jù)
在表單提交的請求頭中 Content-Type 字段值為 application/x-www-form-urlencoded
,也就是其內容通過 urlencoded 的方式編碼內容形成報文體,node-formidable
模塊解析表單提交大概如下:
// 判斷報文頭
if (this.headers['content-type'].match(/urlencoded/i)) {
this._initUrlencoded();
return;
}
// 事件發(fā)布
IncomingForm.prototype._initUrlencoded = function() {
this.type = 'urlencoded';
var parser = new QuerystringParser(this.maxFields);
parser.onField = function(key, val) {
self.emit('field', key, val);
};
};
// 事件訂閱
IncomingForm.prototype.parse = function(req, cb) {
if (cb) {
this
.on('field', function(name, value) {
fields[name] = value;
})
}
}
1.2.2. 附件上傳
一種特殊的表單需要提交文件,該表單中可以含有 file 類型的控件,以及需要指定表單屬性 enctype 為 multipart/form-data
。因為表單中含有多種控件,所有使用名為 boundary
的分隔符進行分割。
模塊 node-formidable 將解析上傳文件和處理普通表單數(shù)據(jù)進行了統(tǒng)一化處理,以下是文件上傳的實例:
var formidable = require('formidable'),
http = require('http'),
util = require('util');
http.createServer(function(req, res) {
if (req.url == '/upload' && req.method.toLowerCase() == 'post') {
// parse a file upload
var form = new formidable.IncomingForm();
form.parse(req, function(err, fields, files) {
res.writeHead(200, {'content-type': 'text/plain'});
res.write('received upload:\n\n');
res.end(util.inspect({fields: fields, files: files}));
});
return;
}
// show a file upload form
res.writeHead(200, {'content-type': 'text/html'});
res.end(
'<form action="/upload" enctype="multipart/form-data" method="post">'+
'<input type="text" name="title"><br>'+
'<input type="file" name="upload" multiple="multiple"><br>'+
'<input type="submit" value="Upload">'+
'</form>'
);
}).listen(8080);
Express 的中間件 Multer
也提供了類似的功能,但是它只能處理特殊的表單也就是表單屬性含有 multipart/form-data
。
var express = require('express')
var multer = require('multer')
var upload = multer({ dest: 'uploads/' })
var app = express()
app.post('/profile', upload.single('avatar'), function (req, res, next) {
// req.file is the `avatar` file
// req.body will hold the text fields, if there were any
})
app.post('/photos/upload', upload.array('photos', 12), function (req, res, next) {
// req.files is array of `photos` files
// req.body will contain the text fields, if there were any
})
var cpUpload = upload.fields([{ name: 'avatar', maxCount: 1 }, { name: 'gallery', maxCount: 8 }])
app.post('/cool-profile', cpUpload, function (req, res, next) {
// req.files is an object (String -> Array) where fieldname is the key, and the value is array of files
//
// e.g.
// req.files['avatar'][0] -> File
// req.files['gallery'] -> Array
//
// req.body will contain the text fields, if there were any
})
1.2.3. 跨站請求偽造 ( CSRF )
通常解決 CSRF 的方法是在表單中添加隨機值。 首先服務器端生成一個隨機值,然后將隨機值內嵌到前端表單,前端表單的請求中攜帶該隨機值,服務器端收到并解析后對比判定是否一致。
Express 中間件 csurf
默認情況下會自動生成隨機值,并且會將該隨機值掛載到 req.session.csrfSecret
上。
1.3. 路由解析
1.3.1. 文件路徑型
這種路由的處理方式,就是將請求路徑中的文件發(fā)送給客戶端即可,而請求 URL 中的文件路徑與文件所在的具體路徑相對應。
1.3.2. MVC
MVC 模型將業(yè)務邏輯按職責分離:
- 模型 (Model) 數(shù)據(jù)相關的操作和封裝
- 控制器(Controller) 行為的集合
- 視圖 (View) 頁面的渲染
它的工作模式:
- 路由解析,根據(jù) URL 查找到對應的控制器及其所定義的行為
- 行為調用相關的模型,進行數(shù)據(jù)操作
- 將操作后的數(shù)據(jù)結合相應的視圖進行頁面渲染,并將頁面返回給客戶端
在 MVC 模型中,路由也是非常重要的概念,它主要實現(xiàn)了 URL 和控制器的映射,具體實現(xiàn)的方式有:
- 手工映射
- 靜態(tài)映射
- 正則匹配
- 參數(shù)解析
- 自然映射
1.3.3. RESTful
REST 的中文含義為表現(xiàn)層狀態(tài)轉化, 符合 REST 規(guī)范的設計成為 RESTful 設計。 它的設計哲學是將服務器端提供的內容實體看作為一個資源,并表現(xiàn)在 URL 上。其中 URL 中的 Method 代表了對這個資源的操作方法。
POST /user/jacksontian // 創(chuàng)建新用戶
DELETE /user/jacksontian // 刪除用戶
PUT /user/jacksontian // 更改用戶
GET /user/jacksontian // 查詢用戶
在 RESTful 設計中,客戶端能夠接受資源的具體格式由請求報文頭中的 Accept 字段給出:
Accept: application/json,application/xml
而服務器端在響應報文中,通過 Content-Type 字段告知客戶端是什么格式:
Content-Type: application/json
所以 RESTful 的設計就是, 通過 URL 設計資源、請求方法定義資源的操作和通過 Accept 決定資源的具體格式。
1.4. 中間件
上述工作有太多的繁瑣細節(jié)要完成,為了簡化和隔離這些基礎功能,讓開發(fā)者關注業(yè)務邏輯的實現(xiàn),引入了中間件這個定義。中間件組件是一個函數(shù),它攔截 HTTP 服務器提供的請求和響應對象,執(zhí)行邏輯,然后或者結束響應,或者傳遞給下一個中間件。
Node 的 http
模塊提供了應用層協(xié)議的封裝,但是對具體業(yè)務沒有支持(小而靈活),因此必須有開發(fā)框架對業(yè)務提供支持。 通過中間件的形式搭建開發(fā)框架,完成各種基礎功能,最終匯成強大的基礎框架。每一種基礎框架對中間件的組織形式不盡相同,下圖是基礎框架 Express 的實現(xiàn)機制。

1.4.1. 普通中間件
在 Express 中,中間件按慣例會接受三個參數(shù):一個請求對象,一個響應對象,還有一個通常命名為 next
的參數(shù),它是一個回調函數(shù),表明該組件已經(jīng)完成了工作,可以執(zhí)行下一個中間件組件了。中間件的分派主要依賴于 next
這個回調函數(shù)的尾觸發(fā),這樣前一個中間件組件完成后才能進入下一個中間件組件。
在 Web 應用中,路由是個至關重要的概念,它會把請求 URL 映射到實現(xiàn)業(yè)務邏輯的函數(shù)上。通過中間件和業(yè)務邏輯的結合可以完成對路由的執(zhí)行。首先使用 app.use()
等方法將所有的中間件和業(yè)務邏輯以及相應的掛載點有序的放入路由數(shù)組,然后通過請求路徑與掛載點的對比,將匹配的數(shù)據(jù)元素重組為新的數(shù)組,最后通過分發(fā)執(zhí)行中間件,中間件執(zhí)行完畢后通過 next()
函數(shù)將結果轉入到下一個匹配的數(shù)組元素。
var handle = function (req, res, stack) {
var next = function () {
// 從stack數(shù)組中取出中間件并執(zhí)行
var middleware = stack.shift();
if (middleware) {
// 傳入next()函數(shù)自身,使中間件能夠執(zhí)行結束后遞歸
middleware(req, res, next);
}
};
// 啟動執(zhí)行
next();
};
1.4.2. 異常處理
為了捕獲中間件拋出的同步異常,保證 Web 應用的穩(wěn)定和健壯,我們?yōu)?next()
方法添加 err
參數(shù)。這主要是因為異步的異常不能直接捕獲,中間件的異常需要自己傳遞出來。
使用中間件的思路將異常的處理交給中間件,同時為了區(qū)分異常處理中間件和普通中間件的區(qū)別,在其參數(shù)中加入 err
參數(shù):
const middleware = function(options) {
return function(err, req, res, next) {
// TODO
next();
};
};
每個異常處理中間件可通過 next(err)
方法將異常傳遞給下一個異常處理中間件,其思路與普通中間件完全一致。
如果異常處理中間件沒有設置
next(err)
方法,那它后面的異常處理中間件都不會起作用。
1.5. 頁面渲染
執(zhí)行完中間件及業(yè)務邏輯后,服務器端該如何響應客戶端?一般有兩種方式:
- 內容響應
- 視圖渲染
1.5.1. 內容響應
因為服務器端響應的報文,最終都會被客戶端處理,具體終端有可能是命令行,也有可能是瀏覽器。這就使得響應報文頭中的 content-*
字段顯得十分重要。
1.5.1.1. MIME
報文頭中的 Content-Type
字段的值決定采用不同的渲染方式,而這個值就是 MIME值。不同的文件類型具有不同的 MIME 值:
- JSON 文件:
application/json
- XML 文件:
application/xml
- PDF 文件:
application/pdf
1.5.1.2. 附件下載
報文頭中的 Content-Disposition
字段影響的行為是客戶端會根據(jù)它的值判斷是應該將報文數(shù)據(jù)當做及時瀏覽的內容(inline),還是可以下載的附件(attachment)。
1.5.1.3. 響應 JSON
為了快捷的響應 JSON 數(shù)據(jù), Express 封裝了響應對象的 res.json()
方法:
res.json = function json(obj) {
var val = obj;
var body = JSON.stringify(val);
// content-type
if (!this.get('Content-Type')) {
this.set('Content-Type', 'application/json');
}
return this.send(body);
};
1.5.1.4. 響應跳轉
當前 URL 因為某些原因不能處理, 需要將用戶跳轉到別的 URL 時,Express 同樣封裝了一個快捷方式 res.redirect()
:
res.redirect = function redirect(url) {
var address = url;
var body;
var status = 302;
// Set location header
address = this.location(address).get('Location');
// Support text/{plain,html} by default
this.format({
text: function(){
body = statuses[status] + '. Redirecting to ' + address
},
html: function(){
var u = escapeHtml(address);
body = '<p>' + statuses[status] + '. Redirecting to <a href="' + u + '">' + u + '</a></p>'
},
default: function(){
body = '';
}
});
// Respond
this.statusCode = status;
this.set('Content-Length', Buffer.byteLength(body));
if (this.req.method === 'HEAD') {
this.end();
} else {
this.end(body);
}
};