手寫koa-static原始碼,深入理解靜態伺服器原理

_蔣鵬飛發表於2020-12-07

這篇文章繼續前面的Koa原始碼系列,這個系列已經有兩篇文章了:

  1. 第一篇講解了Koa的核心架構和原始碼:手寫Koa.js原始碼
  2. 第二篇講解了@koa/router的架構和原始碼:手寫@koa/router原始碼

本文會接著講一個常用的中介軟體----koa-static,這個中介軟體是用來搭建靜態伺服器的。

其實在我之前使用Node.js原生API寫一個web伺服器已經講過怎麼返回一個靜態檔案了,程式碼雖然比較醜,基本流程還是差不多的:

  1. 通過請求路徑取出正確的檔案地址
  2. 通過地址獲取對應的檔案
  3. 使用Node.js的API返回對應的檔案,並設定相應的header

koa-static的程式碼更通用,更優雅,而且對大檔案有更好的支援,下面我們來看看他是怎麼做的吧。本文還是採用一貫套路,先看一下他的基本用法,然後從基本用法入手去讀原始碼,並手寫一個簡化版的原始碼來替換他。

基本用法

koa-static使用很簡單,主要程式碼就一行:

const Koa = require('koa');
const serve = require('koa-static');

const app = new Koa();

// 主要就是這行程式碼
app.use(serve('public'));

app.listen(3001, () => {
    console.log('listening on port 3001');
});

上述程式碼中的serve就是koa-static,他執行後會返回一個Koa中介軟體,然後Koa的例項直接引用這個中介軟體就行了。

serve方法支援兩個引數,第一個是靜態檔案的目錄,第二個引數是一些配置項,可以不傳。像上面的程式碼serve('public')就表示public資料夾下面的檔案都可以被外部訪問。比如我在裡面放了一張圖片:

image-20201125163558774

跑起來就是這樣子:

image.png

注意上面這個路徑請求的是/test.jpg,前面並沒有public,說明koa-static對請求路徑進行了判斷,發現是檔案就對映到伺服器的public目錄下面,這樣可以防止外部使用者探知伺服器目錄結構。

手寫原始碼

返回的是一個Koa中介軟體

我們看到koa-static匯出的是一個方法serve,這個方法執行後返回的應該是一個Koa中介軟體,這樣Koa才能引用他,所以我們先來寫一下這個結構吧:

module.exports = serve;   // 匯出的是serve方法

// serve接受兩個引數
// 第一個引數是路徑地址
// 第二個是配置選項
function serve(root, opts) {
    // 返回一個方法,這個方法符合koa中介軟體的定義
    return async function serve(ctx, next) {
        await next();
    }
}

呼叫koa-send返回檔案

現在這個中介軟體是空的,其實他應該做的是將檔案返回,返回檔案的功能也被單獨抽取出來成了一個庫----koa-send,我們後面會看他原始碼,這裡先直接用吧。

function serve(root, opts) {
    // 這行程式碼如果效果就是
    // 如果沒傳opts,opts就是空物件{}
    // 同時將它的原型置為null
    opts = Object.assign(Object.create(null), opts);

    // 將root解析為一個合法路徑,並放到opts上去
    // 因為koa-send接收的路徑是在opts上
    opts.root = resolve(root);
  
  	// 這個是用來相容資料夾的,如果請求路徑是一個資料夾,預設去取index
    // 如果使用者沒有配置index,預設index就是index.html
    if (opts.index !== false) opts.index = opts.index || 'index.html';

  	// 整個serve方法的返回值是一個koa中介軟體
  	// 符合koa中介軟體的正規化: (ctx, next) => {}
    return async function serve(ctx, next) {
        let done = false;    // 這個變數標記檔案是否成功返回

        // 只有HEAD和GET請求才響應
        if (ctx.method === 'HEAD' || ctx.method === 'GET') {
            try {
                // 呼叫koa-send傳送檔案
                // 如果傳送成功,koa-send會返回路徑,賦值給done
                // done轉換為bool值就是true
                done = await send(ctx, ctx.path, opts);
            } catch (err) {
                // 如果不是404,可能是一些400,500這種非預期的錯誤,將它丟擲去
                if (err.status !== 404) {
                    throw err
                }
            }
        }

        // 通過done來檢測檔案是否傳送成功
        // 如果沒成功,就讓後續中介軟體繼續處理他
        // 如果成功了,本次請求就到此為止了
        if (!done) {
            await next()
        }
    }
}

