Node.js 系列 - 搭建靜態資源伺服器

罐裝汽水_Garrik發表於2018-10-29

作為還在漫漫前端學習路上的一位自學者。我以學習分享的方式來整理自己對於知識的理解,同時也希望能夠給大家作為一份參考。希望能夠和大家共同進步,如有任何紕漏的話,希望大家多多指正。感謝萬分!


在上一章, 我們搭建了一個非常簡單的 "Hello World" 伺服器. 在這一章裡, 我們要繼續上一章所學的知識, 進一步嘗試搭建, 提供靜態資源的伺服器.

什麼是靜態資源伺服器?

那先說什麼是 靜態資源, 它指的是不會被伺服器的動態執行所改變或者生成的檔案. 它最初在伺服器執行之前是什麼樣子, 到伺服器結束執行時, 它還是那個樣子. 比如平時寫的 js, css, html 檔案, 都可以算是靜態資源. 那麼很容易理解, 靜態資源伺服器的功能就是向客戶端提供靜態資源.

話不多說, 開始寫程式碼:

首先我們知道, 它先是一個 "伺服器". 那根據上一章的所學, 我們要先用 http 模組建立一個 HTTP 伺服器.

var http = require('http');

var server = http.createServer(function(req, res) {
    // 業務邏輯, 等會兒再寫.
});

server.listen(3000, function() {
    console.log("靜態資源伺服器執行中.");
    console.log("正在監聽 3000 埠:")
})
複製程式碼

url 模組

url 模組 - 文件

有了 HTTP 伺服器之後, 我們就可以獲取從客戶端發過來的 HTTP 請求了.

請求報文中包含著請求 URL. 前文說過, URL 用於定位網路上的資源. 客戶端通過 URL 來指明想要的伺服器上資源. 那麼伺服器為了搞清楚客戶端到底想要什麼, 我們需要處理和解析 URL. 在 Node.js 中, 我們使用 url 模組來完成這類操作.

我們知道 URL 字串是具有結構的字串,包含多個意義不同的組成部分。 通過 url.parse() 函式, URL 字串可以被解析為一個 URL 物件,其屬性對應於字串的各組成部分。如下圖所示.

Screen Shot 2018-10-05 at 2.18.56 AM


那麼回到我們的靜態檔案伺服器程式碼.:

先在 http.createServer 函式被呼叫之前, 引入 url 模組:

var url = require('url');
複製程式碼

然後在 HTTP 伺服器裡解析請求 URL. 客戶端發來的請求 URL 作為屬性存放在 http.createServer 的回撥函式引數所接收的請求物件裡, 屬性名為 url.

var server = http.createServer(function(req, res) {
    var urlObj = url.parse(req.url);
});
複製程式碼

path 模組

path 模組 - 文件

接下來從解析後的 URL 物件 urlObj 裡取得請求 URL 中的路徑名(pathname). 路徑名儲存在 pathname 屬性裡.

var server = http.createServer(function(req, res) {
    var urlObj = url.parse(req.url);
    var urlPathname = urlObj.pathname;
});
複製程式碼

但是光有 URL 物件裡面的路徑名是不夠的. 我們還需要獲得目標檔案在伺服器中所在目錄的目錄名(dirname).

假如說我們的專案結構是下面這樣的:

.
├── public
│   ├── index.css
│   └── index.html
└── server.js
複製程式碼

我們的伺服器程式碼寫在 server.js 檔案裡. 客戶端想要請求儲存在 public 目錄裡的 index.html 檔案. 使用者在瀏覽器中輸入 URL 的時候, 他只知道他想要的檔案叫 index.html, 但這個檔案在 HTTP 伺服器所在的裝置中的 『 絕對位置 』是不被知道的. 所以我們需要讓 HTTP 伺服器自己去處理這部分操作.

在這裡就需要使用 Node.js 自帶的 path 模組. 其提供了一些工具函式,用於處理檔案與目錄的路徑.
使用起來很簡單, 首先還是在 http.createServer 函式被呼叫之前, 引入 path 模組:

var path = require('path');
複製程式碼

之後我們用 path.join 這個方法來把 目標檔案所在目錄的目錄名和請求 URL 中的路徑名合併起來. 在這個例子中, 客戶端可以訪問的靜態檔案全部在 public 這個目錄中, 而 public 目錄又在 server.js 檔案所在的目錄中. server.js 中儲存的是我們的伺服器程式碼.

想要獲得 server.js 所在目錄的在整個裝置中的絕對路徑, 我們可以在伺服器程式碼中呼叫變數 __dirname, 它是當前檔案在被模組包裝器包裝時傳入的變數, 儲存了當前模組的目錄名。

var server = http.createServer(function(req, res) {
    var urlObj = url.parse(req.url);
    var urlPathname = urlObj.pathname;
    var filePathname = path.join(__dirname, "/public", urlPathname);
});
複製程式碼

