在co/koa里該怎么處理異常

陸陸續續用了koaco也算差不多用了大半年了,大部分的場景都是在服務端使用koa來作為restful服務器用,使用場景比較局限,這里總結一下。

異常處理其實是一個程序非常重要的部分,我自己以前寫前端代碼的時候很不注意這個,用node來寫后臺并被焦作人很多次了之后才真正明白了它的重要性。因為用node來寫后臺時,常常涉及到大量的網絡異步請求、用戶提交、數據庫讀寫等操作。許多場景你即使做了充足的數據校驗和多種情況考慮,但因為是基于網絡和用戶的,必然存在你無法掌控的情況,如果你沒有做好try catch,那么一個bug就可能讓你的node server倒下。雖然借助于pm2等等工具,你可以讓他倒了再立馬爬起來,但是倒下的代價是巨大的,而且如果沒有try catch,沒有日志記錄的話,你甚至都不知道倒在哪了。所以錯誤處理對于提升程序的健壯性來說是必不可少的。",

<p class="tip">這篇文章需要koa和co以及generator的原理知識,因co和generator的處理流程有一點繞(捋順了其實巨好懂),如果你對co/koa/generaotor不太熟悉,最好先看看阮一峰老師es6入門的第十五章和第十七章。當然這篇文章也會講到co的原理過程,也可能解釋你的一些困惑。</p>

co里處理異常

cokoa(1)的核心,整個koa可以說就是http.listen + koa compress + co +context,co保證了你能在koa的中間件generator里進行各種yield,完成異步函數的串行化書寫,所以在koa里處理錯誤有相當大的比重其實是在co里處理錯誤,我們先說說怎么在co里處理錯誤,這里幾乎涵蓋了90%的處理場景。

我以前的兩種寫法

首先說說我以前是怎么在使用co時處理錯誤的。

  • 第一種:
co(function* () {
  let abcd = yield promiseAbcd.catch(function(err){
    //錯誤處理寫在這
  })
})
  • 第二種:
co(function* () {
  let abcd 
  try{
    abcd = yield promiseAbcd
  }catch(err){
    //錯誤處理寫在這
  }
})

第二種寫法其實是明顯優于第一種的,主要原因有三個:

  • 第一種的錯誤處理寫在回調函數里,這就意味著你沒辦法對函數外部的代碼進行流程控制,你無法在錯誤了之后改變外部代碼的執行流程,你沒法returnbreakcontinue,這是最大的問題。而能在異步處理中進行try/catch我覺得co/koa的一個非常大的優勢(對TJ真是大寫的服),要知道你對一個異步函數try/catch是并不能捕捉到他的錯誤的,如下,這是很棘手的問題。
try{
  setTimeout(function(){
    throw new Error()
  },0)
}catch(e){
  //這里根本捕捉不到錯誤的哈
}
  • 第二點需要說的是你如果用這種方式寫的話,你需要很清楚你現在yield出去的是promiseAbcd.catch()方法生成的promise,而不是promiseAbcd,這有什么問題?我們知道.catch會生成一個新的promise,并且會以你在.catch綁定的回調函數中的返回值或者throw的error,作為這個新的promise的成功原因或者失敗error。所以,當promiseAbcd出錯,首先執行你寫的這個.catch()的綁定回調,然后如果你在綁定回調里面的返回值會被co接住,并扔回到generatorFunction,于是上面的這句代碼中:
let abcd = yield promiseAbcd.catch(function(err){})

變量abcd就會是function(err){}的返回值,如果你沒有注意到這個細節,沒有返回值,那么abcd就會得到默認返回值undefined,然后繼續往下執行,于是很有可能后續代碼就會報錯。

  • 此外第一種寫法的問題也依然還是出在回調函數上,既然是函數,作用域就是新的作用域,所以要想訪問回調函數外層的變量的和this的話,需要使用箭頭函數,所以這種寫法就和箭頭函數這種語法糖(其實箭頭函數不是語法糖)綁定起來了,也就帶來了隱患。
  • 第四點問題在于代碼的可讀性上,明顯寫在yield后方的.catch讓你不會明顯的注意到,而改為try catch的方式,在橫向長度上的減少帶來的是縱向方向上的清晰,如果遇到 yield promise.then(function(){......}),本身就已經很長了,你再后面再加個catch,這這這....

