koa@2.5.0原始碼解讀

DoubleDimos發表於2018-03-13

koa簡介

koa是由Express原班人馬開發的一個nodejs伺服器框架。koa使用了ES2017的新標準:async function來實現了真正意義上的中介軟體(middleware)。koa的原始碼極其簡單,但是藉由其強大的中介軟體擴充套件能力,使得koa成為了一個極其強大的伺服器框架。藉助中介軟體,你可以做任何nodejs能做到的事兒。

一些繁瑣的交代

本文並不會像其他的程式碼分析那樣貼段程式碼加註釋,因此需要你自己開啟koa@2.5.0的原始碼一起閱讀。

koa目前已經更新到了2.x版本,1.x以前的版本相對於koa@2.x已經不再相容。本文針對的是koa@2.5.0進行的程式碼分析。

此外,koa的原始碼裡面涉及部分http協議的內容,這部分內容本文不會過分強調,預設讀者已經掌握了基本的知識。

另外,用於koa2是用了ES2017新特性編寫的,因此你需要了解一些ES2017的新語法才行。

為了使得這篇文章簡單,我有意地忽略了錯誤處理,引數判斷之類。

本文你還可以在這裡找到。

$1.檢視package.json

對於nodejs甚至是JavaScript專案,第一件事兒就是看看它的package.jsonpackage.json裡面可以找到不少有用的資訊。

我們開啟koa@2.5.0的目錄,發現它依賴了不少的庫。其實這些庫大多都十分簡單,koa的編寫原則其實就是把功能分割到其他的庫中去。我們暫且先不管這些依賴。

我們找到main欄位,這裡就是‘通往新世界的大門’了。順著main開啟lib/application.js

$2.分析application.js

好傢伙,一上來就是一大串的引入,這可不是什麼好東西,我們麼先不看這些東西。先看下面的程式碼。

首先是定義了一個application的類,接著在建構函式中定義了一些變數。我們主要關注以下幾個變數,因為他們的用處最大:

    this.middleware = [];
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
複製程式碼

Object.create是用來克隆物件的,這裡克隆了三個物件,也是koa最重要的三個物件requestresponsecontext。這三個物件幾乎就是koa的全部內容了。待會兒會逐一分析。

我們接著往下看,listen函式大家都很熟悉了,就是用來監聽埠的。koalisten函式也很簡單。

    const server = http.createServer(this.callback());
    return server.listen(...args);
複製程式碼

短短兩行,對於nodejs不熟的同學,建議在這裡就打住了。其中this.callback()是個什麼玩意兒呢?它返回一個函式,這個函式接收兩個引數requestresponse,也就是createServer的回撥函式,在中介軟體原理章節會更詳細介紹。

接著就是toJSSON這個方法。JSON.stringify呼叫的方法,目的是當你JSON化一個application例項時,返回指定的屬性,而並非所有。這個用處不大,幾乎用不到。

inspect也就是呼叫了toJSON這個方法而已。

接著就是use函式了。use函式本身不是很複雜,但是use函式作為中介軟體的介面,背後的中介軟體卻有點兒複雜。為此,本文在後面專門解讀了中介軟體相關的原始碼,這裡暫時跳過。

callbackhandleRequestrespond這幾個方法涉及中介軟體的,因此放到中介軟體的章節講。

createContext這個方法是用來封裝context的。這個context就是你在使用koause方法,你傳遞的回撥函式的第一個ctx引數。createContext執行的最重要的操作就是把context.request設定成了Request,把context.response設定成了Response。以及把Response.resh和Request.req分別設定成了原生的responserequest

為什麼這樣說,這個就得追到context.jsrequest.js以及response.js的程式碼裡面了,先等等。

值得強調的是,這裡的RequestResponse並不是nodejs裡面的,而是koa封裝過後的。為了區分原生的和koa封裝好的,我把RequestResponse稱為封裝過後的,requestresponse稱為原生的。你需要記住的是context.res是指原生的response,而context.response則是封裝後的ResponseRequest以此類推。