opt.defer

defer是配置選項opt裡面的一個可選引數,他稍微特殊一點,預設為false,如果你傳了truekoa-static會讓其他中介軟體先響應,即使其他中介軟體寫在koa-static後面也會讓他先響應,自己最後響應。要實現這個,其實就是控制呼叫next()的時機。在講Koa原始碼的文章裡面已經講過了,呼叫next()其實就是在呼叫後面的中介軟體,所以像上面程式碼那樣最後呼叫next(),就是先執行koa-static然後再執行其他中介軟體。如果你給defer傳了true,其實就是先執行next(),然後再執行koa-static的邏輯,按照這個思路我們來支援下defer吧:

function serve(root, opts) {
    opts = Object.assign(Object.create(null), opts);

    opts.root = resolve(root);

    // 如果defer為false,就用之前的邏輯,最後呼叫next
    if (!opts.defer) {
        return async function serve(ctx, next) {
            let done = false;    

            if (ctx.method === 'HEAD' || ctx.method === 'GET') {
                try {
                    done = await send(ctx, ctx.path, opts);
                } catch (err) {
                    if (err.status !== 404) {
                        throw err
                    }
                }
            }

            if (!done) {
                await next()
            }
        }
    }

    // 如果defer為true,先呼叫next,然後執行自己的邏輯
    return async function serve(ctx, next) {
        // 先呼叫next,執行後面的中介軟體
        await next();

        if (ctx.method !== 'HEAD' && ctx.method !== 'GET') return

        // 如果ctx.body有值了,或者status不是404,說明請求已經被其他中介軟體處理過了,就直接返回了
        if (ctx.body != null || ctx.status !== 404) return // eslint-disable-line

        // koa-static自己的邏輯還是一樣的,都是呼叫koa-send
        try {
            await send(ctx, ctx.path, opts)
        } catch (err) {
            if (err.status !== 404) {
                throw err
            }
        }
    }
}

koa-static原始碼總共就幾十行:https://github.com/koajs/static/blob/master/index.js

koa-send

上面我們看到koa-static其實是包裝的koa-send,真正傳送檔案的操作都是在koa-send裡面的。文章最開頭說的幾件事情koa-static一件也沒幹,都丟給koa-send了,也就是說他應該把這幾件事都幹完:

  1. 通過請求路徑取出正確的檔案地址
  2. 通過地址獲取對應的檔案
  3. 使用Node.js的API返回對應的檔案,並設定相應的header

由於koa-send程式碼也不多,我就直接在程式碼中寫註釋了,通過前面的使用,我們已經知道他的使用形式是:

send (ctx, path, opts)

他接收三個引數:

  1. ctx:就是koa的那個上下文ctx
  2. pathkoa-static傳過來的是ctx.path,看過koa原始碼解析的應該知道,這個值其實就是req.path
  3. opts: 一些配置項,defer前面講過了,會影響執行順序,其他還有些快取控制什麼的。

下面直接來寫一個send方法吧:

const fs = require('fs')
const fsPromises = fs.promises;
const { stat, access } = fsPromises;

const {
    normalize,
    basename,
    extname,
    resolve,
    parse,
    sep
} = require('path')
const resolvePath = require('resolve-path')

// 匯出send方法
module.exports = send;