所以,第一種方式是很優雅的,但是很多同學可能會懷有疑問,為什么yield出去的異步任務竟然還能在原來的generatorcatch住,那具體什么時候哪些情況可以catch住,這其實是對于co源碼和處理過程的不熟悉導致的。

而且如果是并行的多個任務呢,比如你要并行的查多個sql,如果你希望在每個sql后面都綁定自己的錯誤處理,那么第一種方式的一個try catch肯定不夠,比如例子如下。

  let promiseArr = []
  promiseArr.push(sqlExecPromise('sql A'))
  promiseArr.push(sqlExecPromise('sql B'))
  try{
    let [resultA, resultB] = yield promiseArr
  }catch(e){
    //這里能捕捉到上面的錯誤嗎?
    //如果上面的兩個promise都報錯呢?
    //如果我想為各個promise綁定自己的錯誤處理,這種寫法也不能滿足需求吧?
  }

所以我們需要首先來理清楚co里面的異常控制,結合co的源碼,弄明白co的處理過程,才能知道我們到底可以怎么處理錯誤。

說一嘴 iterator和generator

首先說一點點基礎概念:generator函數,執行后的返回值是這個generator函數的iterator(遍歷器),然后對這個iterator執行.next()方法,可以讓generator函數往下執行,并且.next()的返回值是generator函數yield出來的值。而對這個iterator執行.throw(err)方法,這將err“傳到”generator函數當中:

function * generatorFunction (){
    try {
        yield 'hello'
        yield 'world'
    } catch (err) {
        console.log(err)
    }
}
// 執行generator函數,拿到它的iterator
let iterator = generatorFunction() 
// 返回一個對象,對象的value是你yield出來的值
iterator.next() // { value: \"hello\", done: false } 
iterator.next() // { value: \"world\", done: false }
iterator.throw(new Error('some thing wrong'))
// 此時這個err被\"傳入\" generator函數,
// 并被其generator的try catch捕捉到
// log: Error: some thing wrong

結合源碼看看co的錯誤上報

要說的已經在圖里面的(抱歉圖片左側.catch
里面的代碼應該寫console.log('outer error caught'),寫錯了),我們在co里面其實遇到的錯誤有兩種情況:

  • 一種是yield的出去的這個promise,出了問題
    co(function*(){
      yield Promise.reject(new Error())
    })
    
  • 另外一種是generatorFunction的內部流程代碼里出了問題:
    co(function*(){
      throw new Error()
    })
    

如果是第一種,我們看上面那幅我制作了好半天的圖,co給promise綁定了回調,因此,當promise失敗時,會執行藍色框里的那句代碼iterator.throw(err)那么這個時候,err就被返回到了generator中,于是let a = Promise.reject(new Error('出錯啦'))就“變成”了throw new Error('出錯啦'),因此這就使得yield出去的promise發生錯誤時,也依然可以在內部的try catch捕捉到。

那如果情況是第二種,那也就是順著圖片里面的左側代碼繼續執行,這個throw的err會“流落”到哪去呢?,我們需要知道iterator.thow(err)不僅把錯誤"塞"回到了原來的generatorFunction里,還讓generatorFunction繼續執行了,它和iterator.next(data)的功能都是一樣的,我們可以把他們看做一個函數,這個函數在內部調用了generatorFunction繼續執行的函數,generatorFunction繼續執行的函數出錯,這個函數必然就會像外拋出錯誤,所以iterator.thow(err)iterator.next(data)不僅會把err和data塞回generatorFunction,還會繼續generatorFunction的執行,并且在執行報錯是捕捉到錯誤。

