Koa:核心探祕與入坑指北

Cris_冷崢子發表於2018-04-16
  • 框架目錄
  • 初識
  • ctx
  • use與中介軟體
  • ctx.body
  • 請求體
  • static
  • 關於錯誤捕獲
  • 獲取demo程式碼

pre-notify

給最近的koa2學習做個小結,主要分為使用的注意事項以及原始碼實現兩個部分,感覺寫得有點囉嗦,以後有空再修正吧~

koa2和promise、async-await密切相關,但礙於篇幅這裡並沒有對promise部分詳細介紹,如果對promise、async-await還不是很清楚的同學可以參考我的這篇文章

非同步發展簡明指北

(づ ̄ 3 ̄)づ

框架目錄

koa/
|
| - context.js
| 
| - request.js
|
| - response.js
|
·- application.js
複製程式碼

初識

介紹

首先我們通過Koa包匯入的是一個類(Express中是一個工廠函式),我們可以通過new這個類來建立一個app

let Koa = require('koa');

let app = new Koa();
複製程式碼

這個app物件上就兩個方法

listen 用來啟動一個http伺服器

app.listen(8080);
複製程式碼

use用來註冊一箇中介軟體

app.use((ctx,next)=>{
	...
})

// 一般我們將(ctx,next)=>{}包裝成一個非同步函式
//async (ctx,next)=>{}
複製程式碼

可以發現這個use方法接收一個函式作為引數,這個函式又接收兩個引數ctxnext

其中ctx是koa自己封裝的一個上下文物件,這個物件你可以看做是原生http中req和res的集合。

而next和Express中的next一樣,可以在註冊的函式中呼叫用以執行下一個中介軟體。

框架搭建

/* application.js */

class Koa extends EventEmitter{
    constructor(){
    	super();
        this.middlewares = [];
        this.context = context;
        this.request = request;
        this.response = response;
    }
    
    //監聽&&啟動http伺服器
    listen(){
    	const server = http.createServer(this.handleRequest());
     	return server.listen(...arguments);
    }
    
    //註冊中介軟體
    use(fn){
    	this.middlewares.push(fn);
    }
    
    //具體的請求處理方法
    handleRequest(){
    	return (req,res)=>{...}
    }
   
   //建立上下文物件
    createContext(req,res){
    	...
    }
    
    //將中介軟體串聯起來的方法
    compose(ctx,middlewares){
    	...
    }
    
}
複製程式碼

ctx

用法

ctx,即context,大多數人稱之為上下文物件。

這個物件下有4個主要的屬性,它們分別是

  • ctx.req:原生的req物件
  • ctx.res:原生的res物件
  • ctx.request:koa自己封裝的request物件
  • ctx.response:koa自己封裝的response物件

其中koa自己封裝的和原生的最大的區別在於,koa自己封裝的請求和響應物件的內容不僅囊括原生的還有一些其獨有的東東

...
console.log(ctx.query); //原生中需要經過url.parse(p,true).query才能得到的query物件
console.log(ctx.path); //原生中需要經過url.parse(p).pathname才能得到的路徑(url去除query部分)
...
複製程式碼

除此之外,ctx本身還代理了ctx.request和ctx.response身上的屬性,So以上還能簡化為

...
console.log(ctx.query);
console.log(ctx.path);
...
複製程式碼

原理

首先我們要建立三個模組來代表三個物件

ctx物件/模組

//context.js
let proto = {};
module.exports = proto;
複製程式碼

請求物件/模組

let request = {};
module.export = request;
複製程式碼

響應物件/模組

let response = {};
module.exports = response;
複製程式碼

然後在application.js中引入

let context = require('./context');
let request = require('./request');
let response = require('./response');
複製程式碼

並在constructor中掛載

this.context = context;
this.request = request;
this.response = response;
複製程式碼

接下來我們來理一理流程,ctx.request/response是koa自己封裝的,那麼什麼時候生成的呢?肯定是得到原生的req、res之後才能進行加工吧。

So,我們在專門處理請求的handleRequest方法中來建立我們的ctx

handleRequest(){
    return (req,res)=>{
    	let ctx = this.createContext(req,res);
        ...
    }
}
複製程式碼

createContext