封裝的東西看起來並沒有什麼高大上,無非是把常用的一些方法給簡化了。就像jquery簡化了jsdom的操作一樣。

$3.分析context.js

開啟context.js,程式碼不多,但是含金量挺高的。首先是把proto賦值成一個物件,這個物件也是模組的匯出值。

inspecttoJSON功能和application.js裡面一樣,不做過多介紹了。

接著看到個assert,這個和nodejs裡面的assert其實是差不多,它其實是提供了一些斷言的操作。比如equalnotEqualstrictEqual之類的。比較有意思的是,assert提供了一個深度比較的方法deepEqual,這個可是個好東西。js裡面的深度比較一直是個比較麻煩的問題,有經驗的程式設計師會使用JSON來比較,這裡提供了一種效能更好的方法。程式碼其實不復雜,就是引用了deep-eqaul這個庫而已,有興趣的可以去看看哦。

跳過兩個關於錯誤處理的函式(本文不講解錯誤處理),來到了context.js最精華的地方了。 這裡使用了delegate這個庫。這是個啥?delegate其實很簡單的,你甚至不需要去檢視delegate的原始碼,看我解釋就行了。

delegate提供了一種類似Proxy的手段,也就是代理。代理什麼?具體來說delegate(proto, 'response')這段程式碼的意思就是把proto上的一些屬性代理到proto.response上面去。具體是哪些代理呢?就是接下來排列工整的程式碼做的了。delegate區分了methodgetteraccess等型別。前面兩個還好理解,就是方法和只讀屬性,第三個呢?其實就是可讀可寫屬性罷了,相當於同時代理了gettersetter。所以其實你訪問ctx.redirect實際上訪問的是ctx.request.redirect,以此類推。需要注意的是,這裡的requestresponse不是nodejs原生的,是koa封裝過後的。

context.js就這麼簡單。

$4.request.js & response.js

request.jsresponse.js分別是對createServer回撥函式接收的的requestresponse進行封裝。

先看request.js。還記得createContext嗎?我們說過,他把Request.req設定成了原生的request。所以你可以看到,很多方法其實本質就是在操作this.req,這一點和response.js類似,後面就不重複說了。

首先是一些個常用的屬性,header分別設定了gettersetter,都是對this.req.headers操作。headersheader一模一樣,並不是用來區分單複數的(這有點兒坑,初學以為headers是設定多個的)。接下來還有很多常用的屬性,就不一一介紹了,什麼urlmethod之類的,稍微熟悉點兒nodejs的同學都能夠實現出來。

值得注意的是queryquerystring,一個返回的是物件,一個是字串哦。

你或許會問searchquerystring有啥區別。區別,emmmmn。。。可能是為了完整吧,畢竟express都有個search,koa也要提供。。。

另外需要說一下的是,這裡的很多屬性的操作涉及到了http協議的內容了,比如freshhttp是個很大的內容,不做講解。如果遇到看不懂的程式碼,不妨去檢視相關的http協議哦。

另外在idempotent你可以看到!!~,這是個啥玩意兒???第一次看見都是一臉懵逼。這個其實就是位操作而已。我們一般把!!看做一組,它的作用是把任意資料變成boolean值。這個操作其實很簡單,就是判斷是不是-1,如果是-1,那麼就是false;如果不是-1,那麼都是true。這個操作很巧妙。稍微解釋一下吧。

我們假設數字是8位表示的,那麼-1的原碼就是1000 0001,反碼就是1111 1110,補碼就是1111 1111。而~操作符是取反的意思,所以取反以後就成了0000 0000。計算機儲存負數是用的補碼(相關知識可以取google搜尋一下),所以最後就是判斷是不是-1的。

有幾個accept打頭的函式可以忽略,這幾個函式是判斷是否符合指定型別、語言、編碼的,它內部呼叫了一個accepts的庫。這個功能其實用得很少,但涉及編碼之類較為複雜的知識了。

在最後的程式碼裡面,request.js提供了get方法,其實就是獲取header

讓我們轉到response.js裡面去。劈頭蓋臉一看,和request.js差不多,知識封裝的方法和屬性不一樣而已。

首先是socket,這個是套接字,http模組的底層,不講解。

header呼叫的是getHeaders(),獲取已經設定好的所有的header,同headersstatus設定狀態碼,比如200,404之類的。值得一提的是,通常情況下使用nodejs的statusCode還需要你設定一個statusMessage來提示使用者發生了什麼錯誤,koa會智慧的為你設定好。比如你設定好了status為404,會自動把statusMessage設定成404 not found。這是因為koa使用了statuses這個庫,這個庫會根據你傳入的狀態碼返回指定的狀態資訊。

接下來是Response最重要的一個屬性,也就是body。對body的操作反應在內部的_body上面。body的setter做了各種處理。比如判斷傳給body的值是不是空,如果是空就進行一些操作。比較有意思的是,body的setter會在你沒有設定Content-Type時,判斷一下傳遞給body的資料是個什麼型別。

  1. 當傳遞的是字串時,它使用了一個正則:/^\s*</來判斷是html還是text。很明顯,這個正則很簡陋,在很多情況下並不能正確判斷,比如<----就會被判斷成html。所以body的型別還是要手動的設定type才行。

  2. 當傳遞的是buffer的時候,把型別設定稱為bin(記住,type是koa封裝過後的屬性,它會根據你設定的type自動匹配最佳的Content-Type。比如你把type設定成'json',實際上最後的Content-Type會是application/json。後面會說實現方法的)。

  3. 當傳遞的是個stream(通過判斷它是否擁有pipe這個函式),先繫結回撥函式,當res傳送完畢的時候,銷燬這個stream,避免記憶體浪費。接著錯誤處理。接著判斷以下現在這個stream和原來body的值是否相同,如果不是的話,那就移除Content-Length,交給nodejs自己處理。(實際上nodejs也並不會處理,為啥呢?header必須在正文傳送之前傳送,但是Stream的位元組數要在傳送完才知道,so,你懂得)。最後把type設定成bin,因為stream是二進位制的資料流。

  4. 不滿足以上三種,那麼就只能是json了唄(別問我為什麼不判斷boolean,symbol這些,誰會沒事兒幹傳送這些玩意兒?)。移除Content-Type(你可能想問,為啥呢?因為你傳遞的實際上是個Object物件,需要stringify之後才能知道它的位元組數,這個其實會在後面處理的)。設定typejson

至此,bodysetter分析得差不多了。

接著到了length,這個其實就是封裝了設定Content-Length的方法。反倒是它的getter有點兒複雜來著。我們不妨細看一下。

首先判斷Content-Length設定沒有,有就直接返回,有的話那就分情況讀body的位元組數。當body是stream的時候,啥都不返回。

這裡有個奇淫巧技,~~這個玩意兒可以用來把字串轉換成數字。為什麼呢?!我就知道你要問!其實這個東西要對js有比較高的理解才行的,js裡面存在隱式型別轉換,當遇到一些特殊的操作符,例如位操作符,會把字串轉換成數字來進行計算。其實+這個符號也可以進行字串轉數字(str+str這個不算哈,這個不會進行隱式型別轉換),那麼為什麼要用~~而不是+呢?我思索再三,認為是作者可能不瞭解。但實際上,~~要比'+'安全,+在遇到不能轉換的式子時,會返回NaN,而~~是基於位操作的,返回安全的0。

跳到type,這個和length類似,是對Content-Type實現的封裝。這裡引用了一個mime-types的庫,這個庫功能很強大,可以根據傳入的引數返回指定的mime型別。比如我們設定type為json,會去呼叫mime-typescontentType函式,然後返回json型別的mime,也就是application/json

request.js一樣,response.js同樣封裝了setget兩個方法,用於設定和讀取header

inspecttoJSON又來了。。。

response.js很多的屬性和方法並沒有提及,這是因為這些屬性和方法就是做了簡單的封裝而已,方便呼叫,很容易理解。

好了,至此response.js也分析完了。

$5.koa中介軟體原理分析