而在其實圖片里面,我們可以看到coiterator.throw(err)的代碼外側是用try catch包裹起來的,在catch里面,使用了reject(err),在iterator.next(data)的外側其實也有相似處理,其實在co里,所有的iterator.next(data)和throw外側都是用這個try catch包起來的,這就保證了,任何generatorFunction執行時候的錯誤都能被catch捕捉到,并將這個錯誤reject(err),也就是轉移到co最開始return的那個promise上,因此如果是第二種情況也就是generatorFunction的內部流程代碼里出了問題,那么這個error會報告到co函數返回的promise上。

于是co(function*(){}).catch()的這個.catch就能起到捕捉這個錯誤的作用。所以大家應該明白為什么可以try catch 我們yield出去的promise上發生的錯誤,也知道其中的一整套原理,更明白如果是generatorFunction內部錯誤,那么我們應該怎么去捕捉。

好了,現在基本算是回答了之前兩個問題中的一個。

那,并發的情況呢?

大家先試試下面這段代碼:

co(function * () {
    let promiseArr = []
    promiseArr.push(Promise.reject(new Error('error 1')))
    promiseArr.push(Promise.reject(new Error('error 2')))
    try {
        yield promiseArr
    } catch (err) {
        console.log('inner caught')
        console.log(err.message)
    }
  //inner caught
  //error 1
})

首先我們要知道當我們yield 出去一個填充了promise的array或者object的時候,co幫我們做了什么?我在上面那張圖里面介紹了一個小細節,當co通過value下標從iterator.next()返回的對象中取出你yield出來的東西時,這個東西可能的情況有很多,絕大部分的情況可能是promise或者是thunk,他調用了一個內部的函數toPromise,這個函數會把你傳出來的東西轉換成promise,如果是一個數組,對數組執行promise.all,那么會得到一個新的promise,然后在這個promise上綁定成功和失敗回調,因此,在generatorFunction內對yield進行try catch,會捕捉到這個父promise上的異常。對于promise.all返回的這個父promise,如果所有的子promise都成功了,他才會成功,如果任意一個子promise失敗了,那么會導致他的失敗,而且最關鍵的是,如果一個子promise失敗了,那么這個子promise的失敗原因(error)會作為父promise的失敗原因(error),引起父promise的失敗回調執行,而后續的子promise的失敗都不會在父promise上產生效果,失敗回調都不會執行(其實成功回調也不會執行),所以我們上面只能捕捉到一個error。

插一句,promise.all的這個機制很好理解,我雖然不清楚其內部具體實現,但是其實類似一個普通的promise,當你對它的reject或者resolve執行過一次后,不管你接下來再執行多少次resolve或者reject,都不會導致這個promise上綁定的成功回調或失敗回調繼續執行。

上面說明的這種情況導致只有第一個出現錯誤的子promise的error會被iterator.throw(error),從而被generatorFunction的try catch捕捉到,而后續的錯誤都不會被throw回去,也不會有任何的處理。generatorFunction當catch到第一個error就繼續往后執行了,也不會停下來進行等待。導致的情況就是第一個子promise出bug以后,其他的子promise的就被遺忘在了隕落的賽博坦星球,他們不管成功或者失敗,他們的data或者err我們都拿不到,而且很多時候我們甚至都無法終止他們(也就setTimeout和setInteval這種返回了句柄的可以終止),所以他們成了毫無意義的任務,一方面依然在執行,我們沒法終止,另一方面執行的結果和錯誤我們都根本拿不到,成了占用著網絡資源和計算能力卻又沒任何作用的吸血蟲,如果他們中存在閉包,而且這個任務又有可能一直卡住的話,那么你可能要小心一點了,他可能會造成內存泄露。

那我們應該怎么去寫并發情況下的錯誤控制,上面這種寫法的一個唯一的好處在于你可以在結果拿到之后第一時間得到錯誤信息,如果你在此處就是希望all or nothing,而且你不關心出錯的原因是什么,連日志記錄都不想要,就只希望出錯了不要繼續往下執行,或者你的并發的代碼極少出錯,那么也許上面的寫法你會采用。但是其中的風險你應該已經明白了。

