k-holyのPHPメモ

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

2012-05-10

エラーハンドラが原因でSmartyのエラーが発生していた件

Smarty(3.1.8)を使っていますが、いつからかこんなエラーが発生するようになりました。

 'filemtime(): stat failed for /path/to/compiled_template.html.php' in /path/to/vendor/Smarty/sysplugins/smarty_resource.php on line 693

エラーメッセージから、ファイルの権限でエラーになってるのかなと考えましたが、ディレクトリに書き込み権限はあるし、Smartyが特殊なことしてない限り、ファイルの所有者はPHP実行ユーザーだから何も問題ないはず。
エラーが発生するのはテンプレートファイルの初回コンパイル時だけなので、もしやと思ってソースを見たところ…。

Smarty_Template_Source::getCompiled()より

    /**
     * get a Compiled Object of this source
     *
     * @param Smarty_Internal_Template $_template template objet
     * @return Smarty_Template_Compiled compiled object
     */
    public function getCompiled(Smarty_Internal_Template $_template)
    {
        // check runtime cache
        $_cache_key = $this->unique_resource . '#' . $_template->compile_id;
        if (isset(Smarty_Resource::$compileds[$_cache_key])) {
            return Smarty_Resource::$compileds[$_cache_key];
        }

        $compiled = new Smarty_Template_Compiled($this);
        $this->handler->populateCompiledFilepath($compiled, $_template);
        $compiled->timestamp = @filemtime($compiled->filepath);
        $compiled->exists = !!$compiled->timestamp;

        // runtime cache
        Smarty_Resource::$compileds[$_cache_key] = $compiled;

        return $compiled;
    }

$this->handler->populateCompiledFilepath()で、コンパイル済テンプレートファイルのパスを生成しているらしいのですが、その後ファイルの存在チェックなどせずにエラー制御演算子付きでfilemtime()が実行されていました。
個人的には行儀の悪いコードだとは思いますが(結局 $compiled->exists = !!$compiled->timestamp; とかしてるしなんでfile_exists()しないの…)、標準のエラーハンドラではここでエラーが発生してもなかったことにされるため、通常は問題にならないのでしょう。

ただ、自分の場合は独自のエラーハンドラを使ってるため、エラーが表示されたということです。
自作エラーハンドラでもerror_reporting()を使って適切にハンドリングしていれば、おそらく出力されることのないエラーなんでしょうけど、表示はしたくないけどログには記録したいケースやユニットテストのことを考えて、クラス内部ではerror_reporting()を使わずにクラス独自のエラーレベルを利用する作りにしていたのがまずかったようです。

このようなエラーも同時に発生していたのですが、多分原因は同じでしょう。

 'unlink(/path/to/compiled_template.html.php): No such file or directory' in /path/to/vendor/Smarty/sysplugins/smarty_internal_write_file.php on line 49

ちなみに、上記エラー発生箇所ではerror_reporting()で一時的にエラーレベルを変更してエラー出力を隠した上で、@unlink()とされてました。

/**
 * Smarty Internal Write File Class
 *
 * @package Smarty
 * @subpackage PluginsInternal
 */
class Smarty_Internal_Write_File {

    /**
     * Writes file in a safe way to disk
     *
     * @param string $_filepath complete filepath
     * @param string $_contents file content
     * @param Smarty $smarty    smarty instance
     * @return boolean true
     */
    public static function writeFile($_filepath, $_contents, Smarty $smarty)
    {
        $_error_reporting = error_reporting();
        error_reporting($_error_reporting & ~E_NOTICE & ~E_WARNING);
        if ($smarty->_file_perms !== null) {
            $old_umask = umask(0);
        }

        $_dirpath = dirname($_filepath);
        // if subdirs, create dir structure
        if ($_dirpath !== '.' && !file_exists($_dirpath)) {
            mkdir($_dirpath, $smarty->_dir_perms === null ? 0777 : $smarty->_dir_perms, true);
        }

        // write to tmp file, then move to overt file lock race condition
        $_tmp_file = $_dirpath . DS . uniqid('wrt', true);
        if (!file_put_contents($_tmp_file, $_contents)) {
            error_reporting($_error_reporting);
            throw new SmartyException("unable to write file {$_tmp_file}");
            return false;
        }

        // remove original file
        @unlink($_filepath);

        // rename tmp file
        $success = rename($_tmp_file, $_filepath);
        if (!$success) {
            error_reporting($_error_reporting);
            throw new SmartyException("unable to write file {$_filepath}");
            return false;
        }

        if ($smarty->_file_perms !== null) {
            // set file permissions
            chmod($_filepath, $smarty->_file_perms);
            umask($old_umask);
        }
        error_reporting($_error_reporting);
        return true;
    }

}

