1. 程式人生 > >七天學會NodeJS(檔案操作)

七天學會NodeJS(檔案操作)

檔案操作

讓前端覺得如獲神器的不是NodeJS能做網路程式設計,而是NodeJS能夠操作檔案。小至檔案查詢,大至程式碼編譯,幾乎沒有一個前端工具不操作檔案。換個角度講,幾乎也只需要一些資料處理邏輯,再加上一些檔案操作,就能夠編寫出大多數前端工具。本章將介紹與之相關的NodeJS內建模組。

開門紅

NodeJS提供了基本的檔案操作API,但是像檔案拷貝這種高階功能就沒有提供,因此我們先拿檔案拷貝程式練手。與copy命令類似,我們的程式需要能接受原始檔路徑與目標檔案路徑兩個引數。

小檔案拷貝

我們使用NodeJS內建的fs模組簡單實現這個程式如下。

var fs = require('fs');

function copy(src, dst) {
    fs.writeFileSync(dst, fs.readFileSync(src));
}

function main(argv) {
    copy(argv[0], argv[1]);
}

main(process.argv.slice(2));

以上程式使用fs.readFileSync從源路徑讀取檔案內容,並使用fs.writeFileSync將檔案內容寫入目標路徑。

豆知識: process是一個全域性變數,可通過process.argv獲得命令列引數。由於argv[0]固定等於NodeJS執行程式的絕對路徑,argv[1]固定等於主模組的絕對路徑,因此第一個命令列引數從argv[2]這個位置開始。

大檔案拷貝

上邊的程式拷貝一些小檔案沒啥問題,但這種一次性把所有檔案內容都讀取到記憶體中後再一次性寫入磁碟的方式不適合拷貝大檔案,記憶體會爆倉。對於大檔案,我們只能讀一點寫一點,直到完成拷貝。因此上邊的程式需要改造如下。

var fs = require('fs');

function copy(src, dst) {
    fs.createReadStream(src).pipe(fs.createWriteStream(dst));
}

function main(argv) {
    copy(argv[0], argv[1]);
}

main(process.argv.slice(2));

以上程式使用fs.createReadStream建立了一個原始檔的只讀資料流,並使用fs.createWriteStream建立了一個目標檔案的只寫資料流,並且用pipe方法把兩個資料流連線了起來。連線起來後發生的事情,說得抽象點的話,水順著水管從一個桶流到了另一個桶。

API走馬觀花

我們先大致看看NodeJS提供了哪些和檔案操作有關的API。這裡並不逐一介紹每個API的使用方法,官方文件已經做得很好了。

Buffer(資料塊)

官方文件: http://nodejs.org/api/buffer.html

JS語言自身只有字串資料型別,沒有二進位制資料型別,因此NodeJS提供了一個與String對等的全域性建構函式Buffer來提供對二進位制資料的操作。除了可以讀取檔案得到Buffer的例項外,還能夠直接構造,例如:

var bin = new Buffer([ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]);

Buffer與字串類似,除了可以用.length屬性得到位元組長度外,還可以用[index]方式讀取指定位置的位元組,例如:

bin[0]; // => 0x68;

Buffer與字串能夠互相轉化,例如可以使用指定編碼將二進位制資料轉化為字串:

var str = bin.toString('utf-8'); // => "hello"

或者反過來,將字串轉換為指定編碼下的二進位制資料:

var bin = new Buffer('hello', 'utf-8'); // => <Buffer 68 65 6c 6c 6f>

Buffer與字串有一個重要區別。字串是隻讀的,並且對字串的任何修改得到的都是一個新字串,原字串保持不變。至於Buffer,更像是可以做指標操作的C語言陣列。例如,可以用[index]方式直接修改某個位置的位元組。

bin[0] = 0x48;

.slice方法也不是返回一個新的Buffer,而更像是返回了指向原Buffer中間的某個位置的指標,如下所示。

[ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]
    ^           ^
    |           |
   bin     bin.slice(2)

因此對.slice方法返回的Buffer的修改會作用於原Buffer,例如:

var bin = new Buffer([ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]);
var sub = bin.slice(2);

sub[0] = 0x65;
console.log(bin); // => <Buffer 68 65 65 6c 6f>

也因此,如果想要拷貝一份Buffer,得首先建立一個新的Buffer,並通過.copy方法把原Buffer中的資料複製過去。這個類似於申請一塊新的記憶體,並把已有記憶體中的資料複製過去。以下是一個例子。

var bin = new Buffer([ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]);
var dup = new Buffer(bin.length);

bin.copy(dup);
dup[0] = 0x48;
console.log(bin); // => <Buffer 68 65 6c 6c 6f>
console.log(dup); // => <Buffer 48 65 65 6c 6f>