如果你沒有那么強烈的快速知道錯誤發生并立即停止往下執行的需求(這種也許可以讓你的結果返回得更快那么一點點,然并卵,子promise任務并沒有被中斷),那么我覺得最好的方式還是等所有的子任務的執行完畢,不管他是成功或者失敗(因為它反正都在執行),這是一種無法終止已經開啟的異步任務和promise.all的回調只能執行一次的回退方案,至少保險。而且這樣的話,至少,你能有辦法記錄他出錯的原因,也能針對整個并行任務的完成情況,執行后續的處理策略。

既然子任務的error會導致父promise的執行失敗,那么就不能讓子promise的error直接拋出去,所以子promise yield出去前先綁好.catch是肯定需要的,而且要處理好.catch綁定的錯誤回調里的返回值,不然我們雖然接住了錯誤,但是co返回到generatorFunciton里面的卻是undefined,我們不知道錯誤在哪了。

舉個例子,我們可以寫一個包裝函數,然后為我們的每一個promise綁定好成功回調和錯誤回調,并借助閉包,讓他們成功或失敗后修改一個對象里面的對應屬性,我們根據這個對象去判斷是否執行成功。

function protect (promiseArr) {
    //這個數組用于存儲最后的結果
    let resultArr = []
    //為每個promise綁定成功和失敗回調
    promiseArr.forEach((promise,index) => {
        //在resultArr的對應位置先插入一個對象
        resultArr[index] = {
            success: true,
        }
        promiseArr[index] = promise.then((data) => {
            //如果成功,那么把結果寫回結果數組中的對象
            resultArr[index].data = data
        },(err) => {
            //失敗就寫入失敗原因
            resultArr[index].error = err
            resultArr[index].success = false
        })
    })
    // 這一步綁定.then必可不能少
    return Promise.all(promiseArr).then(() => {
        return resultArr
    })
}
function generateRandomPromise() {
    //這個函數隨機產生一個promise,這個promise在100毫秒內可能成功or失敗
    let randomNum = Math.random() * 100
    return new Promise (function(resolve, reject) {
        setTimeout( function() {
            if(randomNum > 50){
                resolve('data!')
            }else{
                reject(new Error('error'))
            }
        },randomNum)
    })
}

co(function * () {
    let promiseArr = []
    for (var i = 0; i < 10; i++) {
        promiseArr.push(generateRandomPromise())
    }
    //下面這一句不用包裹try catch
    let missionResults = yield protect(promiseArr)
    console.log(missionResults)
})

上面的代碼中,我們最后拿到的這個missionResults是一個數組,里面的每一個元素包含了我們的成功和失敗信息,這種方式因為每個子promise都不會失敗,所以也就不用在yield protect(promiseArr)外層包裹try catch,你要做的就是在拿到這個missionResult之后對里面的元素判斷成功或失敗,接著完成相關的處理,寫日志、向用戶返回500、嘗試重新執行錯誤的任務之類的,這些都比你一行直接的console.log('internal error')要好得多吧。

如果你的某一個子任務在卡住時會很長都不會reject,你覺得你可能難以接受所有的promise都必須成功或失敗時才執行完這個父promise,那么你大可以為這個子promise包裹一層Promise.race(),讓他和一個setTimeout并行執行,時間太長就當做錯誤了去處理,讓setTimeout優先返回(但是這依然是無法解決任務繼續在后臺執行的問題的)。總之這些都是你的細節處理。但是關于并發情況下的我們該怎么寫,各種寫法有什么隱患都說完了。我覺得雖然沒有找到完美的方法解決并行時候的錯誤處理,但是我覺得其實我們自己已經能夠根據自己的使用場景找到了應該還算是不錯的處理方法。其實如果你的并發非常容易出錯,出錯情況非常多,錯誤可能會長期卡住沒返回之類的,我覺得你可能需要思考和優化的是你的并發任務本身。(另外,我正在寫關于co使用過程中的小技巧的文章,里面會包含并發數控制的內容,寫好后會替換這個括號)。

koa里處理異常

