乾貨 | Nodejs非同步程式設計詳解
點選上方“中興開發者社群”,關注我們
每天讀一篇一線開發者原創好文
作者簡介
作者廖元之是一名優秀的全棧開發工程師,對前端效能優化,資料視覺化等有自己獨到的理解。今天他為我們帶來nodejs非同步程式設計解決方案,希望對無論是web開發還是僅僅使用Nodejs做指令碼的同學都有所幫助。
寫在前面
python語法簡單易上手,又有大量的庫的支援,在工具指令碼這格領域體現了它的價值,地位不可動搖。我本來是也是使用python編寫一些指令碼的,但由於一些巧合、契機,我接觸到了Nodejs,基於一些個人原因,我更傾向使用Nodejs來編寫平時使用的工具指令碼,包括資料採集入庫之類,但由於js這個語言本身的一些特性,使得Nodejs作為指令碼來開發的話難度曲線相對陡峭,這篇文章我就關於Nodejs中最關鍵也是最難的非同步程式設計做一些介紹和講解
$這篇文章面向的讀者絕對不是對Nodejs完全沒用過的同學,讀者需要對Nodejs有簡單的瞭解$
Nodejs的非同步
Nodejs本身是單執行緒的,底層核心庫是Google開發的V8引擎,而主要負責實現Nodejs的非同步功能的是一個叫做libuv的開源庫,github上可以找到它。
先看幾行python程式碼
file_obj = open('./test.txt')
print(file_obj.read())
這行程式碼邏輯相當簡單,列印根目錄下一個名為test的txt檔案內容
相同的操作用Nodejs寫是這樣:
const fs = require('fs')
fs.read('./test.txt',(
if(err){
// throw an err
}else{
console.log(doc)
}
)
看起來相當的麻煩。
為什麼要這樣寫?根本原因就是Node的特點,非同步機制。關於非同步機制的深入理解我可能會另寫一篇文章
fs.read()
本身是一個非同步函式,所以返回值是非同步的,必須在回撥函式中捕獲,所以得寫成這個樣子。
一兩個非同步操作可能還好,但如果相當多的非同步操作需要序列執行,就會出現以下這種程式碼:
//callbackHell.js
fs.read('./test1.txt',(err,doc)=>{
//do something
let input =
fs.read('./test2.txt',(err,doc2)=>{
//do something
let input2 = someFunc2(doc2)
fs.write('./output.txt',input+input2,(err)=>{
// err capture
// some other async operations
})
})
})
連續的回撥函式的巢狀,會使得程式碼變得冗長,易讀性大幅度降低並且難以維護,這種情況也被稱作回撥地獄(calllback hell),為了解決這個問題,ES標準推出了一套非同步程式設計解決方案
Promise
人們對於新事物的快速理解一般基於此新事物與生活中某種事物或者規律的的相似性,但這個promise並沒有這種特點,在我看來,可以去類比promise這個概念的東西相當少,而且類比得相當勉強,但這也並不意味著promise難以理解。
promise本身是一個物件,但是可以看做是是一種工具,一種從未見過的工具,解決的是Nodejs非同步介面序列執行導致的回撥地獄問題,它本身對程式碼邏輯沒有影響
廢話少說,直接上程式碼:
function promisifyAsyncFunc(){
returnnewPromise((resolve,reject)=>{
fs.read('./test1.txt'.(err.doc)=>{
if(err)reject(err)
else resolve(doc)
})
})
}
promisifyAsyncFunc()
.then(res=>{
console.log(`read file success ${res}`)
})
.catch(rej=>{
console.log()
})
與之前的非同步程式碼不同的是,我們在非同步函式外層包了一層返回promise物件的函式,promise物件向自己包裹的函式裡傳入了兩個函式引數resolve
和reject
,在非同步函式內部通過呼叫resolve
向外傳遞操作成功資料,呼叫reject
向外傳遞錯誤資訊。
關於promise物件使用的語法牽涉到es6的最新規範和函數語言程式設計,這裡不做詳細介紹
接著我們呼叫這個函式,鏈式呼叫promise物件提供的介面函式.then(function(res){//TODO})
將非同步函式向外傳遞的值取出來,使用.catch()
捕獲內部傳遞的錯誤。
最基本的promise用法大致就是這樣,但這樣看依然沒明白它如何避免了回撥地獄,這裡我們使用promise改寫callbackHell.js檔案
//promisifY.js
function promisifyAsyncFunc(){
returnnewPromise((resolve,reject)=>{
fs.read('./test1.txt'.(err.doc)=>{
if(err)reject(err)
else resolve(doc)
})
})
}
function promisifyAsyncFunc2(input){
returnnewPromise((resolve,reject)=>{
let output1 = someFunc(input)
fs.read('./test2.txt'.(err.doc)=>{
if(err)reject(err)
else resolve({
output1,
doc
})
})
})
}
function promisifyAsyncFunc3({output1,doc}){
returnnewPromise((resolve,reject)=>{
let outpu2 = someFunc2(doc)
fs.write('./output.txt',output1+output2,(err)=>{
// err capture
})
})
}
// some other prmisify function
promisifyAsyncFunc()
.then(promisifyAsyncFunc2)
.then(promisifyAsyncFunc3)
//.then()
程式碼這樣寫應該會看的比較清楚,我們把每個非同步函式都封裝在promise物件裡面,然後通過promise的鏈式呼叫來傳遞資料,從而避免了回撥地獄。
這樣的程式碼可讀性和維護性要好上不少,但很顯然程式碼量增加了一些,就是每個函式的封裝過程,但node裡的util庫中的promisify
函式提供了將滿足node回撥規則的函式自動轉換為promise物件的功能,若沒有對非同步操作的複雜訂製,可以使用這個函式減少程式碼量
雖然promise相對於原生的回撥來說已經是相當良好的程式設計體驗,但對於追求完美的程式設計師來說,這依舊不夠優美,於是es規範在演進的過程中又推出了新的非同步程式設計方式
Generator
Generator並不是最終的非同步解決方案,而是Promise向最終方案演進的中間產物,但是其中利用到的迭代器設計模式值得我們學習和參考。這裡不對這種方法多做介紹,因為有了async,一般就不再使用Generator了。
async/await
Async/Await其實是Generator的語法糖,但是因為使用的時候使非同步程式設計似乎完全變成了同步程式設計,體驗異常的好,而且這是官方推出的最新規範,所以廣受推崇,這裡就對如何使用它進行一些介紹說明。
先看Async的語法,用起來真的是相當簡單
async function main(){
const ret = await someAsynFunc();
const ret2 = await otherAsyncFunc(ret)
return someSyncFunc(ret,ret2)
}
定義一個函式,函式申明前加上一個
async
關鍵字,說明這個函式內部有需要同步執行的非同步函式此函式需要同步執行的非同步函式必須返回的是promise物件,就是我們之前用promise包裹的那個形式
在需同步執行的非同步函式呼叫表示式前加上
await
關鍵字,這時函式會同步執行並將promise物件resolve出來的資料傳遞給等號之前的變數
我們再使用async/await改寫promisify.js檔案
//async/await.js
const promisify = require('util').promisify //引入promisify函式,簡化promise程式碼
const read = promisify(fs.read)
const write = promisify(fs.write)
async function callAsyncSync(){
const res1 = await read('./test1.txt')
const res2 = await read('./test2.txt')
const output1 = someFunc(res1)
const output2 = someFunc(res2)
write('./output.txt',output1+output2)
return
}
這樣看程式碼就像是同步的, 比python速度還快很多,可惜的就是相對於python學習曲線比較陡峭。
這種方式寫出的程式碼可讀性可維護性可以說是非常強,完全沒有之前的回撥地獄或者原生promise帶來的副作用。
進階
試想這麼一種場景:
我們需要從多個數據庫中讀取資料,讀取完成的順序無所謂.
我們需要在多次資料讀取全部完成之後再從每個資料中篩選出某種相同的屬性
再對這些屬性進行一些自定義操作,得到結果資料
最後將結果資料插入某個資料庫
假設每一步的具體實現函式全部已經編寫完成,所有非同步的函式都已經封裝成promise,那麼用原生js組裝以上四步程式碼需要怎麼寫?
我粗略估計一下可能需要二十行左右,而且程式碼的可讀性不會很好,這裡我簡單介紹一個庫:RxJS,中文名為響應式JS。
響應式程式設計發展已久,許多語言都有實現相應的庫,對應的名字也以Rx開頭,比如RxJava。
不說RxJS的設計原理,它的使用都牽涉到多種設計模式和技術,包括觀察者模式,管道模式,函數語言程式設計等,可以說這是一個上手難度相當大的技術,但它帶來的程式設計體驗是相當的好,這裡我給出使用RxJS實現以上四步的程式碼
constOb= require('rxjs/Rx').Observerble//Rxjs的核心觀察者物件
const promiseArray = require('./readDatabase')//封裝好的讀資料庫函式陣列
const myfilter = require('./filter')//資料屬性過濾函式
const