總之,Buffer將JS的資料處理能力從字串擴充套件到了任意二進位制資料。

Stream(資料流)

官方文件: http://nodejs.org/api/stream.html

當記憶體中無法一次裝下需要處理的資料時,或者一邊讀取一邊處理更加高效時,我們就需要用到資料流。NodeJS中通過各種Stream來提供對資料流的操作。

以上邊的大檔案拷貝程式為例,我們可以為資料來源建立一個只讀資料流,示例如下:

var rs = fs.createReadStream(pathname);

rs.on('data', function (chunk) {
    doSomething(chunk);
});

rs.on('end', function () {
    cleanUp();
});

豆知識: Stream基於事件機制工作,所有Stream的例項都繼承於NodeJS提供的EventEmitter

上邊的程式碼中data事件會源源不斷地被觸發,不管doSomething函式是否處理得過來。程式碼可以繼續做如下改造,以解決這個問題。

var rs = fs.createReadStream(src);

rs.on('data', function (chunk) {
    rs.pause();
    doSomething(chunk, function () {
        rs.resume();
    });
});

rs.on('end', function () {
    cleanUp();
});

以上程式碼給doSomething函式加上了回撥,因此我們可以在處理資料前暫停資料讀取,並在處理資料後繼續讀取資料。

此外,我們也可以為資料目標建立一個只寫資料流,示例如下:

var rs = fs.createReadStream(src);
var ws = fs.createWriteStream(dst);

rs.on('data', function (chunk) {
    ws.write(chunk);
});

rs.on('end', function () {
    ws.end();
});

我們把doSomething換成了往只寫資料流裡寫入資料後,以上程式碼看起來就像是一個檔案拷貝程式了。但是以上程式碼存在上邊提到的問題,如果寫入速度跟不上讀取速度的話,只寫資料流內部的快取會爆倉。我們可以根據.write方法的返回值來判斷傳入的資料是寫入目標了,還是臨時放在了快取了,並根據drain事件來判斷什麼時候只寫資料流已經將快取中的資料寫入目標,可以傳入下一個待寫資料了。因此程式碼可以改造如下:

var rs = fs.createReadStream(src);
var ws = fs.createWriteStream(dst);

rs.on('data', function (chunk) {
    if (ws.write(chunk) === false) {
        rs.pause();
    }
});

rs.on('end', function () {
    ws.end();
});

ws.on('drain', function () {
    rs.resume();
});

以上程式碼實現了資料從只讀資料流到只寫資料流的搬運,幷包括了防爆倉控制。因為這種使用場景很多,例如上邊的大檔案拷貝程式,NodeJS直接提供了.pipe方法來做這件事情,其內部實現方式與上邊的程式碼類似。

File System(檔案系統)

官方文件: http://nodejs.org/api/fs.html

NodeJS通過fs內建模組提供對檔案的操作。fs模組提供的API基本上可以分為以下三類:

  • 檔案屬性讀寫。

    其中常用的有fs.statfs.chmodfs.chown等等。

  • 檔案內容讀寫。

    其中常用的有fs.readFilefs.readdirfs.writeFilefs.mkdir等等。

  • 底層檔案操作。

    其中常用的有fs.openfs.readfs.writefs.close等等。

NodeJS最精華的非同步IO模型在fs模組裡有著充分的體現,例如上邊提到的這些API都通過回撥函式傳遞結果。以fs.readFile為例:

fs.readFile(pathname, function (err, data) {
    if (err) {
        // Deal with error.
    } else {
        // Deal with data.
    }
});

如上邊程式碼所示,基本上所有fs模組API的回撥引數都有兩個。第一個引數在有錯誤發生時等於異常物件,第二個引數始終用於返回API方法執行結果。

此外,fs模組的所有非同步API都有對應的同步版本,用於無法使用非同步操作時,或者同步操作更方便時的情況。同步API除了方法名的末尾多了一個Sync之外,異常物件與執行結果的傳遞方式也有相應變化。同樣以fs.readFileSync為例:

try {
    var data = fs.readFileSync(pathname);
    // Deal with data.
} catch (err) {
    // Deal with error.
}

fs模組提供的API很多,這裡不一一介紹,需要時請自行查閱官方文件。

Path(路徑)

官方文件: http://nodejs.org/api/path.html