koa里,我們通過app.use(function*(){}),綁定了許多的中間件generatorFunction,app.use就是負責把你傳入的generatorFunction放到一個中間件數組middleware里,接下來用koa-compose對這個中間件數組處理了一下,使得koa對中間件的執行流程成為了我們大家所熟知的洋蔥結構:

file

koa-compose的源碼如下,巨短,就是先拿到后一個generatorFunction的iterator,然后把這個iterator作為當前generatorFunction的next,這樣你在當前中間件里執行yield next,就可以執行后續的中間件流程,后續的中間件流程執行完之后又回到當前的中間件。而這一整套串好的中間件最終通過co包裹起來。

function compose(middleware){
  return function *(next){
    if (!next) next = noop();

    var i = middleware.length;

    while (i--) {
      next = middleware[i].call(this, next);
    }

    return yield *next;
  }
}
function *noop(){}

所以大概會像這樣:

function out() {
function mid(){
  function in() {
    //....
  }
}
}

也正是如此,內層的中間件中發生的錯誤,是能被外層的中間件給捕獲到的,也就是你先app.use()的中間件能捕捉到后app.use()的中間件的錯誤。

所以:

var app = new Koa()
app.use(function* (next){
  try{
    yield next
  }catch(err){
  //打印錯誤
  //日志記錄
  }
})
app.use(function* (next){
  //業務邏輯
})

同時,我們說了,這一整套串好的中間件最終通過co包裹起來(其實是co.wrap),因此co會返回一個promise(我在前一節說過哈),因此如果這一整套串好的中間件在執行過程中出了什么錯沒有被catch住,那么最終會導致co返回的這個promise的reject,而koa在這個promise上通過.catch綁定了一個默認的錯誤回調,錯誤回調就是設置http status為500,然后把錯誤信息this.res.end(msg)發送出去。因此出錯時,瀏覽器會收到一個說明錯誤的500報文。

同時,這個錯誤回調里執行了this.app.emit('error', err, this),koa的app是繼承自event模塊的EventEmitter,所以可以在app上觸發error事件,而你也可以在app上面監聽錯誤回調,完成最差情況下的錯誤處理。

koa下的錯誤處理方式還是比較齊全的,另有koa-onerror等npm包可供使用,使用了koa-onerror之后,你可以在中間件里直接this.throw(500,\"錯誤原因\"),它會自動根據request的header的accept字段,返回客戶端能accept的返回類型(比如客戶端要application/json,那么返回的body就是{error:\"錯誤原因\"})。npm上有較多類似的包,不再贅述。

此外就是萬一還是出現了你沒有捕捉到的錯誤,在node里如果有未捕捉的錯誤時,會在process上觸發事件uncaughtException,所以我們可以在process上監聽此事件,但是并不應該是單純的把error log記錄好就完了,很明顯如果這個錯誤也是卡住著的,內存是不會回收的,那么很有可能會發生內存泄露的錯誤,對于koa這種需要長期跑著的程序,這是相當大的風險的。所以最好的方法是把這個server關掉。

你聽到這可能就有點崩潰了,“什么?我想方設法不讓服務器掛掉,結果你主動給我關了?”所以為了讓你的服務還能繼續運行,比較好的方法就是用node的cluster模塊起一個master,然后master里面fork出幾個child_process,每個child_process就是你對應的koa server,當child_process遇到錯誤時,那么應該記錄日志,然后把koa給停了,并通過disconnect()告訴master“我關閉了,你再fork一個新的”,再接著等連接都關閉了,把自己給 process.exit()了。但是如果你想實現更加優雅的退出,想實現當前的連接都關閉之后再關閉服務器,那應該怎么做?

我們是
二手轉轉前端(大轉轉FE)
知乎專欄:https://zhuanlan.zhihu.com/zhuanzhuan
官方微信公共號:zhuanzhuanfe
微信公眾二維碼:

微信公眾二維碼

關注我們,我們會定期分享一些團隊對前端的想法與沉淀

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,606評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,582評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,540評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,028評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,801評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,223評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,294評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,442評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,976評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,800評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,996評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,543評論 5 360
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,233評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,662評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,926評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,702評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,991評論 2 374

推薦閱讀更多精彩內容