サーバーサイドファイル操作ローカル環境同期・非同期

【Node.js】 フォルダ(ディレクトリ)のサイズ(使用量)を計測する

更新日:2021/06/02

ディスクの使用量が増えてくると、個々のディレクトリの使用サイズを確認して、多いところから減らしていくことがよくあります。Linuxなどはそのためのコマンドが用意されてますが、Windowsはありません。
そこで、Node.jsでディレクトリの使用サイズを確認できるコードを組んでみます。

 

ディレクトリサイズ計測の概要

Node.jsは、組み込みモジュールfsを使用することで、ファイルやディレクトリの情報を取得できます。

しかしディレクトリの情報には、ディレクトリのサイズが含まれてません。

そのためディレクトリの情報を得るには、子ディレクトリを再帰的に検索しながらファイルのサイズを合計する必要があります。

このことを踏まえて、ディレクトリサイズ計測する関数を作成してみます。

なお今回は、fsモジュールの2つの機能を使用します。
簡単に解説しておきます。

機能1:ディレクトリ内のメンバー取得 fs.readdir

ディレクトリ内に含まれている子メンバーを取得したいときは、fs.readdirを使用します。

fs.readdir( ディレクトリパス , オプション(省略可) , コールバック )

ファイルシステムからの情報読み取りは、比較的に時間がかかります。
その間プログラムの実行をストップしてしまうことを避けるために、読み込み完了を待たずに処理を続行します。

そして、読み込み完了後にコールバック関数で、データを受け渡します。
非同期処理と呼ばれているものですね。

オプション

オプションは省略可能で、省略する場合はfs.readdir( ディレクトリパス , コールバック )という形式になります。

オプションは次のオブジェクトを指定します。

fs.readdirのオプション

{
    encoding: デフォルト"utf8" 省略可
    withFileTypes: デフォルトfalse 省略可
}

encoding:

ファイル名またはディレクトリ名のエンコードを指定します。名前の取り扱いはNode.jsが内部的にうまく調整してくれるので、通常はデフォルトでいいはず。

JavaScriptの文字列の取り扱いについては、次のページを読んでみてください。
【JavaScript】 文字列データの内部形式と関連メソッドについてまとめてみた

withFileTypes:

trueのとき、fs.readdirの処理結果がfs.Direntオブジェクトの配列になります。
falseのときは、子メンバー名の文字列配列になります。

fs.Direntオブジェクト

fs.Direntオブジェクトは、次のプロパティを持っています。

fs.Direntのプロパティ

{
    name: 名前
    [Symbol(type)]: タイプを表す数値
}

また、次のメソッドでタイプを判定できます。

fs.Direntのメソッド

{
    isBlockDevice()      対象がブロックデバイスかどうか
    isCharacterDevice()  対象がキャラクターデバイスかどうか
    isDirectory()        対象がディレクトリかどうか
    isFIFO()             対象がFIFOパイプかどうか
    isFile()             対象がファイルパイプかどうか
    isSocket()           対象がソケットかどうか
    isSymbolicLink()     対象がシンボリックリンクかどうか
}

結果は、真偽値(true/false)で返ります。
メソッド名を見れば何を判定しているのかわかりますが、一応説明を入れてあります。

コールバック関数

コールバック関数は、次の二つの引数を受け取ります。

引数内容
第一引数 正常終了のときNullまたはundefined。

エラーのとき、Error オブジェクト。

第二引数 取得結果。

fs.readdirと同等の機能

fsモジュールには、fs.readdirと同じ結果を取得できる機能が二つ実装されています。

fs.readdirSync

fs.readdirSyncは、結果をリターン値で受け取ります。
fs.readdirとは異なり、こちらは読み込みが終わるのを待ちます。

コールバック関数を使用しないので、流れ的にわかりやすいコードが作成できます。
処理待ちをそれほど気にしないときに、使用するといいですね。

引数は二つです。

fs.readdirSync( ディレクトリパス , オプション(省略可) )

内部エラーは、tryで処理します。

fs.promise.readdir

fs.promise.readdirを実行するとpromiseオブジェクトを返します。
ディレクトリ内容は、promiseオブジェクトを経由して受け取ります。

