アシアルブログ

アシアルの中の人が技術と想いのたけをつづるブログです

(PHPで)指定ディレクトリ以下を全部チェックしてファイル一覧を取得する方法

こんにちは。宇都宮です。

「特定ディレクトリ以下のファイル全てに対して処理を行うプログラム」を書く機会というのは、たまにありますね。

PHP ファイル 再帰的」といったワードで検索すると、色々引っかかります。それぞれ一長一短あります。「特定ディレクトリ以下のファイル全てに対して処理を行うプログラム」の様々な実装例を紹介します。

1. scandir()とis_file()/is_dir()を組み合わせる





<?php
function getFileList($dir) {
    $files = scandir($dir);
    $files = array_filter($files, function ($file) { // 注(1)
        return !in_array($file, array('.', '..'));
    });

    $list = array();
    foreach ($files as $file) {
        $fullpath = rtrim($dir, '/') . '/' . $file; // 注(2)
        if (is_file($fullpath)) {
            $list[] = $fullpath;
        }
        if (is_dir($fullpath)) {
            $list = array_merge($list, getFileList($fullpath));
        }
    }

    return $list;
}


scandir()で指定ディレクトリ配下のファイル・ディレクトリの一覧を取得し、一覧にディレクトリが含まれていればさらに掘り進む、という仕組みです。

(1) $filesに .(カレントディレクトリへの参照) ..(親ディレクトリへの参照)が含まれていると、再帰を繰り返してMaximum function nesting levelに引っかかることがあるので、これらを除外

(2) $dirに'/'や'/var/'といった文字列が入っている場合に、$fullpathが'//usr'や'/var//www'とならないよう、rtrim() で$dirの最後尾の/を取り除いている

この方法でもできなくはありませんが、考慮する必要のある事柄が多く、大変です。この方法はおすすめしません。

2. glob()を使う





<?php
function getFileList($dir) {
    $files = glob(rtrim($dir, '/') . '/*');
    $list = array();
    foreach ($files as $file) {
        if (is_file($file)) {
            $list[] = $file;
        }
        if (is_dir($file)) {
            $list = array_merge($list, getFileList($file));
        }
    }

    return $list;
}


一見すると、1.と大差ないように見えますが、glob()Unixシェルと同様のワイルドカードが使えます。「*.html」というパターンを指定して、.htmlという拡張子のファイルを取ってくる、といったことが簡単にできます。

3. SPLを使ってみる



PHP5には、Standard PHP Library (SPL) というライブラリが付属しています。このライブラリは、PHP5.0.0以降ではデフォルトで使用可能、PHP5.3.0以降では常に使用可能となっています。

ファイル/ディレクトリ操作を便利にしてくれるクラスがあるので、紹介します。



<?php
function getFileList($dir) {
    $iterator = new RecursiveDirectoryIterator($dir);
    $iterator = new RecursiveIteratorIterator($iterator);

    $list = array();
    foreach ($iterator as $fileinfo) { // $fileinfoはSplFiIeInfoオブジェクト
        if ($fileinfo->isFile()) {
            $list[] = $fileinfo->getPathname();
        }
    }

    return $list;
}


RecursiveDirectoryIteratorは、ディレクトリを再帰的に反復処理するためのクラスです。

RecursiveDirectoryIteratorオブジェクトは、それ自体は再帰的な反復処理に対応していないため、RecursiveDirectoryIteratorオブジェクトを、さらにRecursiveIteratorIteratorオブジェクトに変換する必要があります。

文章にするとややこしいですが、コード例を見れば使い方は直感的に分かると思います。

なお、上記コード例では、他のコード例との一貫性のため最終的に配列を返していますが、配列にする必然性はありません。イテレータオブジェクトのままの方が使いやすい場面も多いでしょう。

条件に合致したファイルだけをフィルタリングしたい場合、RecursiveFilterIteratorを継承したクラスを用意します。



<?php
namespace Asial\File;

class HtmlFilterIterator extends \RecursiveFilterIterator
{
    public function accept()
    {
        $iterator = $this->getInnerIterator();
        if ($iterator->isDir()) {
            
            return true;
        }
        if (1 === preg_match('/\.html$/', $iterator->current())) {

            return true;
        }

        return false;
    }
}


RecursiveFilterIteratorを継承するクラスでは、accept()メソッドを実装する必要があります。accept()の中にフィルタリング条件を記述します(この場合、「ディレクトリである」又は「ファイル名の末尾が".html"で終わる」)。



<?php
require_once __DIR__ . '/HtmlFilterIterator.php';

function getHtmlList($dir) {
    $iterator = new \RecursiveDirectoryIterator($dir);
    $iterator = new \Asial\File\HtmlFilterIterator($iterator);
    $iterator = new \RecursiveIteratorIterator($iterator);

    $list = array();
    foreach ($iterator as $fileinfo) {
        if ($fileinfo->isFile()) {
            $list[] = $fileinfo->getPathname();
        }
    }

    return $list;
}


