k-holyのPHPとか諸々メモ

Webで働くk-holyがPHP(スクリプト言語)とか諸々のことをメモしていきます。ソースコードはだいたいWindowsで動かしてます。

Windows環境のPHPで日本語ファイルパスを扱う場合の注意点

まず手元の Windows7 + PHP 5.6.0 で検証した上での結論を書くと Windows で日本語ファイルパスを扱う場合 SplFileInfo は使うな」 です。

ロケール設定を適切に行うことで basename()pathinfo() に関しては、Shift_JISのコード表に起因するいわゆる「5C問題」も回避できました。

しかし、SplFileInfo::getBasename()SplFileInfo::isFile() に関しては、適切にロケールを設定したとしても、残念ながら「5C問題」を回避できなかったのです。

ロケール設定の影響を受ける関数について

文字列関数は基本的にマルチバイト非対応のものが多いのですが、中には対応はしているがロケール設定に依存する、というものもあります。というか結構多いです。

ファイルパスを扱う際によく使われる basename()dirname()pathinfo() などは典型的で、Web上ではよくこれらの関数を「日本語に対応していない」と書かれた記事を見かけますが、実際にはそうではなく、ロケール設定が不適切なケースがほとんどだと思います。

他にも見落としがちなところでは、バリデーション処理にも使われる ctype_alpha() などのctype関数や、strtoupper()strcasecmp() のような大文字小文字を変換したり比較する関数でも、ロケール設定によっては予想外の結果を返す場合があります。

これらロケール設定の影響を受ける関数のまとめは、こちらの記事が参考になります。

トラブルを避けるためには、アプリケーションにおける内部エンコーディングロケール設定を揃えるのはもちろんですが、一時的に異なったエンコーディングを扱う場合は setlocale() 関数を併用することも必要になります。

たとえば fgetcsv()SplFileObjectCSVファイルを扱う場合、「Excelで開ける」という要件を満たそうとすると、一般的なLinux系OSのロケール設定のままでは対応できません。

また、自分のようにLinux環境ではあえてCロケールPOSIXロケール?)に設定している場合でも、これらの関数でマルチバイト文字列を扱う際に setlocale() は必須になります。

PHPUnitでの検証結果

今回は検証のために、PHPUnitでテストコードを書きました。

その結果から分かったことを挙げていきます。

  • pathinfo()['dirname']dirname() は同じ結果を返す
  • pathinfo()['basename']basename() は同じ結果を返す

上記は予想通りでした。

  • pathinfo()['dirname']SplFileInfo::getPath() は必ずしも同じ結果を返さない
  • pathinfo()['basename']SplFileInfo::getBasename() は必ずしも同じ結果を返さない

この辺はちょっと予想外。内部的には同じコードを使っているのかと思ってましたが、どうやら違うようで…これが今回の結論に至った要因です。

おかしな結果が返されたのは以下のケースです。

  • 日本語(非5C)で終わるディレクトリをパスに含むファイルへの dirname() および SplFileInfo::getPath()
  • 日本語(5C)で終わるファイルへの SplFileInfo::getBasename()
  • 日本語(5C)で終わるファイルへの SplFileInfo::isFile()
  • 日本語(5C)で終わるディレクトリへの SplFileInfo::getPathname()
  • 日本語(5C)で終わるディレクトリへの SplFileInfo::getBasename()
  • 日本語(5C)で終わるディレクトリへの SplFileInfo::isDir()
  • 日本語(5C)で終わるディレクトリをパスに含むファイルへの SplFileInfo::getType()
  • 日本語(5C)で終わるディレクトリをパスに含むファイルへの SplFileInfo::isFile()

これらの結果から判断して、 SplFileInfo はロケールを適切に設定しても日本語ファイルパスを正しく扱えない と考えて良いと思います。

一方で、basename()pathinfo()ロケールを適切に設定することで日本語ファイルパスを正しく扱える と考えて良いと思います。