asyncとawaitの組み合わせを上手く取り入れることで、流れ的にわかりやすいコードが作成できます。
しかも非同期で動作してくれるので、可能ならこのメソッドを使用することを推奨します。

asyncとawaitについてはこちら。
【JavaScript】 async/awaitを解説します

引数は二つです。

fs.promise.readdir( ディレクトリパス , オプション(省略可) )

なおこのメソッドは、Node.jsのドキュメント上では、fsPromises.readdirと定義されています。

機能2:ファイル内容の取得 fs.stat

fs.statを実行すると、ファイルのサイズやパーミッション・タイムスタンプなどをメンバーに持つfs.Statsオブジェクトを取得できます。

fs.stat( ファイルパス , オプション(省略可) , コールバック )

ファイルパスにはファイルだけでなく、ディレクトリも指定できます。

このメソッドもfs.readdirと同じように、非同期で動作します。

オプション

オプションは省略可能で、省略する場合はfs.stat( ファイルパス , コールバック )という形式になります。

オプションは次のオブジェクトを指定します。

fs.readdirのオプション

{
    bigint: デフォルトfalse
}

bigint:

bigintをtrueにすると、fs.Statsオブジェクトの数値プロパティ値がBigInt型になります。
このオプションを使用するケースは、ほとんどありません。

fs.statオブジェクト

fs.statオブジェクトは、次のプロパティを持っています。

fs.statのプロパティ

{
    dev: ファイルを含むデバイスの数値識別子
    ino: iファイルのファイル システム固有の「i ノード」番号
    mode: ファイルの種類とモードを説明するビットフィールド。パーミッション。
    nlink: ファイルに存在するハードリンクの数
    uid: ファイル を所有するユーザーの ID
    gid: ファイルを所有するグループの ID
    rdev: ファイルがデバイスの場合のデバイス識別子
    size: ファイルサイズ
    blksize: I/O 操作のファイルシステムブロックサイズ
    blocks: ファイルに割り当てられたブロック数
    atimeMs: 最終アクセスタイムスタンプ。ミリ秒
    mtimeMs: 最終更新タイムスタンプ。ミリ秒
    ctimeMs: ステータスの最終変更タイムスタンプ。ミリ秒
    birthtimeMs: 作成時タイムスタンプ。ミリ秒
    atime: 最終アクセスDateオブジェクト
    mtime: 最終更新Dateオブジェクト
    ctime: 最終状態変更Dateオブジェクト
    birthtime: 作成時Dateオブジェクト
}

fs.statのファイル名にディレクトリを指定した場合、sizeプロパティは0になります。

なおオプションbigintにtrueをセットすると、次のプロパティが追加されます。

bigint:true時のfs.statの追加プロパティ

{
    atimeNs: 最終アクセスタイムスタンプ。ナノ秒
    mtimeNs: 最終更新タイムスタンプ。ナノ秒
    ctimeNs: ステータスの最終変更タイムスタンプ。ナノ秒
}

また、次のメソッドでタイプを判定できます。

fs.statのメソッド

{
    isBlockDevice()      対象がブロックデバイスかどうか
    isCharacterDevice()  対象がキャラクターデバイスかどうか
    isDirectory()        対象がディレクトリかどうか
    isFIFO()             対象がFIFOパイプかどうか
    isFile()             対象がファイルパイプかどうか
    isSocket()           対象がソケットかどうか
    isSymbolicLink()     対象がシンボリックリンクかどうか
}

結果は、真偽値(true/false)で返ります。

コールバック関数

コールバック関数は、次の二つの引数を受け取ります。

引数内容
第一引数 正常終了のときNullまたはundefined。

エラーのとき、Error オブジェクト。

第二引数 取得結果。

fs.statと同等の機能

fsモジュールには、fs.statと同じ結果を取得できる機能が二つ実装されています。

fs.statSync

fs.statの同期処理版です。

fs.statSyncは、結果をリターン値で受け取ります。

引数は二つです。

fs.statSync( ファイルパス , オプション(省略可) )

内部エラーは、tryで処理します。

fs.promise.stat

