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()が期待する結果を返さない」と書いてますが、今となっては当時の設定を覚えてませんが、多分単なるロケール設定の誤りだと思います。

ローカル開発環境(Windows7)のPHPを5.5.16から5.6.0に更新した

ローカル開発環境(32bit Windows7)でのPHP 5.6への更新作業メモです。

アーカイブを展開してシンボリックリンクを切り替える

最新リリース版をダウンロード

過去バージョンの動作環境もそのまま残しておくため、シンボリックリンクで切り替えます。

mklink /d LINK TARGET コマンドで、アーカイブを展開したディレクトリにシンボリックリンクを切り替えます。Linuxのlnコマンドとは逆順です。(要管理者権限)

以下、シェルはNYAOSを使っています。

$ rmdir c:\php
$ mklink /d c:\php c:\php-5.6.0-Win32-VC11-x86

環境変数 Path には c:\php を設定しているので…。

$ php -v
PHP 5.6.0 (cli) (built: Aug 27 2014 11:54:39)
Copyright (c) 1997-2014 The PHP Group
Zend Engine v2.6.0, Copyright (c) 1998-2014 Zend Technologies

OKです。

旧バージョンで追加/編集したファイルをコピーする

旧バージョンから追加/編集したファイルをコピーします。

  • composer.bat
  • composer.phar
  • php.ini

システム環境変数で PATH に c:\php を追加してPHP関連のコマンドを置いてる都合上、composerコマンドもコピーします。

ちなみにPEARを使っていた頃はここがPEARbin_dir でもあったため、PEARでインストールした Stagehand_TestRunner の testrunner コマンドと testrunner.bat もここにありました。

今はこちらの記事の通り、脱PEAR済みです。

新旧バージョンのphp.ini-developmentで変更箇所をチェックする

php.iniはとりあえず旧バージョンのものをコピーして編集しますが、まずは新旧のphp.ini-developmentを比較して変更箇所を差分ツール等で確認します。

今回の 5.5.16 → 5.6.0 に関しては微妙な英文の変更を除くと以下のような感じ。

マルチバイト関連

5.6.0では、default_charsetがUTF-8に指定されたのと、新たに internal_encoding, input_encoding, output_encoding ディレクティブが追加されています。

5.5.16

; PHP's default character set is set to empty.
; http://php.net/default-charset
;default_charset = "UTF-8"

5.6.0

; PHP's default character set is set to UTF-8
; http://php.net/default-charset
default_charset = "UTF-8"

; PHP internal character encoding is set to empty.
; If empty, default_charset is used.
; http://php.net/internal-encoding
;internal_encoding =

; PHP input character encoding is set to empty.
; If empty, default_charset is used.
; http://php.net/input-encoding
;input_encoding =

; PHP output character encoding is set to empty.
; If empty, default_charset is used.
; mbstring or iconv output handler is used.
; See also output_buffer.
; http://php.net/output-encoding
;output_encoding =

mbstringへの影響については後述。

$HTTP_RAW_POST_DATA

PHPマニュアルによれば -1 にすると将来のバージョンでの挙動に合わせられる、つまり $HTTP_RAW_POST_DATA が未定義となるようです。

5.5.16

; Always populate the $HTTP_RAW_POST_DATA variable. PHP's default behavior is
; to disable this feature. If post reading is disabled through
; enable_post_data_reading, $HTTP_RAW_POST_DATA is *NOT* populated.
; http://php.net/always-populate-raw-post-data
;always_populate_raw_post_data = On

5.6.0

; Always populate the $HTTP_RAW_POST_DATA variable. PHP's default behavior is
; to disable this feature and it will be removed in a future version.
; If post reading is disabled through enable_post_data_reading,
; $HTTP_RAW_POST_DATA is *NOT* populated.
; http://php.net/always-populate-raw-post-data
;always_populate_raw_post_data = -1

要するにデフォルト設定では未定義になったということかな。まあ、使っていないので関係ありません。

