1. 構(gòu)建 Web 應(yīng)用(服務(wù)器端)
1.1. 基礎(chǔ)功能
對象 http.Server 的 'request'
事件發(fā)生于網(wǎng)絡(luò)連接建立,客戶端向服務(wù)器端發(fā)送報(bào)文鸯檬,服務(wù)器段解析報(bào)文颁井,發(fā)現(xiàn) HTTP 請求報(bào)文的報(bào)文頭時(shí)物咳。在已出發(fā) 'request'
事件前顺又,http 模塊已準(zhǔn)備好 IncomingMessage 和 ServerResponse 對象以對應(yīng)請求和響應(yīng)報(bào)文的操作萧福。
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');
對于一個(gè) Web 應(yīng)用除了上面的業(yè)務(wù)還有如下的需求:
- 請求方法的判斷
- URL 的路徑解析
- URL 中的查詢字符串的解析
- Cookie 和 Session 的解析
- Basic 認(rèn)證
- 表單數(shù)據(jù)的解析
- 任意格式文件的上傳處理
Web 應(yīng)用可以看成是將上述需求進(jìn)行線性組合一屋,最終生成 'request'
事件的偵聽器涨薪,通過高階函數(shù)將它傳遞給 http.createServer()
方法骑素。
const app = express();
// TODO
http.createServer(app).listen(1337);
1.1.1. HTTP Parser
Node 底層使用 HTTP_Parser 這個(gè) C 語言模塊來解析 HTTP 協(xié)議數(shù)據(jù), 它解析的主要信息有:
- 頭部字段和對應(yīng)值(Header)
- Content-Length
- 請求方法(Method)
- 響應(yīng)狀態(tài)碼(Status Code)
- 傳輸編碼
- HTTP 版本
- 請求 URL
- 報(bào)文主體
1.1.2. 請求方法
HTTP_Parser 在解析請求報(bào)文時(shí)刚夺,將報(bào)文頭抽取出來并將請求方式抽象為 req.method
屬性献丑。
1.1.3. 路徑解析
url 模塊提供了 URL 的解析。URL 是由多個(gè)具有意義的字段組成的字符串光督,具體描述如下:
HTTP_Parser 將請求報(bào)文頭的路徑字段解析成名為 req.url
的 URL 字符串, 它可通過 url.parse()
方法解析成 URL 對象阳距,對象中的 urlObject.pathname
屬性反映了 URL 字符串的 path 字段中的 pathname 部分。
1.1.4. 查詢字符串
在 pathname 部分后就是查詢字符串结借,這部分內(nèi)容經(jīng)常需要為業(yè)務(wù)邏輯所用筐摘, Node 提供了 qureystring 模塊來處理這部分?jǐn)?shù)據(jù)。注意船老,業(yè)務(wù)的判斷一定要檢查值是數(shù)組還是字符串咖熟。
1.1.5. Cookie
HTTP 是一個(gè)無狀態(tài)協(xié)議,無法區(qū)分用戶之間的身份柳畔。如何標(biāo)識(shí)和認(rèn)證一個(gè)用戶馍管,最早的方案就是 Cookie 。
Cookie 的處理分為如下幾步:
- 服務(wù)器向客戶端發(fā)送 Cookie
- 瀏覽器將 Cookie 保存
- 之后每次瀏覽器都會(huì)將 Cookie 發(fā)送給服務(wù)器端
1.1.5.1. 服務(wù)器端解析 Cookie
HTTP_Parser 會(huì)將請求報(bào)文頭的所有字段解析到 req.headers
上薪韩,Cookie 就是 req.headers.cookie
确沸。 Cookie 值的格式是鍵值對捌锭,Express 的中間件 cookie-parser
將其掛載在 req 對象上,讓業(yè)務(wù)代碼可以直接訪問罗捎。
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) // 這里調(diào)用了 cookie 模塊 (https://github.com/jshttp/cookie)
next()
}
}
1.1.5.2. 客戶端初始 Cookie
客戶端的 Cookie 最初來自服務(wù)器端观谦,服務(wù)器端告知客戶端的方式是通過響應(yīng)報(bào)文實(shí)現(xiàn)的,響應(yīng)的 Cookie 值在 Set-Cookie 字段中設(shè)置桨菜。具體格式如下所示:
Set-Cookie: name=value; Path=/; Expires=Sun, 23-Apr-23 09:01:35 GMT; Domain=.domain.com;
-
path
標(biāo)識(shí)這個(gè) Cookie 影響到的路徑豁状。 -
Expires
和Max-Age
告知瀏覽器這個(gè) Cookie 何時(shí)過期。 -
Secure
該屬性為 true 時(shí)倒得,表示 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 的缺點(diǎn)是無法保護(hù)敏感數(shù)據(jù),因此 Session 應(yīng)運(yùn)而生霞掺。 Session 的數(shù)據(jù)只保留在服務(wù)器端谊路,客戶端是無法更改的。服務(wù)器端是如何將每個(gè)用戶和 Session 數(shù)據(jù)對應(yīng)起來的根悼?通常是基于 Cookie 來實(shí)現(xiàn)映射關(guān)系的凶异,具體步驟如下:
- 服務(wù)器端生成 Session 和 sessionID(口令)
- 將 Session 數(shù)據(jù) 和 sessionID 映射存儲(chǔ)在 Store 中(redis、mongodb挤巡、memory)
- 通過 Set-Cookie 將 sessionID 作為 Cookie 的鍵值對發(fā)送給客戶端。
- 對于客戶端的請求酷麦,服務(wù)器端每次檢查 Cookie 中的 sessionID矿卑,并對應(yīng)保留在服務(wù)器端 Store 的 Session 數(shù)據(jù)。
- [ 服務(wù)器端更新存儲(chǔ) Session 數(shù)據(jù) ]
Express 的中間件 express-session
將 Session 數(shù)據(jù)掛載在 req.session
沃饶,方便業(yè)務(wù)邏輯使用母廷。同時(shí) express-session
還提供了多種 Store 。
1.1.7. Basic 認(rèn)證
Basic 認(rèn)證是一個(gè)通過用戶名和密碼實(shí)現(xiàn)的身份認(rèn)證方式糊肤。如果用戶首次訪問網(wǎng)頁琴昆, URL 地址中沒有攜帶認(rèn)證內(nèi)容,那么瀏覽器會(huì)到得一個(gè) 401 未授權(quán)的響應(yīng)馆揉。
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)
響應(yīng)頭中的
WWW-Authenticate
字段告知瀏覽器采用什么樣的認(rèn)證和加密方式业舍。
瀏覽器在后續(xù)請求中都攜帶上 Authorization 信息,服務(wù)器會(huì)檢查請求報(bào)文頭中的 Authorization 字段的內(nèi)容升酣,該字段有認(rèn)證方式和加密值構(gòu)成舷暮。
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ù)上傳
報(bào)文頭部中的內(nèi)容已經(jīng)能夠讓服務(wù)器端進(jìn)行大多數(shù)業(yè)務(wù)邏輯操作了,但是單純的報(bào)文頭部無法攜帶大量的數(shù)據(jù)噩茄,請求報(bào)文中還有攜帶內(nèi)容的報(bào)文體下面,這部分需要用戶自行接收和解析。通過報(bào)文頭部的 Transfer-Encoding
或 Content-Length
字段即可判斷請求中是否帶有報(bào)文體绩聘。
function hasbody (req) {
return req.headers['transfer-encoding'] !== undefined ||
!isNaN(req.headers['content-length'])
}
HTTP_Parser 模塊通過觸發(fā) 'data'
事件獲取 req.rawBody
沥割,然后針對不同類型的報(bào)文體進(jìn)行相應(yīng)的解析耗啦。 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
,也就是其內(nèi)容通過 urlencoded 的方式編碼內(nèi)容形成報(bào)文體机杜,node-formidable
模塊解析表單提交大概如下:
// 判斷報(bào)文頭
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
叉庐。因?yàn)楸韱沃泻卸喾N控件舒帮,所有使用名為 boundary
的分隔符進(jìn)行分割。
模塊 node-formidable 將解析上傳文件和處理普通表單數(shù)據(jù)進(jìn)行了統(tǒng)一化處理陡叠,以下是文件上傳的實(shí)例:
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 的方法是在表單中添加隨機(jī)值枉阵。 首先服務(wù)器端生成一個(gè)隨機(jī)值译红,然后將隨機(jī)值內(nèi)嵌到前端表單,前端表單的請求中攜帶該隨機(jī)值兴溜,服務(wù)器端收到并解析后對比判定是否一致侦厚。
Express 中間件 csurf
默認(rèn)情況下會(huì)自動(dòng)生成隨機(jī)值,并且會(huì)將該隨機(jī)值掛載到 req.session.csrfSecret
上拙徽。
1.3. 路由解析
1.3.1. 文件路徑型
這種路由的處理方式刨沦,就是將請求路徑中的文件發(fā)送給客戶端即可,而請求 URL 中的文件路徑與文件所在的具體路徑相對應(yīng)膘怕。
1.3.2. MVC
MVC 模型將業(yè)務(wù)邏輯按職責(zé)分離:
- 模型 (Model) 數(shù)據(jù)相關(guān)的操作和封裝
- 控制器(Controller) 行為的集合
- 視圖 (View) 頁面的渲染
它的工作模式:
- 路由解析想诅,根據(jù) URL 查找到對應(yīng)的控制器及其所定義的行為
- 行為調(diào)用相關(guān)的模型,進(jìn)行數(shù)據(jù)操作
- 將操作后的數(shù)據(jù)結(jié)合相應(yīng)的視圖進(jìn)行頁面渲染岛心,并將頁面返回給客戶端
在 MVC 模型中来破,路由也是非常重要的概念,它主要實(shí)現(xiàn)了 URL 和控制器的映射忘古,具體實(shí)現(xiàn)的方式有:
- 手工映射
- 靜態(tài)映射
- 正則匹配
- 參數(shù)解析
- 自然映射
1.3.3. RESTful
REST 的中文含義為表現(xiàn)層狀態(tài)轉(zhuǎn)化徘禁, 符合 REST 規(guī)范的設(shè)計(jì)成為 RESTful 設(shè)計(jì)。 它的設(shè)計(jì)哲學(xué)是將服務(wù)器端提供的內(nèi)容實(shí)體看作為一個(gè)資源髓堪,并表現(xiàn)在 URL 上送朱。其中 URL 中的 Method 代表了對這個(gè)資源的操作方法。
POST /user/jacksontian // 創(chuàng)建新用戶
DELETE /user/jacksontian // 刪除用戶
PUT /user/jacksontian // 更改用戶
GET /user/jacksontian // 查詢用戶
在 RESTful 設(shè)計(jì)中旦袋,客戶端能夠接受資源的具體格式由請求報(bào)文頭中的 Accept 字段給出:
Accept: application/json,application/xml
而服務(wù)器端在響應(yīng)報(bào)文中骤菠,通過 Content-Type 字段告知客戶端是什么格式:
Content-Type: application/json
所以 RESTful 的設(shè)計(jì)就是, 通過 URL 設(shè)計(jì)資源疤孕、請求方法定義資源的操作和通過 Accept 決定資源的具體格式商乎。
1.4. 中間件
上述工作有太多的繁瑣細(xì)節(jié)要完成,為了簡化和隔離這些基礎(chǔ)功能祭阀,讓開發(fā)者關(guān)注業(yè)務(wù)邏輯的實(shí)現(xiàn)鹉戚,引入了中間件這個(gè)定義鲜戒。中間件組件是一個(gè)函數(shù),它攔截 HTTP 服務(wù)器提供的請求和響應(yīng)對象抹凳,執(zhí)行邏輯遏餐,然后或者結(jié)束響應(yīng),或者傳遞給下一個(gè)中間件赢底。
Node 的 http
模塊提供了應(yīng)用層協(xié)議的封裝失都,但是對具體業(yè)務(wù)沒有支持(小而靈活),因此必須有開發(fā)框架對業(yè)務(wù)提供支持幸冻。 通過中間件的形式搭建開發(fā)框架粹庞,完成各種基礎(chǔ)功能,最終匯成強(qiáng)大的基礎(chǔ)框架洽损。每一種基礎(chǔ)框架對中間件的組織形式不盡相同庞溜,下圖是基礎(chǔ)框架 Express 的實(shí)現(xiàn)機(jī)制。
1.4.1. 普通中間件
在 Express 中碑定,中間件按慣例會(huì)接受三個(gè)參數(shù):一個(gè)請求對象流码,一個(gè)響應(yīng)對象,還有一個(gè)通常命名為 next
的參數(shù)延刘,它是一個(gè)回調(diào)函數(shù)漫试,表明該組件已經(jīng)完成了工作,可以執(zhí)行下一個(gè)中間件組件了访娶。中間件的分派主要依賴于 next
這個(gè)回調(diào)函數(shù)的尾觸發(fā)商虐,這樣前一個(gè)中間件組件完成后才能進(jìn)入下一個(gè)中間件組件。
在 Web 應(yīng)用中崖疤,路由是個(gè)至關(guān)重要的概念,它會(huì)把請求 URL 映射到實(shí)現(xiàn)業(yè)務(wù)邏輯的函數(shù)上典勇。通過中間件和業(yè)務(wù)邏輯的結(jié)合可以完成對路由的執(zhí)行劫哼。首先使用 app.use()
等方法將所有的中間件和業(yè)務(wù)邏輯以及相應(yīng)的掛載點(diǎn)有序的放入路由數(shù)組,然后通過請求路徑與掛載點(diǎn)的對比割笙,將匹配的數(shù)據(jù)元素重組為新的數(shù)組权烧,最后通過分發(fā)執(zhí)行中間件,中間件執(zhí)行完畢后通過 next()
函數(shù)將結(jié)果轉(zhuǎn)入到下一個(gè)匹配的數(shù)組元素伤溉。
var handle = function (req, res, stack) {
var next = function () {
// 從stack數(shù)組中取出中間件并執(zhí)行
var middleware = stack.shift();
if (middleware) {
// 傳入next()函數(shù)自身般码,使中間件能夠執(zhí)行結(jié)束后遞歸
middleware(req, res, next);
}
};
// 啟動(dòng)執(zhí)行
next();
};
1.4.2. 異常處理
為了捕獲中間件拋出的同步異常,保證 Web 應(yīng)用的穩(wěn)定和健壯乱顾,我們?yōu)?next()
方法添加 err
參數(shù)板祝。這主要是因?yàn)楫惒降漠惓2荒苤苯硬东@,中間件的異常需要自己傳遞出來走净。
使用中間件的思路將異常的處理交給中間件券时,同時(shí)為了區(qū)分異常處理中間件和普通中間件的區(qū)別孤里,在其參數(shù)中加入 err
參數(shù):
const middleware = function(options) {
return function(err, req, res, next) {
// TODO
next();
};
};
每個(gè)異常處理中間件可通過 next(err)
方法將異常傳遞給下一個(gè)異常處理中間件,其思路與普通中間件完全一致橘洞。
如果異常處理中間件沒有設(shè)置
next(err)
方法捌袜,那它后面的異常處理中間件都不會(huì)起作用。
1.5. 頁面渲染
執(zhí)行完中間件及業(yè)務(wù)邏輯后炸枣,服務(wù)器端該如何響應(yīng)客戶端虏等?一般有兩種方式:
- 內(nèi)容響應(yīng)
- 視圖渲染
1.5.1. 內(nèi)容響應(yīng)
因?yàn)榉?wù)器端響應(yīng)的報(bào)文,最終都會(huì)被客戶端處理适肠,具體終端有可能是命令行霍衫,也有可能是瀏覽器。這就使得響應(yīng)報(bào)文頭中的 content-*
字段顯得十分重要迂猴。
1.5.1.1. MIME
報(bào)文頭中的 Content-Type
字段的值決定采用不同的渲染方式慕淡,而這個(gè)值就是 MIME值。不同的文件類型具有不同的 MIME 值:
- JSON 文件:
application/json
- XML 文件:
application/xml
- PDF 文件:
application/pdf
1.5.1.2. 附件下載
報(bào)文頭中的 Content-Disposition
字段影響的行為是客戶端會(huì)根據(jù)它的值判斷是應(yīng)該將報(bào)文數(shù)據(jù)當(dāng)做及時(shí)瀏覽的內(nèi)容(inline)沸毁,還是可以下載的附件(attachment)峰髓。
1.5.1.3. 響應(yīng) JSON
為了快捷的響應(yīng) JSON 數(shù)據(jù), Express 封裝了響應(yīng)對象的 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. 響應(yīng)跳轉(zhuǎn)
當(dāng)前 URL 因?yàn)槟承┰虿荒芴幚恚?需要將用戶跳轉(zhuǎn)到別的 URL 時(shí)息尺,Express 同樣封裝了一個(gè)快捷方式 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);
}
};