fs.promise.statを実行するとpromiseオブジェクトを返します。
結果は、promiseオブジェクトを経由して受け取ります。

引数は二つです。

fs.promise.stat( ファイルパス , オプション(省略可) )

なおこのメソッドは、Node.jsのドキュメント上では、fsPromises.statと定義されています。

fs.readdirとfs.statのタイプ判定に差異がある

僕が次の環境でfs.readdirとfs.statを実行したところ、タイプ判定を行うメソッドで異なる結果が出ました。

Node.jsバージョン:v14.17.0
OS:Windows10

ファイル:c:\Users\ユーザー名\Application Data

コマンドプロンプトでwindowsコマンドdir /aを実行したとき、 <JUNCTION>と表示されるファイルです。

fs.statでは、ディレクトリと判断されます。

fs.statでの確認

C:\Users\ユーザー名>node
Welcome to Node.js v14.17.0.
Type ".help" for more information.
> require("fs").statSync("c:\\Users\\ユーザー名\\Application Data").isFile()
false
> require("fs").statSync("c:\\Users\\ユーザー名\\Application Data").isDirectory()
true
> require("fs").statSync("c:\\Users\\ユーザー名\\Application Data").isSymbolicLink()
false
>

fs.readdirでは、シンボリックリンクと判断されます。

fs.readdirでの確認

C:\Users\ユーザー名>node
Welcome to Node.js v14.17.0.
Type ".help" for more information.
> require("fs").readdirSync("c:\\Users\\ユーザー名",{withFileTypes:true})[24].name
'Application Data'
> require("fs").readdirSync("c:\\Users\\ユーザー名",{withFileTypes:true})[24].isFile()
false
> require("fs").readdirSync("c:\\Users\\ユーザー名",{withFileTypes:true})[24].isDirectory()
false
> require("fs").readdirSync("c:\\Users\\ユーザー名",{withFileTypes:true})[24].isSymbolicLink()
true
>

シンボリックリンクとジャンクションは別物ですが、似たような機能です。
なので、シンボリックリンクとして判定されるべきです。

ディレクトリとして判断した場合、複数のジャンクションが相互にループしていたら無限ループになってしまうからです。

再帰的に呼び出す場合は、特に注意が必要ですね。

 

ディレクトリサイズを計測するコード

前置きが想定以上に長くなってしまいました。

当初の目的である、ディレクトリサイズを計測するコードを作成してみます。

今回は2種類のコードを用意しました。

fs.promiseメソッドおよびasyncとawaitを使用した流れが比較的わかりやすいコードと、コールバックのみで流れを理解するのために少し頭の回転数を上げる必要があるコードです。

共通部

まずは、2つのコードで共通する部分を解説します。

ディレクトリサイズ計測コード共通部


const fs = require("fs");
const path = require("path");

const getCommand = () => (  {
    programName : process.argv.length > 0 ? process.argv[0] : null,
    scriptName : process.argv.length > 1 ? process.argv[1] : null,
    command : process.argv.filter( (e,i)=>i>1)
});
    // 対象ディレクトリの取得
const directory = getCommand().command.length === 0
            ? process.cwd()
            : getCommand().command[0];

仕様として、コマンドライン上でパラメーターが指定されていたら、それを検索対象ディレクトリとして扱います。
指定されていなかったら、カレントディレクトリを検索対象ディレクトリとして扱います。

コードの始めにあるgetCommand関数は【Node.js】 コマンドラインからの引数を取得する方法で紹介している関数です。
この関数を使用して、検索対象となるディレクトリを変数directoryにセットしています。

Promiseを使用したコード

メインとなるコードの一つ目、fs.promiseメソッドおよびasyncとawaitを使用した流れが比較的わかりやすいコードです。
共通部の後に続けてコピペしてください。

Promiseベースのディレクトリサイズ計測コード


