目次


Agavi による MVC プログラミング入門

第 5 回 Agavi アプリケーションにページング機能、ファイルのアップロード機能、そしてカスタム入力バリデーターを追加する

Agavi フレームワークを使用してスケーラブルな Web アプリケーションを作成する方法を学ぶ

Comments

コンテンツシリーズ

このコンテンツは全#シリーズのパート#です: Agavi による MVC プログラミング入門

このシリーズの続きに乞うご期待。

このコンテンツはシリーズの一部分です:Agavi による MVC プログラミング入門

このシリーズの続きに乞うご期待。

はじめに

この連載の第 4 回が終わった時点で、管理モジュール、検索エンジン、そして XML 出力機能を備えた完全に機能する Web アプリケーションが完成しました。読者の皆さんは、WASP (Web Automobiles Sales Platform) アプリケーションの基本要件は満たされたというのに、なぜまだ連載が続くのか不思議に思っていることでしょう。

この最後の記事では、Web アプリケーションを作成するときに必ず必要になる手法と概念を追加で説明します。ここで説明する手法と概念は、データベース・レコードのページングとソートといった平凡な内容から、Web フォームによるファイル・アップロードのサポートやカスタム入力バリデーターの作成などの複雑な内容にまで至ります。しかしいずれにしても、Agavi フレームワークに組み込まれたツールが、作業を簡単に、素早く、そしてより確実にしてくれます。それでは早速、どのようにしてそれが実現されているかを確かめてください。

データベース・レコードのソート

最初に取り掛かるのは、結果セットのページングとソートです。現在、http://wasp.localhost/admin/listing/index にある管理モジュールの要約ページにアクセスすると、結果セットは図 1 のように表示されます。

図 1. WASP リストの要約ページ
WASP リストの要約ページ
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')); ?>">&uArr;</a> 
        <a href="<?php echo $ro->gen('admin.listing.index', 
         array('s' => 'RecordDate', 'd' => 'desc')); ?>">&dArr;</a>
      </td>
      <td class="key">Manufacturer
        <a href="<?php echo $ro->gen('admin.listing.index',
         array('s' => 'VehicleManufacturerID', 'd' => 'asc')); 
         ?>">&uArr;</a> 
        <a href="<?php echo $ro->gen('admin.listing.index',
         array('s' => 'VehicleManufacturerID', 'd' => 'desc')); 
         ?>">&dArr;</a>
      </td>
      <td class="key">Model
        <a href="<?php echo $ro->gen('admin.listing.index',
         array('s' => 'VehicleModel', 'd' => 'asc')); 
         ?>">&uArr;</a> 
        <a href="<?php echo $ro->gen('admin.listing.index',
         array('s' => 'VehicleModel', 'd' => 'desc')); 
         ?>">&dArr;</a>
      </td>
      <td class="key">Year
        <a href="<?php echo $ro->gen('admin.listing.index',
         array('s' => 'VehicleYear', 'd' => 'asc')); ?>">&uArr;</a> 
        <a href="<?php echo $ro->gen('admin.listing.index',
         array('s' => 'VehicleYear', 'd' => 'desc')); 
         ?>">&dArr;</a>
      </td>
      <td class="key">Mileage
        <a href="<?php echo $ro->gen('admin.listing.index',
         array('s' => 'VehicleMileage', 'd' => 'asc')); 
         ?>">&uArr;</a> 
        <a href="<?php echo $ro->gen('admin.listing.index',
         array('s' => 'VehicleMileage', 'd' => 'desc')); 
         ?>">&dArr;</a>
      </td>
      <td class="key">Color
        <a href="<?php echo $ro->gen('admin.listing.index',
         array('s' => 'VehicleColor', 'd' => 'asc')); 
         ?>">&uArr;</a> 
        <a href="<?php echo $ro->gen('admin.listing.index',
         array('s' => 'VehicleColor', 'd' => 'desc')); 
         ?>">&dArr;</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 リストの要約ページ
ソート機能が追加された WASP リストの要約ページ
ソート機能が追加された 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('&nbsp;');
      
      // 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 リストの要約
複数のページに分割された WASP リストの要約
複数のページに分割された 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 管理ページのメイン・メニュー
新しいリンクが追加された WASP 管理ページのメイン・メニュー
新しいリンクが追加された 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/>&nbsp;<p/>
      <?php endforeach; ?>
    </div>
    <div id="specs">
      <table cellspacing="5">
      ...
      </table>
  </div>
</div>

ここで、新しいリストを追加してみてください。フォームには、ファイル・アップロード用のフィールドが追加されているはずです (図 5 を参照)。

図 5. 画像のアップロードをサポートする WASP リストのフォーム
画像のアップロードをサポートする WASP リストのフォーム
画像のアップロードをサポートする WASP リストのフォーム

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

図 6. 無効な画像のアップロードに対するエラー
無効な画像のアップロードに対するエラー
無効な画像のアップロードに対するエラー

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

図 7. 画像ギャラリーが追加された WASP リストの詳細ページ
画像ギャラリーが追加された WASP リストの詳細ページ
画像ギャラリーが追加された 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 出力例
更新後の XML 出力例
更新後の 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 (入力が無効な場合) を返します。リスト 17validate() メソッドは、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 価格範囲バリデーターの動作
WASP 価格範囲バリデーターの動作
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 リストのフォーム
Model フィールドの自動補完機能を備えた WASP リストのフォーム
Model フィールドの自動補完機能を備えた WASP リストのフォーム

まとめ

この連載は以上で終わりです。これまで 5 回にわたり、Agavi フレームワークを駆け足で紹介し、このフレームワークを使ってスケーラブルな Web アプリケーションを作成する際の基本的な手法を説明しました。この連載を読んで納得していただけたと思いますが、Agavi は現在使用できる最良の MVC 実装の 1 つです。Agavi は、モデル、アクション、ビューを明確に分離し、OOP 原則に厳格に従います。そして広範で強力な入力検証用ツールを提供し、セキュリティー、認証、データベース統合、出力バリエーション、そしてアプリケーション・レベルの構成などの機能を備えています。そのすべての組み合わせによってもたらされるのが、セキュアで堅牢、そして柔軟かつ拡張可能なアプリケーションです。

読者の皆さんにとってこの連載が役に立ったこと、そして今度 Web アプリケーションの作成に取り組むときには Agavi を使用することを真剣に検討する気になったことを願います。それでは、またお会いするまでプログラミングをお楽しみください。


ダウンロード可能なリソース


関連トピック


コメント

コメントを登録するにはサインインあるいは登録してください。

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=XML, Open source, Web development
ArticleID=438980
ArticleTitle=Agavi による MVC プログラミング入門: 第 5 回 Agavi アプリケーションにページング機能、ファイルのアップロード機能、そしてカスタム入力バリデーターを追加する
publish-date=09152009