轉自:SwiftCafe公眾號
關於 Callback Hell
Callback Hell 就是異步回調函數過多的嵌套,導致的代碼可讀性下降以及出錯率提高的問題。比如這樣一段代碼:
fs.readdir(source, function (err, files) { if (err) { console.log('Error finding files: ' + err) } else { files.forEach(function (filename, fileIndex) { console.log(filename) gm(source + filename).size(function (err, values) { if (err) { console.log('Error identifying file size: ' + err) } else { console.log(filename + ' : ' + values) aspect = (values.width / values.height) widths.forEach(function (width, widthIndex) { height = Math.round(width / aspect) console.log('resizing ' + filename + 'to ' + height + 'x' + height) this.resize(width, height).write(dest + 'w' + width + '_' + filename, function(err) { if (err) console.log('Error writing file: ' + err) }) }.bind(this)) } }) }) } })
嵌套了這麼多層的回調之後,我們已經很難清晰的看出這段代碼的邏輯了。這就是我們所說的 Callback Hell 了,除了降低代碼的可讀性之外,還會增加我們代碼出錯的幾率,並且增大調試難度。
如何解決
我們了解這個問題之後,接下來就要看看怎麼解決它。 首先咱們先來思考一下軟件開發的一個基本原則。 無論是非常復雜的操作系統, 還是到我們一個簡單的應用級 App, 其實都遵循這樣一個思路, 就是把一個復雜的問題分解成逐個簡單的問題, 然後再各個擊破。
太多的理論咱們就不再重復了, 就用一個實際的例子來說明問題。
相信大家的 App 或多或少都會需要處理一些數據讀取的邏輯, 比如開發一個新聞 App,就會涉及到新聞數據接口的讀取。 但任何接口讀取都會面臨一個問題,就是用戶第一次啟動 App 的時候,你是沒有任何緩存數據的, 這就意味著用戶必須進行一個數據加載的過程。 如果用戶的網絡狀況不好, 可能就會在第一次使用後就放棄了你的 App 了。
所以大多數 App 都會采用一個通用做法, 就是每次發布應用之前,都會抓取一份最新的數據放到應用包裡面, 這樣用戶再第一次啟動的時候, 就不會出現屏幕上空空如也等待加載的情景了。
我最近也在處理一個類似的情況,如果每次都手動去抓取這些數據,肯定會費時費力,而且容易出錯。所以我的計劃是來寫一個 NodeJS 腳本處理所有的數據抓取和保存流程。 這樣,每次發版之前,只需要跑一遍這個腳本就完成了本地數據的更新。
如果你的 App 迭代頻率比較高的話, 這個腳本還是很能夠提升效率的。
開始規劃
開始之前, 首先需要構建好 NodeJS 運行環境。 這個並不復雜, 可以到 https://nodejs.org 主頁上面自行補腦, 這裡不多贅述。
配置好環境後我們就可以開始了。 如果你沒有 NodeJS 的相關經驗,也沒關系, 更重要分享這個思路。
首先我們想一下, 如果要完成這個數據更新腳本, 那麼我們首先應該處理網絡請求的邏輯, 那麼我們可以這樣:
request(url, function(error, response, body){ if(!error && response.statusCode == 200) { //處理成功 } else { //報告錯誤 } });
看起來很簡單是不是, 數據讀取完之後, 我們就該處理 JSON 解析了。 解析完 JSON 後, 如果我們的數據文件中還有要抓取的圖片的話,我們還要再次調用網絡庫去抓取相關的圖片, 等等。
如果我們要處理完這一系列後續的邏輯, 我們的代碼就會變成和開始提到的 Callback Hell 一樣了。而且只會比那段代碼更加龐大。假如過段時間後,你再想給這段代碼增加後者修改一下功能,那就真的和末日一樣了~
Callback Hell 幾乎是所有閉包類語言的普遍問題。 JavaScript 中更是體現的淋漓盡致。 還好, 大牛們為我們找到了一些解決方案。 比如 Promise。
Promise
Promise 簡單來說,就是把嵌套的式的閉包傳遞,修改為線性的調用方式。 比如我們剛才的網絡請求功能, 我們可以先把它封裝成一個 Promise 對象:
function getAPI(url) { return new PromiseKit(function(fullfill, reject) { request(url, function(error, response, body){ if(!error && response.statusCode == 200) { fullfill(body); } else { reject(error); } }); }); };
getAPI 方法返回一個 Promise 對象。 PromiseKit 會接受兩個閉包, fullfill 和 reject。 fullfill 代表 Promise 執行成功, reject 代表執行失敗。 先不需要思考為什麼要這樣,只要按照 Promise 的規范構造這個對象即可。 像我們上面這樣, 如果網絡請求成功,就調用 fullfill 並傳入得到的數據。 如果請求失敗, 則調用 reject 並傳入錯誤信息。
接下來我們這樣調用後,就會得到一個 Promise 對象:
var promiseObj = getAPI("http://swiftcafe.io/xxx");
接著我們調用 then 方法來進行後續處理:
promiseObj.then(function(body){ JSON.parse(body); });
我們看到,then 方法同樣接受一個閉包, 而這個閉包的 body 參數其實就是 getAPI 中 PromiseKit 的 fullfill 閉包調用傳遞進來的。 也就是說 then 方法之後再 Promise 執行成功的時候才會執行, 還記得之前我們的 Promise 對象接受兩個閉包 fullfill 和 reject 麼。 只有 Promise 內部的代碼執行成功並調用 fullfill 的時候才會執行 then 方法。
then 方法帶來的好處是在 callback 的嵌套層數比較多的時候,能給我們提供一個線性的語法結構:
promiseObj.then(function(body){ //... }).then(function(){ //... }).then(function(){ //... }).then(function(){ //... });
這樣我們的代碼邏輯結構就清晰很多了。 相比 callback 模式的這種:
function func(cb) { //... request("network", function(data){ parse(data, function(parsedData){ //... cb(parsedData) }); //... }); }
這還只是 callback 模式的一個基本結構, 如果加入了相關的邏輯代碼, 代碼的就夠就會越來越復雜。 這點就是 Promise 模式為我們提供最大的便利。
代碼架構設計
Promise 解決了我們遇到的 Callback Hell 的問題, 這屬於代碼層面的問題。 當我們解決了代碼單元測組織問題, 我們不妨再向上思考一下。 現在我們又面臨一個新的問題, 就是如何有效的規劃邏輯單元,並把它們有效的組織起來。
開發任何程序,最核心的一點就是要把一個復雜的,模糊的邏輯,拆分成多個細小的,具體的邏輯的能力。 還拿我們這個更新數據的小程序為例, 它的整體邏輯是從我們指定的地址讀取數據並保存下來。 這個整體邏輯是比較復雜的, 並且模糊的。 我們首先要做的就是需要把這個整體的邏輯進行拆分。
我們可以思考一下,如果完成這個邏輯,我們首先要做什麼。 首先,我們需要進行網絡通信讀取數據,然後我們還需要對讀取的數據進行解析。接下來,就需要對解析後的數據進行分析, 如果數據中包含圖片地址,我們是否需要把這些圖片一同緩存下來?
當這些解析操作處理完成後, 我們還需要確定這些數據我們如何保存下來, 比如寫文件。 當這些基本架構確定後, 我們可以規劃一下代碼結構, 這裡使用 JavaScript 的面向對象特性:
function PromiseHelper() { } PromiseHelper.prototype.requestJSON = function (url) { } PromiseHelper.prototype.downloadImage = function(item, imageFolder, funcImgURL, funcImgName, funcSetImgURL) { } PromiseHelper.prototype.readDir = function(path) { } PromiseHelper.prototype.unlinkFile = function(filePath) { } PromiseHelper.prototype.clearFolder = function(folderPath) { }
大家看到,我們這裡定義了一個 PromiseHelper 類。 並指定了一些邏輯單元,把他們封裝成方法。 這些方法都會返回 Promise 對象, 所以不會發生 callback hell。 我們這裡只說明思路,所以具體方法內部的代碼就省略啦。
基本邏輯單元定義完成後,我們就需要將這些邏輯單元有效的組織起來,所以我們再定義一個 DataManager 類:
/** * @description 更新數據文件 * * @param {Object} options * @param {string} options.url 請求的 API * @param {string} options.SavedJSONFolder JSON 數據文件的保存路徑 * @param {string} options.SavedImgFolder 圖片下載保存路徑 * @param {string} options.savedFileName JSON 文件的保存路徑 * @param {function} options.cbImgURL 獲取圖片 URL 的回調 * @param {function} options.cbImgName 獲取圖片名稱的回調 * @param {function} options.cbSetImgURL 設置圖片 URL 的回調 * @return {promise} Promise 對象 */ DataManager.prototype.updateContent = function (options) { var apiURL = options.url; var imageFolder = options.SavedImgFolder; var jsonFileName = options.savedFileName; var jsonPath = options.SavedJSONFolder; var cbImgURL = options.cbImgURL; var cbImgName = options.cbImgName; var cbSetImgURL = options.cbSetImgURL; var promise = new promiseHelper(); console.log("任務開始:" + apiURL); return new PromiseKit(function(fullfill, reject) { promise.requestJSON(apiURL).then(function(items) { console.log("JSON 解析完成."); console.log("清理圖片目錄..."); promise.clearFolder(imageFolder).then(function(){ console.log("清理完成。"); console.log("開始下載圖片..." + items.length); PromiseKit.all(items.map(function(item){ return promise.downloadImage(item, imageFolder, cbImgURL, cbImgName, cbSetImgURL); })).done(function(){ console.log("圖片下載完成。"); console.log("開始保存 JSON"); fs.writeFile(path.join(jsonPath, jsonFileName), JSON.stringify(items), function(err) { if (err) { console.log("保存文件失敗。"); reject(); } else { console.log("成功"); fullfill(); } }); }, function(){ console.log("圖片下載失敗。"); reject(); }); }); }); }); };
updateContent 方法接受一個參數 options, 它裡面包含了我們更新數據這個操作需要的所有信息, 比如數據所在的 URL, JSON 緩存文件的保存路徑, 圖片的存儲路徑等。
首先我們調用 promise.requestJSON(apiURL) 來異步請求 JSON 數據,接著使用 then 方法繼續接下來的操作, 首先調用:
promise.clearFolder(imageFolder)
用來在更新數據前,清理圖片目錄。 然後繼續調用 then 方法:
PromiseKit.all(items.map(function(item){ return promise.downloadImage(item, imageFolder, cbImgURL, cbImgName, cbSetImgURL); })).done(function(){ //... });
items 是解析後的數據條目, 比如我們的數據是新聞, items 就是每一個新聞條目,包括它的標題, 圖片地址等等。
PromiseKit.all 這個方法也很有意思, 它會接受一個數組, 並且會等待這個數組中的所有 Promise 對象都成功完成後,才會繼續執行。
我們這裡比較靈活的運用了它的這個特性, 我們使用 map 方法,對 items 中的所有條目都調用 promise.downloadImage 方法,而這個方法在結束後會返回一個 Promise 對象。
也就是說我們會對所有的新聞條目都調用下載圖片的邏輯, 並且返回一個 Promise 對象的數組,只有這個數組中的所有對象都成功完成後, PromiseKit.all 才會調用後面的 done 方法進行後續操作。
我們再來看看 done 方法中都干了什麼:
fs.writeFile(path.join(jsonPath, jsonFileName), JSON.stringify(items), function(err) { if (err) { console.log("保存文件失敗。"); reject(); } else { console.log("成功"); fullfill(); } });
也並不復雜, 當所有新聞的圖片都下載完成後, done 方法會將 JSON 數據保存到本地文件中。
到這裡,我們看到了如何將一個模糊的邏輯拆分成一些具體的明確的簡單邏輯單元。 並且通過控制層將這些邏輯單元有效的組織起來。這樣的方式會讓我們的代碼變得更加健壯, 相比直接開發整個邏輯,開發一個細小的邏輯單元可以非常大的降低人的出錯幾率。 並且合理的劃分邏輯單元, 還可以將這些單元再次重組成新的邏輯流程,幫助我們提升開發效率。
即將完成
現在,我們的代碼結構都設計好了, DataManager 類提供了我們這個小程序的完整邏輯。 當然,我們還可以把它設計的更完善一些。 比如我們要更新處理的數據結構不止一個的時候, 就更能體現出模塊化設計的好處了。 我們只需要簡單的將不同數據接口的配置維護好, 我們可以定義這樣一個函數:
var dm = new DataManager(); function enqueue(queue, options) { queue.push(function(cb){ dm.updateContent(options).then(function(){ cb(); }); }); }
這裡我們用了 nodejs 的 queue 庫。 無序過多考慮細節, 我們只需要知道 queue 可以實現任務隊列即可。 enqueue 函數的實現也不復雜, 只是將 DataManager 的任務封裝到 queue 隊列中。
接下來我們就可以將需要執行的任務添加進來了:
enqueue(taskQueue, { "url": "http://api.swiftcafe.io/news", "SavedJSONFolder": "../data/", "SavedImgFolder" : "../images/", "savedFileName" : "news.json", "cbImgURL" : function(item){ //... }, "cbImgName" : function(item){ //... }, "cbSetImgURL" : function(item){ //... } }); enqueue(taskQueue, { "url": "http://api.swiftcafe.io/videos", "SavedJSONFolder": "../data/", "SavedImgFolder" : "../videoImages/", "savedFileName" : "videos.json", "cbImgURL" : function(item){ //... }, "cbImgName" : function(item){ //... }, "cbSetImgURL" : function(item){ //... } });
當隊列配置完成後, 我們就可以開始執行隊列任務了:
taskQueue.start(function(){ console.log("全部完成"); });
同樣也很簡單。 這樣,我們的整個任務就開始執行了。 並且當他們都完成後,taskQueue.start 所接受的閉包就會被調用,最後輸出任務完成的消息。
結尾
這次主要跟大家分享的是這種開發思維, 通過一定的模塊設計, 不但能夠讓我們的程序變得更加易懂,而且會顯著的增加我們的開發效率。 它適用於任何具體的語言或者平台。 經過幾次的優化,其實並沒有結束, 一個好的程序會一直都在不斷的優化,變得越來越好。 最後奉上完整代碼, 大家可以在 Github 上面自由研究 https://github.com/swiftcafex/updator, 或者提出你的優化方案。