手寫Node靜態資源伺服器

October發表於2019-02-16

想寫靜態資源伺服器,首先我們需要知道如何建立一個http伺服器,它的原理是什麼

http伺服器是繼承自tcp伺服器 http協議是應用層協議,是基於TCP的

http的原理是對請求和響應進行了包裝,當客戶端連線上來之後先觸發connection事件,然後可以多次傳送請求,每次請求都會觸發request事件

let server = http.createServer();
let url = require(`url`);
server.on(`connection`, function (socket) {
    console.log(`客戶端連線 `);
});
server.on(`request`, function (req, res) {
    let { pathname, query } = url.parse(req.url, true);
    let result = [];
    req.on(`data`, function (data) {
        result.push(data);
    });
    req.on(`end`, function () {
        let r = Buffer.concat(result);
        res.end(r);
    })
});
server.on(`close`, function (req, res) {
    console.log(`伺服器關閉 `);
});
server.on(`error`, function (err) {
    console.log(`伺服器錯誤 `);
});
server.listen(8080, function () {
    console.log(`server started at http://localhost:8080`);
});
  • req 代表客戶端的連線,server伺服器把客戶端的請求資訊進行解析,然後放在req上面
  • res 代表響應,如果希望向客戶端回應訊息,需要通過 res
  • reqres都是從socket來的,先監聽socketdata事件,然後等事件發生的時候,進行解析,解析出請頭物件,再建立請求物件,再根據請求物件建立響應物件
  • req.url 獲取請求路徑
  • req.headers 請求頭物件

接下來我們對一些核心功能進行講解

深刻理解並實現壓縮和解壓

為什麼要壓縮呢?有什麼好處?

  • 可以使用zlib模組進行壓縮及解壓縮處理,壓縮檔案以後可以減少體積,加快傳輸速度和節約頻寬程式碼

壓縮和解壓縮物件都是transform轉換流,繼承自duplex雙工流即可讀可寫流

  • zlib.createGzip:返回Gzip流物件,使用Gzip演算法對資料進行壓縮處理
  • zlib.createGunzip:返回Gzip流物件,使用Gzip演算法對壓縮的資料進行解壓縮處理
  • zlib.createDeflate:返回Deflate流物件,使用Deflate演算法對資料進行壓縮處理
  • zlib.createInflate:返回Deflate流物件,使用Deflate演算法對資料進行解壓縮處理

實現壓縮和解壓

因為壓縮我檔案可能很大也可能很小,所以為了提高處理速度,我們用流來實現

let fs = require("fs");
let path = require("path");
let zlib = require("zlib");
function gzip(src) {
  fs
    .createReadStream(src)
    .pipe(zlib.createGzip())
    .pipe(fs.createWriteStream(src + ".gz"));
}
gzip(path.join(__dirname,`msg.txt`));
function gunzip(src) {
  fs
    .createReadStream(src)
    .pipe(zlib.createGunzip())
    .pipe(
      fs.createWriteStream(path.join(__dirname, path.basename(src, ".gz")))
    );
}
gunzip(path.join(__dirname, "msg.txt.gz"));
  • gzip方法用於實現壓縮
  • gunzip方法用於實現解壓
  • 其中檔案msg.txt是同級目錄
  • 為什麼需要這麼寫:gzip(path.join(__dirname,`msg.txt`));
  • 因為console.log(process.cwd());列印出當前工作目錄是根目錄,並不是檔案所在目錄,如果這麼寫gzip(`msg.txt`);找不到檔案就會報錯
  • basename 從一個路徑中得到檔名,包括副檔名的,可以傳一個副檔名引數,去掉副檔名
  • extname 獲取副檔名
  • 壓縮的格式和解壓的格式需要對上,否則會報錯

有些時候我們拿到的字串不是一個流,那怎麼解決呢

let zlib=require(`zlib`);
let str=`hello`;
zlib.gzip(str,(err,buffer)=>{
    console.log(buffer.length);
    zlib.unzip(buffer,(err,data)=>{
        console.log(data.toString());
    })
});
  • 有可能壓縮後的內容比原來還大,要是內容太少的話,壓縮也沒什麼意義了
  • 文字壓縮的效果會好一點,因為有規律

在http中應用壓縮和解壓
下面實現這樣一個功能,如圖:

clipboard.png