操作檔案時難免不與檔案路徑打交道。NodeJS提供了path內建模組來簡化路徑相關操作,並提升程式碼可讀性。以下分別介紹幾個常用的API。

  • path.normalize

    將傳入的路徑轉換為標準路徑,具體講的話,除了解析路徑中的...外,還能去掉多餘的斜槓。如果有程式需要使用路徑作為某些資料的索引,但又允許使用者隨意輸入路徑時,就需要使用該方法保證路徑的唯一性。以下是一個例子:

      var cache = {};
    
      function store(key, value) {
          cache[path.normalize(key)] = value;
      }
    
      store('foo/bar', 1);
      store('foo//baz//../bar', 2);
      console.log(cache);  // => { "foo/bar": 2 }

    坑出沒注意: 標準化之後的路徑裡的斜槓在Windows系統下是\,而在Linux系統下是/。如果想保證任何系統下都使用/作為路徑分隔符的話,需要用.replace(/\\/g, '/')再替換一下標準路徑。

  • path.join

    將傳入的多個路徑拼接為標準路徑。該方法可避免手工拼接路徑字串的繁瑣,並且能在不同系統下正確使用相應的路徑分隔符。以下是一個例子:

      path.join('foo/', 'baz/', '../bar'); // => "foo/bar"
  • path.extname

    當我們需要根據不同副檔名做不同操作時,該方法就顯得很好用。以下是一個例子:

      path.extname('foo/bar.js'); // => ".js"

path模組提供的其餘方法也不多,稍微看一下官方文件就能全部掌握。

遍歷目錄

遍歷目錄是操作檔案時的一個常見需求。比如寫一個程式,需要找到並處理指定目錄下的所有JS檔案時,就需要遍歷整個目錄。

遞迴演算法

遍歷目錄時一般使用遞迴演算法,否則就難以編寫出簡潔的程式碼。遞迴演算法與數學歸納法類似,通過不斷縮小問題的規模來解決問題。以下示例說明了這種方法。

function factorial(n) {
    if (n === 1) {
        return 1;
    } else {
        return n * factorial(n - 1);
    }
}

上邊的函式用於計算N的階乘(N!)。可以看到,當N大於1時,問題簡化為計算N乘以N-1的階乘。當N等於1時,問題達到最小規模,不需要再簡化,因此直接返回1。

陷阱: 使用遞迴演算法編寫的程式碼雖然簡潔,但由於每遞迴一次就產生一次函式呼叫,在需要優先考慮效能時,需要把遞迴演算法轉換為迴圈演算法,以減少函式呼叫次數。

遍歷演算法

目錄是一個樹狀結構,在遍歷時一般使用深度優先+先序遍歷演算法。深度優先,意味著到達一個節點後,首先接著遍歷子節點而不是鄰居節點。先序遍歷,意味著首次到達了某節點就算遍歷完成,而不是最後一次返回某節點才算數。因此使用這種遍歷方式時,下邊這棵樹的遍歷順序是A > B > D > E > C > F

          A
         / \
        B   C
       / \   \
      D   E   F

同步遍歷

瞭解了必要的演算法後,我們可以簡單地實現以下目錄遍歷函式。

function travel(dir, callback) {
    fs.readdirSync(dir).forEach(function (file) {
        var pathname = path.join(dir, file);

        if (fs.statSync(pathname).isDirectory()) {
            travel(pathname, callback);
        } else {
            callback(pathname);
        }
    });
}

可以看到,該函式以某個目錄作為遍歷的起點。遇到一個子目錄時,就先接著遍歷子目錄。遇到一個檔案時,就把檔案的絕對路徑傳給回撥函式。回撥函式拿到檔案路徑後,就可以做各種判斷和處理。因此假設有以下目錄:

- /home/user/
    - foo/
        x.js
    - bar/
        y.js
    z.css

使用以下程式碼遍歷該目錄時,得到的輸入如下。

travel('/home/user', function (pathname) {
    console.log(pathname);
});

------------------------
/home/user/foo/x.js
/home/user/bar/y.js
/home/user/z.css

非同步遍歷

如果讀取目錄或讀取檔案狀態時使用的是非同步API,目錄遍歷函式實現起來會有些複雜,但原理完全相同。travel函式的非同步版本如下。

function travel(dir, callback, finish) {
    fs.readdir(dir, function (err, files) {
        (function next(i) {
            if (i < files.length) {
                var pathname = path.join(dir, files[i]);

                fs.stat(pathname, function (err, stats) {
                    if (stats.isDirectory()) {
                        travel(pathname, callback, function () {
                            next(i + 1);
                        });
                    } else {
                        callback(pathname, function () {
                            next(i + 1);
                        });
                    }
                });
            } else {
                finish && finish();
            }
        }(0));
    });
}

這裡不詳細介紹非同步遍歷函式的編寫技巧,在後續章節中會詳細介紹這個。總之我們可以看到非同步程式設計還是蠻複雜的。

文字編碼

使用NodeJS編寫前端工具時,操作得最多的是文字檔案,因此也就涉及到了檔案編碼的處理問題。我們常用的文字編碼有UTF8GBK兩種,並且UTF8檔案還可能帶有BOM。在讀取不同編碼的文字檔案時,需要將檔案內容轉換為JS使用的UTF8編碼字串後才能正常處理。