// 再帰によるフォルダサイズ計算
const getDirectorySize = async ( searchPath ) =>{

    const directorySize = async ( searchPath , dirName = null,isTop = true) =>{

        const pathDirent = await fs.promises.readdir( searchPath ,{withFileTypes:true}).catch( ()=>null );
        if( pathDirent === null ) return [dirName,-1];

        let fileSize = 0 , promises = [];

        for( const dirent of pathDirent ){
            const r = path.join( searchPath , dirent.name );
            if( dirent.isFile() ) {
                fileSize += await fs.promises.stat( path.join( searchPath , dirent.name )).then(e=>e.size, ()=>0);
            }
            else if( dirent.isDirectory() )
                promises.push(  directorySize( path.join( searchPath , dirent.name ) ,  dirent.name ,false ) );
        }
        if( promises.length === 0 ) return [dirName , fileSize ];

        const result = await Promise.all( promises );

        fileSize = result.reduce( (a,b)=>a+Math.max(b[1],0),fileSize);

        return isTop ? {size:fileSize,dirList:result,error : false} : [dirName,fileSize];
    };

    const result = await directorySize( searchPath , null );
    return Array.isArray(result) ? { error : true } : result;

};

getDirectorySize( directory )
    .then( result => {
        if( result.error ) {
            console.log( `${directory}を読み込めませんでした` );
            return;
        }
        console.log( directory );
        console.log( `サイズ:${result.size}` );
        result.dirList.forEach(
            dirSize=>console.log( `${dirSize[0]}${
                "\t".repeat(5-Math.floor(dirSize[0].length/8))
            }${dirSize[1] < 0 ? "読み込めませんでした" : dirSize[1]}`)
        );
    });

■getDirectorySize関数

getDirectorySize関数は、async関数なのでpromiseオブジェクトを返します。

async関数内でpromiseオブジェクトを返す関数に対してawaitを使用すると、解決するまで実行を一時停止できます。
asyncとawaitについてはこちら。
【JavaScript】 async/awaitを解説します

getDirectorySize関数は、検索対象となるディレクトリ名を引数で受け取り、次の形式のオブジェクトで解決します。

getDirectorySize関数の解決オブジェクト形式

{
    size: 総サイズ,
    dirList: 配列[子ディレクトリ名,サイズ]の配列,
    error : ディレクトリ内容が読み込めない時false。それ以外はtrue
}

■directorySize関数

getDirectorySize関数内のdirectorySize関数も、async関数なのでpromiseオブジェクトを返します。
この関数内は、3番目の引数isTopがtrueのとき、上記のgetDirectorySize関数の解決オブジェクト形式で解決します。
falseのときは、配列[2番目の引数dirName , 1番目の引数searchPath内の総サイズ]で解決します。

関数の流れは、まず引数searchPathの内容をfs.promises.readdir()で読み取ります。
このとき、オプション{withFileTypes:true}を指定しているので、結果はfs.Direntオブジェクトの配列で受け取ります。

ただし、引数searchPathがディレクトリでなかったり、読み取り権限がないなどでfs.promises.readdir()が失敗した場合、続くcatch()で定義した関数が呼び出されます。そしてその関数で返しているnullで解決したとみなされます。
このとき、directorySize関数は配列[2番目の引数dirName , -1]で解決して終了します。

正常にfs.promises.readdir()が終了したら、fs.Direntオブジェクト内の通常ファイルのサイズを合計します。
それと同時に、fs.Direntオブジェクト内の子ディレクトリ名を引数として現在の関数(directorySize)を呼び出し、その結果のpromiseオブジェクトを配列変数promisesにセットしています。

fs.Direntの配列変数pathDirentを順番に処理する方法として、forEachではなくfor-ofしています。
理由は次のページを読んでみてください。
【JavaScript】 forEachでawaitが使えないんですけど?

全てのfs.Direntオブジェクト要素について処理が終わったら、配列変数promisesの要素数を確認して1以上なら、Promise.all()で全ての子ディレクトリのサイズ計測が終わるのを待ちます。

なお、子ディレクトリの処理結果はisTopにfalseを指定したあるので、結果は配列[dirName , サイズ]となります。
全ての子ディレクトリで結果が出たら、上記の配列の2番目の要素を全て合計し、通常ファイルの合計サイズに加算します。
このとき、読み込みに失敗した子ディレクトリはサイズが-1になっているので、0に補正しています。

最後にisTopを確認して、オブジェクトまたは配列を返して終了です。