unlink()以外はちゃんと戻り値を見てSmartyExceptionをスローしてますし、行儀良いですね。

とりあえずこのままだとダメなので、自作エラーハンドラの方をerror_reporgin()の値を見て適宜returnするよう修正しました。

せっかくなので、いくつかのフレームワークで引数なしのerror_reporting()関数の利用状況とその使い方を調べてみました。

  • CakePHP(ErrorHandlerクラス)…error_reprting() === 0 以外はdeubg設定ONならデバッガに出力、debug設定OFFならログ書き込み。戻り値はCakeLog::write()依存。(true返してる)
  • FuelPHP(Fuel\Core\Errorクラス)…ログは無条件で書き込み、Fuel::$envがPRODUCTIONかつ発生したエラーのレベルがerror_reporting()に含まれる場合はErrorExceptionに詰め替えてエラー表示。ただしエラー発生回数をカウントしてて、設定値を超えた場合はその旨の警告のみ出力。戻り値はtrue。
  • Slim(Slimクラス)…発生したエラーのレベルがerror_reporting()に含まれる場合はErrorExceptionをスロー。戻り値はtrue。(超シンプル…!)
  • Symfony(Symfony\Component\HttpKernel\Debug\ErrorHandlerクラス)…ErrorHandler::register()時に引数で指定されたエラーレベルをプロパティに設定(指定しなければerror_reporting()の値が設定される)、発生したエラーのレベルがerror_reporting()とプロパティに設定したエラーレベルの両方に含まれる場合はメッセージ整形してErrorExceptionをスロー。戻り値はfalse。

(いずれも今日の昼頃にGitHubから取得したバージョン。ZendFrameworkでは引数なしのerror_reporting()はLoggerでしか使われていませんでした)

比較してみると、それぞれの特徴がエラーハンドラの実装にも垣間見えるようで面白いですね。

拙作エラー処理クラス Phanda_Error はGitHubで公開していますので、興味ある方はご参照ください。
https://github.com/k-holy/Phanda_Error

2012-05-08

Windowsでwgetとcomposer

今更ながら Composer というものを使ってみようと思い立ち、Windows7wgetコマンドとcomposer.pharを使ってみたメモです。

検索してみたら、GNU toolsのWindows版プロジェクト「GnuWin32」にwgetコマンドが移植されていることが分かったので、まずはそちらをインストールします。
Wget for Windowsから、BinariesとDependenciesのZipファイルをダウンロード。
(現在のバージョンは1.11.4)

両方を展開して、Dependenciesの方のbinディレクトリにある4つのDLLファイル(libeay32,libiconv2,libintl3,libssl32) を、Binariesの方のbinディレクトリにコピー。
Binariesの方を適当な名前で適当な場所に移動。今回は c:\Applications\GnuTools としました。

以下、Console2にて。wgetコマンドでcomposer.pharをダウンロードしてみます。

$ c:/Applications/GnuTools/bin/wget http://getcomposer.org/composer.phar
SYSTEM_WGETRC = c:/progra~1/wget/etc/wgetrc
syswgetrc = c:\Applications\GnuTools/etc/wgetrc
--2012-05-08 21:59:57--  http://getcomposer.org/composer.phar
getcomposer.org をDNSに問いあわせています... 94.23.218.218
getcomposer.org|94.23.218.218|:80 に接続しています... 接続しました。
HTTP による接続要求を送信しました、応答を待っています... 200 OK
長さ: 464824 (454K) [application/octet-stream]
`composer.phar' に保存中

100%[======================================>] 464,824     77.0K/s 時間 6.2s

2012-05-08 22:00:04 (73.0 KB/s) - `composer.phar' へ保存完了 [464824/464824]