iconv

先ほど見た intput_encoding, internal_encoding, output_encoding ディレクティブの新設に合わせて、適用順序の説明が追加されています。

5.5.16

[iconv]
;iconv.input_encoding = ISO-8859-1
;iconv.internal_encoding = ISO-8859-1
;iconv.output_encoding = ISO-8859-1

5.6.0

[iconv]
; Use of this INI entry is deprecated, use global input_encoding instead.
; If empty, default_charset or input_encoding or iconv.input_encoding is used.
; The precedence is: default_charset < intput_encoding < iconv.input_encoding
;iconv.input_encoding =

; Use of this INI entry is deprecated, use global internal_encoding instead.
; If empty, default_charset or internal_encoding or iconv.internal_encoding is used.
; The precedence is: default_charset < internal_encoding < iconv.internal_encoding
;iconv.internal_encoding =

; Use of this INI entry is deprecated, use global output_encoding instead.
; If empty, default_charset or output_encoding or iconv.output_encoding is used.
; The precedence is: default_charset < output_encoding < iconv.output_encoding
; To use an output encoding conversion, iconv's output handler must be set
; otherwise output encoding conversion cannot be performed.
;iconv.output_encoding =

iconvも使ってないので関係なし。

mbstring

こちらもiconvと同様、intput_encoding, internal_encoding, output_encoding ディレクティブの新設に合わせて、適用順序の説明などが追加されています。

5.5.16

; internal/script encoding.
; Some encoding cannot work as internal encoding.
; (e.g. SJIS, BIG5, ISO-2022-*)
; http://php.net/mbstring.internal-encoding
;mbstring.internal_encoding = UTF-8

; http input encoding.
; http://php.net/mbstring.http-input
;mbstring.http_input = UTF-8

; http output encoding. mb_output_handler must be
; registered as output buffer to function
; http://php.net/mbstring.http-output
;mbstring.http_output = pass

5.6.0

; Use of this INI entry is deprecated, use global internal_encoding instead.
; internal/script encoding.
; Some encoding cannot work as internal encoding. (e.g. SJIS, BIG5, ISO-2022-*)
; If empty, default_charset or internal_encoding or iconv.internal_encoding is used.
; The precedence is: default_charset < internal_encoding < iconv.internal_encoding
;mbstring.internal_encoding =

; Use of this INI entry is deprecated, use global input_encoding instead.
; http input encoding.
; mbstring.encoding_traslation = On is needed to use this setting.
; If empty, default_charset or input_encoding or mbstring.input is used.
; The precedence is: default_charset < intput_encoding < mbsting.http_input
; http://php.net/mbstring.http-input
;mbstring.http_input =

; Use of this INI entry is deprecated, use global output_encoding instead.
; http output encoding.
; mb_output_handler must be registered as output buffer to function.
; If empty, default_charset or output_encoding or mbstring.http_output is used.
; The precedence is: default_charset < output_encoding < mbstring.http_output
; To use an output encoding conversion, mbstring's output handler must be set
; otherwise output encoding conversion cannot be performed.
; http://php.net/mbstring.http-output
;mbstring.http_output =

この辺の設定は .htaccess とかアプリケーションのコードで変更しているので、あまり関係ないのですが、Use of this INI entry is deprecated とあります。

基本は default_charset のみ指定して、後はコメントアウトしておけば問題ないかと。つまり、5.6.0のデフォルト設定に従えばよし。

openssl

5.5.16にはなかった openssl モジュールのディレクティブが追加されています。

5.6.0

[openssl]
; The location of a Certificate Authority (CA) file on the local filesystem
; to use when verifying the identity of SSL/TLS peers. Most users should
; not specify a value for this directive as PHP will attempt to use the
; OS-managed cert stores in its absence. If specified, this value may still
; be overridden on a per-stream basis via the "cafile" SSL stream context
; option.
;openssl.cafile=

