從原始碼入手探索koa2應用的實現

網易考拉前端團隊發表於2018-01-03

koa2特性

A Koa application is an object containing an array of middleware functions which are composed and executed in a stack-like manner upon request.

  • 只提供封裝好http上下文、請求、響應,以及基於async/await的中介軟體容器
  • 基於koa的app是由一系列中介軟體組成,原來是generator中介軟體,現在被async/await代替(generator中介軟體,需要通過中介軟體koa-convert封裝一下才能使用)
  • 按照app.use(middleware)順序依次執行中介軟體陣列中的方法

1.0 版本是通過組合不同的 generator,可以免除重複繁瑣的回撥函式巢狀,並極大地提升錯誤處理的效率。

2.0版本Koa放棄了generator,採用Async 函式實現元件陣列瀑布流式(Cascading)的開發模式。

原始碼檔案

├── lib
│   ├── application.js
│   ├── context.js
│   ├── request.js
│   └── response.js
└── package.json
複製程式碼

核心程式碼就是lib目錄下的四個檔案

  • application.js 是整個koa2 的入口檔案,封裝了context,request,response,以及最核心的中介軟體處理流程。
  • context.js 處理應用上下文,裡面直接封裝部分request.js和response.js的方法
  • request.js 處理http請求
  • response.js 處理http響應

koa流程

koa總體流程圖

koa的流程分為三個部分:初始化 -> 啟動Server -> 請求響應

  • 初始化

    • 初始化koa物件之前我們稱為初始化
  • 啟動server

    • 初始化中介軟體(中介軟體建立聯絡)
    • 啟動服務,監聽特定埠,並生成一個新的上下文物件
  • 請求響應

    • 接受請求,初始化上下文物件
    • 執行中介軟體
    • 將body返回給客戶端

初始化