コールバックを使用したコード

メインとなるコードの二つ目は、コールバックを使用したコードです。

共通部の後に続けてコピペしてください。

コールバックベースのディレクトリサイズ計測コード


// 再帰によるフォルダサイズ計算
const getDirectorySize =  ( searchPath , callBack ) =>{

    const dirSizes = [ ];

    const directorySize = ( dirPath ,  callBack , isTop = true) =>{

        fs.readdir( dirPath , {withFileTypes:true} ,
        (err,pathDirent)=>{
            if( err || pathDirent.length === 0 ) {
                if ( isTop  ) dirSizes.push( [path.basename(dirPath) , err ? -1 : 0 ] );
                return callBack( 0 );
            }

            let lastFiles = pathDirent.length;
            let fileSize = 0;

            for( const dirent of pathDirent ){
                if( dirent.isFile() )
                    fs.stat( path.join( dirPath , dirent.name ) ,
                        (err,stats)=> {
                            if( !err ) fileSize += stats.size;
                            if( --lastFiles === 0 ) callBack( fileSize );
                        }
                    );
                else if( dirent.isDirectory() )
                    directorySize( path.join( dirPath , dirent.name ) ,
                        size => {
                            if( isTop ) dirSizes.push( [dirent.name , size ]);
                            fileSize += size;
                            if( --lastFiles === 0 ) callBack( fileSize );
                        },false);
                else if( --lastFiles === 0 ) callBack( fileSize );
            }
        });
    };

    directorySize( searchPath ,
        size => callBack({size:size,dirList:dirSizes})
    );

};

getDirectorySize( directory , result => {
        console.log( directory );
        console.log( `サイズ:${result.size}` );
        result.dirList.forEach(
            dirSize=>console.log( `${dirSize[0]}${
                "\t".repeat(5-Math.floor(dirSize[0].length/8))
            }${dirSize[1] < 0 ? "読み込めませんでした" : dirSize[1]}`)
        );
    });

処理の流れは、Promiseを使用したコードと同じです。

ただし、子ディレクトリの処理が終了した時点で2番目の引数callBackで情報を通知する必要がありますが、『終了した時点』を補足する仕組みに工夫が必要です。

ここでは、fs.Direntオブジェクトの個数を変数lastFilesにセットしておき、ファイルのサイズ加算や、子ディレクトリの処理が終了した時点で変数lastFilesをカウントダウンしています。

そして、この変数の値がゼロになった時点で、『終了した時点』とみなして、2番目の引数callBackを呼び出しています。

今回のコードは、fs.statやdirectorySize関数に渡したコールバック関数と、変数lastFilesとの関連性が重要になっています。
『同じ関数内なんだから、普通にマイナスすればいいよね』的な認識でもよいのですが、できればクロージャ的な考え方を念頭においてコードを組み立てられるといいですね。

なおこのコードは、何らかの落とし穴があるかもしれません。
ご自分で環境で検証してみてください。

コードの難易度の差異について

僕は2番目のコードを理解するには、少し頭の回転数を上げる必要があると書きました。
これについて少し解説します。

一番目のPromiseを使用したコードは、Promise.all()を使用することで、子ディレクトリの処理が全て終了することが明確になっています。

しかし2番目のコールバックを使用したコードの終了条件は自分が考えたものなので、あらゆる可能性を考慮する必要があります。
さらにいつ呼び出されるか明確でないコールバック関数がからむことで、プログラムの流れが読みにくくなっています。
そのため、処理的に正しくても『本当かな?』という不安が付きまとうのです。
それを払拭するには、もっと頭の回転数を上げて精査する必要があるのです。

「うわ、めんどくせ!」と感じますね。

 

速度比較

フォルダ(ディレクトリ)のサイズ(使用量)計測を二つの方法でコード化しました。
コードのわかりやすさの面では、Promiseを使用したコードが推奨されると思います。

しかし時には、実行速度も重要な要件であるケースがあります。
今回のサイズ計測も、その一つであると言えます。

そこで、二つの方法の速度比較をしてみます。

下表はひとつのディレクトリについて5回計測して平均値を求めて、その結果を比較したものです。

