這是學習 Node.js 的第七篇,這一篇主要是了解 http,同時實現一個靜態資源服務器。先看一下這個服務器有什么功能。
服務器功能展示
首先我們在命令行工具輸入 ss (意為:super server),它會幫我們在當前目錄啟動一個靜態資源服務器。服務器的地址為 「http://localhost:3000」。
當我們訪問 「http://localhost:3000」時,它把我們當前目錄的所有文件都羅列了出來。
我們點擊一個文件,例如 pacakge.json,它會把當前文件的內容顯示出來:
OK,主要功能就是這些,下面我們一起來實現一下。
可以通過 ss --port 3001 指定端口號,通過 ss --directory C:foobar 指定服務器的工作目錄,即靜態資源的根目錄。
http 模塊
既然是服務器,那一定是使用了 Node 的 http 模塊,我們先簡單的了解下如何使用 http 創建一個服務器。
創建一個服務器
const http = require('http')
const server = http.createServer((req, res) => {
console.log('有請求過來了~~~')
})
let port = 3000
server.listen(port, () => {
console.log(`server start ${port}`)
})
使用 http.createServer 即可創建一個服務器,然后再調用 server.listen() 方法監聽一個端口,就算正式創建成功了。這時我們直接訪問 http://localhost:3000 即可在命令行看到打印 有請求過來了。
那么我們如何獲得這個請求的具體信息,并給客戶端做出相應呢?
其實,每次請求過來的時候都會執行 createServer(callback) 中傳入的回調,回調內會傳入兩個參數:「req(request) 與 res(response)」。req 就代表請求信息與相關操作,res 代表響應信息與相關操作。
我們具體來使用一下這兩個對象。
const http = require('http')
const url = require('url')
const server = http.createServer((req, res) => {
// 請求方法名
console.log(req.method)
// 請求url
console.log(req.url)
// 請求頭
console.log(req.headers)
// req 是一個可讀流
req.on('data', chunk => {
console.log(chunk)
})
req.on('end', () => {})
// 響應行->響應頭->響應體順序不能變
// 首先設置響應行(狀態碼與狀態碼描述)
res.statusCode = 200
res.statusMessage = 'success'
// 設置響應頭
res.setHeader('name', 'superYue')
// 最后設置響應體
// res 是一個可寫流
res.write('ok')
res.end('1')
})
let port = 3000
server.listen(port, () => {
console.log(`server start ${port}`)
})
此時,我們在瀏覽器訪問 http://localhost:3000 就可以下看到如下內容:
「這里有一些點需要大家注意」
- req 是一個可讀流,我們獲取請求體的時候,必須要以流的形式獲取,正如我上述代碼所寫的那樣
- res 是一個可寫流,所以我們設置響應體的時候,必須用 write() 方法,結束響應時,必須用 end() 方法
「對可讀流、可寫流不清楚的同學可以看下此系列文章下的《手寫文件流》」
開始寫代碼
現在我們進入主題——「實現一個靜態資源服務器」。
先看一下我們的目錄結構。
bin 目錄是命令行邏輯代碼
src/satic-server.js 靜態資源服務器
src/template.html html 模板
實現命令行功能
我們的服務器是在命令行內輸入 ss 之后自動啟動的,我們看一下這個功能是怎么實現的?
首先,我們要在 package.json 內增加一個 bin 字段,如下代碼所示:
// pacakge.json
{
"bin": {
"ss": "./bin/www.js"
},
}
ss 是我們運行的命令,./bin/www.js 是運行 ss 后要被執行的 js 文件。
然后,./bin/www.js 內注意要添加這行代碼 #! /usr/bin/env node,這行代碼的意思是用 node 環境來執行以下代碼,這樣我們就可以盡情的去寫 Node 代碼了。
最后,我們要在當前的工作目錄去執行 npm link,這樣才能將 ss 命令注冊到全局變量中去,不然系統是不認識 ss 的。
現在我們已經可以執行 ss 命令了,理論上就可以在 bin/www.js 內去實現一個靜態服務器了,但是在真正實現之前,我想有一些定制化的功能,比如自定義啟動服務的端口號,自定義靜態服務器的工作目錄。
要實現這樣的定制化功能,那肯定是在命令行內去輸入,例如:
ss --port 3000 啟動一個 3000 端口的服務器
ss --directory C: 靜態資源服務器的根目錄是 C 盤。
然后我們要解析 ss 輸入的參數,這些參數 Node 都幫我們保存在了 process.argv 屬性里,打印出來的結果如下圖所示。
如果我們想得到正確的結果,需要我們自己去解析。這里給大家推薦一個工具——commander,它是一個完整的 node.js 命令行解決方案,github鏈接點這。
我們來看一下示例:
const { program } = require('commander)
// 聲明一個 prot 參數,要求必須有值,默認值是 3000
// 'set your server port' 是命令描述
program.option('-p, --port <v>', 'set your server port', 3000);
// 開始解析命令
program.parse(process.argv);
// 通過 program.port 拿到解析好參數
console.log(`port: ${program.port}`);
可以看到,最終我們輸入的命令都會被解析到 program 內。
這只是 commander 一部分功能,完整功能可以看具體文檔。
接下來把我 www.js 代碼貼出來
#! /usr/bin/env node
const program = require('commander')
const StaticServer = require('../src/static-server')
console.log(process.argv)
program.name('ss')
program
.option('-p, --port <v>', 'set your server port', 3000)
.option('-d, --directory <v>', 'set your server start directory', process.cwd())
program.on('--help', () => {
console.log('nExamples:')
console.log('ss -p 3000 / ss --port 3000')
console.log('ss -d C: / ss --directory C:')
})
program.parse(process.argv)
const config = {}
config.port = program.port ?? process.cwd()
new StaticServer(config).start()
program.on('--help') 的意思是監聽 --help 命令。每當用戶輸入 ss --help 的時候,我們都把操作提示給打印出來。
靜態資源服務器
在上段代碼內,我們在 www.js 里執行了 new StaticServer(config).start(),這句代碼的意思是啟動一個靜態資源服務器,接下來,我們就來實現一下這個。
初始化參數
首先,我們聲明一個類,并初始化參數。
class StaticServer {
constructor(config) {
this.port = config.port
this.directory = config.directory
}
}
start()
然后,在調用 start 的時候,我們創建一個服務器。
start() {
const server = http.createServer(this.handleRequest.bind(this))
server.listen(this.port, () => {
console.log(`${chalk.yellow('Starting up super-server: ')}${this.directory}`)
console.log(`http://localhost:${chalk.green(this.port)}`)
})
}
為了更好的處理請求,我們把處理請求的邏輯全都放到了 handleRequest() 方法內。
chalk 中文意思為粉筆,是專門用來改變控制臺輸出顏色的第三方包。
handleRequest()
這個方法是專門用來處理請求的。
我們現在想一下,當一個靜態資源的請求過來時,我們應該做什么操作?
- 判斷請求的資源是一個文件還是一個文件夾。如果是文件,則直接返回文件內容;如果是文件夾,則返回文件夾內存在的資源列表。
- 對文件資源進行緩存。因為是靜態資源服務器,所以肯定要有緩存功能,因為文件是不會經常變的。
- 響應文件內容。
我們看下具體代碼
async handleRequest(req, res) {
// 獲取請求路徑
// url 為 Node 的核心模塊
const { pathname } = url.parse(req.url)
// 工作目錄與請求路徑拼接,得到最終的靜態資源地址
// 這里的工作目錄默認是 process.cwd(),意思是當前代碼啟動的目錄
// 可以通過 --directory 去指定
const filePath = path.join(this.directory, pathname)
try {
// 獲取文件信息
const stat = await fs.stat(filePath)
if (stat.isFile()) {
// 如果是文件,則返回文件信息
this.sendFile(req, res, filePath, stat)
} else {
// 如果是文件夾,則返回資源列表
this.sendFolder(req, res, filePath, pathname)
}
} catch(e) {
// 返回錯誤信息
this.sendError(req, res, e)
}
}
代碼注釋非常詳細,相信不用做過多的解釋。
這里最終獲取靜態資源的地址是:請求的路徑 + 服務器工作目錄(默認是 process.cwd(),可以通過 --dircetory 去指定)
sendFile()
sendFile 對客戶端響應文件信息,在響應之前,要做緩存相關的操作,這些操作都放在了 cache() 方法內。
緩存包括強緩存與協商緩存,強緩存取的是瀏覽器客戶端內的內容,瀏覽器不會對服務器發起響應。協商緩存需要服務器判斷文件是否發生了變化,如果未發生變化則返回 304。
在具體返回響應之前,要設置響應內容的 mime 格式,用來告訴客戶端如何處理這段內容。例如,如果是 html 內容,那我們的 Content-Type 響應頭必須是 text/html,不然瀏覽器不能正確的解析。這里我們使用了 mime 這個第三方包,它可以根據文件后綴得到正確的 mime 類型。
響應內容的時候,我們會以流的形式去響應,所以這里我們創建了一個文件可讀流。
sendFile(req, res, filePath, stat) {
if (this.cache(req, res, filePath, stat)) {
res.statusCode = 304
res.end()
return;
}
res.setHeader('Content-Type', mime.getType(filePath))
createReadStream(filePath).pipe(res)
}
sendFolder()
返回文件夾內的文件列表。
將文件夾內的文件全部讀取出來,并以 html 的形式返回給瀏覽器以供展示。這里使用了 ejs 模板引擎來渲染 html。
async sendFolder(req, res, filePath, pathname) {
let dirs = await fs.readdir(filePath)
dirs = dirs.map(item => ({
filename: item,
href: path.join(pathname, item)
}))
console.log(dirs)
const template = await fs.readFile(path.resolve(__dirname, './template.html'), 'utf-8')
const html = await ejs.render(template, { dirs }, { async: true })
res.setHeader('Content-Type', 'text/html;charset=utf-8')
res.end(html)
}
我們將讀取出來的文件列表傳給 template 靜態模板,然后利用 ejs 的得到渲染后的 html。
template.html 模板代碼如下
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>zs</title>
</head>
<body>
<!-- 出路路徑 盡量不要采用./ ../ 絕對路徑 /a/a.js -->
<%dirs.forEach(item=>{%>
<li><a href="<%=item.href%>"><%=item.filename%></a></li>
<%})%>
</body>
</html>
cache()
cache() 方法封裝了文件緩存操作。
首先對文件應用緩存,設置 Expires 與 Cache-Control 響應頭,這兩個字段設置任何一個字段都可以實現緩存,但為了最大的保證兼容性,我們這里都做了設置。
如果瀏覽器緩存失效,會重新發起請求,這時需要服務器判斷資源是否真的被更改了,判斷文件資源的緩存是否失效有兩種方案。
- 通過判斷文件的修改時間來確定是否失效。第一次請求時,將文件的修改時間通過 Last-Modified 響應頭帶給瀏覽器,瀏覽器下次請求時會將修改時間放入 if-modified-since 請求頭內,這時我們就可以通過 if-modified-since 字段與當前文件的修改時間做比較,來判斷文件是否被修改了。
但是這種做法有缺陷,假如我們將文件修改了,然后過一會又修改成原來的內容,這時最終的文件是沒有變化的,但是文件的修改時間卻變了,這樣就導致緩存失效。
- 第二種解決了上述問題。第一次請求時,返回給瀏覽器的是一個文件摘要,以后請求時,根據文件摘要來判斷資源是否過期。由于文件摘要是根據文件內容生成的,所以文件內容不變,摘要就不會變。用來控制緩存的字段分別是,瀏覽器:ifNoneMatch;客戶端:Etag。
cache(req, res, filePath, stat) {
res.setHeader('Expires', new Date(Date.now() + 10 * 1000).toGMTString())
res.setHeader('Cache-Control', `max-age=${10}`)
const ifModifiedSince = req.headers['if-modified-since']
const ctime = stat.ctime.toGMTString()
if (ifModifiedSince !== ctime) {
return false
}
const ifNoneMatch = req.headers['if-none-match']
// 利用 MD5 生成文件摘要
// crypto 為內置的加密算法
const etag = crypto.createHash('md5').update( readFileSync(filePath)).digest('base64')
if (ifNoneMatch !== etag) {
return false
}
res.setHeader('Last-Modified', ctime)
res.setHeader('Etag', etag)
return true;
}
完整代碼
const http = require('http')
const url = require('url')
const fs = require('fs').promises
const path = require('path')
const { createReadStream, readFileSync } = require('fs')
const crypto = require('crypto')
const chalk = require('chalk')
const mime = require('mime')
const ejs = require('ejs')
class StaticServer {
constructor(config) {
this.port = config.port
this.directory = config.directory
}
start() {
const server = http.createServer(this.handleRequest.bind(this))
server.listen(this.port, () => {
console.log(`${chalk.yellow('Starting up super-server: ')}${this.directory}`)
console.log(`http://localhost:${chalk.green(this.port)}`)
})
}
async handleRequest(req, res) {
const { pathname } = url.parse(req.url)
const filePath = path.join(this.directory, pathname)
console.log(filePath)
try {
const stat = await fs.stat(filePath)
if (stat.isFile()) {
this.sendFile(req, res, filePath, stat)
} else {
this.sendFolder(req, res, filePath, pathname)
}
} catch(e) {
this.sendError(req, res, e)
}
}
cache(req, res, filePath, stat) {
res.setHeader('Expires', new Date(Date.now() + 10 * 1000).toGMTString())
res.setHeader('Cache-Control', `max-age=${10}`)
const ifModifiedSince = req.headers['if-modified-since']
const ctime = stat.ctime.toGMTString()
if (ifModifiedSince === ctime) {
return true
}
const ifNoneMatch = req.headers['if-none-match']
const etag = crypto.createHash('md5').update( readFileSync(filePath)).digest('base64')
if (ifNoneMatch === etag) {
return true
}
res.setHeader('Last-Modified', ctime)
res.setHeader('Etag', etag)
return false;
}
sendFile(req, res, filePath, stat) {
if (this.cache(req, res, filePath, stat)) {
res.statusCode = 304
res.end()
return;
}
res.setHeader('Content-Type', mime.getType(filePath))
createReadStream(filePath).pipe(res)
}
async sendFolder(req, res, filePath, pathname) {
let dirs = await fs.readdir(filePath)
dirs = dirs.map(item => ({
filename: item,
href: path.join(pathname, item)
}))
console.log(dirs)
const template = await fs.readFile(path.resolve(__dirname, './template.html'), 'utf-8')
const html = await ejs.render(template, { dirs }, { async: true })
res.setHeader('Content-Type', 'text/html;charset=utf-8')
res.end(html)
}
sendError(req, res, e) {
res.end(e.message)
}
}
module.exports = StaticServer
總結
可以看到,這個靜態資源服務器并不是特別復雜,但是它卻給我們帶來了不少知識點。
- 命令行工具的使用
- 一個 http 請求與響應的具體過程
- http 緩存策略
- http、url、crypto 等核心模塊的使用
希望這篇文章可以給大家帶來一些收獲~~~
也可以看下這一系列的其它文章~~~