RecursiveDirectoryIteratorオブジェクトを、独自実装したHtmlFilterIteratorのコンストラクタに渡し、最後にHtmlFilterIteratorオブジェクトをRecursiveIteratorIteratorのコンストラクタに渡します。

RecursiveDirectoryIteratorを使った場合の利点は、ディレクトリの再帰的な走査を自分で書く必要がないことです。欠点は、各クラスの関係や、持っているメソッドの把握等が必要で、学習コストがかかることです。

4. 便利なライブラリ



再帰的なディレクトリ操作をサポートしているライブラリもあります。決定版がどれかは分かりませんが、個人的に使ってみて良いと思ったのは、SymfonyコンポーネントFinderです(Symfonyフルスタックのフレームワークですが、コンポーネント単位でライブラリとして利用することもできます)。

Finderのインストール方法としては、(1) 公式のGitリポジトリからcloneする (2) Composerを使ってインストールする などがあります。

以下のコード例は、Composerでインストールした場合です(Composerを使わない場合、オートローダを用意する必要があります。こちらの記事を参考にしてください)。



<?php

require_once __DIR__ . '/vendor/autoload.php';

use Symfony\Component\Finder\Finder;

function getHtmlList($dir) {

    $finder = new Finder();
    $iterator = $finder
        ->in($dir) // ディレクトリを指定
        ->name('*.html') // ファイル名を指定(ワイルドカードを使用できる)
        ->files(); // ディレクトリは除外し、ファイルのみ取得

    $list = array();
    foreach ($iterator as $fileinfo) { // $fileinfoはSplFiIeInfoオブジェクト
        $list[] = $fileinfo->getPathname();
    }

    return $list;
}


再帰的なディレクトリ操作について意識する必要もなく、フィルターのためだけにクラスを作る必要もない、非常に使いやすいライブラリです。私なら、Symfonyコンポーネントを導入可能な環境では、迷わずこのライブラリを使いますね。

5. 外部コマンド



単にファイルリストが欲しいだけなら、PHPでわざわざロジックを組まなくても、外部コマンドを呼び出せば済みます。



<?php
system('find "/var/www/htdocs" -type f -name "*.html"');


system()は、外部コマンドを実行し、その結果を表示します。findは、ファイル検索を行うUnixコマンドですね。

外部コマンドの出力をPHPスクリプト内部で再利用したいなら、exec()shell_exec()が使えます(exec()の方が、(1) コマンドからの出力を配列で取得できる (2) コマンド実行後のステータスコードを取得できる といった点で機能が豊富です)。

なお、外部コマンドの呼び出しは別プロセスの起動を伴うため、一般に高コストです。また、ユーザーの入力値をコマンドに反映する場合、セキュリティ的にも危険を伴います。

PHPにはファイル操作を行うための関数が揃っているので、基本的にはPHPの標準関数を使ったほうが良いでしょう。

以下は、exec()でfindを呼び出す場合の実装例です。



<?php
function getFileList($dir) {
    $dir = str_replace("\0", '', $dir); // NULLバイト攻撃対策
    $dir = escapeshellarg($dir); // 外部コマンドへの引数をエスケープ
    $cmd = 'find ' . $dir . ' -type f';
    $cmd .= ' 2> &1'; // 標準エラー出力を標準出力にリダイレクト(エラーメッセージを$listで受け取るため)

    $list       = array(); // コマンド実行後の標準出力を1行毎に受け取る配列
    $return_var = 0; // コマンド実行後のステータスコードを受け取る変数
    exec($cmd, $list, $return_var);

    if (0 === $return_var) { // 正常終了

        return $list;
    }
    throw new Exception($list[0], $return_var); // エラーの場合・例外を投げる
}


6. そもそも、PHPである必然性は…?



PHPアプリケーションの一機能として実装する場合、1〜5のいずれかの方法になると思いますが、日常業務の中で「特定ディレクトリのファイルを一定ルールに従ってコピーしたい」といったニーズに対して、あえてPHPを使う必要はありません。

ファイルリストが欲しいだけなら、前述したfindコマンドなどを実行すれば十分です。

込み入ったことをやる必要があるなら、PerlRubyスクリプトを組むのも良いでしょう。Perlの標準ライブラリに含まれるFile::Findは、とても使いやすいです。また、Rubyにも、File::Findとよく似たインターフェースのFind.findがあります。

以下はRubyのFind.findの例です。SymfonyのFinderほど高機能ではありませんが、シンプルで使いやすいですね。



require 'find'

def get_file_list(dir)
  file_list = []
  Find.find(dir) do |file|
    file_list << file if FileTest.file?(file)
  end
  file_list
end


7. まとめ



車輪の再発明を避けるのは重要だけど、どの車輪を選ぶかは悩ましい。