無事にダウンロードできました。

次にComposerを入れてみます。

Composerとは、JSON形式のファイルでパッケージの依存情報など記述しておけば、コマンドでまとめてインストールできる仕組みで、ここ半年くらいでよく目にするようになりました。
PEARパッケージマネージャ(pear)の後継として pyrus というものがありますが、ComposerはPHPのパッケージ配布サービス Packagist を標準としつつ、PEARパッケージも取り込むもののようです。

まずは、以下のように必要なパッケージの情報を記述した、composer.jsonファイルを用意します。

{
    "require": {
        "symfony/class-loader": "2.1.*",
        "symfony/http-foundation": "2.1.*"
    }
}

composer.pharを使ってインストールしてみます。

$ php composer.phar install
Installing dependencies
  - Package symfony/http-foundation (dev-master)
    Cloning 54c22f4bf8625303503a117dcc68544d3f8ac876




  [RuntimeException]

  Failed to clone http://github.com/symfony/HttpFoundation, git was not found, check that it is installed and in your PATH env.


  'git' は、内部コマンドまたは外部コマンド、

  操作可能なプログラムまたはバッチ ファイルとして認識されていません。







install [--prefer-source] [--dry-run] [--dev]

どうやら内部的にgitコマンドが呼ばれるらしいのですが、Console2からだと失敗してしまいました。
gitコマンド自体は一応使えるようにしてるんですが…

とりあえずGit Bashで試してみます。

$ php composer.phar install
Installing dependencies
  - Package symfony/http-foundation (dev-master)
    Cloning 54c22f4bf8625303503a117dcc68544d3f8ac876

  - Package symfony/class-loader (dev-master)
    Cloning 0e6ee8d07dda6920106048247d41249201604e76

Writing lock file
Generating autoload files

うまくいきました。
コマンドを実行したディレクトリにcomposer.lockファイルが作成され、vendorディレクトリ直下にautoload.phpスクリプト、vendor/composerディレクトリにはそこから利用されているクラスローダ(Composer\Autoload\ClassLoader)などが作成されています。
vendor/composerディレクトリには、autoload_namespaces.phpやinstalled.jsonといったファイルが作成されており、どうやら前者はcomposerからインストールしたパッケージの名前空間を定義、後者はインストールしたパッケージの依存情報を一括して管理しているようです。
もちろん、composer.jsonで指定した symfony/http-foundation (SymfonyのHttpFoundationコンポーネント) と symfony/class-loader (ClassLoaderコンポーネント) も、vendorディレクトリにインストールされています。

そして何をするのかというと…

Create your own framework... on top of the Symfony2 Components (part 1) - Fabien Potencier
http://fabien.potencier.org/article/50/create-your-own-framework-on-top-of-the-symfony2-components-part-1

Fabienさんのシリーズ記事を参考にしながら、SymfonyのHttpFoundationコンポーネントを色々試してみようかと。


ちなみに現在、上記パート1は日本語訳もされてます。

[追記]
パート12まで日本語翻訳されました。
@masakielastic様ありがとうございます!

Web フレームワークをつくろう - Symfony コンポーネントの上に (パート 1) | Symfony2日本語ドキュメント
http://docs.symfony.gr.jp/symfony2/create-your-framework/part01.html


Composerの概要や主旨については、こちらの記事を読めばいいと思います。(英語ですが)

Composer: Part 1 – What & Why | Nelmio Blog
http://nelm.io/blog/2011/12/composer-part-1-what-why/

Composer: Part 2 – Impact | Nelmio Blog
http://nelm.io/blog/2011/12/composer-part-2-impact/

2012-04-24

Windows環境でGrowlによるStagehand_TestRunnerのテスト結果通知

なんかMacユーザの皆さんがGrowl Growlって言ってるけど…どーせWindowsじゃ使えないんでしょ、って勝手にやさぐれてたら、実はWindows用のクローンがあったんです。(今更気付きました…)

そろそろ、エディタの裏で動いてるStagehand_TestRunnerのAutotestをチラ見するのに疲れてきたところなので(?)、早速Growlによるテスト結果通知を試してみました。

まずは、Growl for Windowsをダウンロードして、インストール。
http://www.growlforwindows.com/gfw/

次に、PEAR Net_Growlをインストール。

[追記]
Stagehand\TestRunner\Notification\Notifierのソース見て疑問に思い、別の環境で試してみたところ、PEAR Net_Growl入れなくても大丈夫なようです。(多分V2.18以降から)
Twitterで@itemanさんからも、その旨コメントいただきました。

Growlへの通知機能については、V2のユーザーガイドの記述が詳しいです。
http://piece-framework.com/projects/stagehand-testrunner/wiki/Stagehand_TestRunner_ユーザーガイド_2_18_0#テスト結果の通知

通知に使用されるコマンドは環境によって異なり、Windows + Growl for Windowsの場合は、growlnotifyコマンドへのパスを通す必要があるようです。

f:id:k-holy:20120424165120p:image
Growl for Windowsのインストールディレクトリ(自分の場合 C:\Applications\Growl)をシステム環境変数Pathに設定しました。

それから上記ユーザーガイド記載の -n オプションを試したところ…。

$ testrunner phpunit -p bootstrap.php -Rn

  [RuntimeException]
  The "-n" option does not exist.

そんなオプションねえよ!って怒られました。
testrunner phpunit --help で確認したところ、V3のPHPUnitでは以下のオプションが有効とのこと。

Options:
 --preload-script (-p)    The PHP script to be loaded before running a command
 --config (-c)            The YAML-based configuration file for Stagehand_TestRunner
 --recursive (-R)         Recursively runs tests in the specified directories.
 --autotest (-a)          Monitors for changes in the specified directories and run tests when changes are detected.
 --watch-dir (-w)         The directory to be monitored for changes (default: The directories specified by the arguments) (multiple values allowed)
 --notify (-m)            Notifies test results by using the growlnotify command in Mac OS X and Windows or the notify-send command in Linux.
 --detailed-progress (-d) Prints detailed progress report.
 --stop-on-failure (-s)   Stops the test run when the first failure or error is raised.
 --log-junit              Logs test results into the specified file in the JUnit XML format.
 --log-junit-realtime     Logs test results in real-time into the specified file in the JUnit XML format.
 --test-file-pattern      The regular expression pattern for test files (default: Test(?:Case)?\.php$)
 --test-method            The test method to be run (multiple values allowed)
 --test-class             The test class to be run (multiple values allowed)
 --phpunit-config         The PHPUnit XML configuration file

どうも、テスト結果の通知オプション引数は、Stagehand_TestRunnerのバージョンによって g→n→m と変わったみたいです。

というわけで、mオプションで実行します。

$ testrunner phpunit -p bootstrap.php -Rm

f:id:k-holy:20120424164541p:image
無事にシュッと表示してくれました!


おまけ

通知時に表示されるアイコン画像は、Stagehand/TestRunner/Notification/ ディレクトリにある、failed.png, passed.png, stopped.png が利用されています。
画像を変更したい場合は、これを上書きするか、Stagehand/TestRunner/Notification/Notifier.php 冒頭の以下を書き換えるといいみたいです。

Notifier::$ICON_PASSED = dirname(__FILE__) . DIRECTORY_SEPARATOR . 'passed.png';
Notifier::$ICON_FAILED = dirname(__FILE__) . DIRECTORY_SEPARATOR . 'failed.png';
Notifier::$ICON_STOPPED = dirname(__FILE__) . DIRECTORY_SEPARATOR . 'stopped.png

アイコン画像を変えると開発効率UP!という方は、好きなものに変えてみるといいんじゃないでしょうか。

2012-04-18

XAMPPでPEARを入れ直した件、環境変数PEAR_CONFIG_SYSCONFDIRを忘れずに

Apache 2.4のWindows版バイナリがApache Loungeに公開されてると知って、Windows7Apache 2.4 + PHP5.4環境に挑戦したものの、どうにも安定動作させられませんでした。
時間的な余裕もないので、とりあえずXAMPPのバージョンを1.7.4(PHP 5.3.5)から1.7.7(PHP 5.3.8)に更新したところ、今度はpearコマンドが混乱してしまいました。
以下、XAMPPでPEARを再インストールした記録です。