為了使我們的每次請求都擁有一個全新的ctx物件,我們在createContext方法中採用Object.create來建立一個繼承this.context的物件。

這樣即使我們在每一次請求中改變了ctx,例如ctx.x = xxx,那麼也只會在本次的ctx中建立一個私有屬性而不會影響到下一次請求中的ctx。(response也是同理)

createContext(req,res){
    let ctx = Object.create(this.context); //ctx.__proto__ = this.context
    ctx.response = Object.create(this.response);
}
複製程式碼

呃,說回我們最初的目的,我們要建立一個ctx物件,這個ctx物件下有4個主要的屬性:ctx.reqctx.resctx.requestctx.response

其中ctx.request/response囊括ctx.req/res的所有屬性,那麼我們要怎麼將原本req和res下的屬性賦給koa自己建立的請求和響應物件呢?這麼多屬性,難道要一個一個for過去嗎?顯然這樣操作太重了。

我們能不能想個辦法當我們訪問ctx.request.xx屬性的時候其實就是訪問ctx.req.xx屬性呢?

get/set

of coures,we can!

//application.js

createContext(req,res){
...
    ctx.req = ctx.request.req = req;
    ctx.res = ctx.response.res = res;
    return ctx;
}

// --- --- ---

//request.js
let request = {
    get method(){
    	return this.req.method
    }
}
複製程式碼

通過以上程式碼,我們在訪問ctx.response.method的時候其實訪問的就是ctx.req.method,而ctx.req.method其實就是req.method。

其中的get method(){}這樣的語法時es5裡的特性,當我們訪問該物件下的method屬性時就會執行該方法並以這個方法中的返回值作為我們訪問到的值。

我們還能通過在get中做一些處理來為ctx.request建立一些原生的req物件沒有的屬性

let request = {
...
  get query(){
    return url.parse(this.req.url,true).query;
  }
};
複製程式碼

delateGetter

除了通過ctx.request.query拿到query物件,我們還能通過ctx.query這樣簡寫的方式直接拿到原本在request下的所有屬性。這又是怎麼實現的呢?

很簡單,我們只需要用ctx來代理ctx.request即可

// context.js
...
function delateGetter(property,name){
    proto.__defineGetter__(name,function(){
    	return this[property][name];
    });
}

delateGetter('request','query');
...
複製程式碼

通過proto.__defineGetter__(name,function(){})代理(和上一節所展示的get/set是一樣的功能)

當我們訪問proto.name的時候其實就是訪問的proto.property.name

也就是說ctx.query的值即為ctx.request.query的值。

注意: 這裡get/set,delateGetter/Setter都只演示了一兩個屬性,想要更多,就得新增更多的get()/set(),delateGetter/Setter(),嗯原始碼就這麼幹的。

use與中介軟體

我們通過use方法註冊中介軟體,這些中介軟體會根據註冊時的先後順序,被依次註冊到一個陣列當中,並且當一個請求來臨時,這些中介軟體會按照註冊時的順序依次執行。

但這些中介軟體並不是自動依次執行的,我們需要在中介軟體callback中手動呼叫next方法執行下一個中介軟體callback(和express中一樣),並且最後的顯示的結果是有點微妙的。

next與洋蔥模型

Koa:核心探祕與入坑指北
我們來看下面這樣一個栗子

app.use(async (ctx,next)=>{
  console.log(1);
  await next();
  console.log(2);
});

app.use(async (ctx,next)=>{
  console.log(3);
  await next();
  console.log(4);
});

<<<
1
3
4
2
複製程式碼

嗯,第一次接觸koa的同學肯定很納悶,what the fk???這是什麼鬼?

嗯,我們先記住這個現象先不急探究,再接著往下看看中介軟體其它需要注意的事項。

中介軟體與非同步

我們在註冊中介軟體時,通常會將回撥包裝成一個async函式,這樣,假若我們的回撥中存在非同步程式碼,就能不寫那冗長的回撥而通過await關鍵字像寫同步程式碼一樣寫非同步回撥。

app.use(async (ctx,next)=>{
    let result = await read(...); //promisify的fs.read
    console.log(result);
})
複製程式碼

包裝成promise