定義了三個物件,context, response, request

  • request 定義了一些set/get訪問器,用於設定和獲取請求報文和url資訊,例如獲取query資料,獲取請求的url(詳細API參見Koa-request文件

  • response 定義了一些set/get操作和獲取響應報文的方法(詳細API參見Koa-response 文件

  • context 通過第三方模組 delegate 將 koa 在 Response 模組和 Request 模組中定義的方法委託到了 context 物件上,所以以下的一些寫法是等價的:

    //在每次請求中,this 用於指代此次請求建立的上下文 context(ctx)
    this.body ==> this.response.body
    this.status ==> this.response.status
    this.href ==> this.request.href
    this.host ==> this.request.host
    ......
    複製程式碼

    為了方便使用,許多上下文屬性和方法都被委託代理到他們的 ctx.requestctx.response,比如訪問 ctx.typectx.length 將被代理到 response 物件,ctx.pathctx.method 將被代理到 request 物件。

    每一個請求都會建立一段上下文,在控制業務邏輯的中介軟體中,ctx被寄存在this中(詳細API參見 Koa-context 文件

啟動Server

  1. 初始化一個koa物件例項
  2. 監聽埠
var koa = require('koa');
var app = koa()

app.listen(9000)
複製程式碼

解析啟動流程,分析原始碼

application.js是koa的入口檔案

// 暴露出來class,`class Application extends Emitter`,用new新建一個koa應用。
module.exports = class Application extends Emitter {

    constructor() {
        super();
        
        this.proxy = false; // 是否信任proxy header,預設false // TODO
        this.middleware = [];   // 儲存通過app.use(middleware)註冊的中介軟體
        this.subdomainOffset = 2;
        this.env = process.env.NODE_ENV || 'development';   // 環境引數,預設為 NODE_ENV 或 ‘development’
        this.context = Object.create(context);  // context模組,通過context.js建立
        this.request = Object.create(request);  // request模組,通過request.js建立
        this.response = Object.create(response);    // response模組,通過response.js建立
    }
    ...
複製程式碼

Application.js 除了上面的的建構函式外,還暴露了一些公用的api,比如常用的 listenuse(use放在後面講)。

listen

作用: 啟動koa server

語法糖

// 用koa啟動server
const Koa = require('koa');
const app = new Koa();
app.listen(3000);

// 等價於

// node原生啟動server
const http = require('http');
const Koa = require('koa');
const app = new Koa();
http.createServer(app.callback()).listen(3000);
https.createServer(app.callback()).listen(3001); // on mutilple address
複製程式碼
// listen
listen(...args) {
    const server = http.createServer(this.callback());
    return server.listen(...args);
}
複製程式碼

封裝了nodejs的建立http server,在監聽埠之前會先執行this.callback()

// callback

callback() {
    // 使用koa-compose(後面會講) 串聯中介軟體堆疊中的middleware,返回一個函式
    // fn接受兩個引數 (context, next)
    const fn = compose(this.middleware);
    
    if (!this.listeners('error').length) this.on('error', this.onerror);
    
    // this.callback()返回一個函式handleReqwuest,請求過來的時候,回撥這個函式
    // handleReqwuest接受引數 (req, res)
    const handleRequest = (req, res) => {
        // 為每一個請求建立ctx,掛載請求相關資訊
        const ctx = this.createContext(req, res);
        // handleRequest的解析在【請求響應】部分
        return this.handleRequest(ctx, fn);
    };
    
    return handleRequest;
}

複製程式碼

const ctx = this.createContext(req, res);建立一個最終可用版的context

從原始碼入手探索koa2應用的實現

ctx上包含5個屬性,分別是request,response,req,res,app

request和response也分別有5個箭頭指向它們,所以也是同樣的邏輯

補充瞭解 各物件之間的關係

從原始碼入手探索koa2應用的實現

最左邊一列表示每個檔案的匯出物件

中間一列表示每個Koa應用及其維護的屬性

右邊兩列表示對應每個請求所維護的一些列物件

黑色的線表示例項化

紅色的線表示原型鏈

藍色的線表示屬性

請求響應

回顧一下,koa啟動server的程式碼

app.listen = function() {
    var server = http.createServer(this.callback());
    return server.listen.apply(server, arguments);
};
複製程式碼
// callback
callback() {
    const fn = compose(this.middleware);
    ...
    const handleRequest = (req, res) => {
        const ctx = this.createContext(req, res);
        return this.handleRequest(ctx, fn);
    };
    return handleRequest;
}
複製程式碼

callback()返回了一個請求處理函式this.handleRequest(ctx, fn)

// handleRequest

handleRequest(ctx, fnMiddleware) {
    const res = ctx.res;
    
    // 請求走到這裡標明成功了,http respond code設為預設的404 TODO 為什麼?
    res.statusCode = 404;
    
    // koa預設的錯誤處理函式,它處理的是錯誤導致的異常結束
    const onerror = err => ctx.onerror(err);
    
    // respond函式裡面主要是一些收尾工作,例如判斷http code為空如何輸出,http method是head如何輸出,body返回是流或json時如何輸出
    const handleResponse = () => respond(ctx);
    
    // 第三方函式,用於監聽 http response 的結束事件,執行回撥
    // 如果response有錯誤,會執行ctx.onerror中的邏輯,設定response型別,狀態碼和錯誤資訊等
    onFinished(res, onerror);
    
    // 執行中介軟體,監聽中介軟體執行結果
    // 成功:執行response
    // 失敗,捕捉錯誤資訊,執行對應處理
    // 返回Promise物件
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
複製程式碼
Koa處理請求的過程:當請求到來的時候,會通過 req 和 res 來建立一個 context (ctx) ,然後執行中介軟體

koa中另一個常用API - use

作用: 將函式推入middleware陣列

use(fn) {
    // 首先判斷傳進來的引數,傳進來的不是一個函式,報錯
    if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
    // 判斷這個函式是不是 generator
    // koa 後續的版本推薦使用 await/async 的方式處理非同步
    // 所以會慢慢不支援 koa1 中的 generator,不再推薦大家使用 generator
    if (isGeneratorFunction(fn)) {
        deprecate('Support for generators will be removed in v3. ' +
        'See the documentation for examples of how to convert old middleware ' +
        'https://github.com/koajs/koa/blob/master/docs/migration.md');
        // 如果是 generator,控制檯警告,然後將函式進行包裝
        fn = convert(fn);
    }
    debug('use %s', fn._name || fn.name || '-');
    // 將函式推入 middleware 這個陣列,後面要依次呼叫裡面的每一箇中介軟體
    this.middleware.push(fn);
    // 保證鏈式呼叫
    return this;
}
複製程式碼

koa-compose

const fn = compose(this.middleware)

app.use([MW])僅僅是將函式推入middleware陣列,真正讓這一系列函式組合成為中介軟體的,是koa-compose,koa-compose是Koa框架中介軟體執行的發動機

'use strict'

module.exports = compose

function compose (middleware) {
    // 傳入的 middleware 必須是一個陣列, 否則報錯
    if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
    // 迴圈遍歷傳入的 middleware, 每一個元素都必須是函式,否則報錯
    for (const fn of middleware) {
        if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
    }
    
    return function (context, next) {
        // last called middleware #
        let index = -1
        return dispatch(0)
        function dispatch (i) {
            if (i <= index) return Promise.reject(new Error('next() called multiple times'))
            index = i
            let fn = middleware[i]
            if (i === middleware.length) fn = next
            // 如果中介軟體中沒有 await next ,那麼函式直接就退出了,不會繼續遞迴呼叫
            if (!fn) return Promise.resolve()
            try {
                return Promise.resolve(fn(context, function next () {
                    return dispatch(i + 1)
                }))
            } catch (err) {
                return Promise.reject(err)
            }
        }
    }
}
複製程式碼

Koa2.x的compose方法雖然從純generator函式執行修改成了基於Promise.all,但是中介軟體載入的中心思想沒有發生改變,依舊是從第一個中介軟體開始,遇到await/yield next,就中斷本中介軟體的程式碼執行,跳轉到對應的下一個中介軟體執行期內的程式碼…一直到最後一箇中介軟體,然後逆序回退到倒數第二個中介軟體await/yield next下部分的程式碼執行,完成後繼續會退…一直會退到第一個中介軟體await/yield next下部分的程式碼執行完成,中介軟體全部執行結束

級聯的流程,V型載入機制

洋蔥結構

koa2常用中介軟體

koa-router 路由

對其實現機制有興趣的可以戳看看 -> Koa-router路由中介軟體API詳解

const Koa = require('koa')
const fs = require('fs')
const app = new Koa()

const Router = require('koa-router')

// 子路由1
let home = new Router()
home.get('/', async ( ctx )=>{
  let html = `
    <ul>
      <li><a href="/page/helloworld">/page/helloworld</a></li>
      <li><a href="/page/404">/page/404</a></li>
    </ul>
  `
  ctx.body = html
})

// 子路由2
let page = new Router()
page.get('hello', async (ctx) => {
    ctx.body = 'Hello World Page!'
})

// 裝載所有子路由的中介軟體router
let router = new Router()
router.use('/', home.routes(), home.allowedMethods())
router.use('/page', page.routes(), page.allowedMethods())

// 載入router
app.use(router.routes()).use(router.allowedMethods())

app.listen(3000, () => {
  console.log('[demo] route-use-middleware is starting at port 3000')
})
複製程式碼

koa-bodyparser 請求資料獲取

GET請求資料獲取

獲取GET請求資料有兩個途徑

  1. 是從上下文中直接獲取

    • 請求物件ctx.query,返回如 { a:1, b:2 }
    • 請求字串 ctx.querystring,返回如 a=1&b=2
  2. 是從上下文的request物件中獲取

    • 請求物件ctx.request.query,返回如 { a:1, b:2 }
    • 請求字串 ctx.request.querystring,返回如 a=1&b=2

POST請求資料獲取

對於POST請求的處理,koa2沒有封裝獲取引數的方法需要通過解析上下文context中的原生node.js請求物件req,將POST表單資料解析成query string(例如:a=1&b=2&c=3),再將query string 解析成JSON格式(例如:{"a":"1", "b":"2", "c":"3"})

對於POST請求的處理,koa-bodyparser中介軟體可以把koa2上下文的formData資料解析到ctx.request.body中

...
const bodyParser = require('koa-bodyparser')

app.use(bodyParser())

app.use( async ( ctx ) => {

  if ( ctx.url === '/' && ctx.method === 'POST' ) {
    // 當POST請求的時候,中介軟體koa-bodyparser解析POST表單裡的資料,並顯示出來
    let postData = ctx.request.body
    ctx.body = postData
  } else {
    ...
  }
})

app.listen(3000, () => {
  console.log('[demo] request post is starting at port 3000')
})

複製程式碼

koa-static 靜態資源載入

為靜態資源訪問建立一個伺服器,根據url訪問對應的資料夾、檔案

...
const static = require('koa-static')
const app = new Koa()

// 靜態資源目錄對於相對入口檔案index.js的路徑
const staticPath = './static'

app.use(static(
  path.join( __dirname,  staticPath)
))


app.use( async ( ctx ) => {
  ctx.body = 'hello world'
})

app.listen(3000, () => {
  console.log('[demo] static-use-middleware is starting at port 3000')
})
複製程式碼

PS:廣告一波,網易考拉前端招人啦~有興趣的戳我投遞簡歷

參考

相關文章