まずXAMPPにデフォルトで添付されているPEARは不要なライブラリてんこ盛りなのでサクッと削除します。
cfg, data, DB, docs, PEAR, tests, Text, tmp, www といったディレクトリは全てPEAR関係なので、入れ直す際は全て削除して良いでしょう。

昔は go-pear.bat というスクリプトを実行してインストールしましたが、今は go-pear.phar を直接実行すればいいみたいです。

最新版の go-pear.phar はこちらのURLから入手できます。
http://pear.php.net/go-pear.phar

go-pear.phar をPHPインストールディレクトリに配置して、コマンドプロンプトを起動します。

$ cd c:\xampp\php

ここで php go-pear.phar といきたいところですが、その前に、環境変数 PEAR_CONFIG_SYSCONFDIR を設定します。

$ set PHP_PEAR_SYSCONF_DIR=C:\xampp\php

PHP 5.4をインストールした際、go-pear.phar を実行してpearコマンドといくつかのPEARライブラリをインストールしたんですが、デフォルト設定で入れてしまい、C:\Windows\pear.ini が残ったままになっていました。
で、上記環境変数を設定せず pear config-show を実行したところ、中途半端に前の設定が残ったままで、ディレクトリ関係の設定が c:\xampp\php以下だったりc:\php以下だったりとぐちゃぐちゃになってしまったわけです。

一応、上記のようにsetコマンドで設定することはできますが、新規コマンドプロンプトを開くたびに、User Configuration File がデフォルト値の C:\Windows\pear.ini に設定されてしまいますので、特に理由がなければ「システムのプロパティ」→「環境変数」で設定しておくと楽です。(画像参照)
f:id:k-holy:20120418002937p:image

準備ができたら、go-pear.pharを実行します。

$ php go-pear.phar
Are you installing a system-wide PEAR or a local copy?
(system|local) [system] :

プロジェクトローカルではなくシステム全体のPEARをインストールしますので、そのまま [Enter] を押します。

Below is a suggested file layout for your new PEAR installation.  To
change individual locations, type the number in front of the
directory.  Type 'all' to change all of them or simply press Enter to
accept these locations.

 1. Installation base ($prefix)                   : C:\xampp\php
 2. Temporary directory for processing            : C:\xampp\php\tmp
 3. Temporary directory for downloads             : C:\xampp\php\tmp
 4. Binaries directory                            : C:\xampp\php
 5. PHP code directory ($php_dir)                 : C:\xampp\php\pear
 6. Documentation directory                       : C:\xampp\php\docs
 7. Data directory                                : C:\xampp\php\data
 8. User-modifiable configuration files directory : C:\xampp\php\cfg
 9. Public Web Files directory                    : C:\xampp\php\www
10. Tests directory                               : C:\xampp\php\tests
11. Name of configuration file                    : C:\xampp\php\pear.ini
12. Path to CLI php.exe                           : C:\xampp\php

1-12, 'all' or Enter to continue:

前述の環境変数 PHP_PEAR_SYSCONF_DIR を設定しておけば、上記のようにInstallation baseが設定され、それに基づいたディレクトリの設定が行われます。
変更がなければ [Enter] を押してインストールします。

インストール完了後、pearコマンドをテストします。

$ pear version
PEAR Version: 1.9.4
PHP Version: 5.3.8
Zend Engine Version: 2.3.0
Running on: Windows NT HORII 6.1 build 7601 (Windows 7 Business Edition Service Pack 1) i586

問題ないようなので、php.iniを編集して、include_pathにPHP code directory ($php_dir)を追加します。

include_path = ".;\xampp\php\pear"

次に、 pear config-show で設定内容を確認して、いくつか修正しました。

$ pear config-set php_ini c:\xampp\php\php.ini
$ pear config-set cache_dir C:\xampp\php\cache
$ pear config-set preferred_state beta
$ pear config-set auto_discover 1

上から順に、php.ini の場所を明示、キャッシュディレクトリをデフォルトの設定(%USERPROFILE%\AppData\Local\Temp\pear\cache)から変更、beta版のライブラリのインストールを有効に、PEARチャネルの自動登録を有効に設定しました。
次に、デフォルトのライブラリを全更新して…。