手法1回目2回目3回目4回目5回目平均
ディレクトリ1promise820818796798797806
コールバック563542572556535554
ディレクトリ2promise135431543215256132971360714227
コールバック711072576993709075607202

コールバックを使用したコードの方が、圧倒的に速いですね。
この数値を見ると、コールバックの方がおススメです。

内部的な処理としてPromiseを使用したコードは、awaitの中断処理やPromiseそのものの仕組みの準備や待機処理など、コールバックを使用したコードよりも複雑なことをやっているのは確実です。

差が出るのも当たり前ですね。

どちらを採用するかは、何を重視するかで変わってきます。
正解はないのです。

 

サイズが大きいファイルを取得する

ここまでで作成したコードは全てのファイルのサイズ情報を取得しています。

せっかくなので、ファイルサイズが大きい順にリストアップしてみます。

次のコードは、ファイル名とサイズを受け取って、上位の5ファイルを記憶しておくオブジェクトを用意しています。

上位の5ファイルを記憶しておくコード


const fileSizeRanking = (countNum=>{
    const files = Array.from({length:countNum},()=>[0,""]);
    let min = 0;
    return {
        setFiles : (parentPath , name , size) => {
            if( min > size ) return;
            for( let i = 0 ; i < countNum ; i ++ ){
                if( files[i][0] < size ) {
                    files.splice( i , 0 , [size,path.join(parentPath,name) ] );
                    files.length = countNum;
                    min = files[ countNum - 1 ];
                    return;
                }
            }
        },
        get result(){ return files; }
    };
})(5);

単純に配列に格納しているだけですね。

もし上位20ファイルなど取得する数を増やしたいときは、このコードでは効率が悪いです。
次のページを参考にしてみてください。
【JavaScript】 ソート済みの配列に値を挿入する効率的な方法

次のコードは、Promiseを使用したコードへ適用したものから、該当箇所を抜き出したものです。

promiseバージョンへの適用


if( dirent.isFile() ) {
    fileSize += await fs.promises.stat( path.join( searchPath , dirent.name )).then(e=>{
                    fileSizeRanking.setFiles( searchPath , dirent.name , e.size);
                    return e.size;
                }, ()=>0);
}

次のコードは、コールバックを使用したコードへ適用したものから、該当箇所を抜き出したものです。

コールバックバージョンへの適用


fs.stat( path.join( dirPath , dirent.name ) ,
    (err,stats)=> {
        if( !err ) {
            fileSizeRanking.setFiles( dirPath , dirent.name , stats.size);
            fileSize += stats.size;
        }
        if( --lastFiles === 0 ) callBack( fileSize );
    }
);

次のコードは、検索結果を表示している箇所に、ランキング表示を追加したものです。

結果表示


console.log( directory );
console.log( `サイズ:${result.size}` );
result.dirList.forEach(
    dirSize=>console.log( `${dirSize[0]}${
        "\t".repeat(5-Math.floor(dirSize[0].length/8))
    }${dirSize[1] < 0 ? "読み込めませんでした" : dirSize[1]}`)
);
const rank = fileSizeRanking.result;
console.log( `上位${rank.length}ファイル` );
rank.forEach( e=>console.log( `${e[0]}\t\t${e[1]}` ))

実行してみると、全くファイルがリストアップされて、驚きますよ。
即、削除です。

更新日:2021/06/02

書いた人(管理人):けーちゃん

スポンサーリンク

記事の内容について

null

こんにちはけーちゃんです。
説明するのって難しいですね。

「なんか言ってることおかしくない?」
たぶん、こんなご意見あると思います。

裏付けを取りながら記事を作成していますが、僕の勘違いだったり、そもそも情報源の内容が間違えていたりで、正確でないことが多いと思います。
そんなときは、ご意見もらえたら嬉しいです。

掲載コードについては事前に動作確認をしていますが、貼り付け後に体裁を整えるなどをした結果動作しないものになっていることがあります。
生暖かい視線でスルーするか、ご指摘ください。

ご意見、ご指摘はこちら。
https://note.affi-sapo-sv.com/info.php

 

このサイトは、リンクフリーです。大歓迎です。