BOM的移除

BOM用於標記一個文字檔案使用Unicode編碼,其本身是一個Unicode字元("\uFEFF"),位於文字檔案頭部。在不同的Unicode編碼下,BOM字元對應的二進位制位元組如下:

    Bytes      Encoding
----------------------------
    FE FF       UTF16BE
    FF FE       UTF16LE
    EF BB BF    UTF8

因此,我們可以根據文字檔案頭幾個位元組等於啥來判斷檔案是否包含BOM,以及使用哪種Unicode編碼。但是,BOM字元雖然起到了標記檔案編碼的作用,其本身卻不屬於檔案內容的一部分,如果讀取文字檔案時不去掉BOM,在某些使用場景下就會有問題。例如我們把幾個JS檔案合併成一個檔案後,如果檔案中間含有BOM字元,就會導致瀏覽器JS語法錯誤。因此,使用NodeJS讀取文字檔案時,一般需要去掉BOM。例如,以下程式碼實現了識別和去除UTF8 BOM的功能。

function readText(pathname) {
    var bin = fs.readFileSync(pathname);

    if (bin[0] === 0xEF && bin[1] === 0xBB && bin[2] === 0xBF) {
        bin = bin.slice(3);
    }

    return bin.toString('utf-8');
}

GBK轉UTF8

NodeJS支援在讀取文字檔案時,或者在Buffer轉換為字串時指定文字編碼,但遺憾的是,GBK編碼不在NodeJS自身支援範圍內。因此,一般我們藉助iconv-lite這個三方包來轉換編碼。使用NPM下載該包後,我們可以按下邊方式編寫一個讀取GBK文字檔案的函式。

var iconv = require('iconv-lite');

function readGBKText(pathname) {
    var bin = fs.readFileSync(pathname);

    return iconv.decode(bin, 'gbk');
}

單位元組編碼

有時候,我們無法預知需要讀取的檔案採用哪種編碼,因此也就無法指定正確的編碼。比如我們要處理的某些CSS檔案中,有的用GBK編碼,有的用UTF8編碼。雖然可以一定程度可以根據檔案的位元組內容猜測出文字編碼,但這裡要介紹的是有些侷限,但是要簡單得多的一種技術。

首先我們知道,如果一個文字檔案只包含英文字元,比如Hello World,那無論用GBK編碼或是UTF8編碼讀取這個檔案都是沒問題的。這是因為在這些編碼下,ASCII0~128範圍內字元都使用相同的單位元組編碼。

反過來講,即使一個文字檔案中有中文等字元,如果我們需要處理的字元僅在ASCII0~128範圍內,比如除了註釋和字串以外的JS程式碼,我們就可以統一使用單位元組編碼來讀取檔案,不用關心檔案的實際編碼是GBK還是UTF8。以下示例說明了這種方法。

1. GBK編碼原始檔內容:
    var foo = '中文';
2. 對應位元組:
    76 61 72 20 66 6F 6F 20 3D 20 27 D6 D0 CE C4 27 3B
3. 使用單位元組編碼讀取後得到的內容:
    var foo = '{亂碼}{亂碼}{亂碼}{亂碼}';
4. 替換內容:
    var bar = '{亂碼}{亂碼}{亂碼}{亂碼}';
5. 使用單位元組編碼儲存後對應位元組:
    76 61 72 20 62 61 72 20 3D 20 27 D6 D0 CE C4 27 3B
6. 使用GBK編碼讀取後得到內容:
    var bar = '中文';

這裡的訣竅在於,不管大於0xEF的單個位元組在單位元組編碼下被解析成什麼亂碼字元,使用同樣的單位元組編碼儲存這些亂碼字元時,背後對應的位元組保持不變。

NodeJS中自帶了一種binary編碼可以用來實現這個方法,因此在下例中,我們使用這種編碼來演示上例對應的程式碼該怎麼寫。

function replace(pathname) {
    var str = fs.readFileSync(pathname, 'binary');
    str = str.replace('foo', 'bar');
    fs.writeFileSync(pathname, str, 'binary');
}

小結

本章介紹了使用NodeJS操作檔案時需要的API以及一些技巧,總結起來有以下幾點:

  • 學好檔案操作,編寫各種程式都不怕。

  • 如果不是很在意效能,fs模組的同步API能讓生活更加美好。

  • 需要對檔案讀寫做到位元組級別的精細控制時,請使用fs模組的檔案底層操作API。

  • 不要使用拼接字串的方式來處理路徑,使用path模組。

  • 掌握好目錄遍歷和檔案編碼處理技巧,很實用。