Callback Hell 与编程思路的探索



- 作者: SwiftCafe


最近在搞一个 App 开发自动化相关的功能, 正好抽时间深究了一下之前一些没有太多考虑的问题。 Callback Hell 以及由他引发的开发思路, 跟大家一起探讨交流。

关于 Callback Hell

Callback Hell 就是异步回调函数过多的嵌套,导致的代码可读性下降以及出错率提高的问题。 比如这样一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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 的相关经验,也没关系, 更重要分享这个思路。

首先我们想一下, 如果要完成这个数据更新脚本, 那么我们首先应该处理网络请求的逻辑, 那么我们可以这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
request(url, function(error, response, body){
if(!error && response.statusCode == 200) {
//处理成功
} else {
//报告错误
}
});

看起来很简单是不是, 数据读取完之后, 我们就该处理 JSON 解析了。 解析完 JSON 后, 如果我们的数据文件中还有要抓取的图片的话,我们还要再次调用网络库去抓取相关的图片, 等等。

如果我们要处理完这一系列后续的逻辑, 我们的代码就会变成和开始提到的 Callback Hell 一样了。而且只会比那段代码更加庞大。假如过段时间后,你再想给这段代码增加后者修改一下功能,那就真的和末日一样了~

Callback Hell 几乎是所有闭包类语言的普遍问题。 JavaScript 中更是体现的淋漓尽致。 还好, 大牛们为我们找到了一些解决方案。 比如 Promise。

Promise

Promise 简单来说,就是把嵌套的式的闭包传递,修改为线性的调用方式。 比如我们刚才的网络请求功能, 我们可以先把它封装成一个 Promise 对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 对象:

1
var promiseObj = getAPI("http://swiftcafe.io/xxx");

接着我们调用 then 方法来进行后续处理:

1
2
3
4
5
promiseObj.then(function(body){
JSON.parse(body);
});

我们看到,then 方法同样接受一个闭包, 而这个闭包的 body 参数其实就是 getAPI 中 PromiseKit 的 fullfill 闭包调用传递进来的。 也就是说 then 方法之后再 Promise 执行成功的时候才会执行, 还记得之前我们的 Promise 对象接受两个闭包 fullfill 和 reject 么。 只有 Promise 内部的代码执行成功并调用 fullfill 的时候才会执行 then 方法。

then 方法带来的好处是在 callback 的嵌套层数比较多的时候,能给我们提供一个线性的语法结构:

1
2
3
4
5
6
7
8
9
promiseObj.then(function(body){
//...
}).then(function(){
//...
}).then(function(){
//...
}).then(function(){
//...
});

这样我们的代码逻辑结构就清晰很多了。 相比 callback 模式的这种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function func(cb) {
//...
request("network", function(data){
parse(data, function(parsedData){
//...
cb(parsedData)
});
//...
});
}

这还只是 callback 模式的一个基本结构, 如果加入了相关的逻辑代码, 代码的就够就会越来越复杂。 这点就是 Promise 模式为我们提供最大的便利。

代码架构设计

Promise 解决了我们遇到的 Callback Hell 的问题, 这属于代码层面的问题。 当我们解决了代码单元测组织问题, 我们不妨再向上思考一下。 现在我们又面临一个新的问题, 就是如何有效的规划逻辑单元,并把它们有效的组织起来。

开发任何程序,最核心的一点就是要把一个复杂的,模糊的逻辑,拆分成多个细小的,具体的逻辑的能力。 还拿我们这个更新数据的小程序为例, 它的整体逻辑是从我们指定的地址读取数据并保存下来。 这个整体逻辑是比较复杂的, 并且模糊的。 我们首先要做的就是需要把这个整体的逻辑进行拆分。

我们可以思考一下,如果完成这个逻辑,我们首先要做什么。 首先,我们需要进行网络通信读取数据,然后我们还需要对读取的数据进行解析。接下来,就需要对解析后的数据进行分析, 如果数据中包含图片地址,我们是否需要把这些图片一同缓存下来?

当这些解析操作处理完成后, 我们还需要确定这些数据我们如何保存下来, 比如写文件。 当这些基本架构确定后, 我们可以规划一下代码结构, 这里使用 JavaScript 的面向对象特性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
/**
* @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 方法继续接下来的操作, 首先调用:

1
promise.clearFolder(imageFolder)

用来在更新数据前,清理图片目录。 然后继续调用 then 方法:

1
2
3
4
5
6
7
8
9
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 方法中都干了什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fs.writeFile(path.join(jsonPath, jsonFileName), JSON.stringify(items), function(err) {
if (err) {
console.log("保存文件失败。");
reject();
} else {
console.log("成功");
fullfill();
}
});

也并不复杂, 当所有新闻的图片都下载完成后, done 方法会将 JSON 数据保存到本地文件中。

到这里,我们看到了如何将一个模糊的逻辑拆分成一些具体的明确的简单逻辑单元。 并且通过控制层将这些逻辑单元有效的组织起来。这样的方式会让我们的代码变得更加健壮, 相比直接开发整个逻辑,开发一个细小的逻辑单元可以非常大的降低人的出错几率。 并且合理的划分逻辑单元, 还可以将这些单元再次重组成新的逻辑流程,帮助我们提升开发效率。

即将完成

现在,我们的代码结构都设计好了, DataManager 类提供了我们这个小程序的完整逻辑。 当然,我们还可以把它设计的更完善一些。 比如我们要更新处理的数据结构不止一个的时候, 就更能体现出模块化设计的好处了。 我们只需要简单的将不同数据接口的配置维护好, 我们可以定义这样一个函数:

1
2
3
4
5
6
7
8
9
10
11
var dm = new DataManager();
function enqueue(queue, options) {
queue.push(function(cb){
dm.updateContent(options).then(function(){ cb(); });
});
}

这里我们用了 nodejs 的 queue 库。 无序过多考虑细节, 我们只需要知道 queue 可以实现任务队列即可。 enqueue 函数的实现也不复杂, 只是将 DataManager 的任务封装到 queue 队列中。

接下来我们就可以将需要执行的任务添加进来了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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){
//...
}
});

当队列配置完成后, 我们就可以开始执行队列任务了:

1
2
3
taskQueue.start(function(){
console.log("全部完成");
});

同样也很简单。 这样,我们的整个任务就开始执行了。 并且当他们都完成后,taskQueue.start 所接受的闭包就会被调用,最后输出任务完成的消息。

结尾

这次主要跟大家分享的是这种开发思维, 通过一定的模块设计, 不但能够让我们的程序变得更加易懂, 而且会显著的增加我们的开发效率。 它适用于任何具体的语言或者平台。 经过几次的优化,其实并没有结束, 一个好的程序会一直都在不断的优化,变得越来越好。 最后奉上完整代码, 大家可以在 Github 上面自由研究 https://github.com/swiftcafex/updator, 或者提出你的优化方案。

如果你觉得这篇文章有帮助,还可以关注微信公众号 swift-cafe,会有更多我的原创内容分享给你~

本站文章均为原创内容,如需转载请注明出处,谢谢。




微信公众平台
更多精彩内容,请关注微信公众号


公众号:swift-cafe
邮件订阅
请输入您的邮箱,我们会把最新的内容推送给您: