k-holyのPHPとか諸々メモ

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

グラフ描画ライブラリ Chart.js で凡例を表示する

JavaScriptのグラフ描画ライブラリ Chart.js

サンプルが綺麗でシンプルなためかデザイナーの方に人気のようで、いろんなブログで紹介されてます。

しかしサンプルではラベルを設定しているにも関わらず凡例が表示されておらず、「凡例を表示する機能がない」とまで書かれている記事もあって、そんなバカなと思いつつ公式ドキュメント(Chart.js | Documentation)を確認したら、ちゃんと対応されていました。

凡例にラベルを出力するには legendTemplate オプションと generateLegend() メソッドを使え

凡例にラベルを出力するには、グラフの設定オプション legendTemplate でラベルを出力するよう値を指定した上で、ChartオブジェクトのgenerateLegend()メソッドを実行し、その戻り値で得られるHTMLをどこかに差し込む必要があります。

Chart.js の設定値には全グラフ共通の Global chart configuration と 各グラフ別の設定があります。

例えば Bar Chart (Chart.Bar.js)のデフォルト設定ではこのような値が設定されています。

legendTemplate : "<ul class=\"<%=name.toLowerCase()%>-legend\"><% for (var i=0; i<datasets.length; i++){%><li><span style=\"background-color:<%=datasets[i].fillColor%>\"></span><%if(datasets[i].label){%><%=datasets[i].label%><%}%></li><%}%></ul>"

Chart.jsは内部で簡易のマイクロテンプレート機能を持っていて、どういう仕組みかは分かりませんが、上記のようなfor文とif文の中身を置換してくれます。

Chart.Core.js のコメントによると Javascript micro templating by John Resig が元らしい)

以下は Bar Chartで凡例を表示する例です。

<canvas id="chart_canvas" width="800" height="400"></canvas>
<ul id="chart_legend"></ul>
<script type="text/javascript" src="/static/js/Chart.js/Chart.js"></script>
<script type="text/javascript">/*<![CDATA[*/
var chart_data = {
    labels: ['9月', '10月', '11月'],
    datasets: [
        {
            label: 'りんご',
            fillColor: 'rgba(255, 0, 0, 0.5)',
            strokeColor: 'rgba(255, 0, 0, 0.75)',
            highlightFill: 'rgba(255, 0, 0, 0.75)',
            highlightStroke: 'rgba(255, 0, 0, 1)',
            data: [10, 20, 30]
        },
        {
            label: 'バナナ',
            fillColor: 'rgba(255, 255, 0, 0.5)',
            strokeColor: 'rgba(255, 255, 0, 0.75)',
            highlightFill: 'rgba(255, 255, 0, 0.75)',
            highlightStroke: 'rgba(255, 255, 0, 1)',
            data: [30, 10, 20]
        },
        {
            label: 'みかん',
            fillColor: 'rgba(255, 255, 128, 0.5)',
            strokeColor: 'rgba(255, 255, 128, 0.75)',
            highlightFill: 'rgba(255, 255, 128, 0.75)',
            highlightStroke: 'rgba(255, 255, 128, 1)',
            data: [20, 30, 10]
        }
    ]
};

var chart_context = document.getElementById('chart_canvas').getContext('2d');

var chart_option = {
    legendTemplate : "<% for (var i=0; i<datasets.length; i++){%><li><span style=\"color:<%=datasets[i].strokeColor%>\">■</span><%if(datasets[i].label){%><%=datasets[i].label%><%}%></li><%}%>"
};

var chart = new Chart(chart_context).Bar({
    labels: chart_data.labels,
    datasets: chart_data.datasets
}, chart_option);

document.getElementById('chart_legend').innerHTML = chart.generateLegend();

/*]]>*/</script>

Chart.jsでは現在のところ自動での色分け機能は持っていないので、上記コードのように個別に設定しないといけません。

また、有効な色の設定オプションはグラフによって異なりますので、サーバ側で動的に生成したデータを表示する場合、この辺も少し面倒になると思います。

