Agavi による MVC プログラミング入門
第 5 回 Agavi アプリケーションにページング機能、ファイルのアップロード機能、そしてカスタム入力バリデーターを追加する
Agavi フレームワークを使用してスケーラブルな Web アプリケーションを作成する方法を学ぶ
コンテンツシリーズ
このコンテンツは全#シリーズのパート#です: Agavi による MVC プログラミング入門
このコンテンツはシリーズの一部分です:Agavi による MVC プログラミング入門
このシリーズの続きに乞うご期待。
はじめに
この連載の第 4 回が終わった時点で、管理モジュール、検索エンジン、そして XML 出力機能を備えた完全に機能する Web アプリケーションが完成しました。読者の皆さんは、WASP (Web Automobiles Sales Platform) アプリケーションの基本要件は満たされたというのに、なぜまだ連載が続くのか不思議に思っていることでしょう。
この最後の記事では、Web アプリケーションを作成するときに必ず必要になる手法と概念を追加で説明します。ここで説明する手法と概念は、データベース・レコードのページングとソートといった平凡な内容から、Web フォームによるファイル・アップロードのサポートやカスタム入力バリデーターの作成などの複雑な内容にまで至ります。しかしいずれにしても、Agavi フレームワークに組み込まれたツールが、作業を簡単に、素早く、そしてより確実にしてくれます。それでは早速、どのようにしてそれが実現されているかを確かめてください。
データベース・レコードのソート
最初に取り掛かるのは、結果セットのページングとソートです。現在、http://wasp.localhost/admin/listing/index にある管理モジュールの要約ページにアクセスすると、結果セットは図 1 のように表示されます。
図 1. WASP リストの要約ページ

