Stories

Detail Return Return

手寫koa-static源碼,深入理解靜態服務器原理 - Stories Detail

這篇文章繼續前面的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的代碼更通用,更優雅,而且對大文件有更好的支持,下面我們來看看他是怎麼做的吧。本文還是採用一貫套路,先看一下他的基本用法,然後從基本用法入手去讀源碼,並手寫一個簡化版的源碼來替換他。

本文可運行代碼已經上傳GitHub,大家可以拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaStatic

基本用法

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

總結

本文可運行代碼已經上傳GitHub,大家可以拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaStatic

現在,我們可以用自己寫的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.

  1. 作為一個wrapper他還支持了一個比較特殊的配置項opt.defer,這個配置項可以控制他在所有Koa中間件裏面的執行時機,其實就是調用next的時機。如果你給這個參數傳了true,他就先調用next,讓其他中間件先執行,自己最後執行,反之亦然。有了這個參數,你可以將/test.jpg這種請求先作為普通路由處理,路由沒匹配上再嘗試靜態文件,這在某些場景下很有用。
  2. koa-send才是真正處理靜態文件,他把前面説的三件事全乾了,在拼接文件路徑時還使用了resolvePath來防禦常見攻擊。
  3. koa-send取文件時使用了fs模塊的API創建了一個可讀流,並將它賦值給ctx.body,同時設置了ctx.type
  4. 通過ctx.typectx.body返回給請求者並不是koa-send的功能,而是Koa本身的功能。由於http模塊提供和的res本身就是一個可寫流,所以我們可以通過可讀流的pipe函數直接將ctx.body綁定到res上,剩下的工作Node.js會自動幫我們完成。
  5. 使用流(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小星星,你的支持是作者持續創作的動力。

歡迎關注我的公眾號進擊的大前端第一時間獲取高質量原創~

“前端進階知識”系列文章源碼地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二維碼_2.png

user avatar tianmiaogongzuoshi_5ca47d59bef41 Avatar toopoo Avatar grewer Avatar cyzf Avatar Leesz Avatar zaotalk Avatar smalike Avatar pengxiaohei Avatar nihaojob Avatar savokiss Avatar jingdongkeji Avatar qingzhan Avatar
Favorites 269 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.