$ pear upgrade-all

必要なライブラリをインストール。
今回はチャネル pear.piece-framework.com から 最新のStagehand_TestRunnerを入れました。

$ pear channel-discover pear.piece-framework.com
$ pear install --alldeps piece/stagehand_testrunner
....
(中略)
....
install ok: channel://pear.symfony.com/ClassLoader-2.0.12
install ok: channel://pear.symfony.com/Config-2.0.12
install ok: channel://pear.symfony.com/Console-2.0.12
install ok: channel://pear.symfony.com/DependencyInjection-2.0.12
install ok: channel://pear.symfony.com/Finder-2.0.12
install ok: channel://pear.symfony.com/Yaml-2.0.12
install ok: channel://pear.phpunit.de/Text_Template-1.1.1
install ok: channel://pear.phpspec.net/PHPSpec_Zend-1.3.0beta
install ok: channel://pear.survivethedeepend.com/Mockery-0.7.2
install ok: channel://pear.phpunit.de/File_Iterator-1.3.1
install ok: channel://pear.phpunit.de/PHP_Timer-1.0.2
install ok: channel://pear.symfony-project.com/YAML-1.0.6
install ok: channel://hamcrest.googlecode.com/svn/pear/Hamcrest-1.1.0
install ok: channel://pear.phpunit.de/PHP_TokenStream-1.1.3
install ok: channel://pear.piece-framework.com/Stagehand_AlterationMonitor-2.0.1
install ok: channel://pear.piece-framework.com/Stagehand_ComponentFactory-1.0.0
install ok: channel://pear.phpspec.net/PHPSpec-1.3.0beta
install ok: channel://pear.phpunit.de/PHP_CodeCoverage-1.1.2
install ok: channel://pear.phpunit.de/PHPUnit_MockObject-1.1.1
install ok: channel://pear.piece-framework.com/Stagehand_TestRunner-3.0.0
install ok: channel://pear.phpunit.de/PHPUnit-3.6.10

元々、PHP5.4を入れようとしたのも、XAMPP 1.7.4の環境で Stagehand_TestRunner 3.0.0 のAutotestがうまく動いてくれなかったので、最新の環境で検証しようとしたわけですが…。
そうこうしているうちに、私の環境で引っかかっていたものも含めて、いくつかの不具合が修正された 3.0.1 がリリースされました。

Stagehand TestRunner 3 0 1 (stable) リリースノート - Stagehand_TestRunner - Piece Framework
http://piece-framework.com/projects/stagehand-testrunner/wiki/Stagehand_TestRunner_3_0_1_(stable)_リリースノート


チャネル指定でのアップグレードには、以下のショートカットコマンドが利用できます。

$ pear up -c piece
Error getting channel info from pear.piece-framework.com: SECURITY ERROR: Will not write to C:\xampp\php\cache\32001299bdec0c8ddc33ac83b8014fafrest.cacheid as it is symlinked to C:\xampp\php\cache\32001299bdec0c8ddc33ac83b8014fafrest.cacheid - Possible symlink attack

おそらく Windows Vista 以降からだと思いますが、PEAR Installer cache directory (cache_dir) への書き込み時に上記のようなエラーが発生することがあります。
こういう場合は、pear clear-cache コマンドでキャッシュを破棄すればOKです。(ショートカットコマンドは pear cc)

$ pear cc
reading directory C:\xampp\php\cache
262 cache entries cleared

$ pear up -c piece
downloading Stagehand_TestRunner-3.0.1.tgz ...
Starting to download Stagehand_TestRunner-3.0.1.tgz (73,476 bytes)
.................done: 73,476 bytes
upgrade ok: channel://pear.piece-framework.com/Stagehand_TestRunner-3.0.1

3.0.1に更新して確認したところ、XAMPP 1.7.7添付のPHP5.3.8でも無事にAutotestが動いてくれました。

どうやらWindows環境でproc_open()関数利用時、標準入出力で2048バイト以上の文字列を扱った場合にハングアップするというバグがあり、その影響を受けていたようです。
http://piece-framework.com/issues/390

