Node手把手構建靜態文件伺服器
這篇文章主要將會通過node手把手的構建一個靜態文件伺服器,那麼廢話不多說,開發流程走起來,我們先看一下將要做的這個靜態文件伺服器將會有哪些功能?
這個靜態文件伺服器有哪些功能?
- 讀取靜態文件
- MIME類型支持
- 支持壓縮
- 支持斷點續傳
- 支持緩存與緩存控制
- 實現命令行調用
- 最後將代碼發布到npm,可通過npm install -g全局安裝
好了,通過以上的功能梳理,那麼我們需要實現的功能就很明確了,也就相當於我們項目開發過程中的需求現在已經確定了(原諒我這些天被公司項目別急了),接下來就一步步開始實現功能吧。
功能實現——讀取靜態文件+MIME類型支持
- 首先先構建好項目目錄,項目目錄如下:
project
|---bin 命令行實現放置腳本
| |---public 靜態文件伺服器默認靜態文件夾 ||---src 實現功能的相關代碼
| | | |__template 模板文件夾 | | | |__app.js 主要功能文件| |__config.js 配置文件
| |---package.josn (這個不用多說了吧)- 然後開始實現功能,我們將會通過node的http模塊來啟動一個服務,這裡我先將功能(讀取靜態文件、MIME類型支持)的實現整體代碼貼出來,再慢慢道來:
const http = require(http)const path = require(path)const url = require(url)const fs = require(fs)let chalk = require(chalk);process.env.DEBUG = static:*;let debug = require(debug)(static:app);//每個debug實例都有一個名字,是否在控制台列印取決於環境變數中DEBUG的值是否等於static:appconst mime = require(mime);const {promisify} = require(util)let handlebars = require(handlebars);const config = require(./config)const stat = promisify(fs.stat)const readDir = promisify(fs.readdir)//獲取編譯模板function getTemplet() { let tmpl = fs.readFileSync(path.resolve(__dirname, template, list.html), utf8); return handlebars.compile(tmpl);}class Server { constructor(argv) { this.config = Object.assign({}, config, argv); this.list = getTemplet(); } //啟動服務 start() { let server = http.createServer(); server.on(request, this.request.bind(this)) server.listen(this.config.port); let url=`http://${this.config.host}:${this.config.port}`; debug(`靜態服務啟動成功${chalk.green(url)}`); } async request(req, res) {//服務監聽函數 let pathName = url.parse(req.url).path; let filePath = path.join(this.config.root, pathName); if (filePath.indexOf(favicon.ico) > 0) { this.sendError(req, res, not found); return } try {//在靜態服務文件夾存在訪問的路徑內容 let statObj = await stat(filePath); if (statObj.isDirectory()) {//是文件夾 let directories = await readDir(filePath); let files = directories.map(file => { return { filename: file, url: path.join(pathName, file) } }); let htmls = this.list({ title: pathName, files }); res.setHeader(Content-Type, text/html); res.end(htmls); } else {//是文件 this.sendContent(req, res, filePath, statObj); } } catch (err) {//靜態伺服器內容不存在訪問內容 this.sendError(req, res, err); } } sendContent(req, res, filePath, statObj) {//向客戶端響應內容 let fileType = mime.getType(filePath); res.setHeader(Content-Type, `${fileType};charset=UTF-8`); let rs = this.getStream(filePath);//獲取文件的可讀流 rs.pipe(res); } getStream(filePath) {//返回一個可讀流 return fs.createReadStream(filePath); } sendError(req, res, err) {//發送錯誤 res.statusCode = 500; res.end(`${err.toString()}`) }}module.exports = Server;
通過以上的代碼,我們可以看出,我這裡是創建了一個Server類,然後通過在調用Server類的start()方法來啟動這樣一個服務,在Server類當中有以下方法:
- start 用來啟動服務的——這個方法裡面主要是通過node的http模塊來啟動一個服務,並監聽對應的埠
- request 服務監聽函數——這個方法主要是對啟動服務的監聽,具體邏輯這裡還是在代碼中通過注釋來說明吧:
async request(req, res) {//服務監聽函數 let pathName = url.parse(req.url).path;//獲取到客戶端要訪問的伺服器路徑 let filePath = path.join(this.config.root, pathName);//客戶端要訪問的路徑得到該路徑在伺服器上的對應伺服器物理路徑 if (filePath.indexOf(favicon.ico) > 0) {//這個判斷主要是為了去掉網站默認favicon.ico的請求報錯 this.sendError(req, res, not found); return } try {//在靜態伺服器存在訪問路徑內容 let statObj = await stat(filePath);//通過node來獲取該路徑下的文件信息 if (statObj.isDirectory()) {//如果該路徑是對應的文件夾 let directories = await readDir(filePath);//讀取該文件夾裡面的文件內容,readDir其實是我定義的const readDir = promisify(fs.readdir) let files = directories.map(file => {//這裡主要是為了生成返回html模板內容的對應數據結構如: {title:顯示的頁面標題,files:[{filename:1,url:/1}]}; return { filename: file, url: path.join(pathName, file) } }); let htmls = this.list({//調用模板引擎的渲染方法,這就不對模板引擎做過多說明了,會在最後附上模板引擎的相關連接,這裡用的handlebars title: pathName, files }); res.setHeader(Content-Type, text/html);//因為返回的是html頁面,所以需要設置請求頭,告訴客戶端如何來解析 res.end(htmls);//將讀取到的html發送給客戶端 } else { this.sendContent(req, res, filePath, statObj);//調用Server類的sendContent方法,向客戶端發送內容 } } catch (err) {//靜態伺服器不存在訪問內容 this.sendError(req, res, err);//調用Server類的sendError方法,向客戶端發送錯誤信息 } }
代碼的解讀我會根據上一個方法的調用來一個個的逐行解讀,那麼接下來時sendContent
- sendContent 向客戶端發送內容,代碼段如下:
sendContent(req, res, filePath, statObj) {//向客戶端響應內容 let fileType = mime.getType(filePath);//這裡是為了實現對MIME類型的支持,所以這裡需要判斷訪問路徑的文件的MIME類型,主要是通過npm上的mime包來獲取 res.setHeader(Content-Type, `${fileType};charset=UTF-8`);//設置對應MIME的http響應頭,這樣客戶端才能對應的解析 let rs = this.getStream(filePath);//獲取對應路徑文件的可讀流 rs.pipe(res);//向客戶端發送內容,這主要是因為res本身就是一個流 }
那麼同樣逐行解讀Server類的getStream方法
- getStream 獲取一個流對象,代碼如下:
getStream(filePath) { return fs.createReadStream(filePath);//返回一個可讀流,供sendContent方法使用 }
那麼以上就已經完成了向客戶端返回對應的訪問路徑信息了,最後還剩一個Server類的sendError方法,這個方法主要是向客戶端發送一個錯誤信息。
- sendError 發送錯誤信息,代碼段如下:
sendError(req, res, err) {//發送錯誤 res.statusCode = 500;//設置錯誤碼 res.end(`${err.toString()}`)//向客戶端發送對應的錯誤信息字元串 }
那麼以上的代碼就實現了一個這個靜態伺服器的——1.讀取靜態文件。2.MIME類型支持。這樣兩個功能點,對應的代碼文件app.js github地址
功能實現——支持壓縮
因為這個功能點的實現都是基於前面已實現的功能(讀取靜態文件、MIME類型支持)的基礎上來做的,所以前面那些基礎的就不再做說明,同樣的是先貼上完整代碼,然後再講壓縮的實現思路、以及壓縮的功能實現的核心代碼。整體代碼如下:```javascript//添加上文件壓縮,實現功能有——讀取靜態文件、MIME類型支持,支持壓縮const http = require(http)const path = require(path)const url = require(url)const fs = require(fs)const mime = require(mime)var zlib = require(zlib);let chalk = require(chalk);process.env.DEBUG = static:app;let debug = require(debug)(static:app);//每個debug實例都有一個名字,是否在控制台列印取決於環境變數中DEBUG的值是否等於static:appconst {promisify} = require(util)let handlebars = require(handlebars);const config = require(./config)const stat = promisify(fs.stat)const readDir = promisify(fs.readdir)//獲取編譯模板function getTemplet() { let tmpl = fs.readFileSync(path.resolve(__dirname, template, list.html), utf8); return handlebars.compile(tmpl);}class Server { constructor(argv) { this.config = Object.assign({}, config, argv); this.list = getTemplet() } //啟動服務 start() { let server = http.createServer(); server.on(request, this.request.bind(this)) server.listen(this.config.port); let url=`http://${this.config.host}:${this.config.port}`; debug(`靜態服務啟動成功${chalk.green(url)}`); } async request(req, res) {//服務監聽函數 let pathName = url.parse(req.url).path; let filePath = path.join(this.config.root, pathName); if (filePath.indexOf(favicon.ico) > 0) { this.sendError(req, res, not found,404); return } try {//在靜態服務文件夾存在訪問的路徑內容 let statObj = await stat(filePath); if (statObj.isDirectory()) {//是文件夾 let directories = await readDir(filePath); let files = directories.map(file => { return { filename: file, url: path.join(pathName, file) } }); let htmls = this.list({ title: pathName, files }); res.setHeader(Content-Type, text/html); res.end(htmls); } else {//是文件 this.sendContent(req, res, filePath, statObj); } } catch (err) {//靜態伺服器不存在訪問內容 this.sendError(req, res, err); } } sendContent(req, res, filePath, statObj) {//向客戶端響應內容 let fileType = mime.getType(filePath); res.setHeader(Content-Type, `${fileType};charset=UTF-8`); let enCoding=this.sourceGzip(req,res); let rs = this.getStream(filePath);//獲取文件的可讀流 if(enCoding){//開啟壓縮傳輸模式 rs.pipe(enCoding).pipe(res); }else{ rs.pipe(res); } } sourceGzip(req,res){//資源開啟壓縮傳輸 // Accept-Encoding:gzip, deflate, sdch, br let encoding=req.headers[accept-encoding]; if(/gzip/.test(encoding)){//gzip壓縮格式 res.setHeader(Content-Encoding,gzip); return zlib.createGzip(); }else if(/deflate/.test(encoding)){//deflate壓縮格式 res.setHeader(Content-Encoding,deflate); return zlib.createDeflate(); }else{ return null; } } getStream(filePath) {//返回一個可讀流 return fs.createReadStream(filePath); } sendError(req, res, err,errCode) {//發送錯誤 if(errCode){ res.statusCode=errCode; }else{ res.statusCode = 500; } res.end(`${err.toString()}`) }}module.exports = Server;```通過以上代碼我們會發現,這裡代碼只是對像客戶端發送內容做的sendContent方法做了修改,所以,這裡將會只講sendContent以及sendContent裡面與壓縮相關的sourceGzip方法:那麼我們一起來看看sendContent和sourceGzip方法吧,代碼如下:```javascript sendContent(req, res, filePath, statObj) {//向客戶端響應內容 let fileType = mime.getType(filePath); res.setHeader(Content-Type, `${fileType};charset=UTF-8`); let enCoding=this.sourceGzip(req,res);//調用sourceGzip,來實現資源壓縮傳輸 let rs = this.getStream(filePath);//獲取文件的可讀流 if(enCoding){////如果客戶端支持壓縮格式傳輸,那麼就以壓縮方式傳輸數據 rs.pipe(enCoding).pipe(res);//向客戶端發送壓縮格式數據 }else{ rs.pipe(res); } } sourceGzip(req,res){//資源開啟壓縮傳輸 // Accept-Encoding:gzip, deflate, sdch, br,客戶端會發送這樣的請求頭,給伺服器判斷 let encoding=req.headers[accept-encoding];//獲取客戶端發送的壓縮相關的請求頭信息, if(/gzip/.test(encoding)){//客戶端支持gzip壓縮格式 res.setHeader(Content-Encoding,gzip);//設置請求頭 return zlib.createGzip();//創建並返回一個Gzip流對象 }else if(/deflate/.test(encoding)){//客戶端支持deflate壓縮格式 res.setHeader(Content-Encoding,deflate);//設置請求頭 return zlib.createDeflate();//創建並返回一個Deflate流對象 }else{//代表客戶端不支持壓縮格式數據傳輸, return null; } }```
以上就是對實現數據壓縮傳輸的代碼實現說明,那麼到這裡,總共就已經實現了三個功能(讀取靜態文件、MIME類型的支持,支持壓縮),對應的代碼文件appGzip.js github地址;
功能實現——斷點續傳(同樣是在appGzip.js的基礎上繼續開發)
因為現在的完整代碼越來越多了,所以我這裡就不再貼完整的代碼了,就貼對應功能的核心代碼吧,最後再附上完整的文件鏈接地址。這個功能主要是在獲取文件流的方法getStream裡面去擴展的,斷點續傳的個核心功能如下:
getStream(req,res,filePath,statObj) {//返回一個可讀流 let start = 0;//可讀流的起司位置 let end = statObj.size - 1;//可讀流的結束位置 let range = req.headers[range];//獲取客戶端的range請求頭信息,Server通過請求頭中的Range: bytes=0-xxx來判斷是否是做Range請求 if (range) {//斷點續傳 res.setHeader(Accept-Range, bytes); res.statusCode = 206;//返回指定內容的狀態碼 let result = range.match(/bytes=(d*)-(d*)/);//斷點續傳的分段內容 if (result) { start = isNaN(result[1]) ? start : parseInt(result[1]); end = isNaN(result[2]) ? end : parseInt(result[2]) - 1; } } return fs.createReadStream(filePath, {//返回一個指定起始位置和結束位置的可讀流 start, end }); }
那麼上面的代碼就已經實現了文件的斷點續傳了,對應完整代碼文件github地址;接下來,將繼續實現【支持緩存與緩存控制】這樣一個功能點;
功能實現——斷點續傳(同樣是在前面所有已完成功能基礎上繼續開發)
之所以要實現緩存的支持與控制,主要是為了讓客戶端在訪問服務端時以最小的數據傳輸量得到服務端最新的資源。其實現代碼如下:
sendContent(req, res, filePath, statObj) {//向客戶端響應內容 if (this.checkCache(req, res, filePath, statObj)) return; //通過sendContent方法實現緩存校驗 let fileType = mime.getType(filePath); res.setHeader(Content-Type, `${fileType};charset=UTF-8`); let enCoding=this.sourceGzip(req,res); let rs = this.getStream(req,res,filePath,statObj);//獲取文件的可讀流 if(enCoding){//開啟壓縮傳輸模式 rs.pipe(enCoding).pipe(res); }else{ rs.pipe(res); } } checkCache(req,res,filePath,statObj){//校驗緩存 let ifModifiedSince = req.headers[if-modified-since];//當資源過期時(使用Cache-Control標識的max-age),發現資源具有Last-Modified聲明,則再次向伺服器請求時帶上頭If-Modified-Since。 let isNoneMatch = req.headers[is-none-match];//客戶端想判斷緩存是否可用可以先獲取緩存中文檔的ETag,然後通過If-None-Match發送請求給Web伺服器詢問此緩存是否可用。 res.setHeader(Cache-Control, private,max-age=10);//Cache-Control private 客戶端可以緩存,max-age=10 緩存內容將在10秒後失效 res.setHeader(Expires, new Date(Date.now() + 10 * 1000).toGMTString());//伺服器響應消息頭欄位,在響應http請求時告訴瀏覽器在過期時間前瀏覽器可以直接從瀏覽器緩存取數據 let etag = statObj.size; let lastModified = statObj.ctime.toGMTString(); res.setHeader(ETag, etag);//ETag是實體標籤的縮寫,根據實體內容生成的一段hash字元串,可以標識資源的狀態。當資源發生改變時,ETag也隨之發生變化。 ETag是Web服務端產生的,然後發給瀏覽器客戶端。 res.setHeader(Last-Modified, lastModified);//伺服器文件的最後修改時間 if (isNoneMatch && isNoneMatch != etag) {//緩存過期 return false; } if (ifModifiedSince && ifModifiedSince != lastModified) {//換存過期 return false; } if (isNoneMatch || ifModifiedSince) {//緩存有效 res.writeHead(304); res.end(); return true; } else {//緩存無效 return false; } }
那麼以上代碼就已經把靜態伺服器的【讀取靜態文件、MIME類型支持、支持壓縮、支持斷點續傳、支持緩存與緩存控制】這些功能都已經實現了,完整的代碼文件GitHub地址,接下來將要實現命令行調用我們的靜態文件伺服器啟用;
功能實現——命令行調用
命令行調用的功能主要是什麼? 如果沒有命令行調用,如果我們想要執行我們這個app.js,那麼就只能是先cmd進入命令行面板,然後在裡面輸入node app.js才能執行app.js。如果我們做了命令行調用,那麼我們只需要自定義一個命令假如叫Myserver,這個命令主要功能主要就是執行app.js,那麼我們在cmd命令行裡面就只要輸入Myserver就能實現了,而且還可以通過命令行來實現傳參。例如:我們平時看電腦的ip地址時,我們可以在命令行中輸入ipconfig,就會顯示信息,也可以通過ipconfig /all 這樣一個命令來顯示完整信息,那麼後面的這個/all就相當於一個篩選參數了,這樣子就想Linux裡面的命令一樣了,這裡就不再做太多說明了,這裡主要講一下如何將我們的靜態伺服器通過命令行來調用; 首先在package.json中提供一個bin欄位,主要是將包里包含可執行文件,通過設置這個欄位可以將它們包含到系統的PATH中,這樣直接就可以運行。我這裡添加的bin欄位如下: javascript "bin": { "rcw-staticserver": "bin/app" }
這裡是主要是將rcw-staticserver這個欄位設置到系統PATH當中去,然後記得一定要運行一次npm link,從而將命令執行內容路徑改到,bin/app文件來。那麼我這裡就能通過在命令行輸入rcw-staticserver來啟動我的靜態文件伺服器了。那麼bin文件夾下的app文件代碼內容如下:
```javascript #! /usr/bin/env node //這段代碼一定要寫在開頭,為了兼容各個電腦平台的差異性 const yargs = require(yargs);//yargs模塊,主要是用它提供的argv對象,用來讀取命令行參數 let Server = require(../src/appCache.js); const child = require(child_process); const path=require(path) const os = require(os); let argv = yargs.option(d, {//通過-d別名或者--root 文件夾名稱來指定對應的靜態文件伺服器的文件夾目錄 alias: root,//指令變數名稱 demand: false,//是否必傳欄位 type: string,//輸入值類型 default: path.resolve(process.cwd(),public),//默認值 description: 靜態文件根目錄//欄位描述 }).option(o, { alias: host, demand: false, default: localhost, type: string, description: 請配置監聽的主機 }).option(p, { alias: port, demand: false, type: number, default: 9898, description: 請配置埠號 }) .usage(rcw-staticserver [options])//使用示例 .example( rcw-staticserver -d / -p 9898 -o localhost, 在本機的9898埠上監聽客戶端的請求 ).help(h).argv; let server = new Server(argv).start();//啟動我的靜態文件伺服器```這樣子的話我就能在命令行當中通過輸入rcw-staticserver來直接啟動靜態文件伺服器了,那麼命令行調用的功能也就實現了。
功能實現——代碼發布到npm,可通過npm install -g全局安裝。
這個功能其實相對來說就很簡單了,首先要有個npm官網的賬號,沒有的請自覺註冊吧。命令行里通過npm login先登錄自己的npm賬號,然後再運行npm publish,這個包就很輕鬆的發布到npm上面去了,也就可以通過npm install -g來進行全局安裝了。
通過以上的操作我們一個靜態文件伺服器就已經實現了哦!有不好和錯誤的地方,請大家多多指教。
完整代碼GitHub地址
參考文獻:
- ndejs中文網
- handlebars模板引擎文檔
- mime npm包
- 命令行框架 yargs
- HTTP 緩存機制一二三
推薦閱讀:
※前端日刊-2017.12.15
※前端日刊-2018.01.24
※Android動態日誌系統Holmes
※【亂翻譯】js中的常見Error類和錯誤處理
※10本學習前端必看書籍,讓你豁然開朗