念のため書いておくと、UTF-8であれば5C問題とか無関係なので当然ですが、LinuxUTF-8の日本語ファイルパスについては何ら問題ありません。

Windowsでも実際のファイルパスではなく単なる文字列としてのファイルパス(SplFileInfoは存在しないファイルパスも扱えます)を内部エンコーディングUTF-8で扱う場合も、問題はありません。

また、存在しないファイルやディレクトリへの SplFileInfo::isFile()SplFileInfo::isDir() そして SplFileInfo::getRealPath() が常にFALSEを返すのは予想通りだと思いますが、SplFileInfo::getType() など実際のファイル情報が必要なメソッドが RuntimeException をスローするのは要注意です。(PHPマニュアルにもある通りですが)

Windows環境でSplFileInfoがダメとなれば、それを継承している場合もおそらく同様、ということはSymfonyのFinderコンポーネントも…?

PHPUnitでの検証ソース

長くなりますが、PHPUnitでの検証ソースです。

テストを通すためにアサーションを修正したものなので、おかしいのはテスト結果ではなく、行末コメントで「!!」と書いてあるアサーションの方です。

メソッド名がでたらめ英語なのはご容赦ください…。)

<?php
namespace Acme\Test;

class SplFileInfoTest extends \PHPUnit_Framework_TestCase
{

    private $isWin;
    private $testDir;

    public function setUp()
    {
        $this->isWin = (strncasecmp(PHP_OS, 'WIN', 3) === 0);
        $this->testDir = __DIR__ . DIRECTORY_SEPARATOR . 'SplFileInfoTest';
    }

    private function convert($path)
    {
        return mb_convert_encoding($path, 'CP932', 'UTF-8');
    }

    // 日本語(非5C)のファイル(Windows)
    public function testJapaneseFileOnWindows()
    {
        if (!$this->isWin) {
            $this->markTestSkipped('OS is not Windows');
        }

        $this->setLocale(LC_CTYPE, 'Japanese_Japan.932');
        $path = $this->convert($this->testDir . DIRECTORY_SEPARATOR . '日本語.txt');
        $fileInfo = new \SplFileInfo($path);
        $pathinfo = pathinfo($path);

        $this->assertEquals($path, $fileInfo->getRealPath());
        $this->assertEquals($path, $fileInfo->getPathname());
        $this->assertEquals($this->convert('日本語.txt'), $pathinfo['basename']);
        $this->assertEquals($pathinfo['basename'], $fileInfo->getBasename());
        $this->assertEquals($pathinfo['basename'], basename($path));
        $this->assertEquals($this->testDir, $pathinfo['dirname']);
        $this->assertEquals($pathinfo['dirname'], $fileInfo->getPath());
        $this->assertEquals($pathinfo['dirname'], dirname($path));
        $this->assertEquals( 'txt', $pathinfo['extension']);
        $this->assertEquals($pathinfo['extension'], $fileInfo->getExtension());
        $this->assertEquals('file', $fileInfo->getType());
        $this->assertTrue($fileInfo->isFile());
        $this->assertFalse($fileInfo->isDir());
    }

    // 日本語(非5C)のディレクトリ(Windows)
    public function testJapaneseDirectoryOnWindows()
    {
        if (!$this->isWin) {
            $this->markTestSkipped('OS is not Windows');
        }

        $this->setLocale(LC_CTYPE, 'Japanese_Japan.932');
        $path = $this->convert($this->testDir . DIRECTORY_SEPARATOR . '日本語');
        $fileInfo = new \SplFileInfo($path);
        $pathinfo = pathinfo($path);

        $this->assertEquals($path, $fileInfo->getRealPath());
        $this->assertEquals($path, $fileInfo->getPathname());
        $this->assertEquals($this->convert('日本語'), $pathinfo['basename']);
        $this->assertEquals($pathinfo['basename'], $fileInfo->getBasename());
        $this->assertEquals($pathinfo['basename'], basename($path));
        $this->assertEquals($this->testDir, $pathinfo['dirname']);
        $this->assertEquals($pathinfo['dirname'], $fileInfo->getPath());
        $this->assertEquals($pathinfo['dirname'], dirname($path));
        $this->assertArrayNotHasKey('extension', $pathinfo);
        $this->assertEmpty($fileInfo->getExtension());
        $this->assertEquals('dir', $fileInfo->getType());
        $this->assertFalse($fileInfo->isFile());
        $this->assertTrue($fileInfo->isDir());
    }