客戶端向伺服器發起請求的時候,會通過accept-encoding(比如:Accept-Encoding:gzip,default)告訴伺服器我支援的解壓縮的格式

  • 伺服器端需要根據Accept-Encoding顯示的格式進行壓縮,沒有的格式就不能壓縮,因為瀏覽器無法解壓
  • 如果客戶端需要的Accept-Encoding中的格式服務端沒有,也無法實現壓縮
let http = require("http");
let path = require("path");
let url = require("url");
let zlib = require("zlib");
let fs = require("fs");
let { promisify } = require("util");
let mime = require("mime");
//把一個非同步方法轉成一個返回promise的方法
let stat = promisify(fs.stat);
http.createServer(request).listen(8080);
async function request(req, res) {
  let { pathname } = url.parse(req.url); 
  let filepath = path.join(__dirname, pathname); 
  // fs.stat(filepath,(err,stat)=>{});現在不這麼寫了,非同步的處理起來比較麻煩
  try {
    let statObj = await stat(filepath);
    res.setHeader("Content-Type", mime.getType(pathname));
    let acceptEncoding = req.headers["accept-encoding"];
    if (acceptEncoding) {
      if (acceptEncoding.match(/gzip/)) {
       
        res.setHeader("Content-Encoding", "gzip");
        fs
          .createReadStream(filepath)
          .pipe(zlib.createGzip())
          .pipe(res);
      } else if (acceptEncoding.match(/deflate/)) {
        res.setHeader("Content-Encoding", "deflate");
        fs
          .createReadStream(filepath)
          .pipe(zlib.createDeflate())
          .pipe(res);
      } else {
        fs.createReadStream(filepath).pipe(res);
      }
    } else {
      fs.createReadStream(filepath).pipe(res);
    }
  } catch (e) {
    res.statusCode = 404;
    res.end("Not Found");
  }
}
  • mime:通過檔案的名稱、路徑拿到一個檔案的內容型別, 可以根據不同的檔案內容型別返回不同的Content-Type
  • acceptEncoding:全部寫成小寫是為了相容不同的瀏覽器,node把所有的請求頭全轉成了小寫
  • filepath:得到檔案的絕對路徑
  • 啟動服務後,訪問http://localhost:8080/msg.txt 可看到結果

深刻理解並實現快取

為什麼要快取呢,快取有什麼好處?

  • 減少了冗餘的資料傳輸,節省了網費。
  • 減少了伺服器的負擔, 大大提高了網站的效能
  • 加快了客戶端載入網頁的速度

快取的分類

強制快取:

  • 強制快取,在快取資料未失效的情況下,可以直接使用快取資料
  • 在沒有快取資料的時候,瀏覽器向伺服器請求資料時,伺服器會將資料和快取規則一併返回,快取規則資訊包含在響應header中

clipboard.png
clipboard.png

對比快取:

  • 瀏覽器第一次請求資料時,伺服器會將快取標識與資料一起返回給客戶端,客戶端將二者備份至快取資料庫中
  • 再次請求資料時,客戶端將備份的快取標識傳送給伺服器,伺服器根據快取標識進行判斷,判斷成功後,返回304狀態碼,通知客戶端比較成功,可以使用快取資料

clipboard.png
clipboard.png

兩類快取的區別和聯絡

強制快取如果生效,不需要再和伺服器發生互動,而對比快取不管是否生效,都需要與服務端發生互動

兩類快取規則可以同時存在,強制快取優先順序高於對比快取,也就是說,當執行強制快取的規則時,如果快取生效,直接使用快取,不再執行對比快取規則

實現對比快取

實現對比快取一般是按照以下步驟:

  • 第一次訪問伺服器的時候,伺服器返回資源和快取的標識,客戶端則會把此資源快取在本地的快取資料庫中。
  • 第二次客戶端需要此資料的時候,要取得快取的標識,然後去問一下伺服器我的資源是否是最新的。
  • 如果是最新的則直接使用快取資料,如果不是最新的則伺服器返回新的資源和快取規則,客戶端根據快取規則快取新的資料

實現對比快取一般有兩種方式
通過最後修改時間來判斷快取是否可用