今から、表示されるオブジェクトをユーザーがさまざまな基準でソートできるようにするための機能を追加します。まず AdminIndexSuccess テンプレートを編集して、表の各見出しにソート用のリンクを追加します (リスト 1 を参照)。
リスト 1. Listing/AdminIndexSuccess テンプレート
<h3>View All Listings</h3> <?php if (count($t['records']) == 0): ?> No records available <?php else: ?> <div id="list"> <form action="<?php echo $ro->gen('admin.listing.delete'); ?>" method="post" > <table cellspacing="5"> <tr> <td class="key"></td> <td class="key"></td> <td class="key">Submission Date <a href="<?php echo $ro->gen('admin.listing.index', array('s' => 'RecordDate', 'd' => 'asc')); ?>">⇑</a> <a href="<?php echo $ro->gen('admin.listing.index', array('s' => 'RecordDate', 'd' => 'desc')); ?>">⇓</a> </td> <td class="key">Manufacturer <a href="<?php echo $ro->gen('admin.listing.index', array('s' => 'VehicleManufacturerID', 'd' => 'asc')); ?>">⇑</a> <a href="<?php echo $ro->gen('admin.listing.index', array('s' => 'VehicleManufacturerID', 'd' => 'desc')); ?>">⇓</a> </td> <td class="key">Model <a href="<?php echo $ro->gen('admin.listing.index', array('s' => 'VehicleModel', 'd' => 'asc')); ?>">⇑</a> <a href="<?php echo $ro->gen('admin.listing.index', array('s' => 'VehicleModel', 'd' => 'desc')); ?>">⇓</a> </td> <td class="key">Year <a href="<?php echo $ro->gen('admin.listing.index', array('s' => 'VehicleYear', 'd' => 'asc')); ?>">⇑</a> <a href="<?php echo $ro->gen('admin.listing.index', array('s' => 'VehicleYear', 'd' => 'desc')); ?>">⇓</a> </td> <td class="key">Mileage <a href="<?php echo $ro->gen('admin.listing.index', array('s' => 'VehicleMileage', 'd' => 'asc')); ?>">⇑</a> <a href="<?php echo $ro->gen('admin.listing.index', array('s' => 'VehicleMileage', 'd' => 'desc')); ?>">⇓</a> </td> <td class="key">Color <a href="<?php echo $ro->gen('admin.listing.index', array('s' => 'VehicleColor', 'd' => 'asc')); ?>">⇑</a> <a href="<?php echo $ro->gen('admin.listing.index', array('s' => 'VehicleColor', 'd' => 'desc')); ?>">⇓</a> </td> <td class="key"></td> </tr> <?php foreach ($t['records'] as $record): ?> <tr> <td><input type="checkbox" name="id[]" value="<?php echo $record['RecordID']; ?>" style="width:2px" /> </td> <td><?php echo $record['RecordID']; ?></td> <td><?php echo date('d M Y', strtotime($record['RecordDate'])); ?> </td> <td><?php echo $record['Manufacturer']['ManufacturerName']; ?> </td> <td><?php echo $record['VehicleModel']; ?></td> <td><?php echo $record['VehicleYear']; ?></td> <td><?php echo $record['VehicleMileage']; ?></td> <td><?php echo $record['VehicleColor']; ?></td> <td><a href="<?php echo $ro->gen('admin.listing.edit', array('id' => $record['RecordID'])); ?>">Edit</a></td> </tr> <?php endforeach; ?> <tr> <td colspan="9"><input type="submit" name="submit" style="width:150px" value="Delete Selected" /></td> </tr> </table> </form> </div> <?php endif; ?>
リスト 1 に示されているように、それぞれのリンクごとに以下の 2 つのパラメーターがあります。
s
は、ソートの対象とするフィールドを指定します。d
は、ソートの方向 (昇順または降順) を指定します。
次に、AdminIndexAction バリデーターを編集して、この 2 つのパラメーターをサポートするようにします (リスト 2 を参照)。リスト 2 で注目する点は、AgaviInArrayValidator を使用して各パラメーターの許容値を制限していることです。
リスト 2. Listing/AdminIndexAction バリデーター
<?xml version="1.0" encoding="UTF-8"?> <ae:configurations xmlns="http://agavi.org/agavi/config/parts/validators/1.0" xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0" parent="%core.module_dir%/Listing/config/validators.xml" > <ae:configuration> <validators method="read"> <validator class="inarray"> <arguments> <argument>s</argument> </arguments> <errors> <error>ERROR: Invalid sort field</error> </errors> <ae:parameters> <ae:parameter name="required">false</ae:parameter> <ae:parameter name="values">RecordID,RecordDate, VehicleManufacturerID,VehicleModel, VehicleColor,VehicleYear,VehicleMileage</ae:parameter> <ae:parameter name="sep">,</ae:parameter> </ae:parameters> </validator> <validator class="inarray"> <arguments> <argument>d</argument> </arguments> <errors> <error>ERROR: Invalid sort direction</error> </errors> <ae:parameters> <ae:parameter name="required">false</ae:parameter> <ae:parameter name="values">asc,desc</ae:parameter> <ae:parameter name="sep">,</ae:parameter> </ae:parameters> </validator> </validators> </ae:configuration> </ae:configurations>
続いて AdminIndexAction の executeRead()
メソッドを編集して、この 2 つのパラメーターを読み取るようにするとともに、ORDER BY 節を追加して Doctrine クエリーを変更するようにします (リスト 3 を参照)。
リスト 3. Listing/AdminIndexAction の定義
<?php class Listing_AdminIndexAction extends WASPListingBaseAction { public function getDefaultViewName() { return 'Success'; } public function executeRead(AgaviRequestDataHolder $rd) { try { // get input variables $id = $rd->getParameter('id'); $sort = $rd->isParameterValueEmpty('s') ? 'RecordID' : $rd->getParameter('s'); $dir = $rd->isParameterValueEmpty('d') ? 'asc' : $rd->getParameter('d'); // create query $q = Doctrine_Query::create() ->from('Listing l') ->leftJoin('l.Manufacturer m') ->leftJoin('l.Country c') ->orderBy(sprintf('%s %s', $sort, $dir)); $result = $q->fetchArray(); // set view variables $this->setAttribute('records', $result); return 'Success'; } catch (Exception $e) { $this->setAttribute('error', $e->getMessage()); return 'Error'; } } final public function isSecure() { return true; } } ?>
http://wasp.localhost/admin/listing/index の要約ページにもう一度アクセスすると、表のそれぞれの見出しの横にはソート用のリンクが表示されているはずです。これらのリンクのいずれかを選択すると、結果セットは指定されたフィールドと方向に従ってソートされます。図 2 にその一例を示します。
図 2. ソート機能が追加された WASP リストの要約ページ

データベース・レコードのページング
大量の結果セットを扱う場合の要件として、データをページ単位で表示しなければならないことがよくあります。ページ単位で表示することで、(データベース・サーバーが生成する結果セットが小さくなることによって) データベース・サーバーの負荷を減らすとともに、(ユーザーが小さな塊で情報を表示できることによって) ユーザーがデータを管理しやすくするというわけです。他の一部のフレームワークとは異なり、Agavi にはページング用の組み込みオブジェクトが提供されていません。とは言え、この機能は Doctrine を使えば簡単に実装することができます。
Doctrine に付属の Doctrine_Pager オブジェクトは、データベース・レコードのページングを伴うあらゆる操作の中心的なコマンドとして機能します。このオブジェクトは、データベース結果セットのページングで最もよく使われる 2 つのタイプ (スライドとジャンプ) をサポートするだけでなく、ページ番号およびリンクのフォーマットと表示をさまざまにカスタマイズできるようになっています。このそれぞれについて、ここで詳しく説明することはできませんが、「参考文献」に該当する Doctrine マニュアル・ページへのリンクを記載しているので参照してください。
最初のステップは、追加のページ・パラメーターを許容するように AdminIndexAction バリデーターを更新することです。ここではページ・パラメーターを p
と呼びます。リスト 4 に、追加するコードを記載します。
リスト 4. Listing/AdminIndexAction バリデーター
<?xml version="1.0" encoding="UTF-8"?> <ae:configurations xmlns="http://agavi.org/agavi/config/parts/validators/1.0" xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0" parent="%core.module_dir%/Listing/config/validators.xml" > <ae:configuration> <validators method="read"> ... <validator class="number"> <arguments> <argument>p</argument> </arguments> <errors> <error>ERROR: Invalid page number</error> </errors> <ae:parameters> <ae:parameter name="type">int</ae:parameter> <ae:parameter name="required">false</ae:parameter> <ae:parameter name="min">1</ae:parameter> </ae:parameters> </validator> </validators> </ae:configuration> </ae:configurations>
AdminIndexAction も、この追加パラメーターに留意するように更新する必要があります。リスト 5 に変更後のコードを記載します。
リスト 5. Listing/AdminIndexAction の定義
<?php class Listing_AdminIndexAction extends WASPListingBaseAction { public function getDefaultViewName() { return 'Success'; } public function executeRead(AgaviRequestDataHolder $rd) { try { // get input variables $id = $rd->getParameter('id'); $sort = $rd->isParameterValueEmpty('s') ? 'RecordID' : $rd->getParameter('s'); $dir = $rd->isParameterValueEmpty('d') ? 'asc' : $rd->getParameter('d'); $page = $rd->getParameter('p'); // create query $q = Doctrine_Query::create() ->from('Listing l') ->leftJoin('l.Manufacturer m') ->leftJoin('l.Country c') ->orderBy(sprintf('%s %s', $sort, $dir)); // set pager parameters $perPage = 5; $numPageLinks = 5; // initialize pager $pager = new Doctrine_Pager($q, $page, $perPage); // execute paged query $result = $pager->execute(array(), Doctrine::HYDRATE_ARRAY); // initialize pager layout $pagerRange = new Doctrine_Pager_Range_Sliding( array('chunk' => $numPageLinks), $pager ); $pagerUrlBase = $this->getContext()->getRouting()->gen( 'admin.listing.index', array('s' => $sort, 'd' => $dir) ); $pagerLayout = new Doctrine_Pager_Layout($pager, $pagerRange, $pagerUrlBase); // set page link display template $pagerLayout->setTemplate( '<a href="{%url}&p={%page}">{%page}</a>' ); $pagerLayout->setSelectedTemplate('{%page}'); $pagerLayout->setSeparatorTemplate(' '); // set view variables $this->setAttribute('records', $result); $this->setAttribute('pages', $pagerLayout->display(null, true)); return 'Success'; } catch (Exception $e) { $this->setAttribute('error', $e->getMessage()); return 'Error'; } } final public function isSecure() { return true; } } ?>
リスト 5 には新しい要素が多数あるので、記載順に説明していきます。まず、リスト 5 では Doctrine_Pager オブジェクトを初期化して、オブジェクト・コンストラクターに 3 つのキー・パラメーターを渡します。この 3 つのキー・パラメーターとは、実行する SQL クエリー、現行のページ番号 (入力変数 p
から取得)、そしてページごとに表示する結果件数 (5 と前提) です。次に、Doctrine_Pager オブジェクトが該当する LIMIT 節を設定したクエリーを実行し、要求されたレコードのサブセットだけを取得します。
しかし、このコードで行っているのはそれだけではありません。ユーザーが前後の結果セットにジャンプできるように、ページ番号も一覧にして表示します。そのための手段として使用するのが、Doctrine_Pager_Layout オブジェクトです。このオブジェクトを使用することで、ページング・モード、各ページ・リンクのベース URL、表示するページ・リンクの数を定義することができます。また、Doctrine_Pager_Layout オブジェクトの setTemplate()
メソッドを使えば、最終的なレイアウトでそれぞれ個別に表示されるページ番号のフォーマットを細かく制御することも可能です。すべての構成が完了すると、Doctrine_Pager_Layout オブジェクトの display()
メソッドを呼び出すことによって、必要なページ・リンクの HTML コードが生成され、テンプレート変数 $t['pages']
に割り当てられます。
残りの作業は、AdminIndexSuccess テンプレートを更新し、この HTML コードを挿入することだけです (リスト 6)。
リスト 6. Listing/AdminIndexSuccess テンプレート
<h3>View All Listings</h3> <?php if (count($t['records']) == 0): ?> No records available <?php else: ?> <div class="pager"> Pages: <?php echo $t['pages']; ?> </div> <div id="list"> ... </div> <?php endif; ?> <div class="pager"> Pages: <?php echo $t['pages']; ?> </div>
図 3 に、結果の一例を示します。
図 3. 複数のページに分割された WASP リストの要約

セッションでのデータの保存
管理モジュールに対して行えるもう 1 つの (比較的小規模な) 改善は、ユーザーがすでにログインしている場合に限って特定のリンク (「Log out (ログアウト)」リンクなど) を選択的に表示することです。この改善を行うのは至って簡単で、AgaviUser::isAuthenticated()
メソッドを対象とした条件付きテストを作成するだけで済みます。AdminMaster テンプレートに必要な変更内容は、リスト 7 のとおりです。
リスト 7. AdminMaster テンプレート
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> <head> ... </head> <body> <!-- begin header --> <div id="header"> <div id="logo"> <img src="/images/logo-admin.jpg" /> </div> <div id="menu"> <?php if($this->getContext()->getUser()->isAuthenticated()): ?> <ul> <li><a href="<?php echo $ro->gen('admin.listing.index'); ?>" >Listings</a></li> <li><a href="<?php echo $ro->gen('admin.logout'); ?>" >Log Out</a></li> </ul> <?php endif; ?> </div> </div> <!-- end header --> <!-- begin body --> ... <!-- end body --> <!-- begin footer --> ... <!-- end footer --> </body> </html>
ユーザー・データをセッションに保存したい場合には、AgaviUser::setAttribute()
メソッドを使用します。リスト 8 に、現在ログインしているユーザーのユーザー名を保存するように LoginAction を変更する方法を説明します。
リスト 8. Default/LoginAction の定義
<?php class Default_LoginAction extends WASPDefaultBaseAction { public function getDefaultViewName() { return 'Input'; } public function executeWrite(AgaviRequestDataHolder $rd) { try { // get input parameters $u = $rd->getParameter('username'); $p = $rd->getParameter('password'); // check user credentials $q = Doctrine_Query::create() ->from('User u') ->where('u.Username = ? AND u.Password = PASSWORD(?)', array($u,$p)); $result = $q->fetchArray(); // set authentication flag if valid if (count($result) == 1) { $this->getContext()->getUser()->setAuthenticated(true); $this->getContext()->getUser()->setAttribute('username', $u, 'wasp.user.namespace'); return 'Success'; } else { return 'Error'; } } catch (Exception $e) { return 'Error'; } } } ?>
リスト 9 では、このデータをセッションから取得し、管理モジュールに表示するように AdminMaster テンプレートを変更しています。
リスト 9. AdminMaster テンプレート
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> <head> ... </head> <body> <!-- begin header --> ... <!-- end header --> <!-- begin body --> <div id="body"> <?php if($this->getContext()->getUser()->isAuthenticated()): ?> Logged in as: <?php echo $this->getContext()->getUser()->getAttribute('username', 'wasp.user.namespace'); ?> <?php endif; ?> <?php echo $inner; ?> </div> <!-- end body --> <!-- begin footer --> ... <!-- end footer --> </body> </html>
以上の変更を反映した結果、表示は図 4 のようになります。
図 4. 新しいリンクが追加された WASP 管理ページのメイン・メニュー

ファイル・アップロードの処理
今のところ、販売店がアップロードした自動車データはすべてデータベースに保存されます。しかし 1 枚写真があれば、1000 の言葉を使った説明に匹敵します。そこで、販売店が自動車の画像をアップロードできるようにすれば、WASP アプリケーションにとって効果的な追加機能になります。そのための作業も、それほど難しくはありません。
まず始めに、アップロードされた画像を保存するディレクトリーを $WASP_ROOT/pub/usr/ に作成します。このディレクトリーは必ず、Web サーバーのユーザーに対して書き込み可能にしてください。
shell> cd /usr/local/apache/htdocs/wasp/pub shell> mkdir usr
次に、CreateInput テンプレートを更新してファイル・アップロード用のフィールドを追加します (リスト 10)。
リスト 10. Listing/CreateInput テンプレート
<script src="/js/form.js"></script> <h3>Add Listing</h3> <form action="<?php echo $ro->gen(null); ?>" method="post" enctype="multipart/form-data"> ... <fieldset> <legend>Vehicle Images</legend> <label for="Images[1]">Image #1:</label> <input id="Images[1]" type="file" name="Images[1]" /> <p/> <label for="Images[2]">Image #2:</label> <input id="Images[2]" type="file" name="Images[2]" /> <p/> <label for="Images[3]">Image #3:</label> <input id="Images[3]" type="file" name="Images[3]" /> <p/> <p class="note"> <em>Images should be 200x125 px, <br/> JPEG or GIF format only. </em> </p> </fieldset> <input type="submit" name="submit" class="submit" value="Submit Listing" /> </form>
リスト 10 では multipart を指定したフォームの送信をサポートするため、<form>
要素に enctype
属性も追加していることに注目してください。
アップロードされるファイルは、Web アプリケーションにとって重大な攻撃ベクトルであるため、あらゆるアップロード・ファイルは妥当性を検証してから保存を許可することが肝心です。幸い Agavi には、特定の入力パラメーターが有効な画像ファイルであるかどうかを確認できるフル装備の AgaviImageFileValidator が用意されています。このバリデーターでは、画像が特定のフォーマット、幅、高さに従っているかどうかも確認することができます。
リスト 11 は、AgaviImageFileValidator の呼び出しを追加した更新後の CreateAction バリデーターです。
リスト 11. Listing/CreateAction バリデーター
<?xml version="1.0" encoding="UTF-8"?> <ae:configurations xmlns="http://agavi.org/agavi/config/parts/validators/1.0" xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0" parent="%core.module_dir%/Listing/config/validators.xml" > <ae:configuration> <validators method="write"> ... <validator class="imagefile"> <arguments base="Images[]"> <argument/> </arguments> <errors> <error for="no_image">ERROR: Uploaded file is not an image</error> <error for="format">ERROR: Image file format is invalid</error> <error>ERROR: Image size is incorrect</error> </errors> <ae:parameters> <ae:parameter name="required">false</ae:parameter> <ae:parameter name="format">gif jpeg</ae:parameter> <ae:parameter name="min_width">200</ae:parameter> <ae:parameter name="max_width">200</ae:parameter> <ae:parameter name="min_height">125</ae:parameter> <ae:parameter name="max_height">125</ae:parameter> </ae:parameters> </validator> </validators> </ae:configuration> </ae:configurations>
リスト 11 から明らかにわかるように、アップロードされたファイルは、GIF または JPEG フォーマットのいずれかであり、幅が 200 ピクセルで高さが 125 ピクセルの場合に限って受け入れられます。
アップロードされたファイルが受け入れられると、アプリケーションはファイルの名前を変更して、指定されたディレクトリーである $WASP_ROOT/pub/usr/ に保存します。リスト 12 は、これらのタスクを行うように変更した CreateAction です。
リスト 12. Listing/CreateAction の定義
<?php class Listing_CreateAction extends WASPListingBaseAction { public function getDefaultViewName() { return 'Input'; } public function executeWrite(AgaviRequestDataHolder $rd) { try { // initialize object $listing = new Listing(); // populate with validated input $listing->fromArray($rd->getParameters()); $listing->VehicleAccessoryBit = array_sum($rd->getParameter('VehicleAccessoryBit')); // set some values manually $listing->RecordDate = date('Y-m-d', mktime()); $listing->DisplayStatus = 0; // save record and get record ID $listing->save(); $id = $listing->RecordID; // rename and move uploaded images $target = AgaviConfig::get('core.app_dir') . '/../pub/usr/'; $x = 1; foreach ($rd->getFile('Images') as $file) { switch ($file->getType()) { case 'image/jpeg': case 'image/pjpeg': $name = sprintf('%d_%d', $id, $x) . '.jpg'; $file->move("$target/$name"); break; case 'image/gif': $name = sprintf('%d_%d', $id, $x) . '.gif'; $file->move("$target/$name"); break; } $x++; } return 'Success'; } catch (Exception $e) { $this->setAttribute('error', $e->getMessage()); return 'Error'; } } } ?>
AgaviRequestDataHolder が提供する getFile()
メソッドでは、アップロードされたファイルを簡単に AgaviUploadedFile オブジェクトとして取得することができます。すると、これらのオブジェクトが getType()
、getSize()
、getName()
などのコンビニエンス・メソッドや、ファイルを新しい場所に移動する move()
メソッドを公開します。
しかし、ファイル・アップロードの処理は全体像の一部でしかありません。アップロードされた画像は、対応するリストの詳細ページに表示する必要もあります。それには、リスト 13 に示すコードで DisplaySuccess テンプレートを更新してください。
リスト 13. Listing/DisplaySuccess テンプレート
<h3> FOR SALE: <?php printf('%d %s %s (%s)', $t['listing']['VehicleYear'], $t['listing']['Manufacturer']['ManufacturerName'], ucwords(strtolower($t['listing']['VehicleModel'])), ucwords(strtolower($t['listing']['VehicleColor']))); ?> </h3> <div id="container"> <div id="gallery"> <?php $id = $t['listing']['RecordID']; ?> <?php $target = AgaviConfig::get('core.app_dir') . '/../pub/usr/'; ?> <?php foreach (glob($target.$id.'_*.{gif,jpg}', GLOB_BRACE) as $file): ?> <img width="200" height="125" src="/usr/<?php echo basename($file); ?>" style="float:left"/> <p/> <p/> <?php endforeach; ?> </div> <div id="specs"> <table cellspacing="5"> ... </table> </div> </div>
ここで、新しいリストを追加してみてください。フォームには、ファイル・アップロード用のフィールドが追加されているはずです (図 5 を参照)。
図 5. 画像のアップロードをサポートする WASP リストのフォーム

画像でないファイルや、指定されたサイズに適合しない画像をアップロードしようとすると、AgaviImageFileValidator がエラーおよびメッセージをスローします (図 6 を参照)。
図 6. 無効な画像のアップロードに対するエラー

アップロードが正常に行われると、リストの詳細ページには自動車情報と併せて、アップロードされた画像が表示されます。リスト 7 は、その一例です。
図 7. 画像ギャラリーが追加された WASP リストの詳細ページ

不要なものを残さないようにするため、リストが削除されると関連する画像も削除されるように AdminDeleteAction を更新してください。そのためのコードをリスト 14 に記載します。
リスト 14. Listing/AdminDeleteAction の定義
<?php class Listing_AdminDeleteAction extends WASPListingBaseAction { public function getDefaultViewName() { return 'Success'; } public function executeWrite(AgaviRequestDataHolder $rd) { try { // get record ids $ids = $rd->getParameter('id'); foreach ($ids as $id) { // delete record from database $q = Doctrine_Query::create() ->delete('Listing') ->addWhere('RecordID = ?', $id); $result = $q->execute(); // delete associated image files $target = AgaviConfig::get('core.app_dir') . '/../pub/usr/'; foreach (glob("$target/$id_*") as $file) { unlink($file); } } return 'Success'; } catch (Exception $e) { $this->setAttribute('error', $e->getMessage()); return 'Error'; } } final public function isSecure() { return true; } } ?>
完全を期して、DisplaySuccessView の executeXml()
メソッドを更新し、このメソッドが XML 出力に画像情報を組み込むようにします。そのためのコードはリスト 15 のとおりです。
リスト 15. Listing/DisplaySuccessView の定義
<?php class Listing_DisplaySuccessView extends WASPListingBaseView { public function executeHtml(AgaviRequestDataHolder $rd) { $this->setupHtml($rd); } public function executeXml(AgaviRequestDataHolder $rd) { // get record $record = $this->getAttribute('listing'); // create document $dom = new DOMDocument('1.0', 'utf-8'); // create root element $root = $dom->createElementNS('http://www.melonfire.com/agavi-wasp', 'wasp:result'); $dom->appendChild($root); // import to SimpleXML for easier manipulation $xml = simplexml_import_dom($dom); // add nodes to XML output $xml->addChild('id', $record['RecordID']); $xml->addChild('submissionDate', $record['RecordDate']); $xml->addChild('manufacturer', $record['Manufacturer']['ManufacturerName']); $xml->addChild('model', ucwords(strtolower($record['VehicleModel']))); $xml->addChild('year', $record['VehicleYear']); $xml->addChild('color', strtolower($record['VehicleColor'])); $xml->addChild('mileage', $record['VehicleMileage']); $xml->addChild('singleOwner', $record['VehicleIsFirstOwned']); $xml->addChild('certified', $record['VehicleIsCertified']); $xml->addChild('certifiedDate', $record['VehicleCertificationDate']); $xml->addChild('note', $record['Note']); $accessoryArr = array( '1' => 'Power steering', '2' => 'Power windows', '4' => 'Audio system', '8' => 'Video system', '16' => 'Keyless entry system', '32' => 'GPS', '64' => 'Alloy wheels' ); $accessories = $xml->addChild('accessories'); foreach ($accessoryArr as $k => $v) { if ($record['VehicleAccessoryBit'] & $k) { $accessories->addChild('item', $v); } } $price = $xml->addChild('price'); $price->addChild('min', $record['VehicleSalePriceMin']); $price->addChild('max', $record['VehicleSalePriceMax']); $price->addChild('negotiable', $record['VehicleSalePriceIsNegotiable']); $location = $xml->addChild('location'); $location->addChild('city', $record['OwnerCity']); $location->addChild('country', $record['Country']['CountryName']); // add image information if available $images = $xml->addChild('images'); $id = $record['RecordID']; $target = AgaviConfig::get('core.app_dir') . '/../pub/usr/'; foreach (glob($target.$id.'_*.{gif,jpg}', GLOB_BRACE) as $file) { $images->addChild('image', $this->getContext()->getRouting()->getBaseHref() . 'usr/' . basename($file)); } // return output return $xml->asXML(); } } ?>
図 8 は、更新後の XML 出力例です。
図 8. 更新後の XML 出力例

カスタム入力バリデーターの作成
これまでの記事で説明したように、Agavi に備わっている多種多様な入力バリデーターによって、開発者は至って簡単に、正しい入力だけがアクションの対象となることを確実にすることができます。E メール・アドレスや日付を確認するなどのありふれた検証タスクには、これらの組み込みバリデーターで十分すぎるほどです。その一方、デフォルトで組み込まれていない検証チェックを行わなければならないとしたら、どうなるでしょうか。その場合にはもちろん、カスタム・バリデーターを作成してください。
アプリケーションにはそれぞれに特有の検証要件があることを認識している Agavi では、極めて簡単に基底クラス AgaviValidator を継承して独自のバリデーターを作成できるようにしています。作成したカスタム・バリデーターは、アクションごとの XML 検証ファイルを使用して通常どおりの方法で呼び出すことができます。
カスタム・バリデーターの作成プロセスを説明するために、次の例を検討してみましょう。CreateInput フォームには各販売店が販売対象の自動車の最小予想価格と最大予想価格を入力することになっています。当然のことながら、最小価格は最大価格より低くなければなりません。しかし Agavi の組み込みバリデーターでこのような比較を行えるものは 1 つもないため、現状のままでは、販売店が最大価格よりも高い最小価格を入力する可能性があります (実際に試してみてください)。こうしたことが起こるのを防ぐため、ここからはカスタム・バリデーターを作成します。
最初に以下のルールを追加して CreateAction バリデーターを更新します (リスト 16)。
リスト 16. Listing/CreateAction バリデーター
<?xml version="1.0" encoding="UTF-8"?> <ae:configurations xmlns="http://agavi.org/agavi/config/parts/validators/1.0" xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0" parent="%core.module_dir%/Listing/config/validators.xml" > <ae:configuration> <validators method="write"> ... <validator class="PriceRangeCustomValidator"> <arguments> <argument name="max">VehicleSalePriceMax</argument> <argument name="min">VehicleSalePriceMin</argument> </arguments> <errors> <error for="max_min_mismatch">ERROR: Vehicle maximum price is lower than vehicle minimum price</error> </errors> <ae:parameters> <ae:parameter name="required">true</ae:parameter> </ae:parameters> </validator> </validators> </ae:configuration> </ae:configurations>
上記のルールが参照するカスタム・バリデーターには、PriceRangeCustomValidator という適切な名前が付けられています。このバリデーターに渡す引数は、最小価格と最大価格の 2 つです。また、このバリデーターには max_min_mismatch
エラーをキャッチした場合に生成するカスタム・エラー・メッセージも用意します。
もちろん、この PriceRangeCustomValidator はまだ存在しません。このバリデーターを作成するために、WASP_ROOT/app/lib/validator/PriceRangeCustomValidator.class.php ファイルを新規に作成し、そのファイルを開いてリスト 17 のコードを入力してください。
リスト 17. Listing/PriceRangeCustomValidator の定義
<?php class PriceRangeCustomValidator extends AgaviValidator { protected function validate() { $args = $this->getArguments(); $max = $this->getData($args['max']); $min = $this->getData($args['min']); if ($min > $max) { $this->throwError('max_min_mismatch'); return false; } return true; } } ?>
あらゆる AgaviValidator のコアとなるのは、validate() メソッドです。このメソッドが検証を行い、true (入力が有効な場合) または false (入力が無効な場合) を返します。リスト 17 の validate()
メソッドは、getData() メソッドを使ってバリデーターに渡された引数を読み取り、単純な比較テストを行った後、その結果を呼び出し側に返します。入力がテストに失敗した場合には、validate() メソッドは max_min_mismatch
エラーをスローします。すると AgaviFormPopulationFilter が実行されて、対応するエラー・メッセージがリクエスト側クライアントに表示されることになります。
このバリデーターも自動的にロードする必要があるので、$WASP_ROOT/app/config/autoload.xml を更新してリスト 18 のエントリーを追加します。
リスト 18. 自動ロードするクラスの一覧
<?xml version="1.0" encoding="UTF-8"?> <ae:configurations xmlns="http://agavi.org/agavi/config/parts/autoload/1.0" xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0" parent="%core.system_config_dir%/autoload.xml"> <ae:configuration> <autoload name="WASPBaseAction"> %core.lib_dir%/action/WASPBaseAction.class.php </autoload> <autoload name="WASPBaseModel"> %core.lib_dir%/model/WASPBaseModel.class.php </autoload> <autoload name="WASPBaseView"> %core.lib_dir%/view/WASPBaseView.class.php </autoload> <autoload name="Doctrine"> %core.app_dir%/../libs/doctrine/Doctrine.php </autoload> <autoload name="PriceRangeCustomValidator"> %core.lib_dir%/validator/PriceRangeCustomValidator.class.php </autoload> </ae:configuration> </ae:configurations>
この動作を確認するため、新しいリストを作成して、最大価格より高い最小価格を入力してみてください。図 9 の結果が表示されるはずです。
図 9. WASP 価格範囲バリデーターの動作

自動補完機能の追加
最近では、Web アプリケーションに自動補完機能を組み込むことが一般的になってきました。自動補完機能を備えたアプリケーションでは、ユーザーが単語の一部を入力すると、入力された部分に基づいて単語の一致候補が自動的に表示されます。
リスト追加フォームのなかで、この自動補完機能を適用するフィールドとして有力なのは Model フィールドです。なぜかと言えば、自動車モデルの名前は限られています。そのため、自動車リストのデータベースが大きくなるにつれ、既にデータベースに存在するモデル名をユーザーが入力する確率も高くなります。この事実を利用すれば、ユーザーの入力に併せて一致するモデル名のリストを提示することで、ユーザーは必要に応じてリストからモデル名を選択できるようになり、キー入力の手間を省くことができます。
現在、この機能を Web アプリケーションに簡単に追加できる既存のサード・パーティー・ライブラリーは、PEAR HTML_QuickForm、Dojo、YUI (Yahoo! User Interface) など数多くあります。なかでも YUI ライブラリーは、このようなクライアント・サイドの機能を強化するには最も完成されたツールキットの 1 つなので、ここではこのライブラリーを使用してリスト追加フォームに自動補完機能を追加することにします。
まずは、JavaScript および CSS ソース・ファイルの両方で構成されている YUI AutoComplete ウィジェットをダウンロードしてください (「参考文献」にリンクを記載)。$WASP_ROOT/pub/css/yui と $WASP_ROOT/pub/js/yui という 2 つのディレクトリーを作成し、CSS ファイルと JavaScript ファイルをそれぞれの場所にコピーします。次に、連載第 3 回で作成したベースとなるメソッド WASPListingBaseView::setInputViewAttributes()
を編集し、データベースから固有のモデル名リストを生成する SQL クエリーを追加して更新します (リスト 19)。
リスト 19. Listing/WASPListingBaseView の定義
<?php /** * The base view from which all Listing module views inherit. */ class WASPListingBaseView extends WASPBaseView { // set values from selection lists function setInputViewAttributes() { $q = Doctrine_Query::create() ->from('Country c'); $this->setAttribute('countries', $q->fetchArray()); $q = Doctrine_Query::create() ->from('Manufacturer m'); $this->setAttribute('manufacturers', $q->fetchArray()); $q = Doctrine_Query::create() ->select('DISTINCT l.VehicleModel AS VehicleModel') ->from('Listing l'); $this->setAttribute('models', $q->fetchArray()); } } ?>
このクエリーの結果は、テンプレート変数 $t['models']
に格納されます。
リスト 20 では CreateInput テンプレートを更新し、YUI AutoComplete 機能を有効にするために必要なクライアント・サイドのコードを追加しています。
リスト 20. Listing/CreateInput テンプレート
<script src="/js/form.js"></script> <h3>Add Listing</h3> <form action="<?php echo $ro->gen(null); ?>" method="post"> ... <label for="VehicleModel" class="required">Model:</label> <input id="VehicleModel" type="text" name="VehicleModel"> <div id="ac1" class="yui-skin-sam yui-ac-container" style="position:relative; width:300px; margin-left:210px"> </div> </input> <p/> ... </form> <!-- YUI autocomplete widget --> <!-- based on example at http://developer.yahoo.com/yui/examples/autocomplete/ac_basic_array.html --> <script src="js/yui/yahoo-min.js"></script> <script src="js/yui/dom-min.js"></script> <script src="js/yui/event-min.js"></script> <script src="js/yui/datasource-min.js"></script> <script src="js/yui/autocomplete-min.js"></script> <script> arrayModels = [ <?php if (isset($t['models']) && count($t['models']) > 0): ?> <?php foreach ($t['models'] as $m): ?> <?php echo "\"" . $m['VehicleModel'] . "\",\r\n"; ?> <?php endforeach; ?> <?php endif; ?> ]; YAHOO.example.BasicLocal = function() { // Use a LocalDataSource var oDS = new YAHOO.util.LocalDataSource(arrayModels); // Instantiate the AutoCompletes var oAC = new YAHOO.widget.AutoComplete("VehicleModel", "ac1", oDS); oAC.prehighlightClassName = "yui-ac-prehighlight"; return { oDS: oDS, oAC: oAC, }; }(); </script>
リスト 20 の終わりにある JavaScript コードは、最初に YUI LocalDataSource オブジェクトを初期化した上で、PHP テンプレート変数 $t['models']
に格納された、モデル名が含まれる JavaScript 配列をこの LocalDataSource オブジェクトに格納します。すると、この LocalDataSource が YUI AutoComplete オブジェクトに追加され、この AutoComplete オブジェクトが VehicleModel
という ID を持つフォーム要素にリンクされます。
以上の処理を追加した結果、ユーザーが VehicleModel フィールドにデータを入力し始めると、AutoComplete ウィジェットがその入力を LocalDataSource オブジェクトに含まれる値の配列と突き合わせ、一致する用語のリストを表示して、ユーザーがそこから選択できるようにします。図 10 は、このリスト表示の一例です。
図 10. Model フィールドの自動補完機能を備えた WASP リストのフォーム

まとめ
この連載は以上で終わりです。これまで 5 回にわたり、Agavi フレームワークを駆け足で紹介し、このフレームワークを使ってスケーラブルな Web アプリケーションを作成する際の基本的な手法を説明しました。この連載を読んで納得していただけたと思いますが、Agavi は現在使用できる最良の MVC 実装の 1 つです。Agavi は、モデル、アクション、ビューを明確に分離し、OOP 原則に厳格に従います。そして広範で強力な入力検証用ツールを提供し、セキュリティー、認証、データベース統合、出力バリエーション、そしてアプリケーション・レベルの構成などの機能を備えています。そのすべての組み合わせによってもたらされるのが、セキュアで堅牢、そして柔軟かつ拡張可能なアプリケーションです。
読者の皆さんにとってこの連載が役に立ったこと、そして今度 Web アプリケーションの作成に取り組むときには Agavi を使用することを真剣に検討する気になったことを願います。それでは、またお会いするまでプログラミングをお楽しみください。
ダウンロード可能なリソース
- このコンテンツのPDF
- Archive of the WASP app with functions to date (wasp-05.zip | 3,881KB)
関連トピック
- 「Agavi による MVC プログラミング入門: 第 1 回 Agavi を使って、まったく新しい世界の扉を開く: Agavi フレームワークを使用してスケーラブルな Web アプリケーションを作成する方法を学ぶ」(Vikram Vaswani 著、developerWorks、2009年7月): 5 回連載の第 1 回で Agavi フレームワークの基本概念を調べて、他のフレームワークと比べてください。この記事で、ビュー、アクション、テンプレート、ルートのすべてについて読んで、独自のスケーラブルな Agavi アプリケーションの作成を開始してください。
- 「Agavi による MVC プログラミング入門: 第 2 回 Agavi と Doctrine により、フォームとデータベース・サポートを追加する」(Vikram Vaswani 著、developerWorks、2009年7月): 入力フォームを作成し、Doctrine を使ってデータ・モデルの自動生成し、それらのモデルを Agavi プロジェクトに統合する手順をとおして、引き続き柔軟なオープンソースのフレームワーク、Agavi を探っていきます (5 回連載の第 2 回)。
- 「Agavi による MVC プログラミング入門: 第 3 回 Agavi を使って認証機能と管理機能を追加する」(Vikram Vaswani 著、developerWorks、2009年8月): Web Automobile Sales Platform サンプル・アプリケーションに自動車のレコードを追加、削除、更新できる機能を追加することで、さらなる機能を作成します。また、ユーザー用の機能を認証付きの管理者用の機能と切り離す方法についても説明しています。(5 回連載の第 3 回)
- 「Agavi による MVC プログラミング入門: 第 4 回 XML、RSS、SOAP をはじめとする複数の出力タイプに対応する Agavi 検索エンジンを作成する」(Vikram Vaswani 著、developerWorks、2009年8月): Agavi を使ったサンプル・プログラムに単純な検索エンジンを実装し、XML、RSS、または SOAP などの複数の出力タイプのサポートを追加します。(5 回連載の第 4 回)。
- 公式 Agavi Web サイトおよび Agavi Guide: MVC パラダイムに従った、このスケーラブルな PHP5 アプリケーション・フレームワークについて詳しく学んでください。
- Agavi API マニュアル: Agavi 基本クラスの詳細を参照してください。
- Agavi cookbook: 一般的なタスクの実行方法を調べてください。
- Custom validator: AgaviValidator を基にしたクラスで、true または false を返す Agavi バリデーターを作成してください。このページには、サンプルのカスタム・バリデーターも記載されています。
- Doctrine Pager オブジェクトのマニュアル: Doctrine の柔軟なページャー・パッケージについて説明しています。ページャー・オブジェクトを作成し、ページャー・スタイルを制御し、ページャー・レイアウト・オブジェクトを外観する強力なページ・リンク表示機能です。
- YUI AutoComplete ウィジェット: テキストの入力候補表示および補完機能のためのフロント・エンド・ロジックについて詳細を学んでください。
- Agavi ブログ: Agavi に関するニュースを読んでください。
- Agavi メーリング・リストおよび IRC チャネル: Agavi コミュニティーに加わって、質問を投稿して回答をもらってください。
- developerWorks の Open source エリア: CakePHP でのアプリケーション開発 (Duane O'Brien 著、2009年6月) や、その他の PHP フレームワークでのアプリケーション開発 (Duane O'Brien 著、2007年10月)、そして PHP ウィキの作成方法 (Duane O'Brien 著、2007年2月) について学んでください。
- IBM XML 認定: XML や関連技術の IBM 認定技術者になる方法について調べてください。
- XML Technical library: 広範な技術に関する記事とヒント、チュートリアル、標準、そして IBM Redbooks については、developerWorks XML ゾーンを参照してください。
- developerWorks podcasts: ソフトウェア開発者向けの興味深いインタビューとディスカッションを聞いてください。
- Agavi フレームワーク: この PHP5-MVC パターンのアプリケーション・フレームワークをダウンロードして、より簡潔で拡張可能なコードを作成してください。
- MySQL データベース・サーバー: このよく使われているオープンソースのデータベースをダウンロードしてください。
- Doctrine ORM パッケージ: PHP に対応したこのオブジェクト・リレーショナル・マッパーをダウンロードしてください。
- YUI ライブラリー: このユーティリティーとコントロールのセットをダウンロードしてください。JavaScript で作成された YUI ライブラリーにより、DOM スクリプト、DHTML、Ajax を使用したインタラクティブな Web アプリケーションを作成できます。
- IBM 製品の試用版: DB2®、Lotus®、Rational®、Tivoli®、および WebSphere® のアプリケーション開発ツールとミドルウェア製品を体験するには、試用版をダウンロードするか、IBM SOA Sandbox のオンライン試用版を試してみてください。