    // 日本語(非5C)のディレクトリをパスに含むファイル(Windows)
    public function testFileIncludesJapaneseInPathOnWindows()
    {
        if (!$this->isWin) {
            $this->markTestSkipped('OS is not Windows');
        }

        $this->setLocale(LC_CTYPE, 'Japanese_Japan.932');
        $path = $this->convert($this->testDir . DIRECTORY_SEPARATOR . '日本語' . DIRECTORY_SEPARATOR . 'test.txt');
        $fileInfo = new \SplFileInfo($path);
        $pathinfo = pathinfo($path);

        $this->assertEquals($path, $fileInfo->getRealPath());
        $this->assertEquals($path, $fileInfo->getPathname());
        $this->assertEquals('test.txt', $pathinfo['basename']);
        $this->assertEquals($pathinfo['basename'], $fileInfo->getBasename());
        $this->assertEquals($pathinfo['basename'], basename($path));
        $this->assertNotEquals($this->convert($this->testDir . DIRECTORY_SEPARATOR . '日本語'), $pathinfo['dirname']); // !!
        $this->assertNotEquals($pathinfo['dirname'], $fileInfo->getPath()); // !!
        $this->assertEquals($pathinfo['dirname'], dirname($path));
        $this->assertEquals('txt', $pathinfo['extension']);
        $this->assertEquals($pathinfo['extension'], $fileInfo->getExtension());
        $this->assertEquals('file', $fileInfo->getType());
        $this->assertTrue($fileInfo->isFile());
        $this->assertFalse($fileInfo->isDir());
    }

    // 日本語(5C)のファイル(Windows)
    public function testJapaneseFileIncludes5cOnWindows()
    {
        if (!$this->isWin) {
            $this->markTestSkipped('OS is not Windows');
        }

        $this->setLocale(LC_CTYPE, 'Japanese_Japan.932');
        $path = $this->convert($this->testDir . DIRECTORY_SEPARATOR . '表.txt');
        $fileInfo = new \SplFileInfo($path);
        $pathinfo = pathinfo($path);

        $this->assertFalse($fileInfo->getRealPath()); // !!
        $this->assertEquals($path, $fileInfo->getPathname());
        $this->assertEquals($this->convert('表.txt'), $pathinfo['basename']);
        $this->assertNotEquals($pathinfo['basename'], $fileInfo->getBasename()); // !!
        $this->assertEquals($pathinfo['basename'], basename($path));
        $this->assertEquals($this->testDir, $pathinfo['dirname']);
        $this->assertNotEquals($pathinfo['dirname'], $fileInfo->getPath()); // !!
        $this->assertEquals($pathinfo['dirname'], dirname($path));
        $this->assertEquals( 'txt', $pathinfo['extension']);
        $this->assertEquals($pathinfo['extension'], $fileInfo->getExtension());
        $this->assertEquals('file', $fileInfo->getType());
        $this->assertFalse($fileInfo->isFile()); // !!
        $this->assertFalse($fileInfo->isDir());
    }

