對於Node.js新手,搭建一個靜態資源服務器是個不錯的鍛鍊,從最簡單的返回文件或錯誤開始,漸進加強,還能夠逐步加深對http的理解。那就開始吧,讓咱們的雙手沾滿網絡請求!css
Note:html
固然在項目中若是有使用express框架,用express.static一行代碼就能夠達到目的了:node
app.use(express.static('public'))這裏咱們要實現的正是
express.static
背後所作工做的一部分,建議同步閱讀該模塊源碼。git
不急着寫下第一行代碼,而是先梳理一下就基本功能而言有哪些步驟。github
建立一個nodejs-static-webserver
目錄,在目錄內運行npm init
初始化一個package.json文件。web
mkdir nodejs-static-webserver && cd "$_" // initialize package.json npm init
接着建立以下文件目錄:chrome
-- config ---- default.json -- static-server.js -- app.js
default.jsonexpress
{ "port": 9527, "root": "/Users/sheila1227/Public", "indexPage": "index.html" }
default.js
存放一些默認配置,好比端口號、靜態文件目錄(root)、默認頁(indexPage)等。當這樣的一個請求http://localhost:9527/myfiles/
抵達時. 若是根據root
映射後獲得的目錄內有index.html,根據咱們的默認配置,就會給客戶端發回index.html的內容。npm
static-server.jsjson
const http = require('http'); const path = require('path'); const config = require('./config/default'); class StaticServer { constructor() { this.port = config.port; this.root = config.root; this.indexPage = config.indexPage; } start() { http.createServer((req, res) => { const pathName = path.join(this.root, path.normalize(req.url)); res.writeHead(200); res.end(`Requeste path: ${pathName}`); }).listen(this.port, err => { if (err) { console.error(err); console.info('Failed to start server'); } else { console.info(`Server started on port ${this.port}`); } }); } } module.exports = StaticServer;
在這個模塊文件內,咱們聲明瞭一個StaticServer
類,並給其定義了start
方法,在該方法體內,建立了一個server
對象,監聽rquest
事件,並將服務器綁定到配置文件指定的端口。在這個階段,咱們對於任何請求都暫時不做區分地簡單地返回請求的文件路徑。path
模塊用來規範化鏈接和解析路徑,這樣咱們就不用特地來處理操做系統間的差別。
app.js
const StaticServer = require('./static-server'); (new StaticServer()).start();
在這個文件內,調用上面的static-server
模塊,並建立一個StaticServer實例,調用其start
方法,啓動了一個靜態資源服務器。這個文件後面將不須要作其餘修改,全部對靜態資源服務器的完善都發生在static-server.js
內。
在目錄下啓動程序會看到成功啓動的log:
> node app.js Server started on port 9527
在瀏覽器中訪問,能夠看到服務器將請求路徑直接返回了。
以前咱們對任何請求都只是向客戶端返回文件位置而已,如今咱們將其替換成返回真正的文件:
routeHandler(pathName, req, res) { } start() { http.createServer((req, res) => { const pathName = path.join(this.root, path.normalize(req.url)); this.routeHandler(pathName, req, res); }).listen(this.port, err => { ... }); }
將由routeHandler
來處理文件發送。
讀取文件以前,用fs.stat
檢測文件是否存在,若是文件不存在,回調函數會接收到錯誤,發送404響應。
respondNotFound(req, res) { res.writeHead(404, { 'Content-Type': 'text/html' }); res.end(`<h1>Not Found</h1><p>The requested URL ${req.url} was not found on this server.</p>`); } respondFile(pathName, req, res) { const readStream = fs.createReadStream(pathName); readStream.pipe(res); } routeHandler(pathName, req, res) { fs.stat(pathName, (err, stat) => { if (!err) { this.respondFile(pathName, req, res); } else { this.respondNotFound(req, res); } }); }
Note:
讀取文件,這裏用的是流的形式
createReadStream
而不是readFile
,是由於後者會在獲得完整文件內容以前將其先讀到內存裏。這樣萬一文件很大,再趕上多個請求同時訪問,readFile
就承受不來了。使用文件可讀流,服務端不用等到數據徹底加載到內存再發回給客戶端,而是一邊讀一邊發送分塊響應。這時響應裏會包含以下響應頭:Transfer-Encoding:chunked默認狀況下,可讀流結束時,可寫流的
end()
方法會被調用。
如今給客戶端返回文件時,咱們並無指定Content-Type
頭,雖然你可能發現訪問文本或圖片瀏覽器均可以正確顯示出文字或圖片,但這並不符合規範。任何包含實體主體(entity body)的響應都應在頭部指明文件類型,不然瀏覽器無從得知類型時,就會自行猜想(從文件內容以及url中尋找可能的擴展名)。響應如指定了錯誤的類型也會致使內容的錯亂顯示,如明明返回的是一張jpeg
圖片,卻錯誤指定了header:'Content-Type': 'text/html'
,會收到一堆亂碼。
雖然有現成的mime模塊可用,這裏仍是本身來實現吧,試圖對這個過程有更清晰的理解。
在根目錄下建立mime.js
文件:
const path = require('path'); const mimeTypes = { "css": "text/css", "gif": "image/gif", "html": "text/html", "ico": "image/x-icon", "jpeg": "image/jpeg", ... }; const lookup = (pathName) => { let ext = path.extname(pathName); ext = ext.split('.').pop(); return mimeTypes[ext] || mimeTypes['txt']; } module.exports = { lookup };
該模塊暴露出一個lookup
方法,能夠根據路徑名返回正確的類型,類型以‘type/subtype’
表示。對於未知的類型,按普通文本處理。
接着在static-server.js
中引入上面的mime
模塊,給返回文件的響應都加上正確的頭部字段:
respondFile(pathName, req, res) { const readStream = fs.createReadStream(pathName); res.setHeader('Content-Type', mime.lookup(pathName)); readStream.pipe(res); }
從新運行程序,會看到圖片能夠在瀏覽器中正常顯示了。
Note:
須要注意的是,
Content-Type
說明的應是原始實體主體的文件類型。即便實體通過內容編碼(如gzip
,後面會提到),該字段說明的仍應是編碼前的實體主體的類型。
至此,已經完成了基本功能中列出的幾個步驟,但依然有不少須要改進的地方,好比若是用戶輸入的url對應的是磁盤上的一個目錄怎麼辦?還有,如今對於同一個文件(從未更改過)的屢次請求,服務端都是勤勤懇懇地一遍遍地發送回一樣的文件,這些冗餘的數據傳輸,既消耗了帶寬,也給服務器添加了負擔。另外,服務器若是在發送內容以前能對其進行壓縮,也有助於減小傳輸時間。
現階段,用url: localhost:9527/testfolder
去訪問一個指定root文件夾下真實存在的testfolder
的文件夾,服務端會報錯:
Error: EISDIR: illegal operation on a directory, read
要增添對目錄訪問的支持,咱們從新整理下響應的步驟:
/
做爲要轉到的location咱們須要重寫一下routeHandler內的邏輯:
routeHandler(pathName, req, res) { fs.stat(pathName, (err, stat) => { if (!err) { const requestedPath = url.parse(req.url).pathname; if (hasTrailingSlash(requestedPath) && stat.isDirectory()) { this.respondDirectory(pathName, req, res); } else if (stat.isDirectory()) { this.respondRedirect(req, res); } else { this.respondFile(pathName, req, res); } } else { this.respondNotFound(req, res); } }); }
繼續補充respondRedirect
方法:
respondRedirect(req, res) { const location = req.url + '/'; res.writeHead(301, { 'Location': location, 'Content-Type': 'text/html' }); res.end(`Redirecting to <a href='${location}'>${location}</a>`); }
瀏覽器收到301響應時,會根據頭部指定的location
字段值,向服務器發出一個新的請求。
繼續補充respondDirectory
方法:
respondDirectory(pathName, req, res) { const indexPagePath = path.join(pathName, this.indexPage); if (fs.existsSync(indexPagePath)) { this.respondFile(indexPagePath, req, res); } else { fs.readdir(pathName, (err, files) => { if (err) { res.writeHead(500); return res.end(err); } const requestPath = url.parse(req.url).pathname; let content = `<h1>Index of ${requestPath}</h1>`; files.forEach(file => { let itemLink = path.join(requestPath,file); const stat = fs.statSync(path.join(pathName, file)); if (stat && stat.isDirectory()) { itemLink = path.join(itemLink, '/'); } content += `<p><a href='${itemLink}'>${file}</a></p>`; }); res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(content); }); } }
當須要返回目錄列表時,遍歷全部內容,併爲每項建立一個link,做爲返回文檔的一部分。須要注意的是,對於子目錄的href
,額外添加一個尾部斜槓,這樣能夠避免訪問子目錄時的又一次重定向。
在瀏覽器中測試一下,輸入localhost:9527/testfolder
,指定的root
目錄下並無名爲testfolder
的文件,卻存在同名目錄,所以第一次會收到重定向響應,併發起一個對目錄的新請求。
爲了減小數據傳輸,減小請求數,繼續添加緩存支持。首先梳理一下緩存的處理流程:
ETag
,設置ETag
頭Last-Modified
,設置Last-Modified
頭Expires
頭Cache-Control
頭(設置其max-age
值)瀏覽器收到響應後會存下這些標記,並在下次請求時帶上與ETag
對應的請求首部If-None-Match
或與Last-Modified
對應的請求首部If-Modified-Since
。
Cache-Control
和Expires
肯定)
If-None-Match
或If-Modified-Since
,或者兼具二者If-None-Match
首部,沒有則繼續下一步,有則將其值與文檔的最新ETag匹配,失敗則認爲緩存不新鮮,成功則繼續下一步If-Modified-Since
首部,沒有則保留上一步驗證結果,有則將其值與文檔最新修改時間比較驗證,失敗則認爲緩存不新鮮,成功則認爲緩存新鮮當兩個首部皆不存在或者驗證結果是不新鮮時,發送200及最新文件,並在首部更新新鮮度。
當驗證結果是緩存仍然新鮮時(也就是弱緩存命中),不需發送文件,僅發送304,並在首部更新新鮮度
爲了能啓用或關閉某種驗證機制,咱們在配置文件裏增添以下配置項:
default.json:
{ ... "cacheControl": true, "expires": true, "etag": true, "lastModified": true, "maxAge": 5 }
這裏爲了能測試到緩存過時,將過時時間設成了很是小的5秒。
在StaticServer
類中接收這些配置:
class StaticServer { constructor() { ... this.enableCacheControl = config.cacheControl; this.enableExpires = config.expires; this.enableETag = config.etag; this.enableLastModified = config.lastModified; this.maxAge = config.maxAge; }
如今,咱們要在原來的respondFile
前橫加一槓,增長是要返回304仍是200的邏輯。
respond(pathName, req, res) { fs.stat(pathName, (err, stat) => { if (err) return respondError(err, res); this.setFreshHeaders(stat, res); if (this.isFresh(req.headers, res._headers)) { this.responseNotModified(res); } else { this.responseFile(pathName, res); } }); }
準備返回文件前,根據配置,添加緩存相關的響應首部。
generateETag(stat) { const mtime = stat.mtime.getTime().toString(16); const size = stat.size.toString(16); return `W/"${size}-${mtime}"`; } setFreshHeaders(stat, res) { const lastModified = stat.mtime.toUTCString(); if (this.enableExpires) { const expireTime = (new Date(Date.now() + this.maxAge * 1000)).toUTCString(); res.setHeader('Expires', expireTime); } if (this.enableCacheControl) { res.setHeader('Cache-Control', `public, max-age=${this.maxAge}`); } if (this.enableLastModified) { res.setHeader('Last-Modified', lastModified); } if (this.enableETag) { res.setHeader('ETag', this.generateETag(stat)); } }
須要注意的是,上面使用了ETag
弱驗證器,並不能保證緩存文件與服務器上的文件是徹底同樣的。關於強驗證器如何實現,能夠參考etag包的源碼。
下面是如何判斷緩存是否仍然新鮮:
isFresh(reqHeaders, resHeaders) { const noneMatch = reqHeaders['if-none-match']; const lastModified = reqHeaders['if-modified-since']; if (!(noneMatch || lastModified)) return false; if(noneMatch && (noneMatch !== resHeaders['etag'])) return false; if(lastModified && lastModified !== resHeaders['last-modified']) return false; return true; }
須要注意的是,http首部字段名是不區分大小寫的(但http method應該大寫),因此日常在瀏覽器中會看到大寫或小寫的首部字段。
可是node
的http
模塊將首部字段都轉成了小寫,這樣在代碼中使用起來更方便些。因此訪問header要用小寫,如reqHeaders['if-none-match']
。不過,仍然能夠用req.rawreq.rawHeaders
來訪問原headers,它是一個[name1, value1, name2, value2, ...]
形式的數組。
如今來測試一下,由於設置的緩存有效時間是極小的5s,因此強緩存幾乎不會命中,因此第二次訪問文件會發出新的請求,由於服務端文件並沒作什麼改變,因此會返回304。
如今來修改一下請求的這張圖片,好比修改一下size,目的是讓服務端的再驗證失敗,於是必須給客戶端發送200和最新的文件。
接下來把緩存有效時間改大一些,好比10分鐘,那麼在10分鐘以內的重複請求,都會命中強緩存,瀏覽器不會向服務端發起新的請求(但network依然能觀察到這條請求)。
服務器在發送很大的文檔以前,對其進行壓縮,能夠節省傳輸用時。其過程是:
Accept-Encoding
頭Accept-Encoding
請求頭,而且支持該文件類型的壓縮,壓縮響應的實體主體(並不壓縮頭部),並附上Content-Encoding
首部Content-Encoding
首部,按其值指定的格式解壓報文對於圖片這類已經通過高度壓縮的文件,無需再額外壓縮。所以,咱們須要配置一個字段,指明須要針對哪些類型的文件進行壓縮。
default.json
{ ... "zipMatch": "^\\.(css|js|html)$" }
static-server.js
constructor() { ... this.zipMatch = new RegExp(config.zipMatch); }
用zlib
模塊來實現流壓縮:
compressHandler(readStream, req, res) { const acceptEncoding = req.headers['accept-encoding']; if (!acceptEncoding || !acceptEncoding.match(/\b(gzip|deflate)\b/)) { return readStream; } else if (acceptEncoding.match(/\bgzip\b/)) { res.setHeader('Content-Encoding', 'gzip'); return readStream.pipe(zlib.createGzip()); } else if (acceptEncoding.match(/\bdeflate\b/)) { res.setHeader('Content-Encoding', 'deflate'); return readStream.pipe(zlib.createDeflate()); } }
由於配置了圖片不需壓縮,在瀏覽器中測試會發現圖片請求的響應中沒有Content-Encoding
頭。
最後一步,使服務器支持範圍請求,容許客戶端只請求文檔的一部分。其流程是:
Accept-Ranges
頭(值表示表示範圍的單位,一般是「bytes」),告訴客戶端其接受範圍請求Ranges
頭,告訴服務端請求的是一個範圍206 Partial Content
,發送指定範圍內內容,並在Content-Range
頭中指定該範圍416 Requested Range Not Satisfiable
,並在Content-Range
中指明可接受範圍請求中的Ranges
頭格式爲(這裏不考慮多範圍請求了):
Ranges: bytes=[start]-[end]
其中 start 和 end 並非必須同時具備:
響應中的Content-Range
頭有兩種格式:
當範圍有效返回 206 時:
Content-Range: bytes (start)-(end)/(total)
當範圍無效返回 416 時:
Content-Range: bytes */(total)
添加函數處理範圍請求:
rangeHandler(pathName, rangeText, totalSize, res) { const range = this.getRange(rangeText, totalSize); if (range.start > totalSize || range.end > totalSize || range.start > range.end) { res.statusCode = 416; res.setHeader('Content-Range', `bytes */${totalSize}`); res.end(); return null; } else { res.statusCode = 206; res.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${totalSize}`); return fs.createReadStream(pathName, { start: range.start, end: range.end }); } }
用 Postman來測試一下。在指定的root
文件夾下建立一個測試文件:
testfile.js
This is a test sentence.
請求返回前六個字節 」This 「 返回 206:
請求一個無效範圍返回416:
至此,已經完成了靜態服務器的基本功能。可是每一次須要修改配置,都必須修改default.json
文件,很是不方便,若是能接受命令行參數就行了,能夠藉助 yargs 模塊來完成。
var options = require( "yargs" ) .option( "p", { alias: "port", describe: "Port number", type: "number" } ) .option( "r", { alias: "root", describe: "Static resource directory", type: "string" } ) .option( "i", { alias: "index", describe: "Default page", type: "string" } ) .option( "c", { alias: "cachecontrol", default: true, describe: "Use Cache-Control", type: "boolean" } ) .option( "e", { alias: "expires", default: true, describe: "Use Expires", type: "boolean" } ) .option( "t", { alias: "etag", default: true, describe: "Use ETag", type: "boolean" } ) .option( "l", { alias: "lastmodified", default: true, describe: "Use Last-Modified", type: "boolean" } ) .option( "m", { alias: "maxage", describe: "Time a file should be cached for", type: "number" } ) .help() .alias( "?", "help" ) .argv;
瞅瞅 help 命令會輸出啥:
這樣就能夠在命令行傳遞端口、默認頁等:
node app.js -p 8888 -i main.html
戳個人 GitHub repo: nodejs-static-webserver
博文也同步在 GitHub,歡迎討論和指正:使用Node.js搭建靜態資源服務器