原文點選這裡
在js世界裡,我們眾所周知的惡魔,或許沒有那麼可怕,我們是不是多了一些誤解?
走進回撥地獄
我不會對術語回撥地獄挖的太深,僅僅只是通過這篇文章解釋一些問題和典型的解決方案。如果你對這個術語還不太熟悉,可以先去看看其他的文章。我會一直在這等你回來!
Ok,我先複製貼上一下問題程式碼,然後,讓我們一起用回撥函式來解決,而不是採用promise/async/await
。
const verifyUser = function(username, password, callback) {
dataBase.verifyUser(username, password, (error, userInfo) => {
if (error) {
callback(error);
} else {
dataBase.getRoles(username, (error, roles) => {
if (error) {
callback(error);
} else {
dataBase.logAccess(username, error => {
if (error) {
callback(error);
} else {
callback(null, userInfo, roles);
}
});
}
});
}
});
};
複製程式碼
壓垮金字塔
觀察程式碼,你會發現,每次需要執行非同步操作時,必須傳遞一個回撥函式來接收非同步的結果。 由於我們線性且匿名定義了所有的回撥函式,致使它成為一個自下而上,層層危險疊加的回撥函式金字塔(實際過程中,這種巢狀可能會更多,更深,更復雜)。
第一步,我們先簡單重構一下程式碼:將每個匿名函式賦值給獨立的變數。引入柯里化引數(curried aruguments)來繞過環境作用域中的變數。
const verifyUser = (username, password, callback) =>
dataBase.verifyUser(username, password, f(username, callback));
const f = (username, callback) => (error, userInfo) => {
if (error) {
callback(error);
} else {
dataBase.getRoles(username, g(username, userInfo, callback));
}
};
const g = (username, userInfo, callback) => (error, roles) => {
if (error) {
callback(error);
} else {
dataBase.logAccess(username, h(userInfo, roles, callback));
}
};
const h = (userInfo, roles, callback) => (error, _) => {
if (error) {
callback(error);
} else {
callback(null, userInfo, roles);
}
};
複製程式碼
如果沒點其他東西的話,肯定有點吹捧的意思。但是這些程式碼仍然有以下的問題:
if (error) { ... } else { ... }
模式重複使用;- 變數名字對邏輯毫無意義;
verifyUser
、f
、g
和h
相互高度耦合,因為他們互相引用。
看看這種模式
在我們處理任何這些問題之前,讓我們注意這些表示式之間的一些相似之處:
所有這些函式都接受一些資料和callback
引數。f,g並且h另外接受一對引數(error, something)
,其中只有一個將是一個非null/ undefined
值。如果error
不為null
,該函式立即拋給callback並終止。否則,something
會被執行來做更多的工作,最終導致callback
接收到不同的錯誤,或者null和一些結果值。
腦海中記住這些共性,我們將開始重構中間表示式,使它們看起來越來越相似。
魔術化妝!!
我發現if
語句很累贅,所以我們花點時間用三元表示式來代替。由於返回值被丟棄,以下程式碼不會有任何的行為。
const f = (username, callback) => (error, userInfo) =>
error
? callback(error)
: dataBase.getRoles(username, g(username, userInfo, callback));
const g = (username, userInfo, callback) => (error, roles) =>
error
? callback(error)
: dataBase.logAccess(username, h(userInfo, roles, callback));
const h = (userInfo, roles, callback) => (error, _) =>
error ? callback(error) : callback(null, userInfo, roles);
複製程式碼
柯里化
因為我們即將開始用函式引數進行一些嚴肅的操作,所以我將藉此機會儘可能的柯里化函式。
我們不能柯里化(error,xyz)
引數,因為databese
API期望回撥函式攜帶兩個引數,但是我們可以柯里化其他引數。我們後面將圍繞dataBase
API 使用以下柯里化包裝器:
const dbVerifyUser = username => password => callback =>
dataBase.verifyUser(username, password, callback);
const dbGetRoles = username => callback =>
dataBase.getRoles(username, callback);
const dbLogAccess = username => callback =>
dataBase.logAccess(username, callback);
複製程式碼
另外,我們替換callback(null, userInfo, roles)
為callback(null, { userInfo, roles })
,以便於除了不可避免的error
引數之外我們只處理一個引數即可。
const verifyUser = username => password => callback =>
dbVerifyUser(username)(password)(f(username)(callback));
const f = username => callback => (error, userInfo) =>
error
? callback(error)
: dbGetRoles(username)(g(username)(userInfo)(callback));
const g = username => userInfo => callback => (error, roles) =>
error ? callback(error) : dbLogAccess(username)(h(userInfo)(roles)(callback));
const h = userInfo => roles => callback => (error, _) =>
error ? callback(error) : callback(null, { userInfo, roles });
複製程式碼
把它翻出來
讓我們多做一些重構。我們將把所有錯誤檢查程式碼“向外”拉出一個級別,程式碼就會暫時變得清晰。我們將使用一個接收當前步驟的錯誤或結果的匿名函式,而不是每個步驟都執行自己的錯誤檢查,如果沒有問題,則將結果和回撥轉發到下一步:
const verifyUser = username => password => callback =>
dbVerifyUser(username)(password)((error, userInfo) =>
error ? callback(error) : f(username)(callback)(userInfo)
);
const f = username => callback => userInfo =>
dbGetRoles(username)((error, roles) =>
error ? callback(error) : g(username)(userInfo)(callback)(roles)
);
const g = username => userInfo => callback => roles =>
dbLogAccess(username)((error, _) =>
error ? callback(error) : h(userInfo)(roles)(callback)
);
const h = userInfo => roles => callback => callback(null, { userInfo, roles });
複製程式碼
注意錯誤處理如何完全從我們的最終函式中消失:h
。它只接受幾個引數然後立即將它們輸入到它接收的回撥中。
callback
引數現在在各個位置傳遞,因此為了保持一致性,我們將移動引數,以便所有資料首先出現並且callback最後出現:
const verifyUser = username => password => callback =>
dbVerifyUser(username)(password)((error, userInfo) =>
error ? callback(error) : f(username)(userInfo)(callback)
);
const f = username => userInfo => callback =>
dbGetRoles(username)((error, roles) =>
error ? callback(error) : g(username)(userInfo)(roles)(callback)
);
const g = username => userInfo => roles => callback =>
dbLogAccess(username)((error, _) =>
error ? callback(error) : h(userInfo)(roles)(callback)
);
const h = userInfo => roles => callback => callback(null, { userInfo, roles });
複製程式碼
逐漸形成的模式
到目前為止,您可能已經開始在混亂中看到一些模式。特別是callback通過計算進行錯誤檢查和執行緒處理的程式碼非常重複,可以使用以下兩個函式進行分解:
const after = task => next => callback =>
task((error, v) => (error ? callback(error) : next(v)(callback)));
const succeed = v => callback => callback(null, v);
複製程式碼
我們的步驟變成:
const verifyUser = username => password =>
after(dbVerifyUser(username)(password))(f(username));
const f = username => userInfo =>
after(dbGetRoles(username))(g(username)(userInfo));
const g = username => userInfo => roles =>
after(dbLogAccess(username))(_ => h(userInfo)(roles));
const h = userInfo => roles => succeed({ userInfo, roles });
複製程式碼
是時候停一下了,嘗試將after
和suceed
內聯入這些新的表示式中。這些新表達確實等同於我們考慮的因素。
OK,看一下,f
、g
和h
看起來已經沒什麼用了呢!
減負
······所以,讓我們甩了它們!我們所要做的就是從h向後,將每個函式內聯到引用它的定義中:
// 內聯 h 到 g 中
const g = username => userInfo => roles =>
after(dbLogAccess(username))(_ => succeed({ userInfo, roles }));
複製程式碼
// 內聯 g 到 f
const f = username => userInfo =>
after(dbGetRoles(username))(roles =>
after(dbLogAccess(username))(_ => succeed({ userInfo, roles }))
);
複製程式碼
// 內聯 f 到 verifyUser
const verifyUser = username => password =>
after(dbVerifyUser(username)(password))(userInfo =>
after(dbGetRoles(username))(roles =>
after(dbLogAccess(username))(_ => succeed({ userInfo, roles }))
)
);
複製程式碼
我們可以使用引用透明度來引入一些臨時變數並使其更具可讀性:
const verifyUser = username => password => {
const userVerification = dbVerifyUser(username)(password);
const rolesRetrieval = dbGetRoles(username);
const logEntry = dbLogAccess(username);
return after(userVerification)(userInfo =>
after(rolesRetrieval)(roles =>
after(logEntry)(_ => succeed({ userInfo, roles }))
)
);
};
複製程式碼
現在你已經得到了!它相當簡潔,沒有任何重複的錯誤檢查,甚至和promise
模式有點相似。你會像這樣呼叫verifyUser
:
const main = verifyUser("someusername")("somepassword");
main((e, o) => (e ? console.error(e) : console.log(o)));
複製程式碼
最終程式碼
// callback測序工具APIs
const after = task => next => callback =>
task((error, v) => (error ? callback(error) : next(v)(callback)));
const succeed = v => callback => callback(null, v);
// 柯里化後的database Api
const dbVerifyUser = username => password => callback =>
dataBase.verifyUser(username, password, callback);
const dbGetRoles = username => callback =>
dataBase.getRoles(username, callback);
const dbLogAccess = username => callback =>
dataBase.logAccess(username, callback);
// 成果
const verifyUser = username => password => {
const userVerification = dbVerifyUser(username)(password);
const rolesRetrieval = dbGetRoles(username);
const logEntry = dbLogAccess(username);
return after(userVerification)(userInfo =>
after(rolesRetrieval)(roles =>
after(logEntry)(_ => succeed({ userInfo, roles }))
)
);
};
複製程式碼
終極魔法
我們完成了嗎?有些人可能仍然覺得verifyUser
的定義有點過於三角化。有辦法解決,但是首先我們做點其他的事。
我沒有獨立發現重構此程式碼時定義after
和succeed
過程。我實際上預先定義了這些定義,因為我從Haskell庫中複製了它們,它們的名稱為>>=
和pure
。這兩個函式共同構成了"continuation monad"
(譯者注:可以理解為把巢狀式的金字塔結構打平變成鏈式結構能力的一種模式)的定義。
讓我們以不同的方式格式化定義verifyUser
:
const verifyUser = username => password => {
const userVerification = dbVerifyUser(username)(password);
const rolesRetrieval = dbGetRoles(username);
const logEntry = dbLogAccess(username);
// prettier-ignore
return after (userVerification) (userInfo =>
after (rolesRetrieval) (roles =>
after (logEntry) (_ =>
succeed ({ userInfo, roles }) )));
};
複製程式碼
更換succeed
和after
與那些奇怪的別名:
const M = { ">>=": after, pure: succeed };
const verifyUser = username => password => {
const userVerification = dbVerifyUser(username)(password);
const rolesRetrieval = dbGetRoles(username);
const logEntry = dbLogAccess(username);
return M[">>="] (userVerification) (userInfo =>
M[">>="] (rolesRetrieval) (roles =>
M[">>="] (logEntry) (_ =>
M.pure ({ userInfo, roles }) )));
};
複製程式碼
M
是我們對"continuation monad"
的定義,具有錯誤處理和不純的副作用。這裡省略了細節以防止文章變長兩倍,但是這種相關性是有許多方便的方法來排序不受金字塔末日效應影響的單子計算("continuation monad"
)。沒有進一步的解釋,這裡有幾種表達方式verifyUser
:
const { mdo } = require("@masaeedu/do");
const verifyUser = username => password =>
mdo(M)(({ userInfo, roles }) => [
[userInfo, () => dbVerifyUser(username)(password)],
[roles, () => dbGetRoles(username)],
() => dbLogAccess(username),
() => M.pure({ userInfo, roles })
]);
複製程式碼
//適用提升
const verifyUser = username => password =>
M.lift(userInfo => roles => _ => ({ userInfo, roles }))([
dbVerifyUser(username)(password),
dbGetRoles(username),
dbLogAccess(username)
]);
複製程式碼
我故意避免在這篇文章的大部分內容中引入型別簽名或monad
這樣的概念,以使事情變得平易近人。也許在未來的帖子中,我們可以用我們頭腦中最重要的monad
和monad-transformer
概念重新推匯出這種抽象,並特別注意型別和規律。
致謝
非常感謝@jlavelle,@mvaldesdeleon和@gabejohnson提供有關此帖子的反饋和建議。