需要補充的一點時,要讓await有效,就需要將非同步函式包裝成一個promise,通常我們直接使用promisify方法來promise化一個非同步函式。

next也要使用await

還需要注意的是假若下一個要執行的中介軟體回撥中也存在非同步函式,我們就需要在呼叫next時也使用await關鍵字

app.use(async (ctx,next)=>{
    let result = await read(...); //promisify的fs.read
    console.log(result);
    await next(); //本身async函式也是一個promise物件,故使用await有效
    console.log('1');
})
複製程式碼

不使用awiat的話,假若下一個中介軟體中存在非同步就不會等待這個非同步執行完就會列印1

原理

接下來我們來看怎麼實現中介軟體洋蔥模型。

如果一箇中介軟體回撥中沒有非同步的話其實很簡單

let fns = [fn1,fn2,fn3];
function dispatch(index){
    let middle = fns[index];
    if(fns.length === index)return;
    middle(ctx,()=>dispatch(index+1));
}
複製程式碼

我們只需要有一個dispatch方法來遍歷存放中介軟體回撥函式的陣列。並將這個dispatch方法作為next引數傳給本次執行的中介軟體回撥。

這樣我們就能在一個回撥中通過呼叫next來執行下一次遍歷(dispatch)。

但一箇中介軟體回撥中往往存在非同步程式碼,如果我們像上面這樣寫是達不到我們想要的效果的。

那麼,要怎樣做呢?我們需要藉助promise的力量,將每個中介軟體回撥串聯起來。

handleRequest(){
    ...
    let composeMiddleWare = this.compose(ctx,this.middlewares)
    ...
}
複製程式碼
compose(ctx,middlewares){
    function dispatch(index){
    	let middleware = middlewares[index];
        if(middlewares.length === index)return Promise.resolve();
        return Promise.resolve(middleware(ctx,()=>dispatch(index+1)));
    }
    return dispatch(0);
}
複製程式碼

其中一個middleware即是一個async fn,而每一個async fn都是一個promise,

在上面的程式碼中我們讓這個promise轉換為成功態後才會去遍歷下一個middleware,而什麼時候promise才會轉為成功態呢?

嗯,只有當一個async fn執行完畢後,async fn這個promise才會轉為成功態,而每一個async fn在內部若存在非同步函式的話又可以使用await,

SO,我們就這樣將各個middleware串聯了起來,即使其內部存在非同步程式碼,也會按照洋蔥模型執行。

ctx.body

使用

ctx.body即是koa中對於原生res的封裝。

app.use(async (ctx,next)=>{
	ctx.body = 'hello';
});

<<<
hello
複製程式碼

需要注意的是,ctx.body可以被多次連續呼叫,但只有最後被呼叫的會生效

...
ctx.body = 'hello';
ctx.body = 'world';
...

<<<
world
複製程式碼

ctx.body支援以流、object作為響應值。

ctx.body = {...}
複製程式碼
ctx.body = require('fs').createReadStream(...);
複製程式碼

原理

我們呼叫ctx.body實際上呼叫的是ctx.response.body(參考ctx代理部分),並且我們只是給這個屬性賦值,這僅僅是個屬性並不會立馬呼叫res.end等來進行響應

而我們真正響應的時候是在所有中介軟體都執行完畢以後

//application.js

handleRequest(){
  let composeMiddleWare = this.compose(ctx,this.middlewares);
    composeMiddleWare.then(function(){
        let body = ctx.body;
        if(body == undefined){
          return res.end('Not Found');
        }
        if(body instanceof Stream){ //如果ctx.body是一個流
          return body.pipe(res);
        }
        if(typeof body === 'object'){ //如果ctx.body是一個物件
          return res.end(JSON.stringify(body));
        }
        res.end(ctx.body); //ctx.body是字串和buffer
    })
}

複製程式碼

請求體

上面我們說過在async fn中我們能使用await來"同步"非同步方法。

其實除了一些非同步方法需要await外,請求體的接收也需要await

app.use(async (ctx,next)=>{
    ctx.req.on('data',function(data){ //非同步的
      buffers.push(data);
    });
    ctx.req.on('end',function(){
      console.log(Buffer.concat(buffers));
    });
});

app.use(async (ctx,next)=>{
	console.log(1);
})
複製程式碼