let http = require(`http`);
let url = require(`url`);
let path = require(`path`);
let fs = require(`fs`);
let mime = require(`mime`);
// http://localhost:8080/index.html
http.createServer(function (req, res) {
    let { pathname } = url.parse(req.url, true);
    //D:vipcode20180120.cacheindex.html
    let filepath = path.join(__dirname, pathname);
    fs.stat(filepath, (err, stat) => {
        if (err) {
            return sendError(req, res);
        } else {
            let ifModifiedSince = req.headers[`if-modified-since`];
            let LastModified = stat.ctime.toGMTString();
            if (ifModifiedSince == LastModified) {
                res.writeHead(304);
                res.end(``);
            } else {
                return send(req, res, filepath, stat);
            }
        }
    });
}).listen(8080);
function sendError(req, res) {
    res.end(`Not Found`);
}
function send(req, res, filepath, stat) {
    res.setHeader(`Content-Type`, mime.getType(filepath));
    //發給客戶端之後,客戶端會把此時間儲存起來,下次再獲取此資源的時候會把這個時間再發回伺服器
    res.setHeader(`Last-Modified`, stat.ctime.toGMTString());
    fs.createReadStream(filepath).pipe(res);
}

這種方式有很多缺陷

  • 某些伺服器不能精確得到檔案的最後修改時間, 這樣就無法通過最後修改時間來判斷檔案是否更新了
  • 某些檔案的修改非常頻繁,在秒以下的時間內進行修改. Last-Modified只能精確到秒。
  • 一些檔案的最後修改時間改變了,但是內容並未改變。 我們不希望客戶端認為這個檔案修改了
  • 如果同樣的一個檔案位於多個CDN伺服器上的時候內容雖然一樣,修改時間不一樣

ETag

ETag是根據實體內容生成的一段hash字串,可以標識資源的狀態
資源發生改變時,ETag也隨之發生變化。 ETag是Web服務端產生的,然後發給瀏覽器客戶端

let http = require(`http`);
let url = require(`url`);
let path = require(`path`);
let fs = require(`fs`);
let mime = require(`mime`);
let crypto = require(`crypto`);

http.createServer(function (req, res) {
    let { pathname } = url.parse(req.url, true);
    
    let filepath = path.join(__dirname, pathname);
    fs.stat(filepath, (err, stat) => {
        if (err) {
            return sendError(req, res);
        } else {
            let ifNoneMatch = req.headers[`if-none-match`];
            let out = fs.createReadStream(filepath);
            let md5 = crypto.createHash(`md5`);

            out.on(`data`, function (data) {
                md5.update(data);
            });
            out.on(`end`, function () {
           
                let etag = md5.digest(`hex`);
                let etag = `${stat.size}`;
                if (ifNoneMatch == etag) {
                    res.writeHead(304);
                    res.end(``);
                } else {
                    return send(req, res, filepath, etag);
                }
            });

        }
    });
}).listen(8080);
function sendError(req, res) {
    res.end(`Not Found`);
}
function send(req, res, filepath, etag) {
    res.setHeader(`Content-Type`, mime.getType(filepath));
   
    res.setHeader(`ETag`, etag);
    fs.createReadStream(filepath).pipe(res);

}
  • 客戶端想判斷快取是否可用可以先獲取快取中文件的ETag,然後通過If-None-Match傳送請求給Web伺服器詢問此快取是否可用。
  • 伺服器收到請求,將伺服器的中此檔案的ETag,跟請求頭中的If-None-Match相比較,如果值是一樣的,說明快取還是最新的,Web伺服器將傳送304 Not Modified響應碼給客戶端表示快取未修改過,可以使用。
  • 如果不一樣則Web伺服器將傳送該文件的最新版本給瀏覽器客戶端

實現強制快取

  • 把資源快取在客戶端,如果客戶端再次需要此資源的時候,先獲取到快取中的資料,看是否過期,如果過期了。再請求伺服器
  • 如果沒過期,則根本不需要向伺服器確認,直接使用本地快取即可
let http = require(`http`);
let url = require(`url`);
let path = require(`path`);
let fs = require(`fs`);
let mime = require(`mime`);
let crypto = require(`crypto`);
http.createServer(function (req, res) {
    let { pathname } = url.parse(req.url, true);
    let filepath = path.join(__dirname, pathname);
    console.log(filepath);
    fs.stat(filepath, (err, stat) => {
        if (err) {
            return sendError(req, res);
        } else {
            send(req, res, filepath);
        }
    });
}).listen(8080);
function sendError(req, res) {
    res.end(`Not Found`);
}
function send(req, res, filepath) {
    res.setHeader(`Content-Type`, mime.getType(filepath));
    res.setHeader(`Expires`, new Date(Date.now() + 30 * 1000).toUTCString());
    res.setHeader(`Cache-Control`, `max-age=30`);
    fs.createReadStream(filepath).pipe(res);
}
  • 瀏覽器會將檔案快取到Cache目錄,第二次請求時瀏覽器會先檢查Cache目錄下是否含有該檔案,如果有,並且還沒到Expires設定的時間,即檔案還沒有過期,那麼此時瀏覽器將直接從Cache目錄中讀取檔案,而不再傳送請求
  • Expires是伺服器響應訊息頭欄位,在響應http請求時告訴瀏覽器在過期時間前瀏覽器可以直接從瀏覽器快取取資料
  • Cache-ControlExpires的作用一致,都是指明當前資源的有效期,控制瀏覽器是否直接從瀏覽器快取取資料還是重新發請求到伺服器取資料,如果同時設定的話,其優先順序高於Expires