    // 日本語(5C)のディレクトリ(Windows)
    public function testJapaneseDirectoryIncludes5cOnWindows()
    {
        if (!$this->isWin) {
            $this->markTestSkipped('OS is not Windows');
        }

        $this->setLocale(LC_CTYPE, 'Japanese_Japan.932');
        $path = $this->convert($this->testDir . DIRECTORY_SEPARATOR . '');
        $fileInfo = new \SplFileInfo($path);
        $pathinfo = pathinfo($path);

        $this->assertFalse($fileInfo->getRealPath()); // !!
        $this->assertNotEquals($path, $fileInfo->getPathname()); // !!
        $this->assertEquals($this->convert(''), $pathinfo['basename']);
        $this->assertNotEquals($pathinfo['basename'], $fileInfo->getBasename()); // !!
        $this->assertEquals($pathinfo['basename'], basename($path));
        $this->assertEquals($this->testDir, $pathinfo['dirname']);
        $this->assertEquals($pathinfo['dirname'], $fileInfo->getPath());
        $this->assertEquals($pathinfo['dirname'], dirname($path));
        $this->assertArrayNotHasKey('extension', $pathinfo);
        $this->assertEmpty($fileInfo->getExtension());
        $this->assertEquals('dir', $fileInfo->getType());
        $this->assertFalse($fileInfo->isFile());
        $this->assertFalse($fileInfo->isDir()); // !!
    }

    // 日本語(5C)のディレクトリをパスに含むファイル(Windows)
    public function testFileIncludesJapaneseIncludes5cInPathOnWindows()
    {
        if (!$this->isWin) {
            $this->markTestSkipped('OS is not Windows');
        }

        $this->setLocale(LC_CTYPE, 'Japanese_Japan.932');
        $path = $this->convert($this->testDir . DIRECTORY_SEPARATOR . '' . DIRECTORY_SEPARATOR . 'test.txt');
        $fileInfo = new \SplFileInfo($path);
        $pathinfo = pathinfo($path);

        $this->assertFalse($fileInfo->getRealPath()); // !!
        $this->assertEquals($path, $fileInfo->getPathname());
        $this->assertEquals('test.txt', $pathinfo['basename']);
        $this->assertEquals($pathinfo['basename'], $fileInfo->getBasename());
        $this->assertEquals($pathinfo['basename'], basename($path));
        $this->assertEquals($this->convert($this->testDir . DIRECTORY_SEPARATOR . ''), $pathinfo['dirname']);
        $this->assertEquals($pathinfo['dirname'], $fileInfo->getPath());
        $this->assertEquals($pathinfo['dirname'], dirname($path));
        $this->assertEquals('txt', $pathinfo['extension']);
        $this->assertEquals($pathinfo['extension'], $fileInfo->getExtension());
        try {
            $this->assertEquals('file', $fileInfo->getType());
        } catch (\RuntimeException $e) {
            $this->assertStringStartsWith('SplFileInfo::getType(): Lstat failed', $e->getMessage()); // !!
        }
        $this->assertFalse($fileInfo->isFile()); // !!
        $this->assertFalse($fileInfo->isDir());
    }

    // 日本語のファイル(非Windows)
    public function testJapaneseFile()
    {
        if ($this->isWin) {
            $this->markTestSkipped('OS is Windows');
        }

        $this->setLocale(LC_CTYPE, 'ja_JP.UTF-8');
        $path = $this->testDir . DIRECTORY_SEPARATOR . '日本語.txt';
        $fileInfo = new \SplFileInfo($path);
        $pathinfo = pathinfo($path);

        $this->assertEquals($path, $fileInfo->getRealPath());
        $this->assertEquals($path, $fileInfo->getPathname());
        $this->assertEquals('日本語.txt', $pathinfo['basename']);
        $this->assertEquals($pathinfo['basename'], $fileInfo->getBasename());
        $this->assertEquals($pathinfo['basename'], basename($path));
        $this->assertEquals($this->testDir, $pathinfo['dirname']);
        $this->assertEquals($pathinfo['dirname'], $fileInfo->getPath());
        $this->assertEquals($pathinfo['dirname'], dirname($path));
        $this->assertEquals( 'txt', $pathinfo['extension']);
        $this->assertEquals($pathinfo['extension'], $fileInfo->getExtension());
        $this->assertEquals('file', $fileInfo->getType());
        $this->assertTrue($fileInfo->isFile());
        $this->assertFalse($fileInfo->isDir());
    }