(サンプルの Bar Chart の場合は、fillColorが棒グラフの塗りつぶし色、strokeColorが棒グラフの線の色、highlightFillがマウスオーバー時の塗りつぶし色、highlightStrokeがマウスオーバー時の線の色になっています。)

サーバ側でグラフの値と設定値を生成する例

自分の場合は、色の指定も含めてサーバ側で生成したJSONデータを隠しフォームに出力して、これをJavaScriptから読み込むという単純な方法にしました。

以下は簡単な例ですが、どういう構造の配列を生成すればいいかの参考にしていただければ。(Ajaxは使っていません)

<?php
$createData = function() {
    return [
        '4月' => mt_rand(0, 100),
        '5月' => mt_rand(0, 100),
        '6月' => mt_rand(0, 100),
        '7月' => mt_rand(0, 100),
        '8月' => mt_rand(0, 100),
        '9月' => mt_rand(0, 100),
        '10月' => mt_rand(0, 100),
        '11月' => mt_rand(0, 100),
        '12月' => mt_rand(0, 100),
    ];
};

$hexToRGB = function($hex) {
    return [
        hexdec(substr($hex, 0, 2)),
        hexdec(substr($hex, 2, 2)),
        hexdec(substr($hex, 4, 2)),
    ];
};

$list = [
    [
        'label' => 'りんご',
        'data' => $createData(),
        'rgb' =>  $hexToRGB('ff0000'),
    ],
    [
        'label' => 'バナナ',
        'data' => $createData(),
        'rgb' =>  $hexToRGB('ffff00'),
    ],
    [
        'label' => 'みかん',
        'data' => $createData(),
        'rgb' =>  $hexToRGB('ff8000'),
    ],
];

$chart_data = [
    'labels' => array_keys($list[0]['data']),
    'datasets' => array_reduce($list, function($dataset, $item) {
        $dataset[] = [
            'label' => $item['label'],
            'fillColor' => sprintf('rgba(%s,0.5)', implode(',', $item['rgb'])),
            'strokeColor' => sprintf('rgba(%s,0.75)', implode(',', $item['rgb'])),
            'highlightFill' => sprintf('rgba(%s,0.75)', implode(',', $item['rgb'])),
            'highlightStroke' => sprintf('rgba(%s,1)', implode(',', $item['rgb'])),
            'data' => array_values($item['data']),
        ];
        return $dataset;
    }, [])
];

$chart_json = json_encode($chart_data, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT);
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<title>Chart.js</title>
</head>
<body>
<form>
<input type="hidden" id="chart_json" value="<?=htmlspecialchars($chart_json, ENT_QUOTES, 'UTF-8')?>" />
</form>
<canvas id="chart_canvas" width="800" height="400"></canvas>
<ul id="chart_legend"></ul>
<script type="text/javascript" src="/static/js/Chart.js/Chart.js"></script>
<script type="text/javascript">/*<![CDATA[*/

var chart_json = document.getElementById('chart_json').getAttribute('value');

var chart_data = JSON.parse(chart_json);

var chart_context = document.getElementById('chart_canvas').getContext('2d');

var chart_option = {
    legendTemplate : "<% for (var i=0; i<datasets.length; i++){%><li><span style=\"color:<%=datasets[i].strokeColor%>\">■</span><%if(datasets[i].label){%><%=datasets[i].label%><%}%></li><%}%>"
};

var chart = new Chart(chart_context).Bar({
    labels: chart_data.labels,
    datasets: chart_data.datasets
}, chart_option);

document.getElementById('chart_legend').innerHTML = chart.generateLegend();

/*]]>*/</script>
</body>
</html>

出力結果はこんな感じです。

PHPのWebアプリケーションで動的にグラフ生成といえば昔は GD + JpGraph が定番で、自分もこの業界に入って最初の仕事がそれだったこともあって感慨深いのですが、HTML5やらJavaScriptの隆盛で今ではグラフはクライアント側で描画するのが当たり前になりましたね。それもこんなに簡単にかっこいいグラフが表示できるなんて。

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)

やったー。