; If openssl.cafile is not specified or if the CA file is not found, the
; directory pointed to by openssl.capath is searched for a suitable
; certificate. This value must be a correctly hashed certificate directory.
; Most users should not specify a value for this directive as PHP will
; attempt to use the OS-managed cert stores in its absence. If specified,
; this value may still be overridden on a per-stream basis via the "capath"
; SSL stream context option.
;openssl.capath=

ここで指定しておくと、SSLコンテキストオプション のデフォルト値として使われるみたいです。

php.iniを編集する

新旧バージョンでチェックしたphp.ini-developmentの変更内容を取り込みます。

元々ポータビリティ重視でphp.iniは極力変更せずアプリケーション毎に .htaccess や ini_set() を使う方針なので、今回変更したのはここだけでした。

5.5.16

mbstring.http_output = pass

5.6.0

;mbstring.http_output =

その他には php_oci8.dll, php_oci8_11g.dll がなくなって php_oci8_12c.dll に変わってたりもしますが、使ってないので関係なし…。

自分で追加したextensionのDLLファイルを入手する

今回はマイナーバージョン更新のため、自分で追加したextensionのDLLファイルも入れ替えになると思います。(といっても今ではXdebugくらいしか使ってませんが…)

Xdebugは公式サイトでWindows用DLLファイルが提供されているので、これをダウンロードします。

インストールしたPHPのバージョンに合わせて選択します。今回はこちら。

c:\php\ext に保存して、php.iniの該当箇所を変更。

[XDebug]
zend_extension_ts = "ext/php_xdebug-2.2.5-5.6-vc11.dll"

手動でシンボリックリンク切替デプロイしてみたメモ

コマンドは許可されてるけど訳ありでFTP/SCPアップロードから逃れられない、でもComposerだって普通に使いたい、vendorディレクトリ以下を一括アップロードなんてしたくない…。

そこで、手動シンボリックリンク切り替えデプロイを断行してみました。

以下の例は Gehirn RS2 で動かしている自分用サンプルサイト www.k-holy.net の構成です。

| home
|-- kholy
  |-- config
  |-- public_html
    |-- k-holy
      |-- releases
      |  |-- 2014080801
      |  |  |-- app
      |  |  |  |-- cache
      |  |  |  |-- config -> /home/kholy/config
      |  |  |  |-- log -> /home/kholy/public_html/k-holy/shared/app/log
      |  |  |  |-- session -> /home/kholy/public_html/k-holy/shared/app/session
      |  |  |  |-- templates_c
      |  |  |
      |  |  |-- public
      |  |  |-- src
      |  |  |-- vendor
      |  |
      |  |-- 2014080802
      |     |-- app
      |     |  |-- cache
      |     |  |-- config -> /home/kholy/config
      |     |  |-- log -> /home/kholy/public_html/k-holy/shared/app/log
      |     |  |-- session -> /home/kholy/public_html/k-holy/shared/app/session
      |     |  |-- templates_c
      |     |
      |     |-- public
      |     |-- src
      |     |-- vendor
      |
      |-- shared
      |  |-- app
      |    |-- log
      |    |-- session
      |
      |-- staging
      |  |-- public -> /home/kholy/public_html/k-holy/releases/2014080802/public
      |  |-- app -> /home/kholy/public_html/k-holy/releases/2014080802/app
      |
      |-- current
         |-- public -> /home/kholy/public_html/k-holy/releases/2014080801/public
         |-- app -> /home/kholy/public_html/k-holy/releases/2014080801/app

ステージングのドキュメントルート

/home/kholy/public_html/k-holy/staging/public

プロダクションのドキュメントルート

/home/kholy/public_html/k-holy/current/public

ステージングと言っても省エネ運用のため、同じサーバでVirtualHostを使い分ける想定です。