    // 日本語(非5C)のディレクトリ(非Windows)
    public function testJapaneseDirectory()
    {
        if ($this->isWin) {
            $this->markTestSkipped('OS is Windows');
        }

        $this->setLocale(LC_CTYPE, 'ja_JP.UTF-8');
        $path = $this->testDir . DIRECTORY_SEPARATOR . '日本語';
        $fileInfo = new \SplFileInfo($path);
        $pathinfo = pathinfo($path);

        $this->assertEquals($path, $fileInfo->getRealPath());
        $this->assertEquals($path, $fileInfo->getPathname());
        $this->assertEquals('日本語', $pathinfo['basename']);
        $this->assertEquals($pathinfo['basename'], $fileInfo->getBasename());
        $this->assertEquals($pathinfo['basename'], basename($path));
        $this->assertEquals($this->testDir, $pathinfo['dirname']);
        $this->assertEquals($pathinfo['dirname'], $fileInfo->getPath());
        $this->assertEquals($pathinfo['dirname'], dirname($path));
        $this->assertArrayNotHasKey('extension', $pathinfo);
        $this->assertEmpty($fileInfo->getExtension());
        $this->assertEquals('dir', $fileInfo->getType());
        $this->assertFalse($fileInfo->isFile());
        $this->assertTrue($fileInfo->isDir());
    }

    // 日本語(非5C)のディレクトリをパスに含むファイル(非Windows)
    public function testFileIncludesJapaneseInPath()
    {
        if ($this->isWin) {
            $this->markTestSkipped('OS is Windows');
        }

        $this->setLocale(LC_CTYPE, 'ja_JP.UTF-8');
        $path = $this->testDir . DIRECTORY_SEPARATOR . '日本語' . DIRECTORY_SEPARATOR . 'test.txt';
        $fileInfo = new \SplFileInfo($path);
        $pathinfo = pathinfo($path);

        $this->assertEquals($path, $fileInfo->getRealPath());
        $this->assertEquals($path, $fileInfo->getPathname());
        $this->assertEquals('test.txt', $pathinfo['basename']);
        $this->assertEquals($pathinfo['basename'], $fileInfo->getBasename());
        $this->assertEquals($pathinfo['basename'], basename($path));
        $this->assertEquals($this->testDir . DIRECTORY_SEPARATOR . '日本語', $pathinfo['dirname']);
        $this->assertEquals($pathinfo['dirname'], $fileInfo->getPath());
        $this->assertEquals($pathinfo['dirname'], dirname($path));
        $this->assertEquals('txt', $pathinfo['extension']);
        $this->assertEquals($pathinfo['extension'], $fileInfo->getExtension());
        $this->assertEquals('file', $fileInfo->getType());
        $this->assertTrue($fileInfo->isFile());
        $this->assertFalse($fileInfo->isDir());
    }

    // 存在しない日本語のファイル
    public function testJapaneseFileNotExisting()
    {
        if (!$this->isWin) {
            $this->setLocale(LC_CTYPE, 'ja_JP.UTF-8');
        }

        $path = $this->testDir . DIRECTORY_SEPARATOR . 'ソ.txt';
        $fileInfo = new \SplFileInfo($path);
        $pathinfo = pathinfo($path);

        $this->assertFalse($fileInfo->getRealPath());
        $this->assertEquals($path, $fileInfo->getPathname());
        $this->assertEquals('ソ.txt', $pathinfo['basename']);
        $this->assertEquals($pathinfo['basename'], $fileInfo->getBasename());
        $this->assertEquals($pathinfo['basename'], basename($path));
        $this->assertEquals($this->testDir, $pathinfo['dirname']);
        $this->assertEquals($pathinfo['dirname'], $fileInfo->getPath());
        $this->assertEquals($pathinfo['dirname'], dirname($path));
        $this->assertEquals( 'txt', $pathinfo['extension']);
        $this->assertEquals($pathinfo['extension'], $fileInfo->getExtension());
        try {
            $fileInfo->getType();
        } catch (\RuntimeException $e) {
            $this->assertStringStartsWith('SplFileInfo::getType(): Lstat failed', $e->getMessage());
        }
        $this->assertFalse($fileInfo->isFile());
        $this->assertFalse($fileInfo->isDir());
    }

