当前位置:嗨网首页>书籍在线阅读

13-嵌套回调和异常处理

  
选择背景色: 黄橙 洋红 淡粉 水蓝 草绿 白色 选择字体: 宋体 黑体 微软雅黑 楷体 选择字体大小: 恢复默认

[toc]

2.4 嵌套回调和异常处理

在客户端JavaScript应用中可能不常见到下面的代码:

val1 = callFunctionA();
val2 = callFunctionB(val1);
val3 = callFunctionC(val2);

函数是按顺序调用的,把上一个函数的结果传给下一个函数。由于所有函数都是同步的,所以不用担心函数的调用顺序出错,也不会出现意想不到的执行结果。

例2-8展示了一个相对常见的顺序编程的例子。程序使用了Node文件系统方法的同步版本来打开文件并获取数据,通过将“apple”的所有引用替换为“orange”来修改数据,并将生成的字符串输出到新文件。

例2-8 顺序同步应用

var fs = require('fs');
try {
   var data = fs.readFileSync('./apples.txt','utf8');
   console.log(data);
   var adjData = data.replace(/[A|a]pple/g,'orange');
   fs.writeFileSync('./oranges.txt', adjData);
} catch(err) {
  console.error(err);
}

由于可能出错,而我们不能保证错误在某个模块函数内会被处理,所以我们会把所有函数调用封装在 try 中,从而让异常处理更优雅或者至少能收集到更多信息。以下是一个当程序找不到要读取的文件时出错的例子:

{ [Error: ENOENT: no such file or directory, open './apples.txt']
   errno: -2,
   code: 'ENOENT',
   syscall: 'open',
   path: './apples.txt' }

上面这些信息可能不那么友好,但至少比下面这种信息好一些:

$ node nested2
fs.js:549
  return binding.open(pathModule._makeLong(path), stringToFlags(flags),
mode);
^ Error: ENOENT: no such file or directory, open './apples.txt'
      at Error (native)
      at Object.fs.openSync (fs.js:549:18)
      at Object.fs.readFileSync (fs.js:397:15)
      at Object.<anonymous>
                 (/home/examples/public_html/learnnode2/nested2.js:3:18)
      at Module._compile (module.js:435:26)
      at Object.Module._extensions..js (module.js:442:10)
      at Module.load (module.js:356:32)
      at Function.Module._load (module.js:311:12)
      at Function.Module.runMain (module.js:467:10)
      at startup (node.js:136:18)

如果要把这种同步顺序应用模式转换成异步实现,则需要一些修改。首先,要把所有函数替换成它的异步版本。但是,还必须考虑到每个函数在调用时不阻塞的事实,也就是说如果函数调用之间不互相依赖,那么我们就无法保证函数的调用顺序。唯一能保证函数按照一定顺序调用的方法就是嵌套回调。

例2-9是例2-8中程序的异步版本。所有的文件系统函数调用都被其异步形式替换,并且函数调用通过嵌套回调保证了正确的调用顺序。另外, try…catch 块也被删除了。

我们不能用 try…catch ,因为使用异步函数意味着 try…catch 块实际是在调用异步函数之前就被处理了。所以如果试图在回调函数中抛出一个错误,也就相当于在进程之外抛出一个错误并且捕捉它。相反,在异步调用中我们会直接处理错误:如果有错误,就处理它并返回;如果没有错误,就继续执行回调函数。

例2-9 将例2-8中的应用转化成异步嵌套回调

var fs = require('fs');
fs.readFile('./apples.txt','utf8', function(err,data) {
   if (err) {
      console.error(err);
   } else {
     var adjData = data.replace(/apple/g,'orange');
     fs.writeFile('./oranges.txt', adjData, function(err) {
        if (err) console.error(err);
     });
   }  
}); 

在例2-9中,程序会打开并读取输入的文件,只有当这两个动作都完成后,程序才会调用回调函数。在这个函数中,它会检查 error 是否有值。如果有,就在控制台中打印 error 对象。如果没有错误,则程序继续运行并调用异步的 writeFile() 函数。这里的回调函数只有一个参数,就是 error 对象。如果它不是 null ,也会被打印在控制台中。