koa的中介軟體原理很強大,實現起來其實並不是特別複雜。記得怎麼使用koa中介軟體嗎?只需要use一個函式就行了!這個函式接受兩個引數,一個是context,我們已經分析過了。另一個是next,這個就是中介軟體的核心了。

讓我們回到開頭,看看use怎麼實現的。不看錯誤處理的那些內容,這裡先對fn進行了一次判斷。判斷什麼呢?判斷fn是不是generator function。koa官方建議是不要繼續使用Generator function了,換成了async function。如果你使用的是Generator function,那麼內部會呼叫co模組來處理。由於處理內容比較晦澀,且與正文關係不大,故不作講解。我們假設所有的中介軟體都是async function。

application維護了一個middleware的佇列,use方法把中介軟體推送進這個佇列,除此之外什麼都沒做。

還記得listen方法嗎?它呼叫了callback這個方法。最終的答案都在這裡了!

看到callback方法。首先,它對middleware佇列呼叫了compose方法。我們開啟compose對應的模組,短短几十行程式碼。

不看錯誤處理,那麼compose只有一個return語句,返回一個函式。這個函式有兩個引數contextnext,熟悉嗎?這不就是中介軟體函式嗎!別慌,接著往下看。

首先宣告一個index遊標,接著定義一個dispatch函式,然後預設返回dispatch(0)

dispatch函式用來分發中介軟體(和分發事件很像)。它接收一個數字,這個數字是中介軟體佇列中某個中介軟體的下標。首先先判斷一下有沒有越界,也就是index和傳入的i進行比較,沒有越界把遊標移動到當前分發的中介軟體。接著判斷i是否已經遍歷完了中介軟體佇列,i === middleware.length判斷。如果完了,就把fn設定成傳入的next。接著使用Promise.resolve,並呼叫當前中介軟體,注意

return Promise.resolve(fn(context, function next () {
    return dispatch(i + 1)
}))
複製程式碼

這裡傳入中介軟體的第二個引數,也就是next,是一個函式,這個函式正是用來分發下一個事件的!!!中介軟體最重要的原理就在這裡,為什麼可以用next轉移控制權,邏輯就在這裡!

compose函式分析完畢了,記住compose的返回值,是一個類似中介軟體的函式。

回到applicationcallback方法中。定義了一個handleRequest函式並且直接返回,handleRequest其實就是http.createServer的回撥函式。這個回撥函式首先封裝一下createContext,上面已經講過了。接著呼叫了application上的handleRequest方法(別搞混了,這個是下面那個handleRequest方法)。

我們看看handleRequest方法,它接受兩個引數,第一個是context,第二個是什麼呢?其實就是compose處理後的middleware中介軟體佇列。拋開一些’多餘‘的程式碼不看,把它精簡成這樣:

  handleRequest(ctx, fnMiddleware) {
    const handleResponse = () => respond(ctx);
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
  }
複製程式碼

記得fnMiddleware的返回值是什麼嗎?是dispatch(0)。那記得dispatch的返回值是什麼嗎?是一個Promise。我們再來看看這個Promise

return Promise.resolve(fn(context, function next () {
    return dispatch(i + 1)
}))
複製程式碼

好好想一想,fn現在是第一個中介軟體,它先被呼叫了。在這個中介軟體裡面呼叫了next函式,也就是相當於呼叫了dispatch(i + 1),如此下去。這不就相當於依次呼叫了dispatch函式嗎?

最後一點,中介軟體是async function,你明白為什麼要使用Promise了嗎?對了,就是為了await。

最後的最後,就是respond這個方法了,這個方法實際上就是對statusCodeheader以及body進行處理,最後呼叫nodejs提供了傳送資料的方法,向客戶端傳送資料。最後呼叫ctx.end(body),結束本次http請求

那麼至此,koa中介軟體也就完了。

結語

koa的原始碼並不是十分複雜,有興趣的同學可以自己再看看。希望這篇文章能給你幫助。

推廣一下自己的GitHub,我的開源專案doxjs,有興趣的可以看看,給個star之類的。

相關文章