    // 存在しない日本語のディレクトリ
    public function testJapaneseDirectoryNotExisting()
    {
        if (!$this->isWin) {
            $this->setLocale(LC_CTYPE, 'ja_JP.UTF-8');
        }

        $path = $this->testDir . DIRECTORY_SEPARATOR . '';
        $fileInfo = new \SplFileInfo($path);
        $pathinfo = pathinfo($path);

        $this->assertFalse($fileInfo->getRealPath());
        $this->assertEquals($path, $fileInfo->getPathname());
        $this->assertEquals('', $pathinfo['basename']);
        $this->assertEquals($pathinfo['basename'], $fileInfo->getBasename());
        $this->assertEquals($pathinfo['basename'], basename($path));
        $this->assertEquals($this->testDir, $pathinfo['dirname']);
        $this->assertEquals($pathinfo['dirname'], $fileInfo->getPath());
        $this->assertEquals($pathinfo['dirname'], dirname($path));
        $this->assertArrayNotHasKey('extension', $pathinfo);
        $this->assertEmpty($fileInfo->getExtension());
        try {
            $fileInfo->getType();
        } catch (\RuntimeException $e) {
            $this->assertStringStartsWith('SplFileInfo::getType(): Lstat failed', $e->getMessage());
        }
        $this->assertFalse($fileInfo->isFile());
        $this->assertFalse($fileInfo->isDir());
    }

    // 存在しない日本語のディレクトリをパスに含むファイル
    public function testFileIncludesJapaneseInPathNotExisting()
    {
        if (!$this->isWin) {
            $this->setLocale(LC_CTYPE, 'ja_JP.UTF-8');
        }

        $path = $this->testDir . DIRECTORY_SEPARATOR . '' . DIRECTORY_SEPARATOR . 'test.txt';
        $fileInfo = new \SplFileInfo($path);
        $pathinfo = pathinfo($path);

        $this->assertFalse($fileInfo->getRealPath());
        $this->assertEquals($path, $fileInfo->getPathname());
        $this->assertEquals('test.txt', $pathinfo['basename']);
        $this->assertEquals($pathinfo['basename'], $fileInfo->getBasename());
        $this->assertEquals($pathinfo['basename'], basename($path));
        $this->assertEquals($this->testDir . DIRECTORY_SEPARATOR . '', $pathinfo['dirname']);
        $this->assertEquals($pathinfo['dirname'], $fileInfo->getPath());
        $this->assertEquals($pathinfo['dirname'], dirname($path));
        $this->assertEquals('txt', $pathinfo['extension']);
        $this->assertEquals($pathinfo['extension'], $fileInfo->getExtension());
        try {
            $fileInfo->getType();
        } catch (\RuntimeException $e) {
            $this->assertStringStartsWith('SplFileInfo::getType(): Lstat failed', $e->getMessage());
        }
        $this->assertFalse($fileInfo->isFile());
        $this->assertFalse($fileInfo->isDir());
    }

}

過去記事の補足

上記の過去記事では「Linux版PHP5.3.14でbasename(), pathinfo()が期待する結果を返さない」と書いてますが、今となっては当時の設定を覚えてませんが、多分単なるロケール設定の誤りだと思います。