如果有错误,程序会输出类似于下面信息的内容:

{ [Error: ENOENT: no such file or directory, open './apples.txt']
  errno: -2,
  code: 'ENOENT',
  syscall: 'open',
  path: './apples.txt' }

如果需要错误的堆栈信息,可以打印 error 对象的 stack 属性:

if (err) {
   console.error(err.stack);
}

会打印出如下结果:

Error: ENOENT: no such file or directory, open './apples.txt'
    at Error (native)

每增加一个顺序的异步函数调用,都会增加一级嵌套回调,而且可能会使错误处理更麻烦。在例2-10中,我们访问了一个文件夹下的一组文件。在每个文件中,我们通过 replace 方法用一个特殊的域名替换了一般的域名,并且将结果写回原始文件中。我们使用打开的写入流来为每个更改的文件维护日志。

例2-10 检索要修改的文件的目录列表

var fs = require('fs');
var writeStream = fs.createWriteStream('./log.txt',
      {'flags' : 'a',
       'encoding' : 'utf8',
       'mode' : 0666});
writeStream.on('open', function() {
   // get list of files
   fs.readdir('./data/', function(err, files) {
      // for each file
      if (err) {
         console.log(err.message);
      } else {
         files.forEach(function(name) {
            // modify contents
            fs.readFile('./data/' + name,'utf8', function(err,data) {
               if (err){
                  console.error(err.message);
               } else {
                  var adjData = data.replace(/somecompany\.com/g,
                             'burningbird.net');
                  // write to file
                  fs.writeFile('./data/' + name, adjData, function(err)
                    { 
                      if (err) {
                         console.error(err.message);
                      } else { 
                         // log write
                         writeStream.write('changed ' + name + '\n',
                         'utf8', function(err) {
                            if(err) console.error(err.message);
                         });
                      }
                  }); 
               }
            }); 
         });
      }
   });
}); 
writeStream.on('error', function(err) {
  console.error("ERROR:" + err);
});

首先,有一种新的用法:在调用 fs.createWriteStream 时,使用事件处理来处理错误。这样做的原因是 createWriteStream 是异步的,所以不能用传统的 try…catch 来处理错误。同时,这个函数也没有提供用来捕获错误的回调函数的参数。相反,我们会监听一个 error 事件并通过打印错误信息来处理它。接下来我们会寻找一个 open 事件(一个成功的操作)来进行文件操作。

应用程序会直接打印错误信息。

即使这段程序看起来像每次处理完一个文件才会继续下一个,不过记住,这里面的每个函数都是异步的。如果多运行几次这个程序并且查看log.txt文件,你会看到文件以不同的、看似随机的顺序处理。在我的data子文件夹中,有5个文件。连续运行3次程序会在log.txt中得到以下输出(为了清楚,下列输出插入了空行):

changed data1.txt
changed data2.txt
changed data3.txt
changed data4.txt
changed data5.txt
changed data2.txt
changed data4.txt
changed data3.txt
changed data1.txt
changed data5.txt
changed data1.txt
changed data2.txt
changed data5.txt
changed data3.txt
changed data4.txt

如果你需要在所有文件都修改完成之后做一些事情,那么就会出现另一个问题。 forEach 方法异步地调用了一个迭代回调函数,所以它并不会阻塞进程。在 forEach 后面加上如下语句:

console.log('all done');

这并不是真正地表示程序结束了,只是表明 forEach 不会阻塞进程。如果你同时还添加了 console.log 语句来纪录修改过的文件:

// log write
writeStream.write('changed ' + name + '\n',
'utf8', function(err) {
    if(err) {
      console.log(err.message);
    } else {
      console.log('finished ' + name);
    }
});

并且在 forEach 调用后添加如下语句:

console.log('all finished');

那么最终会得到如下的输出:

all finished
finished data3.txt
finished data1.txt
finished data5.txt
finished data2.txt
finished data4.txt

