Node手把手構建靜態文件服務器

Node手把手構建一個靜態文件服務器

這篇文章主要將會經過node手把手的構建一個靜態文件服務器,那麼廢話很少說,開發流程走起來,咱們先看一下將要作的這個靜態文件服務器將會有哪些功能?javascript

這個靜態文件服務器有哪些功能?

  • 讀取靜態文件
  • MIME類型支持
  • 支持壓縮
  • 支持斷點續傳
  • 支持緩存與緩存控制
  • 實現命令行調用
  • 最後將代碼發佈到npm,可經過npm install -g全局安裝

好了,經過以上的功能梳理,那麼咱們須要實現的功能就很明確了,也就至關於咱們項目開發過程當中的需求如今已經肯定了(原諒我這些天被公司項目別急了),接下來就一步步開始實現功能吧。html

功能實現——讀取靜態文件+MIME類型支持

  1. 首先先構建好項目目錄,項目目錄以下:java

    project
     |---bin 命令行實現放置腳本
     |
     |---public 靜態文件服務器默認靜態文件夾
     |
     |---src 實現功能的相關代碼
     |   |
     |   |__template 模板文件夾
     |   |
     |   |__app.js 主要功能文件
     |   |__config.js 配置文件
     |
     |---package.josn (這個不用多說了吧)
    
    複製代碼
  2. 而後開始實現功能,咱們將會經過node的http模塊來啓動一個服務,這裏我先將功能(讀取靜態文件、MIME類型支持)的實現總體代碼貼出來,再慢慢道來:node

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:app
const 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類當中有如下方法:git

  • 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方法,向客戶端發送錯誤信息
         }
     }
複製代碼

代碼的解讀我會根據上一個方法的調用來一個個的逐行解讀,那麼接下來時sendContentgithub

  • 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方法web

  • getStream 獲取一個流對象,代碼以下:
getStream(filePath) {
         return fs.createReadStream(filePath);//返回一個可讀流,供sendContent方法使用
     }
複製代碼

那麼以上就已經完成了向客戶端返回對應的訪問路徑信息了,最後還剩一個Server類的sendError方法,這個方法主要是向客戶端發送一個錯誤信息。npm

  • sendError 發送錯誤信息,代碼段以下:
sendError(req, res, err) {//發送錯誤
        res.statusCode = 500;//設置錯誤碼
        res.end(`${err.toString()}`)//向客戶端發送對應的錯誤信息字符串
    }
複製代碼

那麼以上的代碼就實現了一個這個靜態服務器的——1.讀取靜態文件。2.MIME類型支持。這樣兩個功能點,對應的代碼文件app.js github地址json

功能實現——支持壓縮

由於這個功能點的實現都是基於前面已實現的功能(讀取靜態文件、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:app
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',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(/\bgzip\b/.test(encoding)){//gzip壓縮格式
            res.setHeader('Content-Encoding','gzip');
            return zlib.createGzip();
        }else if(/\bdeflate\b/.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(/\bgzip\b/.test(encoding)){//客戶端支持gzip壓縮格式
                 res.setHeader('Content-Encoding','gzip');//設置請求頭
                 return zlib.createGzip();//建立並返回一個Gzip流對象
             }else if(/\bdeflate\b/.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地址

參考文獻:

相關文章
相關標籤/搜索