// send方法的實現
async function send(ctx, path, opts = {}) {
    // 先解析配置項
    const root = opts.root ? normalize(resolve(opts.root)) : '';  // 這裡的root就是我們配置的靜態檔案目錄,比如public
    const index = opts.index;    // 請求資料夾時,會去讀取這個index檔案
    const maxage = opts.maxage || opts.maxAge || 0;     // 就是http快取控制Cache-Control的那個maxage
    const immutable = opts.immutable || false;   // 也是Cache-Control快取控制的
    const format = opts.format !== false;   // format預設是true,用來支援/directory這種不帶/的資料夾請求

    const trailingSlash = path[path.length - 1] === '/';    // 看看path結尾是不是/
    path = path.substr(parse(path).root.length)             // 去掉path開頭的/

    path = decode(path);      // 其實就是decodeURIComponent, decode輔助方法在後面
    if (path === -1) return ctx.throw(400, 'failed to decode');

    // 如果請求以/結尾,肯定是一個資料夾,將path改為資料夾下面的預設檔案
    if (index && trailingSlash) path += index;

    // resolvePath可以將一個根路徑和請求的相對路徑合併成一個絕對路徑
    // 並且防止一些常見的攻擊,比如GET /../file.js
    // GitHub地址:https://github.com/pillarjs/resolve-path
    path = resolvePath(root, path)

    // 用fs.stat獲取檔案的基本資訊,順便檢測下檔案存在不
    let stats;
    try {
        stats = await stat(path)

        // 如果是資料夾,並且format為true,拼上index檔案
        if (stats.isDirectory()) {
            if (format && index) {
                path += `/${index}`
                stats = await stat(path)
            } else {
                return
            }
        }
    } catch (err) {
        // 錯誤處理,如果是檔案不存在,返回404,否則返回500
        const notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR']
        if (notfound.includes(err.code)) {
          	// createError來自http-errors庫,可以快速建立HTTP錯誤物件
            // github地址:https://github.com/jshttp/http-errors
            throw createError(404, err)
        }
        err.status = 500
        throw err
    }

    // 設定Content-Length的header
    ctx.set('Content-Length', stats.size)

    // 設定快取控制header
    if (!ctx.response.get('Last-Modified')) ctx.set('Last-Modified', stats.mtime.toUTCString())
    if (!ctx.response.get('Cache-Control')) {
        const directives = [`max-age=${(maxage / 1000 | 0)}`]
        if (immutable) {
            directives.push('immutable')
        }
        ctx.set('Cache-Control', directives.join(','))
    }

    // 設定返回型別和返回內容
   if (!ctx.type) ctx.type = extname(path)
    ctx.body = fs.createReadStream(path)

    return path
}

function decode(path) {
    try {
        return decodeURIComponent(path)
    } catch (err) {
        return -1
    }
}

上述程式碼並沒有太複雜的邏輯,先拼一個完整的地址,然後使用fs.stat獲取檔案的基本資訊,如果檔案不存在,這個API就報錯了,直接返回404。如果檔案存在,就用fs.stat拿到的資訊設定Content-Length和一些快取控制的header。

koa-send的原始碼也只有一個檔案,百來行程式碼:https://github.com/koajs/send/blob/master/index.js

ctx.type和ctx.body

上述程式碼我們看到最後並沒有直接返回檔案,而只是設定了ctx.typectx.body這兩個值就結束了,為啥設定了這兩個值,檔案就自動返回了呢?要知道這個原理,我們要結合Koa原始碼來看。

之前講Koa原始碼的時候我提到過,他擴充套件了Node原生的res,並且在裡面給type屬性新增了一個set方法:

set type(type) {
  type = getType(type);
  if (type) {
    this.set('Content-Type', type);
  } else {
    this.remove('Content-Type');
  }
}

這段程式碼的作用是當你給ctx.type設定值的時候,會自動給Content-Type設定值,getType其實是另一個第三方庫cache-content-type,他可以根據你傳入的檔案型別,返回匹配的MIME type。我剛看koa-static原始碼時,找了半天也沒找到在哪裡設定的Content-Type,後面發現是在Koa原始碼裡面。所以設定了ctx.type其實就是設定了Content-Type

koa擴充套件的type屬性看這裡:https://github.com/koajs/koa/blob/master/lib/response.js#L308

之前講Koa原始碼的時候我還提到過,當所有中介軟體都執行完了,最後會執行一個方法respond來返回結果,在那篇文章裡面,respond是簡化版的,直接用res.end返回了結果:

function respond(ctx) {
  const res = ctx.res; // 取出res物件
  const body = ctx.body; // 取出body

  return res.end(body); // 用res返回body
}

直接用res.end返回結果只能對一些簡單的小物件比較合適,比如字串什麼的。對於複雜物件,比如檔案,這個就合適了,因為你如果要用res.write或者res.end返回檔案,你需要先把檔案整個讀入記憶體,然後作為引數傳遞,如果檔案很大,伺服器記憶體可能就爆了。那要怎麼處理呢?回到koa-send原始碼裡面,我們給ctx.body設定的值其實是一個可讀流:

ctx.body = fs.createReadStream(path)

這種流怎麼返回呢?其實Node.js對於返回流本身就有很好的支援。要返回一個值,需要用到http回撥函式裡面的res,這個res本身其實也是一個流。大家可以再翻翻Node.js官方文件,這裡的res其實是http.ServerResponse類的一個例項,而http.ServerResponse本身又繼承自Stream類:

image-20201203154324281

所以res本身就是一個流Stream,那Stream的API就可以用了ctx.body是使用fs.createReadStream建立的,所以他是一個可讀流,可讀流有一個很方便的API可以直接讓內容流動到可寫流:readable.pipe,使用這個API,Node.js會自動將可讀流裡面的內容推送到可寫流,資料流會被自動管理,所以即使可讀流更快,目標可寫流也不會超負荷,而且即使你檔案很大,因為不是一次讀入記憶體,而是流式讀入,所以也不會爆。所以我們在Koarespond裡面支援下流式body就行了:

function respond(ctx) {
  const res = ctx.res; 
  const body = ctx.body; 
  
  // 如果body是個流,直接用pipe將它繫結到res上
  if (body instanceof Stream) return body.pipe(res);

  return res.end(body); 
}

Koa原始碼對於流的處理看這裡:https://github.com/koajs/koa/blob/master/lib/application.js#L267

總結

現在,我們可以用自己寫的koa-static來替換官方的了,執行效果是一樣的。最後我們再來回顧下本文的要點:

  1. 本文是Koa常用靜態服務中介軟體koa-static的原始碼解析。

  2. 由於是一個Koa的中介軟體,所以koa-static的返回值是一個方法,而且需要符合中介軟體正規化: (ctx, next) => {}

  3. 作為一個靜態服務中介軟體,koa-static本應該完成以下幾件事情:

    1. 通過請求路徑取出正確的檔案地址
    2. 通過地址獲取對應的檔案
    3. 使用Node.js的API返回對應的檔案,並設定相應的header

    但是這幾件事情他一件也沒幹,都扔給koa-send了,所以他官方文件也說了他只是wrapper for koa-send.

  4. 作為一個wrapper他還支援了一個比較特殊的配置項opt.defer,這個配置項可以控制他在所有Koa中介軟體裡面的執行時機,其實就是呼叫next的時機。如果你給這個引數傳了true,他就先呼叫next,讓其他中介軟體先執行,自己最後執行,反之亦然。有了這個引數,你可以將/test.jpg這種請求先作為普通路由處理,路由沒匹配上再嘗試靜態檔案,這在某些場景下很有用。

  5. koa-send才是真正處理靜態檔案,他把前面說的三件事全乾了,在拼接檔案路徑時還使用了resolvePath來防禦常見攻擊。

  6. koa-send取檔案時使用了fs模組的API建立了一個可讀流,並將它賦值給ctx.body,同時設定了ctx.type

  7. 通過ctx.typectx.body返回給請求者並不是koa-send的功能,而是Koa本身的功能。由於http模組提供和的res本身就是一個可寫流,所以我們可以通過可讀流的pipe函式直接將ctx.body繫結到res上,剩下的工作Node.js會自動幫我們完成。

  8. 使用流(Stream)來讀寫檔案有以下幾個優點:

    1. 不用一次性將檔案讀入記憶體,暫用記憶體小。
    2. 如果檔案很大,一次性讀完整個檔案,可能耗時較長。使用流,可以一點一點讀檔案,讀到一點就可以返回給response,有更快的響應時間。
    3. Node.js可以在可讀流和可寫流之間使用管道進行資料傳輸,使用也很方便。

參考資料:

koa-static文件:https://github.com/koajs/static

koa-static原始碼:https://github.com/koajs/static/blob/master/index.js

koa-send文件:https://github.com/koajs/send

koa-send原始碼:https://github.com/koajs/send/blob/master/index.js

文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。

作者博文GitHub專案地址: https://github.com/dennis-jiang/Front-End-Knowledges

我也搞了個公眾號[進擊的大前端],不打廣告,不寫水文,只發高質量原創,歡迎關注~

相關文章