下面開始寫靜態伺服器
首先建立一個http服務,配置監聽埠

 let http = require(`http`);
 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(`server started at ${chalk.green(url)}`);
        });

下面寫個靜態檔案伺服器
先取到客戶端想說的檔案或資料夾路徑,如果是目錄的話,應該顯示目錄下面的檔案列表

 async request(req, res) {
        let { pathname } = url.parse(req.url);
        if (pathname == `/favicon.ico`) {
            return this.sendError(`not found`, req, res);
        }
        let filepath = path.join(this.config.root, pathname);
        try {
            let statObj = await stat(filepath);
            if (statObj.isDirectory()) {
                let files = await readdir(filepath);
                files = files.map(file => ({
                    name: file,
                    url: path.join(pathname, file)
                }));
                let html = this.list({
                    title: pathname,
                    files
                });
                res.setHeader(`Content-Type`, `text/html`);
                res.end(html);
            } else {
                this.sendFile(req, res, filepath, statObj);
            }
        } catch (e) {
            debug(inspect(e));
            this.sendError(e, req, res);
        }
    }
    
    sendFile(req, res, filepath, statObj) {
        if (this.handleCache(req, res, filepath, statObj)) return;
        res.setHeader(`Content-Type`, mime.getType(filepath) + `;charset=utf-8`);
        let encoding = this.getEncoding(req, res);
        let rs = this.getStream(req, res, filepath, statObj);

        if (encoding) {
            rs.pipe(encoding).pipe(res);
        } else {
            rs.pipe(res);
        }
    }

支援斷點續傳

 getStream(req, res, filepath, statObj) {
        let start = 0;
        let end = statObj.size - 1;
        let range = req.headers[`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
        });
    }

支援對比快取,通過etag的方式

handleCache(req, res, filepath, statObj) {
        let ifModifiedSince = req.headers[`if-modified-since`];
        let isNoneMatch = req.headers[`is-none-match`];
        res.setHeader(`Cache-Control`, `private,max-age=30`);
        res.setHeader(`Expires`, new Date(Date.now() + 30 * 1000).toGMTString());
        let etag = statObj.size;
        let lastModified = statObj.ctime.toGMTString();
        res.setHeader(`ETag`, etag);
        res.setHeader(`Last-Modified`, lastModified);
        if (isNoneMatch && isNoneMatch != etag) {
            return fasle;
        }
        if (ifModifiedSince && ifModifiedSince != lastModified) {
            return fasle;
        }
        if (isNoneMatch || ifModifiedSince) {
            res.writeHead(304);
            res.end();
            return true;
        } else {
            return false;
        }
    }

支援檔案壓縮

    getEncoding(req, res) {
        let acceptEncoding = req.headers[`accept-encoding`];
        if (/gzip/.test(acceptEncoding)) {
            res.setHeader(`Content-Encoding`, `gzip`);
            return zlib.createGzip();
        } else if (/deflate/.test(acceptEncoding)) {
            res.setHeader(`Content-Encoding`, `deflate`);
            return zlib.createDeflate();
        } else {
            return null;
        }
    }

編譯模板,得到一個渲染的方法,然後傳入實際資料資料就可以得到渲染後的HTML了

編譯模板,得到一個渲染的方法,然後傳入實際資料資料就可以得到渲染後的HTML了
function list() {
    let tmpl = fs.readFileSync(path.resolve(__dirname, `template`, `list.html`), `utf8`);
    return handlebars.compile(tmpl);
}

這樣一個簡單的靜態伺服器就完成了,其中包含了靜態檔案服務,實現快取,實現斷點續傳,分塊獲取,實現壓縮的功能

相關文章