上一篇文章中,主要是介紹了什麼是異步編程,而這從這篇文章開始,我會介紹一些異步編程的一些解決方案。
目前異步編程的解決方案主要有一下幾種:
1.事件發佈/訂閱模式
2.Promise/Deferred模式
3.流程控制庫
而我們這一篇文章主要是介紹第一種,即事件發佈/訂閱模式,後續會介紹Promise/Deferred模式以及es6的Promise實現,至於第三種,我並不是太過於瞭解,所以,近期不會介紹。
好了,話不都說,下面就開始介紹事件發佈/訂閱模式是如何改善我們的異步編程的。
事件發佈/訂閱模式
說到前端的事件,腦子第一想到的估計就是js給dom添加的那些事件,但是,這裏的異步編程靠的並不是那些已經被js定義好的那些事件,真正用於異步編程的是事件發佈/訂閱的這種模式:
在這個模式中,會存在一個事件對象,他的作用在於發佈事件,叫做發佈者。
還有一個叫做觀察者(或者訂閱者)的用來訂閱發佈者所發佈出來的事件。
當發佈者中所發佈的某一個事件發生時,發佈者會通知(其實就是調用)所有訂閱了這個事件的觀察者。
其實他們的關係就好比雜誌社和讀者的關係:
雜誌社發佈了一種雜誌,讀者可以訂閱這種雜誌。而每當雜誌社每個月發佈最新的一期的雜誌時,都會把最新的雜誌寄給所有訂閱了這種雜誌的讀者。就是這麼個關係。
在這裏或者說在前端中,事件對象(發佈者)是一個對象,他可以發佈事件,取消事件,觸發事件,並能夠通知所有訂閱了這個事件的觀察者。而觀察者說白了就是一個函數、方法,當發佈者觸發了某個事件時,通知觀察者其實就等價於調用這個函數罷了。
好,現在來說一說這種事件發佈/訂閱模式和異步編程的關係以及他爲什麼會適合異步編程的思想
異步編程在執行了某個異步操作時,直接返回,不會再去理會,而我們只需要爲其添加一個回調函數,用於當異步操作結束時,再執行這個回調函數即可。
而事件模式和這種異步思維極其類似。我訂閱某個事件,並把事件發生時的處理函數作爲觀察者添加上去,當事件發生時,發佈者會執行觀察者這個處理函數。他是回調函數的事件化,極其類似卻更加靈活。
熟悉前端的都非常熟悉dom事件了,不過,其實專門用於異步編程的事件發佈/訂閱模式,可是比前端大量dom事件更爲簡單,因爲用戶異步編程的事件訂閱/發佈模式不存在事件捕獲,冒泡,preventDefault()以及sotpPropagation()這些東西。所以,是不是聽起來鬆了一口氣。確實是這樣,因爲異步編程的事件發佈/訂閱模式根據上面所解釋的,只需要以下幾個函數:
on 用戶註冊觀察者
removeListener 刪除某一個事件的所有觀察者(其實就等於刪除這個事件了)
emit 觸發某一個事件,從而調用這個事件的所有觀察者。
// 其他
once 註冊只執行一次的事件,只觸發一次就刪除的事件
removeAllListener 刪除這個事件對象的所有事件。
我們來看一下事件訂閱/發佈模式的使用:
// 訂閱
emitter.on("event1", function (message) {
console.log(message);
});
// 觸發
emitter.emit('event1', "I am message!");
// 打印 I am message!
其中emitter.on方法中第一個參數是訂閱的事件名稱,第二個參數是事件發生時的處理函數,也就是觀察者。而emitter.emit則是用來觸發event1這個事件,"I am message!" 是傳遞的參數,這個參數會傳遞給觀察者。
一個事件可以有多個觀察者,也就是可以有多個處理函數,並且事件和觀察者可以隨意的刪除和修改,非常的靈活,並且事件發生和處理函數之間可以很方便的解耦。我不管這個事件發生時怎麼去處理,也不用管這個事件有多少個觀察者,而且數據通過消息參數的方式可以很靈活的傳遞。
那麼事件訂閱/發佈模式他對比普通的回調函數,可以解決異步編程的哪些問題呢?
1.利用事件解決雪崩問題
首先這一個是上述異步編程存在的問題中所沒有提及的,因爲他也不算是異步本身編程的問題吧。那到底是啥問題呢?
這個例子是典型,所以我直接從書中拿來用了啊:
在計算機中,通常緩存用於加速對同一數據的重複請求,所謂雪崩問題就是在高訪問,大併發的情況下,緩存失效的情景。這時候,大量數據請求同時訪問服務器,服務器無法同時處理這麼多的處理請求,導致影響網站整體響應速度。
比如請求數據庫:
var select = function (callback) {
db.select("SQL", function (results) {
callback(results);
});
};
如果這時候,站點剛好啓動,那麼緩存不存在,那麼同一條sql會被反覆在數據庫中查詢,影響服務的整體性能(這裏查詢出來的數據設定爲都是相同的結果)。
那麼我們應該在第一條數據請求時才真正執行查詢數據庫,而後續的請求如果發現如果已經有一條數據庫在執行了,那麼就應該等待第一條數據請求返回結果,然後讓後續的請求都直接使用這個結果即可。這樣想是不是比較合理,那麼該如何實現?
這時候可以使用事件的once方法這種執行一次就刪除的特點來很方便的實現(事件隊列)。
var event= new events;
var status = "ready"; //狀態鎖 ready爲準備中 pending爲執行中
var select = function (callback) {
event.once("selected", callback);
if (status === "ready") {
status = "pending";
db.select("SQL", function (results) {
event.emit("selected", results);
status = "ready";
});
}
};
上述代碼中,使用status來標識,判斷是否有請求去訪問數據庫了,並且把所有請求的回調都作爲觀察者,觀察selected事件的發生。當第一條請求A進入時,執行select方法,把回調添加至觀察者,然後發現status爲ready,也就是沒有請求在查詢數據庫,那麼就會去查詢數據庫,並把狀態設置爲正在請求“pending”。然後立馬第二條請求進來,發現已經在查詢數據庫了,那麼就不再查詢,只是添加回調在觀察者隊列中。然後等待數據庫查詢出結果後,會觸發selected事件,把查詢出的結果傳遞給所有請求的回調函數,並執行回調,並更改status的狀態。
這裏針對相同的sql語句,保證查詢開始到結束的過程永遠只有一次。而其他請求只需要等待查詢返回結果即可。這樣,可以節省重複數據請求的開銷,而且不止用於緩存失效的情況,還可以用於有些時候不太好設置緩存的情況。
上面這個例子是《深入淺出node.js》書籍中的異步編程解決方案中的。然後說一下我在實際中遇到的一個類似問題,感覺也可以使用這種方式解決:
在我寫一個抽獎小程序時,後臺大佬,爲了跟隨微信官方小程序的登陸標準,從而商量使用登陸態(一串字符串)而不是openid來作爲用戶的登陸狀態。這個登陸態啊,和openid不一樣,他對於用戶來說不是唯一的,而且還設置了一天的時間限制,一天沒有登陸就失效了。而且,每個接口都在請求時,都需要傳遞登陸態過去驗證。其實吧,這本身也沒什麼大問題,不過是爲了舉例從而拿出來說而已:
1.接口A,B,C請求時都需要登錄態。
2.而登錄態沒有傳遞,或者登錄態過期時,都會返回一個300給我。
3.如果返回300了,這時候,我會請求wx.login獲取code並且使用code向後臺請求login接口獲取到新的登陸態(這個接口不需要傳登陸態)然後,再使用這個新的登陸態重新請求那個A,B,C,並設置登陸態緩存。
﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋我是華麗分割線﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊
┈━═┈━═┈━═┈━═┈━═☆、┈━═┈━═┈━═┈━═┈━═☆、┈━═┈━═┈━═┈━═┈━═☆、┈━═┈━═┈━═┈━═┈━═☆、
看着沒毛病是吧,但是問題在這裏:接口的請求,不是一起的,沒有任何關聯,也就是A,B,C三個接口是分開請求的,那麼:
我第一次進入小程序時,或者緩存登陸態過期的時候,那麼:
A接口的請求行爲:請求A接口 -> 報300 -> wx.lonin並請求login接口獲取新登陸態 -> 使用新登陸態再請求A接口並設置登陸態緩存。
然而麻煩的是,剛進入小程序頁面時,A,B,C接口同時請求,各不相干,而且絕對會全部報300(因爲剛進入小程序沒有登陸態緩存),但他們三個互相不知道,是獨立的,所以B,C也同時走了一遍和接口A一樣的請求行爲,也就是全部都重新請求了一遍新的登陸態。然而這會有一點點影響頁面加載的速度的,雖然問題不大但例子卻很典型。
這時候如果使用一般的解決辦法:
那也無非就是用一個數組,然後報300時,判斷如果正在請求新的登陸態了,那麼就把這個請求接口函數給存到數組中先存着,然後等登陸態請求到了,在把這些請求從數組中取出來請求一下。
看到這,不就是上面數據庫中使用事件訂閱/發佈模式的once方法解決的問題一樣的套路嗎:在A(或者其他請求)請求返回300時,把A請求添加到一個名爲logining的once事件中,請求新的登陸態(這時候會刪除掉緩存,並且有一個登陸態請求中的status字段),並等待新的登陸態返回,然後B,C如果也返回了300,這時候發現登陸態正在請求中,那麼也把B,C請求添加到logining這個once事件中,等待新的登陸態請求返回。等待新的登陸態請求返回了,就直接觸發logining事件,重新用新的登陸態請求A,B,C接口,並把status設置爲準備中。這樣就不用多次請求新的登陸態了。
此處可能還有一種情況,A在重新請求登陸態時,B,C可能等到A重新請求到新的登陸態了,B,C還沒有返回狀態,這時候,當B,C請求返回時,A已經使用了新的登陸態,並且,沒有接口正在請求新的登陸態,那麼B又會重新進行請求新的登陸態的行爲,這明顯不是想要的。所以,這時候可以在接口重新請求新登陸態之前加一個判斷,判斷緩存中的登陸和我這一次失敗時所使用的登陸態是否是一樣的,如果是一樣的,那麼就請求新的登陸態,如果不是,那麼使用緩存中登陸態的再次重新請求這個接口。至於如何拿到我這次請求所使用的登陸態,可以根據你的代碼編寫情況看是否拿得到,或者和後端商量,在發現登陸態失效時,再把這個登陸態再返回給你即可。
這是事件發佈/訂閱模式可以解決的第一個問題:重複請求相同數據的問題。並且實現起來相當容易。
2.代碼嵌套過深和多異步協同問題
代碼嵌套過深和多異步協同問題我在我的關於異步編程的第一篇文章:何爲異步編程 中已經提出來了,即如果每個操作依賴於另外一個異步操作的結果,那麼可能多個異步操作可能會出現許多個異步回調的嵌套過深的問題,而多異步協同問題指:如果一個操作依賴於多個異步操作的返回值,那麼如何既利用異步操作帶來的性能提升,又能夠避免嵌套,寫出優美的代碼呢?我們可以嘗試使用事件發佈/訂閱模式來解決一下這些問題。
1.使用事件發佈/訂閱模式解決嵌套問題:以nodejs中讀取文件爲例,先讀取test.txt文件內容,然後以test.txt文件中內容爲路徑,請求test2.txt文件的數據,並打印出來
test.txt文件的內容爲:./test2.txt
test2.txt文件的內容爲:我是test2文件的內容
const fs = require("fs"); // nodejs 文件模塊(用於操作文件的模塊)
const event = require("events"); // nodejs 的事件模塊
const readFileEvent = new event.EventEmitter(); //創建事件對象
// 監聽事件
readFileEvent.on("readText1Succeed",(data) => {
let file1Data = data;
// 根據讀取到的test.txt文件的內容爲地址,讀取test2.txt文件的內容。
fs.readFile(file1Data.toString(),(err,data) => {
readFileEvent.emit("readText2Succeed",data)
})
})
// 綁定文件test2.txt讀取成功時的處理函數
readFileEvent.on("readText2Succeed",(data) => {
console.log("文件2的內容爲:"+data.toString());
})
// 讀取test.txt的內容
fs.readFile("./test.txt",(err,data) => {
readFileEvent.emit("readText1Succeed",data)
})
// 打印:
我是test2文件的內容
以上就是使用事件發佈/訂閱模式來實現的文件讀取操作。使用事件的訂閱,來監聽文件的一的讀取成功,然後在文件一讀取成功時,讀取文件二,當文件二讀取成功時觸發文件二的成功事件,因爲文件讀取成功時的處理函數可以在別的地方通過on進行綁定,所以,就沒有多回調的嵌套問題。
那麼事件訂閱/發佈模式又是如何解決多異步協同問題呢?在此之前,我們先來回顧一下需要多異步協同的個api的調用過程:
1.wx.login登陸獲取code
2.使用code向後臺請求openid
3.使用openid獲取用戶綁定的門店,還需要通過openid獲取這個用戶的團購id(此處需要多異步協同)
4.再用通過門店和團購id,請求該門店的團購商品。
他的問題在於:請求門店和請求團購id不在同一個接口中,第3步中,獲取門店和團購id他們都是兩個不同的異步操作。但是,我的下一步4中的操作要依賴這兩個異步的結果那麼如何既可以使用異步請求帶來的性能提升又可以比較優美且不那麼麻煩的編寫和處理好代碼呢?
一般的方法爲使用哨兵變量,即用來記錄次數之類的變量:
(以下代碼爲了可理解性,使用了半僞代碼表示)
var count = 0; //哨兵變量
var results = {}; //存儲每個接口返回的結果的變量
var done = function (key, value) {
results[key] = value;
count++;
if (count === 2) {
// 當門店和團購id都獲取到時,執行4。
}
}
使用openid獲取門店
當獲取成功執行done函數:done('store',data)
使用openid獲取團購id
當獲取成功執行done函數:done('bulkId',data)
上面代碼中,獲取門店和獲取團購id成功返回時,都會判斷其他請求是否也返回了,當門店和團購id都返回時,才執行後面的第4步
一般這麼寫沒什麼問題的。不嫌麻煩也還可以。那麼看一下使用事件訂閱/發佈模式怎麼寫吧:
var events = require("events");
var emitter = new events.EventEmitter(); // 創建新的事件對象
var count = 0; //哨兵變量
var results = {}; //存儲每個接口返回的結果的變量
var done = function (key, value) {
results[key] = value;
count++;
if (count === 2) {
// 當門店和團購id都獲取到時,執行4。
}
}
emitter.on("done", done);
使用openid獲取門店
當獲取成功時執行觸發done事件:emitter.emit("done", "store", data);
使用openid獲取團購id
當獲取成功時執行觸發done事件:emitter.emit("done", "bulkId", data);
這種其實就是上一個例子的一種事件發佈/訂閱模式的寫法罷了。不過真正在實際用途中,還是應該要封裝起來再用的,不然就不怎麼舒服了。
比如在附件中的event.js中的all方法可以這樣使用:
event.all("store", "bulkId", function (store, bulkId) {
// TODO
});
這個all其實就是對上面代碼的一種封裝。只有當"store", "bulkId"這兩個事件全部都觸發一次時,all的第二個參數的處理函數纔會被執行,並且,他的參數就是這兩個事件觸發時傳遞的參數。
還有一個和all不同的tail方法,他的區別在於all方法觸發時,處理函數只會執行一次,而tail的方法如果觸發了一次,監聽的"store", "bulkId"中任一一個事件再次觸發時,都會重新觸發tail的處理函數,並且傳遞的參數是使用最新的參數。
還有其他方法:after用於註冊,當一個事件觸發固定次數時,纔會執行給after所添加的回調函數。
以上就是事件發佈/訂閱模式針對嵌套回調和多異步協同這兩個異步編程的典型問題所做出的改善。不過,我們不妨也來看看他對於異步編程的異常處理,採取了一種怎樣的方式。
3.事件發佈訂閱的異常處理
異步方法中,異常處理還是佔用了一定的精力的。回調函數的異常處理一般無非就是如微信小程序接口中的,傳入一個成功時的回調,和一個異常時的回調函數。或者如node中的,只傳入一個回調函數,第一個參數爲err,如果有異常則err爲異常對象,如果沒有異常,err爲undefined。但是他們其實針對的異常捕獲都是針對於接口的調用時的異常捕獲,接口調用的成功或者失敗,而如果是我們寫的回調函數中拋出的異常,那他是無法捕獲到的,這一點要注意了。
而事件發佈/訂閱模式的異常處理通常爲:
每一個事件對象中應該有一個error事件,我們給這個error事件添加處理函數,當發生異常時,應該觸發這個error異常事件,並把錯誤對象傳遞給error事件的處理函數(所有在這個事件對象中的事件,發生異常都觸發這個事件對象的error事件,不是每個單獨的事件都有error事件,而是這個事件對象的所有事件共享error事件,不過你可以通過傳遞給error處理函數中的參數添加一個事件類型名稱來標識是哪個事件觸發的錯誤,並且,最好是根據這個來,這樣,可以爲不同事件處理不同的錯誤)。
一個異常處理的例子:如果讀取test.txt文件時發生錯誤,那麼會觸發readFileEvent事件對象的error事件,進行錯誤處理。
fs.readFile("./test.txt",(err,data) => {
if(err) { //發生錯誤,觸發error事件
readFileEvent.emit("error",err);
return;
}
//處理文件內容
})
// 監聽error事件,進行錯誤處理
readFileEvent.on("error",(err) => {
// 這裏進行錯誤處理
})
其實在使用時,可以進行一個代碼約定,約定:當發生錯誤時,再傳遞一個發生錯誤的標示符,那麼只有當某個標示符匹配時,才執行相應的處理函數,比如:
fs.readFile("./test.txt",(err,data) => {
if(err) {
readFileEvent.emit("error",err,"testError")
return;
}
//處理文件內容
})
fs.readFile("./test2.txt",(err,data) => {
if(err) {
readFileEvent.emit("error",err,"test2Error")
return;
}
//處理文件內容
})
// 針對讀取文件test時發生的錯誤處理
readFileEvent.on("error",(err,errorType) => {
if(errorType == "testError") {
// 錯誤:testError處理
}
})
// 針對讀取文件test2時發生的錯誤處理
readFileEvent.on("error",(err,errorType) => {
if(errorType == "test2Error") {
// 錯誤:test2Error處理
}
})
// 當不傳遞錯誤類型時的錯誤處理,即,通用的錯誤處理
readFileEvent.on("error",(err,errorType) => {
if(errorType === undefined) {
// 錯誤處理
}
})
以上大致就是事件發佈/訂閱模式的內容。其實事件的發佈訂閱模式相對於普通的異步回調函數來說,大致可以改善異步編程的嵌套問題,多異步協同以及還可以利用once方法解決隊列的雪崩問題。
不過,他其實還是有一些缺點存在的,比如:在解決回調嵌套問題時,其實每一個異步請求都需要事先設定好,一步一步的按照異步接口的順序進行事件的監聽以及觸發,在一開始接觸時對於這種代碼風格的改變還是有點不適應的,一旦再加上錯誤處理,而且想要精細的根據不同錯誤設置不同的處理方式,那就需要細心一點才能夠處理好代碼的關係了。
所以,事件發佈/訂閱模式還有許多值得研究的地方,對於如何將這種模式能夠運用實際代碼中,根據不同的需要使用他去解決實際的問題纔是我們學習瞭解事件訂閱/發佈的根本目的。
附加上我自己對事件訂閱/發佈模式的一種實現,不過只是很基礎的,並且僅僅用作學習練習之用(好像csnd資源不能設置免積分下載啊,):https://download.csdn.net/download/qq_33024515/10598482
但是在生產環境中,應該使用那些成熟的事件庫類,比如:
eventproxy github地址:https://github.com/JacksonTian/eventproxy
EventEmitter github地址:https://github.com/Olical/EventEmitter
下一篇我將會着重介紹由社區提出制定的Promise/Deferred模式,看一下由社區的力量所制定出來的規範,如何靠他來寫出優雅的異步編程代碼。
敬請期待!