(Gehirn RS2 + Gehirn DNS であればドメインへのCNAMEの設定とWebサーバの設定が同じコントロールパネル上で操作できるので楽です。 k-holy.netドメインを取得してGehirn DNSとRS2に登録したメモ

ログファイルやセッションファイル等、切り替え後も物理的に同じファイルを継続して扱うディレクトリについては、リリース単位のディレクトリとは独立した共有ディレクトリを作成し、リリース単位のディレクトリからシンボリックリンクを張ります。

ステージングへのデプロイ作業はこんな感じ。

$ cd /home/kholy/public_html/k-holy/releases/2014080802/app
$ chmod 777 log
$ chmod 777 session
$ chmod -R 777 cache
$ chmod 777 templates_c
$ ln -sfn /home/kholy/config config
$ cd /home/kholy/public_html/k-holy/releases/2014080802
$ composer self-update
$ composer install --no-dev --optimize-autoloader --no-interaction
$ cd /home/kholy/public_html/k-holy/staging
$ ln -sfn /home/kholy/public_html/k-holy/releases/2014080802/public public
$ ln -sfn /home/kholy/public_html/k-holy/releases/2014080802/app app

ステージングで問題なく動くことを確認したら、シンボリックリンクを張り替えて、そのままプロダクションに移行します。

$ cd /home/kholy/public_html/k-holy/releases/2014080802/app
$ rm -rf log
$ rm -rf session
$ ln -sfn /home/kholy/public_html/k-holy/shared/app/log log
$ ln -sfn /home/kholy/public_html/k-holy/shared/app/session session
$ cd /home/kholy/public_html/k-holy/current
$ ln -sfn /home/kholy/public_html/k-holy/releases/2014080802/public public
$ ln -sfn /home/kholy/public_html/k-holy/releases/2014080802/app app

データベースの切り替えは、ステージング用とプロダクション用の設定ファイルをそれぞれ用意して、アプリケーションの初期スクリプトphp_uname('n') やHTTPホスト名を見て切り替えてます。

(コマンドでリンク張り替えてもいいんですが、Windows開発環境の設定ファイルもあるのでめんどくさくて…)

あと、今回の環境には入ってませんが、Zend OPcache を使っている場合は opcache_reset() とかもやります。

Fabricなど何らかのツールを使えば、ファイル転送も含めて1コマンドでやれそうですが、それは次の段階ということで…。

Composerでプロジェクトグローバルにインストールした Stagehand_TestRunner + PHPUnit を ver.4 に更新(Windows7 + NYAOS編)

PHPUnit 4 に対応、PEARおよびPHPUnit3.6のサポートを終えた Stagehand_TestRunner V4 をローカル開発環境 (Windows7) に入れたメモです。特に新しいことはしていません。

内容的には Composerでプロジェクトグローバルに Stagehand_TestRunner + PHPUnit をインストール(Windows7 + NYAOS編) の続きになります。

前回時点で Stagehand_TestRunner は PHPUnit4系に未対応だったのですが、その後すぐ対応されてまして、もう2ヶ月経ってしまいました。

そういうわけで今回は PHPUnit 3.7 → 4.1, Stagehand_TestRunner 3.6 → 4.1 という更新を行います。

以下、シェルは NYAOS を使っています。

Windowsの場合 composer global コマンドでインストールしたファイルは %APPDATA%\Composer 以下に配置されます。

(私の環境では C:\Users\k_horii\AppData\Roaming\Composer になりました。)

composer.json ファイルもここにあります。

{
    "require": {
        "piece/stagehand-testrunner": "~4.1@dev",
        "phpunit/phpunit": "4.1.*"
    }
}

GitHubリポジトリを見たところ、Stagehand_TestRunnerの開発版4.1ではまだ安定版が出ていないPHPUnit4.2まですでに対応されているようです。

READMEの導きに従い @devフラグを付けて入れることにします。

@devの意味については kohkimakimoto さんの翻訳記事が参考になります。

なおPHPUnitの方は、安定版の4.1を指定しました。チキンですみません…。

$ composer global update
Changed current directory to C:/Users/k_horii/AppData/Roaming/Composer
Loading composer repositories with package information
Updating dependencies (including require-dev)
  - Removing symfony/class-loader (v2.4.5)
  - Installing sebastian/version (1.0.3)
    Downloading: 100%

  - Removing symfony/yaml (v2.4.5)
  - Installing symfony/yaml (v2.5.2)
    Loading from cache

  - Removing symfony/process (v2.4.5)
  - Installing symfony/process (v2.5.2)
    Downloading: 100%

  - Removing symfony/finder (v2.4.5)
  - Installing symfony/finder (v2.5.2)
    Downloading: 100%

  - Removing symfony/dependency-injection (v2.4.5)
  - Installing symfony/dependency-injection (v2.5.2)
    Downloading: 100%

  - Removing symfony/console (v2.4.5)
  - Installing symfony/console (v2.5.2)
    Loading from cache

  - Removing symfony/filesystem (v2.4.5)
  - Installing symfony/filesystem (v2.5.2)
    Loading from cache

  - Removing symfony/config (v2.4.5)
  - Installing symfony/config (v2.5.2)
    Loading from cache

  - Removing piece/stagehand-testrunner (v3.6.2)
  - Installing piece/stagehand-testrunner (dev-master 184496d)
    Cloning 184496d0c39b304a5db907e6d5b704ad0c436267

  - Installing sebastian/exporter (1.0.1)
    Downloading: 100%

  - Installing sebastian/environment (1.0.0)
    Downloading: 100%

  - Installing sebastian/diff (1.1.0)
    Loading from cache

  - Installing sebastian/comparator (1.0.0)
    Downloading: 100%

  - Removing phpunit/phpunit-mock-objects (1.2.3)
  - Installing phpunit/phpunit-mock-objects (2.1.5)
    Downloading: 100%

  - Removing phpunit/php-code-coverage (1.2.17)
  - Installing phpunit/php-code-coverage (2.0.9)
    Downloading: 100%

  - Removing phpunit/phpunit (3.7.37)
  - Installing phpunit/phpunit (4.1.4)
    Downloading: 100%

Writing lock file
Generating autoload files

PHPUnitはライブラリの一部が sebastian/*** に外出しされたみたいです。Stagehand_TestRunnerは最新のdev-masterが入りました。ついでにSymfonyコンポーネントも2.5系に更新されてます。

testrunner コマンドを実行してみると…

$ testrunner
PHP Warning:  require_once(Stagehand/TestRunner/Core/Bootstrap.php): failed to open stream: No such file or directory in C:\php\testrunner on line 86

Warning: require_once(Stagehand/TestRunner/Core/Bootstrap.php): failed to open stream: No such file or directory in C:\php\testrunner on line 86
PHP Fatal error:  require_once(): Failed opening required 'Stagehand/TestRunner/Core/Bootstrap.php' (include_path='.;c:\php\includes') in C:\php\testrunner on line 86

Fatal error: require_once(): Failed opening required 'Stagehand/TestRunner/Core/Bootstrap.php' (include_path='.;c:\php\includes') in C:\php\testrunner on line 86

以前にPEARでインストールした際の C:\php にtestrunnerコマンドが残っていて、PATH環境変数のせいでそちらが呼ばれているようです。

そういえば、NAYOSのalias設定にまかせてGlobal Composerのコマンドにパスを通してなかったような。

C:\php 以下のファイルは削除、NYAOSの設定ファイルを編集してPATH環境変数にパスを追加、前回aliasに定義したコマンドも修正します。

alias testrunner-phpunit "testrunner phpunit -c testrunner.yml | cat"

set PATH=%PATH%;%APPDATA%\Composer\vendor\bin

PEARサポート切り、Composerオートロード対応ということで、これだけでいけるようになったはず…。

$ source ~/_nya
$ testrunner-phpunit
Please run the following command before running the phpunit command:

  testrunner compile

あっ、また…(汗)

インストール後 phpunit コマンドの前に testrunner compile が必要なのでした。

$ testrunner compile
$ testrunner-phpunit
PHPUnit 4.1.4 by Sebastian Bergmann.

Configuration read from C:\Users\k_horii\Documents\Projects-priv\Volcanus\Volcanus_TemplateRenderer\phpunit.xml

The Xdebug extension is not loaded. No code coverage will be generated.

....................................

(中略)

Time: 1.85 seconds, Memory: 10.00Mb

OK (36 tests, 39 assertions)

やったー。

PHPTALテンプレート変数のパス参照でPHPTAL_VariableNotFoundExceptionがスローされる件

PHPTALにおけるテンプレート変数のパス記述に関するメモです。多分他の人にはあまり役に立たないと思います。

PHPTAL_Context::path() におけるパス参照時の PHPTAL_VariableNotFoundException発生条件

ソースはここ PHPTAL/classes/PHPTAL/Context.php

パスは / 区切りで分割されて終端に達するまで繰り返し処理される。

現在の要素がオブジェクトの場合

  1. オブジェクトがクロージャであれば、実行結果を取得して次の要素へ。 (実行結果がクロージャの場合、さらに実行を繰り返す)

  2. オブジェクトに現在の要素名のメソッドが存在し、呼び出し可能な場合は実行結果を取得して次の要素へ。

  3. オブジェクトに現在の要素名のプロパティが存在すれば、値を取得して次の要素へ。

  4. オブジェクトが ArrayAccess のインスタンスで現在の要素名のキーが有効であれば、値を取得して次の要素へ。

  5. オブジェクトが Countable のインスタンスで現在の要素名が length または size であれば要素を count() した結果を取得して次の要素へ。

  6. オブジェクトに __isset() メソッドが存在し、現在の要素名で __isset() が有効であれば、現在の要素名で値を取得して次の要素へ。

  7. オブジェクトに __get() メソッドが存在し、現在の要素名で参照した値がNULLでなければ、その値を取得して次の要素へ。

  8. オブジェクトに __call() メソッドが存在すれば、現在の要素名のメソッドで実行を試み、BadMethodCallException がスローされなければ、実行結果を取得して次の要素へ。

  9. 実行中の path() メソッドの第3引数 $nothrow が TRUE であれば(初期値は FALSE )NULLを返す。

  10. 上記の流れで次の要素に処理が移っていない場合 PHPTAL_Context::patherror() が実行され PHPTAL_VariableNotFoundException がスローされる。

現在の要素が配列の場合

  1. 現在の要素名のキーが存在すれば、値を取得して次の要素へ。

  2. 現在の要素名が length または size であれば要素を count() した結果を取得して次の要素へ。

  3. 実行中の path() メソッドの第3引数 $nothrow が TRUE であれば(初期値は FALSE)NULLを返す。

  4. 上記の流れで次の要素に処理が移っていない場合 PHPTAL_Context::patherror() が実行され PHPTAL_VariableNotFoundException がスローされる。

現在の要素が文字列の場合

  1. 現在の要素名が length または size であれば要素を strlen() した結果を取得して次の要素へ。

  2. 現在の要素名が数値(is_numeric() が真)の場合、現在の要素からその位置の文字を取得して次の要素へ。

これまでの流れで次の要素に処理が移っていない場合

  1. 実行中の path() メソッドの第3引数 $nothrow が TRUE であれば(初期値は FALSE)NULLを返す。

  2. PHPTAL_Context::patherror() が実行され PHPTAL_VariableNotFoundException がスローされる。

パスの要素を全て処理し終わったら、最終的に取得した値を返す。

なお、頻出する path() メソッドの第3引数 $nothrow についてはPHPDOCコメントにこう書かれています。

$nothrow is used by phptal_exists(). Prevents this function from throwing an exception when a part of the path cannot be resolved, null is returned instead.

コンパイル済みテンプレートファイルを検索してみたところ、後述するdefaultキーワードをパスに付与した場合、このフラグにTRUEが指定されるようでした。

isset() と __isset() と offsetExist() の実装内容によって PHPTAL_VariableNotFoundException がスローされるケース

たとえばテンプレート変数のパスをこう指定したとする。

<p tal:content="object/name"></p>
  1. $object->__isset('name') がFALSEを返した

  2. $object->__get('name') がNULLを返した

  3. $object->__call() が実装されていない、または $object->__call('name', array()) を実行した結果 BadMethodCallException がスローされた

これらの条件に該当する場合に PHPTAL_VariableNotFoundException がスローされる。

配列の場合は array_key_exists('name', $array) がTRUEを返していれば、値がNULLでも例外はスローされない。

これに対して、オブジェクトでプロパティ値がNULLの場合に __isset() がFALSEを返すような実装(isset()関数と同じ動作)をしていると、例外がスローされてしまう。

ArrayAccess を実装したオブジェクトで $object->offsetExists('name') がFALSEを返した場合も同様に例外がスローされる。

以前にもこれに似たような問題にはまって、こういう記事を書きました。

事ここに至って痛感したのは、property_exists($object, 'name') && $object->name !== nullと同じ意味で isset($object->name) と書くのをやめろということです。

ArrayAccess::offsetExists()__isset() は、そういう前提で実装しなくてはいけない。

<?php
/**
 * 悪い __isset()
 * @param mixed
 * @return bool
 */
public function __isset($name)
{
    return (property_exists($this, $name) && $this->{$name} !== null);
}

↑こう書いてはダメ!!

<?php
/**
 * 良い __isset()
 * @param mixed
 * @return bool
 */
public function __isset($name)
{
    return property_exists($this, $name);
}

↑こう書く。

これがどう影響するかというと、ユニットテストのコードを見ると一目瞭然です。

<?php
public function testIsset()
{
    $test = new EntityTraitTestData(array(
        'string' => 'Foo',
        'null'   => null,
    ));
    $this->assertTrue(isset($test->string));
    $this->assertTrue(isset($test->null)); // !! CAUTION !!
    $this->assertFalse(isset($test->undefined_property));
}

isset($object) はいいとして isset($object->name)isset($array['name']) という書き方は害でしかなくなってしまうわけで、Notice: Undefined index を親の仇のように憎悪して isset() を使いまくってきた自分にとって非常に困難を伴いますが…。

どうしても isset() を使いたい場合は isset($object->name) && $object->name !== null あるいは isset($array['name']) && $array['name'] !== null と書くしかありません。

PHPTALテンプレートで何とか対処する場合

オブジェクトの利用側コードで isset($objact->name) してて、むしろ出力時に対応した方が良さそうな場合の対応策。

defaultキーワード

PHPTALES の default キーワードを付けることで、値がNULLまたは PHPTAL_VariableNotFoundException がスローされた場合の代替値として、要素の中身(この場合は空文字)が出力されます。

<p tal:content="object/name|default"></p>

exists:式

代替値ではなく値の出力をスルーしたい、そしてより丁寧に書くのであれば PHPTALES の exists:式が使えます。

<p><span tal:condition="exists:object/name" tal:replace="object/name"></span></p>

普通にtal:condition

もちろん $object->hasName() みたいなメソッドが実装されているのであれば、こう書けます。

<p><span tal:condition="object/hasName" tal:replace="object/name"></span></p>

これは良くない例ですが、PHPTALの入力フォーム処理には、フォームオブジェクトみたいな物を導入して、入力値、バリデーション結果、エラーメッセージ等を項目単位でまとめて扱えるようにした方がシンプルに書けますね。

テンプレートの書きやすさだけでなく、一つのフォームの編集対象は一つのエンティティに限らないこと、エンティティの属性とフォームで扱う値の型は必ずしも一致しないこともありますし、何かしらレイヤ間のギャップを吸収する仕組みが必要になります。

具体的な実装については、まだ自分でも納得のいく物は書けてませんが…。