要解决这个问题,需要添加一个随着日志消息递增的计数器,当计数器数字和文件数组的长度相等时,再打印出 all done 的消息:

// before accessing directory
var counter = 0;
...
  // log write
  writeStream.write('changed ' + name + '\n',
  'utf8', function(err) {
     if(err) {
        console.log(err.message);
     } else {
        console.log ('finished ' + name);
        counter++;
        if (counter >= files.length) {
           console.log('all done');
        }
     }
});

接着你会得到预期的输出:所有文件都被更新后会显示一个 all done 的信息。

程序的运行没有问题——除非我们访问的目录中含有子目录和别的文件。如果程序将子目录也考虑在内,那么即使它还在继续处理别的内容,也会显示以下错误信息:

EISDIR: illegal operation on a directory, read

例2-11中,我们通过使用 fs.stats 方法返回一个用于表示 UNIX stat 命令的对象来防止这种类型的错误发生。这个对象中含有一些文件系统的信息,包括我们所处理的这个文件系统对象是否是文件。当然, fs.stats 方法也是一个异步方法,它需要更多的回调嵌套。

例2-11 在每个目录对象中加入 stats 检查,确保它是一个文件

var fs = require('fs');
var writeStream = fs.createWriteStream('./log.txt',
      {flags : 'a',
       encoding : 'utf8',
       mode : 0666});
writeStream.on('open', function() {
var counter = 0;
// get list of files
fs.readdir('./data/', function(err, files) {
   // for each file
   if (err) {
      console.error(err.message);
   } else {
      files.forEach(function(name) {
         fs.stat('./data/' + name, function (err, stats) {
            if (err) return err;
            if (!stats.isFile()) {
               counter++; 
               return; 
            } 
            // modify contents
            fs.readFile('./data/' + name,'utf8', function(err,data) {
               if (err){
                  console.error(err.message);
               } else {
                  var adjData = data.replace(/somecompany\.com/g,
                          'burningbird.net');
                  // write to file
                  fs.writeFile('./data/' + name, adjData,
                                               function(err) {
                     if (err) {
                        console.error(err.message);
                     } else { 
                        // log write
                        writeStream.write('changed ' + name + '\n',
                         function(err) {
                           if(err) {
                              console.error(err.message);
                           } else {
                              console.log('finished ' + name);
                              counter++;
                              if (counter >= files.length) {
                                 console.log('all done');
                              }
                           }
                         }); 
                       }
                    }); 
                  }
               }); 
           });
        }); 
       }
   }); 
}); 
writeStream.on('error', function(err) {
  console.error("ERROR:" + err);
});

现在程序又能正常工作了,并且能很好地完成任务,但却很难阅读和维护。即使使用了一个 return 来进行错误处理,消除了一个条件嵌套,但依旧难以维护。有人将这种嵌套回调称为回调意大利面(callback spaghetti),还有人更形象地称为金字塔噩梦(pyramid of doom),这两个名词用来形容嵌套回调都很合适。

嵌套回调会让我们的代码结构越来越差,以至于越来越难将正确的代码放进正确的回调中。但是这也没办法,我们无法将回调的嵌套结构打散,因为所有的方法都必须按照以下顺序调用:

(1)开始查找目录;

(2)找到所有子目录;

(3)读取所有文件内容;

(4)修改文件内容;

(5)将结果写回原始文件。

我们希望能找到一种不依赖嵌套回调的方式来实现这一系列的函数调用。要达到这个目的,我们需要借助第三方模块或者其他方案。在第3章中,我会用异步模块来解决这个金字塔噩梦。在第9章中,我们会看到如何使用ES6的 promise 来解决这个问题。

还有一种方式是使用一个具名函数来作为每一个方法的回调函数。这样的话,就可以拍平金字塔,而且也很容易消除bug。不过,这种方式并不能解决所有问题,比如,它并不能让我们知道所有进程是什么时候结束的。所以,我们还需要一个异步控制的处理模块。