如果你想的話, 你可以用 console.log(filePathname) 來看看伺服器執行後, 從客戶端收到的請求 URL 會被轉換成什麼樣.

fs 模組

fs 模組 - 文件

現在來到了最重要的一步, 讀取目標檔案, 並且返回檔案給客戶端.

我們需要用 Node.js 自帶的 fs 模組中的 fs.write 方法來實現這一步. 該方法第一個引數為目標檔案的路徑, 最後一個引數為一個回撥函式, 回撥有兩個引數 (err, data),其中 data 是檔案的內容, 如果發生錯誤的話 err 儲存錯誤資訊. fs.write 方法可以在第二個引數中指定字元編碼, 如果未指定則返回原始的 buffer. 在這個例子中, 我們不考慮這一項.

那麼具體程式碼如下:

首先引入 fs 模組, 我就不贅述了, 參照前面就可以了. 下面是讀取檔案的程式碼.

var server = http.createServer(function(req, res) {
    var urlObj = url.parse(req.url);
    var urlPathname = urlObj.pathname;
    var filePathname = path.join(__dirname, "/public", urlPathname);
    
    fs.readFile(filePathname, (err, data) => {
        // 如果有問題返回 404
        if (err) {
            res.writeHead(404);
            res.write("404 - File is not found!");
            res.end();
        // 沒問題返回檔案內容
        } else {
            res.writeHead(200);
            res.write(data);
            res.end();
        }
    })
});
複製程式碼

現在我們就實現了一個基本的『 靜態檔案伺服器 』可以在允許客戶端請求儲存在伺服器中公開的靜態檔案了. 你可以嘗試啟動伺服器, 然後讓瀏覽器中訪問 http://localhost:3000/index.html. 我的效果如下:

Screen Shot 2018-10-06 at 2.22.08 PM

設定 MIME 型別

MIME 文件 - MDN Content-Type 文件 - MDN

多用途 Internet 郵件擴充套件(MIME)型別是用一種標準化的方式來表示文件的 "性質" 和 "格式"。 簡單說, 瀏覽器通過 MIME 型別來確定如何處理文件. 因此在響應物件的頭部設定正確 MIME 型別是非常重要的.

MIME 的組成結構非常簡單: 由型別與子型別兩個字串中間用 '/' 分隔而組成, 其中沒有空格. MIME 型別對大小寫不敏感,但是傳統寫法都是小寫.

例如:

  • text/plain : 是文字檔案預設值。意思是 未知的文字檔案 ,瀏覽器認為是可以直接展示的.
  • text/html : 是所有的HTML內容都應該使用這種型別.
  • image/png : 是 PNG 格式圖片的 MIME 型別.

在伺服器中, 我們通過設定 Content-Type 這個響應頭部的值, 來指示響應回去的資源的 MIME 型別. 在 Node.js 中, 可以很方便的用響應物件的 writeHead 方法來設定響應狀態碼和響應頭部.

假如我們要響應給客戶端一個 HTML 檔案, 那麼我們應該使用下面這條程式碼:

res.writeHead(200, {"Content-Type":"text/html"});
複製程式碼

你會發現我在上面的靜態資源伺服器的程式碼中, 沒有設定響應資源的 MIME 型別. 但如果你試著執行伺服器的話, 你會發現靜態資源也以正確方式被展示到了瀏覽器.

之所以會這樣的原因是在缺失 MIME 型別或客戶端認為檔案設定了錯誤的 MIME 型別時,瀏覽器可能會通過檢視資源來進行猜測 MIME 型別, 叫做 『 MIME 嗅探 』. 不同的瀏覽器在不同的情況下可能會執行不同的操作。所以為了保證資源在每一個瀏覽器下的行為一致性, 我們需要手動設定 MIME 型別.

那麼首先我們需要獲取到準備響應給客戶端的檔案的 字尾名.

要做到這一步我們需要使用 path 模組的 parse 方法. 這個方法可以將一段路徑解析成一個物件, 其中的屬性對應路徑的各個部位.

繼續再剛才靜態檔案伺服器案例的程式碼上新增:

var server = http.createServer(function(req, res) {
    var urlObj = url.parse(req.url);
    var urlPathname = urlObj.pathname;
    var filePathname = path.join(__dirname, "/public", urlPathname);
    
    // 解析後物件的 ext 屬性中儲存著目標檔案的字尾名
    var ext = path.parse(urlPathname).ext;
    
    // 讀取檔案的程式碼...
});
複製程式碼

獲取了檔案字尾之後, 我們需要查詢其對應的 MIME 型別了. 這一步可以很輕鬆的使用第三方模組 MIME 來實現. 你可以自行去 NPM 上去查閱它的使用文件.

對於我們目前的需求來說, 只需要用到 MIME 模組的 getType() 方法. 這個方法接收一個字串引數 (字尾名), 返回其對應的 MIME 型別, 如果沒有就返回 null.

使用的話, 首先要用 npm 安裝 MIME 模組 ( 如果你還沒建立 package.json 檔案的話, 別忘了先執行 npm init )

npm install mime --save
複製程式碼

安裝完畢. 引入模組到伺服器程式碼中, 然後我們就直接用剛剛獲得的字尾去找到其對應的 MIME 型別

var mime = require('mime');

var server = http.createServer(function(req, res) {
    var urlObj = url.parse(req.url);
    var urlPathname = urlObj.pathname;
    var filePathname = path.join(__dirname, "/public", urlPathname);
    
    // 解析後物件的 ext 屬性中儲存著目標檔案的字尾名
    var ext = path.parse(urlPathname).ext;
    // 獲取字尾對應的 MIME 型別
    var mimeType = mime.getType(ext);
    
    // 讀取檔案的程式碼...
});
複製程式碼

好了, 現在最重要的東西 MIME 型別我們已經得到了. 接下來只要在響應物件的 writeHead 方法裡設定好 Content-Type 就行了.

var server = http.createServer(function(req, res) {
    // 程式碼省略...
    var mimeType = mime.getType(ext);

    fs.readFile(filePathname, (err, data) => {
        // 如果有問題返回 404
        if (err) {
            res.writeHead(404, { "Content-Type": "text/plain" });
            res.write("404 - File is not found!");
            res.end();
            // 沒問題返回檔案內容
        } else {
            // 設定好響應頭
            res.writeHead(200, { "Content-Type": mimeType });
            res.write(data);
            res.end();
        }
    })

});
複製程式碼

階段性勝利 ✌️ 現在執行伺服器, 在瀏覽器裡訪問一下 localhost:3000/index.html 試試吧!

Screen_Shot_2018-10-07_at_11_12_19_PM

可以看到現在 Content-Type 已經被正確設定了!

重構程式碼

現在來看看你的程式碼, 是不是開始感覺有點亂糟糟的. 我想聰明的你已經發現, 整個靜態檔案伺服器的程式碼就是在做一件事: 響應回客戶端想要的靜態檔案. 這段程式碼職責單一, 且複用頻率很高. 那麼我們有理由將其封裝成一個模組.

具體的過程我就不贅述了. 以下是我的模組程式碼:

// readStaticFile.js

// 引入依賴的模組
var path = require('path');
var fs = require('fs');
var mime = require('mime');

function readStaticFile(res, filePathname) {

    var ext = path.parse(filePathname).ext;
    var mimeType = mime.getType(ext);
    
    // 判斷路徑是否有字尾, 有的話則說明客戶端要請求的是一個檔案 
    if (ext) {
        // 根據傳入的目標檔案路徑來讀取對應檔案
        fs.readFile(filePathname, (err, data) => {
            // 錯誤處理
            if (err) {
                res.writeHead(404, { "Content-Type": "text/plain" });
                res.write("404 - NOT FOUND");
                res.end();
            } else {
                res.writeHead(200, { "Content-Type": mimeType });
                res.write(data);
                res.end();
            }
        });
        // 返回 false 表示, 客戶端想要的 是 靜態檔案
        return true;
    } else {
        // 返回 false 表示, 客戶端想要的 不是 靜態檔案
        return false;
    }
}

// 匯出函式
module.exports = readStaticFile;
複製程式碼

用於讀取靜態檔案的模組 readStaticFile 封裝好了之後. 我們可以在專案目錄裡新建一個 modules 目錄, 用於存放模組. 以下是我目前的專案結構.

Screen Shot 2018-10-08 at 3.55.58 PM

封裝好了模組之後, 我們就可以刪去伺服器程式碼裡那段讀取檔案的程式碼了, 直接引用模組就行了. 以下是我修改後的 server.js 程式碼:

// server.js 

// 引入相關模組
var http = require('http');
var url = require('url');
var path = require('path');
var readStaticFile = require('./modules/readStaticFile');

// 搭建 HTTP 伺服器
var server = http.createServer(function(req, res) {
    var urlObj = url.parse(req.url);
    var urlPathname = urlObj.pathname;
    var filePathname = path.join(__dirname, "/public", urlPathname);
    
    // 讀取靜態檔案
    readStaticFile(res, filePathname);
});

// 在 3000 埠監聽請求
server.listen(3000, function() {
    console.log("伺服器執行中.");
    console.log("正在監聽 3000 埠:")
})
複製程式碼

? 好啦,今天的分享就告一段落啦。下一篇中,我會介紹 "如何搭建伺服器路由" 和 "處理瀏覽器表單提交"

傳送門 - Node.js 系列 - 搭建路由 & 處理表單提交

如果喜歡的話就點個關注吧!O(∩_∩)O 謝謝各位的支援❗️

相關文章