関連リビジョンのdiffを見たところ、Windows用の回避コードが追加されていました。
http://piece-framework.com/projects/stagehand-testrunner/repository/revisions/8d3a128c9ab3569f5ace7bac7e3143d84588cea6/diff/src/Stagehand/TestRunner/Process/Autotest/Autotest.php

@itemanさん素早い対応ありがとうございます。


以下、参考にしたサイトです。

Manual :: PEAR パッケージマネージャの取得とインストール
http://pear.php.net/manual/ja/installation.getting.php

Manual :: Checking if PEAR works
http://pear.php.net/manual/ja/installation.checking.php

PHP/PEARインストールメモ - Glamenv-Septzen.net
http://www.glamenv-septzen.net/view/40

PHP_PEAR_SYSCONF_DIRの件でソースを追跡して検証されていて、助かりました。
バージョン違いやプロジェクトローカル等、複雑な環境でPEARを使った経験がないので、色々分かってなかったようです…。

2012-04-06

値をセットした時に任意のフィルタ処理を行うアクセサクラス

フレームワークのコードをななめ読みしていると、getterとsetterを兼ねた短い名前のメソッドが使われている例を、ちょくちょく見かけます。
たとえばJava風なら getName(), setName('foo') とするところを、name(), name('foo') とするようなメソッドです。

実はPerlではメジャーな流儀のようで、よく考えるとPHPのネイティブ関数でもこの流儀が結構使われてるんですね。error_reporting()とか。

メソッド名は短くできるし、案外気持ちいいかもと思って、最近Traitのサンプルで流行している(?)アクセサクラスに応用してみました。

__call()と__set()と__get()を利用して、値のセット時に任意のフィルタ処理を行うアクセサクラスです。

FilterableAccessor

これを使って、値のセット時にサニタイズ(言うな)とバリデーションを行うサンプルがこちら。

クロージャ厨みたいなコードですみません。
これ書いてて、__call()でアクセサを実装した場合、__set()ではできないメソッドチェインが実現できることに気付きました。
マジックメソッドで実装するかはともかく、この流儀のメソッドは今後、積極的に使っていこうと思います。

2012-03-22

Smarty3で文字列をテンプレートとして使う

今時Smartyなんて使うのは情弱だけだそうですが(すみません)、Smarty3で文字列をテンプレートとして使う方法を紹介します。

コード

出力結果

なんてことはない、標準で対応していたのでした。
Smarty->registerResource()とか使わなくても良かった。

リソース | Smarty#文字列からのテンプレート
http://www.smarty.net/docs/ja/template.resources.tpl#templates.from.string

2012-03-08

RedBeanで見るMass Assignment

Railsの仕様(と言っていいのかな?)に起因する、GitHubのMass Assignment脆弱性が狙い撃ちされたのが話題になりましたが、ORMライブラリRedBeanの、RedBean_OODBBean::import()がそのまんま、そういう実装だったのを思い出したので確認してみました。

You can import an array into a bean using: 

    $book->import($_POST);

 The code above is handy if your $_POST request array only contains book data. It will simply load all data into the book bean. You can also add a selection filter: 

    $book->import($_POST, 'title,subtitle,summary,price');

 This will restrict the import to the fields specified. Note that this does not apply any form of validation to the bean. Validation rules have to be written in the model or the controller.

http://www.redbeanphp.com/manual/import_and_export より引用

このサンプルコードだけでも一目瞭然なのですが、該当部分のRedBean_OODBBean::import()のコードはこうなってます。

    public function import( $arr, $selection=false, $notrim=false ) {
        if (is_string($selection)) $selection = explode(",",$selection);
        //trim whitespaces
        if (!$notrim && is_array($selection)) foreach($selection as $k=>$s){ $selection[$k]=trim($s); }
        foreach($arr as $k=>$v) {
            if ($k!='__info') {
                if (!$selection || ($selection && in_array($k,$selection))) {
                    $this->$k = $v;
                }
            }
        }
        return $this;
    }

https://github.com/gabordemooij/redbean/blob/master/RedBean/OODBBean.php より引用

