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.json
。package.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
最重要的三個物件request
,response
和context
。這三個物件幾乎就是koa
的全部內容了。待會兒會逐一分析。
我們接著往下看,listen
函式大家都很熟悉了,就是用來監聽埠的。koa
的listen
函式也很簡單。
const server = http.createServer(this.callback());
return server.listen(...args);
複製程式碼
短短兩行,對於nodejs
不熟的同學,建議在這裡就打住了。其中this.callback()
是個什麼玩意兒呢?它返回一個函式,這個函式接收兩個引數request
和response
,也就是createServer
的回撥函式,在中介軟體原理章節會更詳細介紹。
接著就是toJSSON
這個方法。JSON.stringify
呼叫的方法,目的是當你JSON
化一個application
例項時,返回指定的屬性,而並非所有。這個用處不大,幾乎用不到。
inspect
也就是呼叫了toJSON
這個方法而已。
接著就是use
函式了。use
函式本身不是很複雜,但是use
函式作為中介軟體的介面,背後的中介軟體卻有點兒複雜。為此,本文在後面專門解讀了中介軟體相關的原始碼,這裡暫時跳過。
callback
,handleRequest
,respond
這幾個方法涉及中介軟體的,因此放到中介軟體的章節講。
createContext
這個方法是用來封裝context
的。這個context
就是你在使用koa
的use
方法,你傳遞的回撥函式的第一個ctx
引數。createContext
執行的最重要的操作就是把context.request
設定成了Request
,把context.response
設定成了Response
。以及把Response.res
h和Request.req
分別設定成了原生的response
和request
。
為什麼這樣說,這個就得追到context.js
和request.js
以及response.js
的程式碼裡面了,先等等。
值得強調的是,這裡的Request
和Response
並不是nodejs
裡面的,而是koa
封裝過後的。為了區分原生的和koa
封裝好的,我把Request
和Response
稱為封裝過後的,request
和response
稱為原生的。你需要記住的是context.res
是指原生的response
,而context.response
則是封裝後的Response
。Request
以此類推。
封裝的東西看起來並沒有什麼高大上,無非是把常用的一些方法給簡化了。就像jquery
簡化了js
對dom
的操作一樣。
$3.分析context.js
開啟context.js
,程式碼不多,但是含金量挺高的。首先是把proto
賦值成一個物件,這個物件也是模組的匯出值。
inspect
和toJSON
功能和application.js
裡面一樣,不做過多介紹了。
接著看到個assert
,這個和nodejs裡面的assert
其實是差不多,它其實是提供了一些斷言的操作。比如equal
,notEqual
,strictEqual
之類的。比較有意思的是,assert
提供了一個深度比較的方法deepEqual
,這個可是個好東西。js
裡面的深度比較一直是個比較麻煩的問題,有經驗的程式設計師會使用JSON
來比較,這裡提供了一種效能更好的方法。程式碼其實不復雜,就是引用了deep-eqaul
這個庫而已,有興趣的可以去看看哦。
跳過兩個關於錯誤處理的函式(本文不講解錯誤處理),來到了context.js
最精華的地方了。
這裡使用了delegate
這個庫。這是個啥?delegate
其實很簡單的,你甚至不需要去檢視delegate
的原始碼,看我解釋就行了。
delegate
提供了一種類似Proxy
的手段,也就是代理。代理什麼?具體來說delegate(proto, 'response')
這段程式碼的意思就是把proto
上的一些屬性代理到proto.response
上面去。具體是哪些代理呢?就是接下來排列工整的程式碼做的了。delegate
區分了method
,getter
,access
等型別。前面兩個還好理解,就是方法和只讀屬性,第三個呢?其實就是可讀可寫屬性罷了,相當於同時代理了getter
和setter
。所以其實你訪問ctx.redirect
實際上訪問的是ctx.request.redirect
,以此類推。需要注意的是,這裡的request
和response
不是nodejs原生的,是koa
封裝過後的。
context.js
就這麼簡單。
$4.request.js & response.js
request.js
和response.js
分別是對createServer
回撥函式接收的的request
和response
進行封裝。
先看request.js
。還記得createContext
嗎?我們說過,他把Request.req
設定成了原生的request
。所以你可以看到,很多方法其實本質就是在操作this.req
,這一點和response.js
類似,後面就不重複說了。
首先是一些個常用的屬性,header
分別設定了getter
和setter
,都是對this.req.headers
操作。headers
和header
一模一樣,並不是用來區分單複數的(這有點兒坑,初學以為headers是設定多個的)。接下來還有很多常用的屬性,就不一一介紹了,什麼url
,method
之類的,稍微熟悉點兒nodejs的同學都能夠實現出來。
值得注意的是query
和querystring
,一個返回的是物件,一個是字串哦。
你或許會問search
和querystring
有啥區別。區別,emmmmn。。。可能是為了完整吧,畢竟express都有個search,koa
也要提供。。。
另外需要說一下的是,這裡的很多屬性的操作涉及到了http
協議的內容了,比如fresh
。http
是個很大的內容,不做講解。如果遇到看不懂的程式碼,不妨去檢視相關的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
,同headers
。status
設定狀態碼,比如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的資料是個什麼型別。
-
當傳遞的是字串時,它使用了一個正則:
/^\s*</
來判斷是html還是text。很明顯,這個正則很簡陋,在很多情況下並不能正確判斷,比如<----
就會被判斷成html。所以body
的型別還是要手動的設定type
才行。 -
當傳遞的是buffer的時候,把型別設定稱為
bin
(記住,type
是koa封裝過後的屬性,它會根據你設定的type
自動匹配最佳的Content-Type
。比如你把type
設定成'json',實際上最後的Content-Type
會是application/json
。後面會說實現方法的)。 -
當傳遞的是個stream(通過判斷它是否擁有pipe這個函式),先繫結回撥函式,當res傳送完畢的時候,銷燬這個stream,避免記憶體浪費。接著錯誤處理。接著判斷以下現在這個stream和原來
body
的值是否相同,如果不是的話,那就移除Content-Length
,交給nodejs自己處理。(實際上nodejs也並不會處理,為啥呢?header必須在正文傳送之前傳送,但是Stream的位元組數要在傳送完才知道,so,你懂得)。最後把type
設定成bin
,因為stream是二進位制的資料流。 -
不滿足以上三種,那麼就只能是json了唄(別問我為什麼不判斷boolean,symbol這些,誰會沒事兒幹傳送這些玩意兒?)。移除
Content-Type
(你可能想問,為啥呢?因為你傳遞的實際上是個Object物件,需要stringify之後才能知道它的位元組數,這個其實會在後面處理的)。設定type
成json
。
至此,body
的setter
分析得差不多了。
接著到了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-types
的contentType
函式,然後返回json
型別的mime
,也就是application/json
。
同request.js
一樣,response.js
同樣封裝了set
和get
兩個方法,用於設定和讀取header
。
inspect
和toJSON
又來了。。。
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
語句,返回一個函式。這個函式有兩個引數context
和next
,熟悉嗎?這不就是中介軟體函式嗎!別慌,接著往下看。
首先宣告一個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
的返回值,是一個類似中介軟體的函式。
回到application
的callback
方法中。定義了一個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
這個方法了,這個方法實際上就是對statusCode
,header
以及body
進行處理,最後呼叫nodejs提供了傳送資料的方法,向客戶端傳送資料。最後呼叫ctx.end(body)
,結束本次http請求
。
那麼至此,koa
中介軟體也就完了。
結語
koa
的原始碼並不是十分複雜,有興趣的同學可以自己再看看。希望這篇文章能給你幫助。