如何使用Node寫(xiě)靜態(tài)文件服務(wù)器
背景
作為前端工程師,我想大家一定對(duì)靜態(tài)文件服務(wù)器
不會(huì)陌生。所謂的靜態(tài)文件服務(wù)器做的工作就是將我們的前端靜態(tài)文件
(.js/.css/.html)傳輸給瀏覽器,然后瀏覽器再將我們的頁(yè)面渲染出來(lái)。我們常用的webpack-dev-server
就是本地開(kāi)發(fā)用的靜態(tài)文件服務(wù)器,而一般線上環(huán)境我們會(huì)使用nginx
,因?yàn)樗臃€(wěn)定和高效。既然靜態(tài)文件服務(wù)器無(wú)處不在,那么它們又是如何實(shí)現(xiàn)的呢?本篇文章將帶你手把手實(shí)現(xiàn)一個(gè)高效的靜態(tài)文件服務(wù)器
。
功能介紹
我們的靜態(tài)服務(wù)器包括下面兩個(gè)功能:
- 當(dāng)用戶請(qǐng)求的內(nèi)容是
文件夾
時(shí),展示當(dāng)前文件夾的結(jié)構(gòu)信息
- 當(dāng)用戶請(qǐng)求的內(nèi)容是
文件
時(shí),返回文件的內(nèi)容
我們來(lái)看一下實(shí)際效果,服務(wù)端的靜態(tài)文件目錄是這樣的:
static └── index.html
訪問(wèn)localhost:8080
可以獲取根目錄
的信息:
在根目錄下只有一個(gè)index.html
文件。我們點(diǎn)擊index.html
文件可以獲取這個(gè)文件的具體內(nèi)容:
代碼實(shí)現(xiàn)
根據(jù)上面的需求描述,我們先用流程圖
來(lái)設(shè)計(jì)一下我們的邏輯如何實(shí)現(xiàn):
其實(shí)靜態(tài)文件服務(wù)器的實(shí)現(xiàn)思路還是很簡(jiǎn)單的:先判斷資源存不存在
,不存在就直接報(bào)錯(cuò),資源存在的話根據(jù)資源的類型返回對(duì)應(yīng)的結(jié)果給客戶端
就可以了。
基礎(chǔ)代碼實(shí)現(xiàn)
看完上面的流程圖
,我相信大家的思路基本清晰了,接著我們看一下具體的代碼實(shí)現(xiàn):
const http = require('http') const url = require('url') const fs = require('fs') const path = require('path') const process = require('process') // 獲取服務(wù)端的工作目錄,也就是代碼運(yùn)行的目錄 const ROOT_DIR = process.cwd() const server = http.createServer(async (req, resp) => { const parsedUrl = url.parse(req.url) // 刪除開(kāi)頭的'/'來(lái)獲取資源的相對(duì)路徑,e.g: `/static`變?yōu)閌static` const parsedPathname = parsedUrl.pathname.slice(1) // 獲取資源在服務(wù)端的絕對(duì)路徑 const pathname = path.resolve(ROOT_DIR, parsedPathname) try { // 讀取資源的信息, fs.Stats對(duì)象 const stat = await fs.promises.stat(pathname) if (stat.isFile()) { // 如果請(qǐng)求的資源是文件就交給sendFile函數(shù)處理 sendFile(resp, pathname) } else { // 如果請(qǐng)求的資源是文件夾就交給sendDirectory函數(shù)處理 sendDirectory(resp, pathname) } } catch (error) { // 訪問(wèn)的資源不存在 if (error.code === 'ENOENT') { resp.statusCode = 404 resp.end('file/directory does not exist') } else { resp.statusCode = 500 resp.end('something wrong with the server') } } }) server.listen(8080, () => { console.log('server is up and running') })
在上面的代碼中我使用http
模塊創(chuàng)建了一個(gè)server
實(shí)例,這個(gè)實(shí)例里面定義了處理所有HTTP請(qǐng)求的handler
函數(shù)。handler
函數(shù)實(shí)現(xiàn)比較簡(jiǎn)單,讀者根據(jù)上面的代碼注釋就可以看明白了,這里想要說(shuō)明一下我為什么使用fs.promises.stat
來(lái)獲取資源的元信息(fs.Stats
類,包括資源的類型和更改時(shí)間等)而不使用可以實(shí)現(xiàn)同一個(gè)功能的fs.stat
和fs.statSync
:
fs.promises.stat vs fs.stat
:fs.promises.stat
是promise-style
的,可以使用async
和await
來(lái)實(shí)現(xiàn)異步的邏輯,代碼很干凈。而fs.stat
是callback-style
的,這種API寫(xiě)異步邏輯最后可能會(huì)變成意大利面條
,后期維護(hù)困難。fs.promises.stat vs fs.statSync
:fs.promises.stat
讀取文件的信息是一個(gè)異步操作
,不會(huì)阻塞主線程的執(zhí)行。而fs.statSync
是同步的,這也就意味著當(dāng)這個(gè)API執(zhí)行的時(shí)候,JS
主線程會(huì)卡死,其它的資源請(qǐng)求是處理不了的。這里我也建議當(dāng)大家需要在服務(wù)端進(jìn)行文件系統(tǒng)的讀寫(xiě)
的時(shí)候,一定要優(yōu)先使用異步API
而避免使用同步式的API
。
接著我們來(lái)看一下sendFile
和sendDirectory
這兩個(gè)函數(shù)的具體實(shí)現(xiàn):
const sendFile = async (resp, pathname) => { // 使用promise-style的readFile API異步讀取文件的數(shù)據(jù),然后返回給客戶端 const data = await fs.promises.readFile(pathname) resp.end(data) } const sendDirectory = async (resp, pathname) => { // 使用promise-style的readdir API異步讀取文件夾的目錄信息,然后返回給客戶端 const fileList = await fs.promises.readdir(pathname, { withFileTypes: true }) // 這里保存一下子資源相對(duì)于根目錄的相對(duì)路徑,用于后面客戶端繼續(xù)訪問(wèn)子資源 const relativePath = path.relative(ROOT_DIR, pathname) // 構(gòu)造返回的html結(jié)構(gòu)體 let content = '<ul>' fileList.forEach(file => { content += ` <li> <a href=${ relativePath }/${file.name}>${file.name}${file.isDirectory() ? '/' : ''} </a> </li>` }) content += '</ul>' // 返回當(dāng)前的目錄結(jié)構(gòu)給客戶端 resp.end(`<h1>Content of ${relativePath || 'root directory'}:</h1>${content}`) }
sendDirectory
通過(guò)fs.promises.readdir
來(lái)獲取其底下的目錄信息,然后以列表
的形式返回一個(gè)html結(jié)構(gòu)給客戶端。這里值得一提的是:由于客戶端需要按照返回的子資源信息進(jìn)一步訪問(wèn)子資源,所以我們需要記錄子資源相對(duì)于根目錄的相對(duì)路徑
。sendFile
函數(shù)的實(shí)現(xiàn)相對(duì)于sendDirectory
會(huì)簡(jiǎn)單一點(diǎn),它只需要讀取文件的內(nèi)容然后返回給客戶端就可以了。
上面的代碼寫(xiě)完后,我們其實(shí)已經(jīng)實(shí)現(xiàn)了上面說(shuō)的需求了,可是這個(gè)服務(wù)端是生產(chǎn)不可用的
,因?yàn)樗泻芏酀撛诘膯?wèn)題沒(méi)有解決,接著就讓我們看一下如何解決這些問(wèn)題來(lái)優(yōu)化我們的服務(wù)端代碼。
大文件優(yōu)化
我們先來(lái)看看在現(xiàn)在的實(shí)現(xiàn)下,客戶端請(qǐng)求一個(gè)大文件會(huì)發(fā)生什么。首先我們?cè)?code>static文件夾下準(zhǔn)備一個(gè)大文件test.txt
,這個(gè)文件里面有1000萬(wàn)行Hello World!
,文件的大小為124M
:
然后我們啟動(dòng)服務(wù)器,查看服務(wù)器啟動(dòng)完成后Node的內(nèi)存占用情況
:
可以看到Node服務(wù)只占用了8.5M
的內(nèi)存,我們?cè)跒g覽器訪問(wèn)一下test.txt
:
瀏覽器在瘋狂輸出Hello World!
,這個(gè)時(shí)候再看一眼Node的內(nèi)存占用情況:
內(nèi)存使用一下子由8.5M
激增到了132.9M
,而增加的資源差不多就是文件的大小124M
,這到底是為什么呢?我們?cè)賮?lái)看一下sendFile
文件的實(shí)現(xiàn):
const sendFile = async (resp, pathname) => { // readFile會(huì)讀取文件的數(shù)據(jù)然后存在data變量里面 const data = await fs.promises.readFile(pathname) resp.end(data) }
上面的代碼中,其實(shí)我們會(huì)一次性讀取文件的內(nèi)容然后保存在data
變量里面,也就是說(shuō)我們會(huì)將124M
的文本信息保存在內(nèi)存里面
!你試想一下,如果有多個(gè)用戶同時(shí)訪問(wèn)大資源,我們的程序肯定會(huì)因?yàn)閮?nèi)存爆炸而OOM
(Out of Memory)的。那么這個(gè)問(wèn)題如何解決呢?其實(shí)node提供的stream
模塊可以很好地解決我們的問(wèn)題。
Stream
我們先來(lái)看一下stream
的官方介紹:
A stream is an abstract interface for working with
streaming data
in Node.js. There are many stream objects provided by Node.js. For instance, a request to an HTTP server andprocess.stdout
are both stream instances.Streams can be readable, writable, or both. All streams are instances ofEventEmitter
簡(jiǎn)單來(lái)說(shuō),stream
就是給我們流式處理
數(shù)據(jù)用的,那么什么是流式處理
呢?用最簡(jiǎn)單的話來(lái)說(shuō)就是:不是一下子處理完數(shù)據(jù)
而是一點(diǎn)一點(diǎn)地處理
它們。使用stream
, 我們要處理的數(shù)據(jù)就會(huì)一點(diǎn)一點(diǎn)地加載到內(nèi)存的某一個(gè)固定大小的區(qū)域(buffer
)以給其它消費(fèi)者消費(fèi)。由于保存數(shù)據(jù)的buffer
大小一般是固定的,當(dāng)舊的數(shù)據(jù)處理完才會(huì)加載新的數(shù)據(jù),因此它可以避免內(nèi)存的崩潰。話不多說(shuō),我們馬上使用stream
來(lái)重構(gòu)一下上面的sendFile
函數(shù):
const sendFile = async (resp, pathname) => { // 為需要讀取的文件創(chuàng)建一個(gè)可讀流readableStream const fileStream = fs.createReadStream(pathname) fileStream.pipe(resp) }
上面的代碼中,我們?yōu)樾枰x取的文件創(chuàng)建了一個(gè)可讀流
(ReadableStream),然后將這個(gè)流和resp
對(duì)象連接(pipe
)在一起,這樣文件的數(shù)據(jù)就會(huì)源源不斷發(fā)送給客戶端了。看到這里你可能會(huì)問(wèn),為什么resp對(duì)象可以和fileStream
連接在一起呢?原因就是這個(gè)resp
對(duì)象底層是一個(gè)可寫(xiě)流
(WritableStream),而可讀流的pipe
函數(shù)接收的就是可寫(xiě)流
。優(yōu)化完后我們?cè)賮?lái)請(qǐng)求一下test.txt
大文件,同樣瀏覽器一頓瘋狂輸出,不過(guò)這個(gè)時(shí)候Node服務(wù)的內(nèi)存用量是這樣的:
Node的內(nèi)存基本穩(wěn)定在9.0M
,比服務(wù)剛啟動(dòng)時(shí)只多了0.5M
!從這個(gè)可以看出我們通過(guò)stream
來(lái)優(yōu)化確實(shí)達(dá)到了很好的效果。由于文章篇幅的限制,這里沒(méi)有詳細(xì)介紹stream
的API如何使用,需要了解的同學(xué)可以自行查看官方文檔。
減少文件傳輸帶寬
使用stream
的確可以減少服務(wù)端的內(nèi)存占用問(wèn)題
,可是它沒(méi)有減少服務(wù)端和客戶端傳輸?shù)臄?shù)據(jù)大小
。換句話來(lái)說(shuō),假如我們的文件大小是2M
我們就實(shí)打?qū)崅鬏斶@2M
的數(shù)據(jù)給客戶端。如果客戶端是手機(jī)或者其它移動(dòng)設(shè)備的話,這么大的帶寬消耗肯定是不可取的。這個(gè)時(shí)候我們需要對(duì)被傳輸?shù)臄?shù)據(jù)進(jìn)行壓縮
然后再在客戶端進(jìn)行解壓,這樣傳輸?shù)臄?shù)據(jù)量才能大幅度減少。服務(wù)端數(shù)據(jù)壓縮的算法有很多,這里我使用了一個(gè)比較常用的gzip
算法,我們來(lái)看一下如何更改sendFile
以支持?jǐn)?shù)據(jù)壓縮:
// 引入zlib包 const zlib = require('zlib') const sendFile = async (resp, pathname) => { // 通過(guò)header告訴客戶端:服務(wù)端使用的是gzip壓縮算法 resp.setHeader('Content-Encoding', 'gzip') // 創(chuàng)建一個(gè)可讀流 const fileStream = fs.createReadStream(pathname) // 文件流首先通過(guò)zip處理再發(fā)送給resp對(duì)象 fileStream.pipe(zlib.createGzip()).pipe(resp) }
在上面的代碼中,我使用Node原生的zlib
模塊創(chuàng)建了一個(gè)轉(zhuǎn)換流
(Transform Stream),這種流是既可讀又可寫(xiě)的
(Readable and Writable Stream),所以它像是一個(gè)轉(zhuǎn)換器
將輸入的數(shù)據(jù)進(jìn)行加工然后輸出到下游的可寫(xiě)流。我們請(qǐng)求index.html
文件來(lái)看一下優(yōu)化后的效果:
上圖中,第一行的請(qǐng)求是沒(méi)有經(jīng)過(guò)gzip壓縮的請(qǐng)求大小,大概是2.6kB
,而經(jīng)過(guò)gzip
壓縮后傳輸數(shù)據(jù)一下子變成373B
,優(yōu)化效果十分顯著!
使用瀏覽器緩存
數(shù)據(jù)壓縮
雖然解決了服務(wù)端客戶端傳輸數(shù)據(jù)的帶寬問(wèn)題,可是沒(méi)有解決重復(fù)數(shù)據(jù)傳輸?shù)膯?wèn)題
。我們知道一般來(lái)說(shuō)服務(wù)器的靜態(tài)文件是很少會(huì)改變的,在服務(wù)端資源沒(méi)有發(fā)生改變的前提下,同一個(gè)客戶端多次訪問(wèn)同一個(gè)資源,服務(wù)端會(huì)傳輸一樣的數(shù)據(jù)
,而這種情況下更有效的方式是:服務(wù)器告訴客戶端資源沒(méi)有變化,你直接使用緩存就可以了。瀏覽器緩存的方式有很多種,有協(xié)商緩存
和強(qiáng)緩存
。關(guān)于這兩種緩存的區(qū)別我想網(wǎng)絡(luò)上已經(jīng)有很多文章說(shuō)得很清晰了,我在這里也不再多說(shuō),本篇文章主要想說(shuō)一下強(qiáng)緩存
的Etag
機(jī)制如何實(shí)現(xiàn)。
什么是Etag
其實(shí)Etag(Entity-Tag)可以理解為文件內(nèi)容的指紋
,如果文件內(nèi)容發(fā)生了改變那么這個(gè)指紋是大概率
是會(huì)變的。這里注意的是我用了大概率而不是絕對(duì),這是因?yàn)?code>HTTP1.1協(xié)議里面并沒(méi)有規(guī)定etag
具體生成算法是什么,這完全是由開(kāi)發(fā)者自己決定的。通常對(duì)于文件來(lái)說(shuō),etag
是由文件的長(zhǎng)度
+ 更改時(shí)間
生成的,這種做法其實(shí)是會(huì)存在瀏覽器讀取不到最新文件內(nèi)容的情況的,不過(guò)這不是本文的重點(diǎn),有興趣的同學(xué)可以參考網(wǎng)上的其它資料。
接著讓我們圖解一下基于etag
的協(xié)商緩存
過(guò)程:
具體的過(guò)程如下:
- 瀏覽器第一次請(qǐng)求服務(wù)端的資源時(shí),服務(wù)端會(huì)在Response里面設(shè)置當(dāng)前資源的
etag
信息,例如Etag: 5d-1834e3b6ea2
- 瀏覽器第二次請(qǐng)求服務(wù)端資源時(shí),會(huì)在請(qǐng)求頭部的
If-None-Match
字段帶上最新的etag
信息5d-1834e3b6ea2
。服務(wù)端收到請(qǐng)求解析出If-None-Match
字段并將其和最新的服務(wù)端etag
進(jìn)行對(duì)比,如果是一樣的就會(huì)返回304
給瀏覽器表示資源無(wú)更新,如果資源發(fā)生了更改則將最新的etag
設(shè)置到頭部并且將最新的資源返回給瀏覽器。
接著我們來(lái)看一下sendFile
函數(shù)如何支持etag
:
// 這個(gè)函數(shù)會(huì)根據(jù)文件的fs.Stats信息計(jì)算出etag const calculateEtag = (stat) => { // 文件的大小 const fileLength = stat.size // 文件的最后更改時(shí)間 const fileLastModifiedTime = stat.mtime.getTime() // 數(shù)字都用16進(jìn)制表示 return `${fileLength.toString(16)}-${fileLastModifiedTime.toString(16)}` } const sendFile = async (req, resp, stat, pathname) => { // 文件的最新etag const latestEtag = calculateEtag(stat) // 客戶端的etag const clientEtag = req.headers['if-none-match'] // 客戶端可以使用緩存 if (latestEtag == clientEtag) { resp.statusCode = 304 resp.end() return } resp.statusCode = 200 resp.setHeader('etag', latestEtag) resp.setHeader('Content-Encoding', 'gzip') const fileStream = fs.createReadStream(pathname) fileStream.pipe(zlib.createGzip()).pipe(resp) }
在上面的代碼中我新增了一個(gè)計(jì)算etag
的函數(shù)calculateEtag
,這個(gè)函數(shù)會(huì)根據(jù)文件的大小和最后更改時(shí)間算出文件最新的etag
信息。接著我還修改了sendFile
的函數(shù)簽名,接收了req
(HTTP請(qǐng)求體)和stat
(文件的信息,fs.Stats類)兩個(gè)新參數(shù)。sendFile
會(huì)先判斷客戶端的etag
和服務(wù)端的etag
是不是一樣的,如果相同就返回304
給客戶端否則返回文件的最新內(nèi)容并且在header設(shè)置最新的etag
信息。同樣我們?cè)俅卧L問(wèn)index.html
文件來(lái)驗(yàn)證優(yōu)化效果:
上圖可以看到第一次請(qǐng)求資源時(shí)瀏覽器沒(méi)有緩存,服務(wù)端返回了文件的最新內(nèi)容和200
狀態(tài)碼,這個(gè)請(qǐng)求的實(shí)際帶寬是396B
,第二次請(qǐng)求時(shí),由于瀏覽器有緩存并且服務(wù)端資源沒(méi)有更新
,所以服務(wù)端返回304
狀態(tài)碼而沒(méi)有返回實(shí)際的文件內(nèi)容,這個(gè)時(shí)候的文件實(shí)際帶寬是113B
!可以看出優(yōu)化效果是很明顯的,我們稍微更改一下index.html
的內(nèi)容來(lái)驗(yàn)證一下客戶端會(huì)不會(huì)拉到最新的數(shù)據(jù):
從上圖可以看出當(dāng)index.html
更新后,舊的etag失效,瀏覽器可以獲取最新的數(shù)據(jù)。我們最后再來(lái)看一下這三個(gè)請(qǐng)求的詳細(xì)信息,下面是第一次請(qǐng)求時(shí),服務(wù)端給瀏覽器返回etag
信息:
接著是第二次請(qǐng)求時(shí),客戶端請(qǐng)求服務(wù)端資源時(shí)帶上etag
信息:
第三次請(qǐng)求,etag
失效,拿到新的數(shù)據(jù):
值得一提的是,這里我們只通過(guò)etag
實(shí)現(xiàn)了瀏覽器的緩存,這是不完備的,實(shí)際的靜態(tài)服務(wù)器可能會(huì)加上基于Expires/Cache-Control
的強(qiáng)緩存
和基于Last-Modified/Last-Modified-Since
的協(xié)商緩存
來(lái)優(yōu)化。
總結(jié)
本篇文章我先實(shí)現(xiàn)了一個(gè)最簡(jiǎn)單能用的靜態(tài)文件服務(wù)器
,然后通過(guò)解決三個(gè)實(shí)際使用時(shí)會(huì)遇到的問(wèn)題優(yōu)化了我們的代碼,最后完成了一個(gè)簡(jiǎn)單高效的靜態(tài)文件服務(wù)器
。
如上文所說(shuō),由于篇幅的限制,我們的實(shí)現(xiàn)上還是漏了很多東西的,例如MIME類型的設(shè)置,支持更多的壓縮算法如deflate
以及支持更多的緩存方式如Last-Modified/Last-Modified-Since
等。這些內(nèi)容其實(shí)在掌握了上面的方法后很容易就可以實(shí)現(xiàn)了,所以就留給大家在需要真正用到的時(shí)候自己實(shí)現(xiàn)了。
到此這篇關(guān)于如何使用Node寫(xiě)靜態(tài)文件服務(wù)器的文章就介紹到這了,更多相關(guān)Node靜態(tài)文件服務(wù)器內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
- 使用nodejs、Python寫(xiě)的一個(gè)簡(jiǎn)易HTTP靜態(tài)文件服務(wù)器
- Node.js靜態(tài)文件服務(wù)器改進(jìn)版
- 在windows上用nodejs搭建靜態(tài)文件服務(wù)器的簡(jiǎn)單方法
- 用Nodejs搭建服務(wù)器訪問(wèn)html、css、JS等靜態(tài)資源文件
- 實(shí)戰(zhàn)node靜態(tài)文件服務(wù)器的示例代碼
- Node.js一行代碼實(shí)現(xiàn)靜態(tài)文件服務(wù)器的方法步驟
- Node4-5靜態(tài)資源服務(wù)器實(shí)戰(zhàn)以及優(yōu)化壓縮文件實(shí)例內(nèi)容
- node靜態(tài)服務(wù)器實(shí)現(xiàn)靜態(tài)讀取文件或文件夾
相關(guān)文章
node事件循環(huán)和process模塊實(shí)例分析
這篇文章主要介紹了node事件循環(huán)和process模塊,結(jié)合實(shí)例形式分析了node事件循環(huán)和process模塊具體功能、使用方法及相關(guān)操作注意事項(xiàng),需要的朋友可以參考下2020-02-02nodejs中簡(jiǎn)單實(shí)現(xiàn)Javascript Promise機(jī)制的實(shí)例
這篇文章主要介紹了nodejs中簡(jiǎn)單實(shí)現(xiàn)Javascript Promise機(jī)制的實(shí)例,本文在nodejs中簡(jiǎn)單實(shí)現(xiàn)一個(gè)promise/A 規(guī)范,需要的朋友可以參考下2014-12-12在 Node.js 中使用 async 函數(shù)的方法
利用 async 函數(shù),你可以把基于 Promise 的異步代碼寫(xiě)得就像同步代碼一樣。一旦你使用 async 關(guān)鍵字來(lái)定義了一個(gè)函數(shù),那你就可以在這個(gè)函數(shù)內(nèi)使用 await 關(guān)鍵字。下面通過(guò)本文給大家分享Node.js 中使用 async 函數(shù)的方法,一起看看吧2017-11-11詳解nodejs 開(kāi)發(fā)企業(yè)微信第三方應(yīng)用入門(mén)教程
這篇文章主要介紹了詳解nodejs 開(kāi)發(fā)企業(yè)微信第三方應(yīng)用入門(mén)教程,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2019-03-03node+express框架中連接使用mysql(經(jīng)驗(yàn)總結(jié))
這篇文章主要介紹了node+express框架中連接使用mysql(經(jīng)驗(yàn)總結(jié)),小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2018-11-11