第2引数で配列またはカンマ区切りで、取り込むフィールドを指定できるようになってるんですね。
モデルに実装したメソッドを呼べるので勘違いしそうですが、OODBBean自身はモデルへのアクセス機能を持った配列オブジェクトみたいなものです。

OODBBeanにセットされた値は、RedBean_Facade::store()経由で実行されるRedBean_OODB::store()に渡されて保存されるのですが、このメソッド、RedBean_Facade::freeze(true)しておかないと勝手にカラムが追加されてしまう危険な仕様になっています。
そのため、上記のRedBean_OODBBean::import()で第2引数で取り込むカラム名を指定せず$_POSTを直接渡していると、好きなようにカラムを追加されてしまうという恐ろしいことになります。

恥ずかしながら、ソースを追ったわけではなく(追おうとしたけど、200行近いメソッドのコード内で更に他のメソッドが次々と呼ばれてたので早々に挫折しました…)、フォームのname属性をtypoしたせいでSQLiteのテーブルにカラムが追加されてしまって、初めてこの仕様に気付きました。
Mass Assignmentという言葉は知らなかったのですが、この時の失敗のおかげで、RedBean_Facade::freeze()の意味とRedBean_OODBBean::import()の第2引数の重要性を理解できたわけです。

ちなみに今の自分のコードでは、RedBean_SimpleModelを継承したモデルに操作対象テーブルのフィールド名を定義し、それを使ってRedBean_OODBBean::import()の第2引数を指定しています。

AbstractModelクラスから該当部分を抜粋

abstract class AbstractModel extends \RedBean_SimpleModel
{
    protected $fields = array();
    public function getFieldNames()
    {
        return array_keys($this->fields);
    }
}

ProfilesModelクラスから該当部分を抜粋

class ProfilesModel extends AbstractModel
{
    protected $fields = array(
        'surname' => '苗字',
        'name'    => '名前',
        'notes'   => '備考',
        'year_of_birth' => '生年',
        'year_of_death' => '没年',
    );
}

呼び出し元のスクリプトから抜粋

    $profile = \R::dispense('profiles');
    switch($request->getMethod()) {
    case 'POST':
        $profile->import($request->request->all(),
            $profile->getFieldNames());
        if ($request->get(CSRF_TOKEN_NAME) === $sessionId) {
            try {
                \R::store($profile);
            } catch (InputValidationException $ex) {
                $app['phptal']->set('errors', $ex->getErrors());
                break;
            }
            $app['session']->setFlash('message', '人物情報を登録しました。');
            return $app->redirect('/profiles/', 303);
        }
        break;
    }

こういった方法で、モデルクラス経由で更新されたくないカラムの値を保護することはできるのですが、たとえば管理画面と一般利用者画面、あるいは利用者の権限によって操作できるカラムを制限したいケースには対応できません。
アプリケーションフレームワークがなかった頃、リクエスト変数を操作対象となる配列にセットするコードを画面毎にズラズラと書いていましたが、そういう方法ではこのような問題は起こりえないのですが…。
DRYを突き詰める過程で、今まで明示的にやってたことをやめようという時は、不備がないのかよくよく考えないといけません。

またこの件に関連して、バリデーションをModelかFormのどちらでやるか?といった話も目にしました。
自分は業務では今のところ、フォームと対になったコントローラでは受け入れるフィールドの定義とモデル用の値への変換に留めて、バリデーションはモデルで一括して行う派です。
(と言っても、いわゆるドメインモデルではなく、URL別のページスクリプト→ユースケース別のコントローラー→ユースケース別のトランザクションスクリプト→CRUD&共通バリデーションを行うクラス→DAOでひたすら配列をリレーするという旧型な構造で作ってて、設定ファイル的なものも使っていないのですが)

このあたりは以前からずっとモヤモヤ考えてて、複数の層にまたがって実行されるバリデーション処理と、バリデーション結果からユーザーへの通知メッセージを生成する処理をどうやって分けるかが課題になってます。

なお、GitHubのMass Asssignment脆弱性については、これらの記事で把握しました。
github の mass assignment 脆弱性が突かれた件 - blog.sorah.jp
Git Hubの脆弱性とMass Asssignment