2013年8月9日 星期五

Node Pattern - Recursive Loop

今天要討論的主要是在 Asynchronous function 和 Loop 之間的關係。用過 Node.js 的朋友們,應該都知道 Node.js 的特色的 Nonblocking I/O 或者另一個說法是 Node.js 的寫法裡面充斥著許多的 callback function。這也是 Node.js 的優點之一,因為這個 Nonblocking 的特色可以讓 Node.js 只有一個 Thread 而遇到多個 I/O 存取的情況下還可以保持存取效能非常的高。
但是這樣的 Nonblocking 的特色會讓大部分的開發者,已經習慣 blocking 寫法的開發者,在開發 Node.js 應用的時候常常會遇到不知所措的錯誤發生。這篇文章就是要來討論其中一個很常見的錯的寫法。錯誤的寫法是在非常常用的 for-loop 上。
先把問題述敘一下:
利用 fs 這個 Module 來呈現,某個資料夾裡面所有的子資料夾的名稱。
我們會用到 fs 裡的 fs.readdirfs.stat。假設我們產生一個 function 如下
function listDir (rootDir, callBack){
}
當我們在使用這個 listDir 的時候只要給開頭的 dirName 就會在 Console 呈現出,這個 dirName 裡面所有的子資料夾。於是我們準備一下測試用的資料夾。如下圖(藍色的為資料夾)
假設我們把上方提到的 fanit_pattern_for.js 這個檔案裡,同一個資料夾(NodePattern)裡有兩個空的子資料夾,各別是 folder1 和 folder2。如此一來寫程式前的準備動作就做好了。
程式方便同樣寫在 anti_pattern_for.js 裡面就要有使用 listDir 的程式碼。如下
listDir(".",function  (err, dirs) {
if(!err){
console.log("got folders : "+dirs);
}else{
console.log("error occurs : "+ err);
}
});

使用的時候就把第一個 rootDir 的名稱 "." ,這個 js 檔所存在的目錄當成根目錄,所以我們預期在 Console 的結果就是 
got folders : folder1,folder2

工作準備好,我們要來動手寫 listDir 的程式了。首先來看一下很常見的版本。但是是錯的 XD。

var fs = require("fs");

function listDir (dirName, callBack) {
    fs.readdir(dirName, function  (err, files) {
 if(err){
     callBack(err);
  return;
 }
 var dirs = [];
 for (var i = files.length - 1; i >= 0; i--) {
  fs.stat(dirName+"/"+files[i], function  (err, stats) {
   if(stats.isDirectory()){
    dirs.push(files[i]);
   }
  });
 };
 callBack(null, dirs);
    });
}
上面的程式碼。如果用
listDir(".",function  (err, dirs) {
if(!err){
console.log("got folders : "+dirs);
}else{
console.log("error occurs : "+ err);
}
});
來呼叫的話,會得到一個出乎我們意料的結果。
got folders
沒有任何的資料夾?怎麼會是這樣的結果?我們來檢視一下程式碼,看 for-loop 這個地方
for (var i = files.length - 1; i >= 0; i--) {
    fs.stat(dirName+"/"+files[i], function (err, stats) {
       if(stats.isDirectory()){
         dirs.push(files[i]);
       }
    });
};

看起來的複雜但可以簡單成如下
for (var i = files.length - 1; i >= 0; i--) {
   fs.stat(....);
};

就是做 fs.stat 很多次。也沒什麼奇怪,奇怪的是在 fs.stat 本身是 Nonblocking 的機制。整個 for-loop 會認為 fs.stat(); 已經結束,但是 fs.stat(); 裡面的 callback funciton。
fs.stat(dirName+"/"+files[i], function (err, stats) {
    if(stats.isDirectory()){
       dirs.push(files[i]);
    }
});
第二個參數就是 callback function 都還沒被呼叫,for-loop 就結束,然後就呼叫
callBack(null, dirs); 
因為整個執行的速度很快 dirs 就沒有值被寫入。就得到空的值。
對於習慣 blocking 寫法的開發者而言,這麼常用的寫法,竟然會在 Nonblocking 裡出錯,對於剛接觸 Node.js 的開發者應該相當的困擾。
那要怎麼解決呢?
讀者介紹一個常用的解決方法,不能用 for-loop 而改用遞迴的方式。如下先來看一種遞回方法來達到和 for-loop 一樣的效果。
基本原型長得像這樣。
function iterator (index) {
if(index < array.length){
asynchronous function  () {
// do something
iterator(index+1);
});
}else{
callBack();
}
} ;
iterator(0);    // 執行 iterator
更簡單的寫法可以寫成
(function iterator (index) {
if(index < array.length){
asynchronous function  () {
// do something
iterator(index+1);
});
}else{
callBack();
}
} )(0);
這樣的寫法,可以想成是 for-loop 的遞迴寫法。我們以一個 array 有三個元素來舉例,可以展開如下。
function iterator (0) {
function iterator (1) {
               function iterator (2) {
                }
        }
}
// 執行下一行程式
這樣的寫法除了可個保證是依照 index 一個一個執行之外,最後一個 index ( 在這邊是 2) 執行完之後,整個程式才可以執行下一行。我們把原本的 for-loop 版本有問題的地方改寫成如下
(function iterator (index) {
if(index < files.length){
fs.stat(rootDir+"/"+files[index], function  (err, stats) {
if(stats.isDirectory()){
dirs.push(files[index]);
}
iterator(index+1);
});
}else{
callBack(null, dirs);
}
})(0);

完整的程式如下。

var fs = require("fs");

function listDir (rootDir, callBack) {
  fs.readdir(rootDir, function  (err, files) {
    if(err){
 callBack(err);
   return;
    }
    var dirs = []; 
    (function iterator (index) {
 if(index < files.length){
   fs.stat(rootDir+"/"+files[index], function  (err, stats) {
     if (err) {
  callBack(err);
  return;
     };
     if(stats.isDirectory()){
  dirs.push(files[index]);
     }
     iterator(index+1);
   });
 }else{
   callBack(null, dirs);
 }
    })(0);
  });
}


listDir(".",function  (err, dirs) {
  if(!err){
 console.log("got folders : "+dirs);
  }else{
 console.log("error occurs : "+ err);
  }
});

就可以看到我們所預期的結果。
got folders : folder1,folder2 
以上程式碼都放在 GitHub