像上面這樣的例子1是會被先列印的,這意味著如果我們想要在一箇中介軟體中獲取完請求體並在下一個中介軟體中使用它,是做不到。

那麼要怎樣才能達到我們預期的效果呢?在await一節中我們講過,我們可以將程式碼封裝成一個promise然後再去await就能達到同步的效果。

我們可以通過npm下載到這樣的一個庫——koa-bodyparser

let bodyparser = require('koa-bodyparser');
app.use(bodyparser());
複製程式碼

這樣,我們就能在任何中介軟體回撥中通過ctx.request.body獲取到請求體

app.use(async (ctx,next)=>{
	console.log(ctx.request.body);
})
複製程式碼

但需要注意的是,koa-bodyparser並不支援檔案上傳,如果要支援檔案上傳,可以使用better-body-parser這個包。

body-parser 實現

function bodyParser(options={}){
  let {uploadDir} = options;
  return async (ctx,next)=>{
    await new Promise((resolve,reject)=>{
      let buffers = [];
      ctx.req.on('data',function(data){
        buffers.push(data);
      });
      ctx.req.on('end',function(){
        let type = ctx.get('content-type');
        // console.log(type);//multipart/form-data; boundary=----WebKitFormBoundary8xKcmy8E9DWgqZT3
        let buff = Buffer.concat(buffers);
        let fields = {};

        if(type.includes('multipart/form-data')){
          //有檔案上傳的情況
        }else if(type === 'application/x-www-form-urlencoded'){
          // a=b&&c=d
          fields = require('querystring').parse(buff.toString());
        }else if(type === 'application/json'){
          fields = JSON.parse(buff.toString());
        }else{
          // 是個文字
          fields = buff.toString();
        }
        ctx.request.fields = fields;
        resolve();
      });
    });
    await next();
  };
}
複製程式碼

可以發現 bodyParser本身即是一個async fn,它將on data on end接收請求體部分程式碼封裝成了一個promise,並且await這個promise,這意味著只有當這個promise轉換為成功態時,才會走next(遍歷下一個中介軟體)。

而我們什麼時候將這個promise轉換為成功態的呢?是在將請求體解析完畢封裝成一個fields物件並掛載到ctx.request.fields之後,我們才resolve了這個promise。

以上就是bodyParser實現的大體思路,還有一點我們沒有詳細解釋的部分既是有檔案上傳的情況。

當我們將enctype設定為multipart/form-data,我們就可以通過表單上傳檔案了,此時請求體的樣子是長這樣的

Koa:核心探祕與入坑指北

嗯。。。其實接下來要乾的的事情即是對這個請求體進行拆分拼接。。一頓字串操作,這裡就不再展開啦

有興趣的朋友可以到我的倉庫中檢視完整程式碼示例點我~

static

Koa中為我們提供了靜態伺服器的功能,不過需要額外引一個包

let static = require('koa-static');
let path = require('path');
app.use(static(path.join(__dirname,'public')));
app.listen(8000);
複製程式碼

只需三行程式碼,咳咳,靜態伺服器你值得擁有。

原理

原理也很簡單啦,static首先它也是一個async fn

function static(p){

  return async(ctx,next)=>{
    try{
      p = path.join(p,'.'+ctx.path);
      let statObj = await stat(p);
      if(statObj.isDirectory()){
		...
      }else{
        ctx.body = fs.createReadStream(p); //在body上掛載可讀流,會在所有中介軟體執行完畢後以pipe形式輸出到客戶端
      }
    }catch(e) {
      await next();
    }
  }
}
複製程式碼

關於錯誤捕獲

最後,koa還允許我們在一個async fn中丟擲一個異常,此時它會返回個客戶端一串字串Internal Server Error,並且它還會觸發一個error事件

app.use(async (ctx,next)=>{
  throw Error('something wrong');
});

app.on('error',function(err){
  console.log('e',err);
});
複製程式碼

原理

// application.js
handleRequest(){
	...
    composeMiddleWare.then(function(){
    	...
    }).catch(e=>{
    	this.emit('error',e);
        res.end('Internal Server Error');
    })
    ...
}
複製程式碼

獲取demo程式碼

倉庫:點我

相關文章