目次


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

第 4 回 XML、RSS、SOAP をはじめとする複数の出力タイプに対応する Agavi 検索エンジンを作成する

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

Comments

コンテンツシリーズ

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

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

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

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

はじめに

この連載の第 3 回では、Web ベースのアプリケーションを作成する際に直面する最も一般的なタスクとして、管理者が Web インターフェースで CRUD 操作を実行できるようにする管理モジュールを実装しました。さらに Agavi のセキュリティー・モデルを検討し、アプリケーション・リソースへのアクセスを保護するために、ユーザー認証を行うためのログイン・システムを作成しました。

この記事でも引き続き Agavi を習得するために、サンプル・アプリケーションの WASP (Web Automotive Sales Platform) にさらに機能を追加していきます。今回実装するのは検索エンジンです。この検索エンジンによって、ユーザーが特定の基準と一致するリストをデータベースで直接検索できるようにします。それだけではありません。Agavi は、開発者が XML、RSS、または SOAP などといった複数の出力タイプに対するサポートを簡単に追加することができる高度なフレームワークです。今回は、検索エンジンから XML にエンコードした結果を返すための最小限のプログラミング手順をとおして、Agavi の出力タイプに対するサポートについて学んでください。それでは早速、本題に入りましょう。

検索基準の処理

今まで行った作業から、WASP アプリケーションが販売店から送信された自動車のリストを受け入れ、承認のためにリストをデータベースに保管できることは明らかです。連載第 3 回で開発した管理モジュールによって、管理者は送信されたリストを検討し、Web サイトでの表示を承認できるようにもなっています。また、それぞれのリストをサイトに表示する期間についても管理者が定義することができます。

潜在的な購入者が各自の基準と一致する自動車を簡単に見つけられるように、今度はこの WASP アプリケーションに検索機能を追加するのが妥当です。この検索インターフェースによって、購入者から特定の基準を受け取り、承認済みリストでその基準と一致するものをスキャンし、その結果を表示して購入者がじっくり検討できるようにしたいと思います。

まずは、Agavi ビルド・スクリプトを使用して新しい SearchAction を Listing モジュールに追加します。

shell> agavi action-wizard
Module name: Listing
Action name: Search
Space-separated list of views to create for Search [Success]: Error Success

さらにアプリケーションのルーティング・テーブルも更新して、このアクション用の新しいルートを追加します (リスト 1 を参照)。

リスト 1. Listing/SearchAction ルートの定義
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0" 
 xmlns="http://agavi.org/agavi/config/parts/routing/1.0">
  <ae:configuration>
    <routes>
      ...
      <!-- action for listing pages "/listing" -->
      <route name="listing" pattern="^/listing" module="Listing">
        <route name=".create" pattern="^/create$" action="Create" />
        <route name=".display" pattern="^/display/(id:\d+)$" action="Display" />
        <route name=".search" pattern="^/search$" action="Search" />
      </route>
      ...
    </routes>
  </ae:configuration>
</ae:configurations>

この SearchAction のデフォルトの振る舞いは、ユーザーが各種の検索基準を入力できるように、検索フォームを表示することです。この場合、フォームには SearchInputView を使用し、検索結果には SearchSuccessView を使用するという方法が一般的ですが、この手法はもうご存知のはずです。読者を飽きさせることはしたくないので、今回は少し異なる手法として、フォームとその結果の両方を 1 つの SearchSuccessView に結合し、SearchAction のデフォルト・ビューとしても使用することにします (リスト 2 を参照)。

リスト 2. Listing/SearchAction の定義
<?php
class Listing_SearchAction extends WASPListingBaseAction
{
  public function getDefaultViewName()
  {
    return 'Success';
  }
}
?>

次に検索フォーム自体に取り掛かります。リスト 3 に SearchSuccessView のコードを記載します。このビューでは AgaviFormPopulationFilter を使用することによって、ユーザーが入力した基準が検索フォームに自動的に取り込まれるようにしていることに注目してください。

リスト 3. Listing/SearchSuccessView の定義
<?php
class Listing_SearchSuccessView extends WASPListingBaseView
{
  public function executeHtml(AgaviRequestDataHolder $rd)
  {
    $this->setupHtml($rd);
    $this->getContext()->getRequest()->setAttribute('populate', 
     array('fsearch' => true), 'org.agavi.filter.FormPopulationFilter');    
  }
}
?>

リスト 4 に、上記に対応する SearchSuccess テンプレートを記載します。

リスト 4. Listing/SearchSuccess テンプレート
<h3>Search Listings</h3>
<form id="fsearch" action="<?php echo $ro->gen('listing.search'); ?>" 
 method="get">
  <fieldset>
    <legend>Criteria</legend> 
    Color: 
    <input id="color" type="text" name="color" style="width:120px" />
    
    Year: 
    <input id="year" type="text" name="year" size="4" style="width:100px" />
    
    Price: 
    <input id="price" type="text" name="price" style="width:140px" />
    
    <button id="search" type="submit"/>
  </fieldset>    
</form>

<h3>Search Results</h3>

<?php if (count($t['records']) == 0): ?>
No records available
<?php else: ?>
  <?php $x = 1; ?>
  <?php foreach ($t['records'] as $record): ?>
  <div>
    <strong><?php echo $x; ?>. 
    <a href="<?php echo $ro->gen('listing.display', 
     array('id' => $record['RecordID'])); ?>"><?php printf('%d %s %s (%s)',
     $record['VehicleYear'], $record['Manufacturer']['ManufacturerName'], 
     ucwords(strtolower($record['VehicleModel'])), 
     ucwords(strtolower($record['VehicleColor']))); ?></a>
    </strong>
    <br/>
    Mileage: <?php echo $record['VehicleMileage']; ?>     
    <br/> 
    Sale price: $<?php echo $record['VehicleSalePriceMin']; ?> - 
    $<?php echo $record['VehicleSalePriceMax']; ?> 
    <?php echo ($record['VehicleSalePriceIsNegotiable'] == 1) ? 
     '(negotiable)' : null; ?>
    <br/> 
    Location: <?php echo $record['OwnerCity']; ?>, 
     <?php echo $record['Country']['CountryName']; ?>
    <br/> 
    Submitted: <?php echo date('d M Y', strtotime($record['RecordDate'])); ?>
    <p/>
  </div>
  <?php $x++; ?>
  <?php endforeach; ?>
<?php endif; ?>

<p/>
<strong>
  <a href="<?php echo $ro->gen('listing.create'); ?>">
  Add a new listing</a>
</strong>

リスト 4 を見るとわかるように、このテンプレートには 2 つのセクションがあります。最初のセクションに記載されているのは、購入者が購入基準を入力するための検索フォームです。入力された値が送信されて処理されると、データベースでの検索結果が 2 番目のセクションに表示されます。ここには新しいリストを追加するためのリンクもあります。

複雑なことにならないように、このフォームの検索基準は、色、価格、製造年の 3 つに絞りました。言うまでもなく、この 3 つの入力値は検証してからでないと SearchAction には渡すことはできません。リスト 5 に、この例の場合に必要な検証ルールを記載します。

リスト 5. Listing/SearchAction バリデーター
<?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>
    
      <validator class="string">
        <arguments>
          <argument>color</argument>
        </arguments>
        <errors>
          <error>ERROR: Vehicle color is invalid</error>
        </errors>
        <ae:parameters>
          <ae:parameter name="required">false</ae:parameter>
        </ae:parameters>
      </validator>              
                  
      <validator class="number">
        <arguments>
          <argument>year</argument>
        </arguments>
        <errors>
          <error for="type">ERROR: Vehicle year of manufacture is invalid
          </error>
          <error for="min">ERROR: Vehicle year of manufacture is before 1900
          </error>
          <error for="max">ERROR: Vehicle year of manufacture is after 2020
          </error>
        </errors>
        <ae:parameters>
          <ae:parameter name="type">int</ae:parameter>
          <ae:parameter name="min">1901</ae:parameter>
          <ae:parameter name="max">2020</ae:parameter>
          <ae:parameter name="required">false</ae:parameter>
        </ae:parameters>
      </validator>             
            
      <validator class="number">
        <arguments>
          <argument>price</argument>
        </arguments>
        <errors>
          <error>ERROR: Vehicle price is invalid</error>
        </errors>
        <ae:parameters>
          <ae:parameter name="type">int</ae:parameter>
          <ae:parameter name="min">0</ae:parameter>
          <ae:parameter name="required">false</ae:parameter>
        </ae:parameters>
      </validator>                            
                  
    </validators>
    
  </ae:configuration>
</ae:configurations>

リスト 4 からおわかりだと思いますが、この検索フォームは今までの例とは異なり、POST メソッドではなく、GET メソッドを使用しています。一般に、検索フォームでは GET をリクエスト・メソッドとして使用しなければなりません。その理由は、検索フォームがサーバー上のデータを変更することはないからです。これとは反対に、第 1 回第 2 回第 3 回で扱ったフォームはサーバー上のデータを変更したり、サーバーにデータを追加したりするため、POST メソッドを使用するのが妥当です。また、GET をリクエスト・メソッドとして使用すると、常に最新の検索結果を指す URL を生成できるという利点もあります。この利点が、この後の作業で生かされることになります。

検索結果の処理

この検索フォームは GET メソッドを使って入力を送信することから、リスト 2 の SearchAction を更新し、送信された入力を読み取って検索クエリーに取り込む executeRead() メソッドを追加する必要があります。さらに、ユーザーが送信する検索パラメーターに応じてこの検索クエリーを繰り返し構成することで、表示する結果がその時点で有効な承認済みリストのみになるように制限しなければなりません。そのために必要なコードはリスト 6 のとおりです。

リスト 6. 変更後の Listing/SearchAction の定義
<?php
class Listing_SearchAction extends WASPListingBaseAction
{
  public function getDefaultViewName()
  {
    return 'Success';
  }
  
  public function executeRead(AgaviRequestDataHolder $rd) 
  {   
    try {
      // create base query
      $q = Doctrine_Query::create()
            ->from('Listing l')
            ->leftJoin('l.Manufacturer m')
            ->leftJoin('l.Country c')
            ->addWhere('l.DisplayStatus = 1')
            ->addWhere('l.DisplayUntilDate >= CURDATE()');
            
      // add criteria
      if ($rd->getParameter('color')) {
        $q->addWhere('l.VehicleColor LIKE ?', sprintf('%%%s%%', 
         $rd->getParameter('color')));  
      }            
      
      if ($rd->getParameter('year')) {
        $q->addWhere('l.VehicleYear = ?', $rd->getParameter('year'));  
      }            
      
      if ($rd->getParameter('price')) {
        $q->addWhere('? BETWEEN l.VehicleSalePriceMin AND l.VehicleSalePriceMax', 
         $rd->getParameter('price'));  
      }            

      $q->orderBy('l.RecordDate DESC');
      
      // execute query and assign results to template variable
      $results = $q->fetchArray();
      $this->setAttribute('records', $results);
      return 'Success';
    } catch (Exception $e) {
      $this->setAttribute('error', $e->getMessage());  
      return 'Error';
    }
  }
}
?>

重要な点として、基準を提供せずにリスト 6 のクエリーを呼び出すと、承認済みの有効なすべてのリストが日付順に並べられて返されることに注意してください。

エラーは、それが検証に関連するものか、クエリーの実行に関連するものかに関わらず、SearchErrorView が前回の記事で概説した手法を使って処理します。つまり、SearchErrorView が SearchSuccess テンプレートと SearchError のどちらをレンダリングするかを決定するということです。リスト 7 に SearchErrorView のコードを記載し、リスト 8 に対応する SearchError テンプレートのコードを記載します。

リスト 7. Listing/SearchErrorView の定義
<?php
class Listing_SearchErrorView extends WASPListingBaseView
{
  public function executeHtml(AgaviRequestDataHolder $rd)
  {
    $this->setupHtml($rd);
    // if validation errors, render input template
    if ($this->container->getValidationManager()->getReport()->count()) {
      $this->getLayer('content')->setTemplate('SearchSuccess');   
      $this->getContext()->getRequest()->setAttribute('populate', 
       array('fsearch' => true), 'org.agavi.filter.FormPopulationFilter');    
    }   
  }
}
?>
リスト 8. Listing/SearchError テンプレート
<h3>Search Listings</h3>
There was an error processing your submission. Please try again later.
<br/><br/>
<code style="color: red">
<?php echo $t['error']; ?>
</code>

リスト 7/ を見ると明らかなように、$this->getContext()->getRequest()->setAttribute の呼び出しに検索フォームの id 属性 ('fsearch') を指定することにより、AgaviFormPopulationFilter は検索フォームに明示的に追加されます。そのため、フォームにはバリデーターによって生成されたエラー・メッセージが正しく表示されることになります。この処理は前に行ったことがないので、なぜこうしなければならないのか不思議に思うことでしょう。Agavi 開発チームによると、このステップはこの特定のケースで必要になるからです。通常 AgaviFormPopulationFilter は現行の URL をフォームのアクション URL と突き合わせて処理対象のフォームを把握しますが、この例の場合、検索フォームは GET メソッドを使ってデータを送信するため、現行の URL には検索パラメーターのリストが追加されることになります。そうなると、AgaviFormPopulationFilter が現行の URL をフォームのアクション URL と一致させることはできません。この問題を解決するために、AgaviFormPopulationFilter を呼び出す際にフォームの id 属性を明示的に指定しているというわけです。

この検索機能の実際の動作を確認するには、http://wasp.localhost/listings/search にアクセスして、検索フォームに任意の基準を入力してください。フォームを送信すると、基準と一致する結果のリストでページが最新表示されます。図 1 は、検索結果の表示例です。

図 1. 検索結果を表示する WASP 検索フォーム
検索結果を表示する WASP 検索フォーム
検索結果を表示する WASP 検索フォーム

無効な入力を送信すると、AgaviFormPopulationFilter は図 2 のようにエラー・メッセージを返します。

図 2. 無効な入力であることを示す WASP 検索フォーム
無効な入力であることを示す WASP 検索フォーム
無効な入力であることを示す WASP 検索フォーム

これで、WASP アプリケーションに有効な検索エンジンが備わりましたが、ちょっとした作業が 1 つだけ残っています。それは、アプリケーションのメイン・メニューにリンクを設定して、ユーザーが最新の自動車のリストにアクセスできるようにすることです。前に説明したように (リスト 6)、基準を提供しないで SearchAction を呼び出すと、結果セットには承認済みの有効なすべてのリストが日付順に並べられて含まれることになります。したがって、この作業を片付けるには、$WASP_ROOT/app/templates/Master.php にあるマスター・テンプレート・ファイルを編集して、メイン・メニューの「For Sale」で Agavi のルート・ジェネレーターを使用するようにすればよいだけです (リスト 9 を参照)。

リスト 9. 変更後のマスター・テンプレート:
<!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">

    ...
    <!-- begin header -->
    <div id="header">
      <div id="logo">
        <img src="/images/logo.jpg" />
      </div>
      <div id="menu">
        <ul>
          <li><a href="<?php echo $ro->gen('index'); ?>">
          Home</a></li>
          <li><a href="<?php echo $ro->gen('listing.search'); ?>">
          For Sale</a></li>
          <li><a href="<?php echo $ro->gen('content', 
           array('page' => 'other-services')); ?>">Other Services</a>
          </li>
          <li><a href="<?php echo $ro->gen('content', 
           array('page' => 'about-us')); ?>">About Us</a></li>
          <li><a href="<?php echo $ro->gen('contact'); ?>">
          Contact Us</a></li>
        </ul>
      </div>
    </div>
    <!-- end header -->
    ...

</html>

出力タイプについて

最近のアプリケーションでは、同じデータを多種多様なフォーマットで表示しなければならないことがよくあります。例えば、同じ結果セットが HTML の表、CSV ファイル、XML 文書、あるいは RSS フィードで表示されるといった場合もあります。このような場合でも、Agavi であれば簡単に対処することができます。Agavi ではビューのそれぞれに複数の出力タイプをサポートさせることができるため、アクション・コードを重複させることなく、同じアクションの結果をさまざまな方法で表示することができます。

すべての Agavi アプリケーションは、デフォルトで HTML 出力タイプを使用するようにあらかじめ構成されます。$WASP_ROOT/app/config/output_types.xml にある WASP アプリケーションの出力タイプ構成ファイルの中身を見てみると、出力タイプがどのように構成されているかがわかります (リスト 10 を参照)。

リスト 10. Agavi の出力タイプの構成
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0" 
 xmlns="http://agavi.org/agavi/config/parts/output_types/1.0">
  
  <ae:configuration>
    <output_types default="html">
      <output_type name="html">
        
        <renderers default="php">
          <renderer name="php" class="AgaviPhpRenderer">
            <ae:parameter name="assigns">
              <ae:parameter name="routing">ro</ae:parameter>
              <ae:parameter name="request">rq</ae:parameter>
              <ae:parameter name="controller">ct</ae:parameter>
              <ae:parameter name="user">us</ae:parameter>
              <ae:parameter name="translation_manager">tm</ae:parameter>
              <ae:parameter name="request_data">rd</ae:parameter>
            </ae:parameter>
            <ae:parameter name="default_extension">.php</ae:parameter>
            <!-- this changes the name of the variable with all template attributes 
            from the default $template to $t -->
            <ae:parameter name="var_name">t</ae:parameter>
          </renderer>
        </renderers>
        
        <layouts default="standard">
          <!-- standard layout with a content and a decorator layer -->
          <layout name="standard">
            <!-- content layer without further params. this means the standard 
            template is used, i.e. the one with the same name as the current view -->
            <layer name="content" />
            <!-- decorator layer with the HTML skeleton, navigation etc; set to 
            a specific template here -->
            <layer name="decorator">
              <ae:parameter name="directory">%core.template_dir%
              </ae:parameter>
              <ae:parameter name="template">Master</ae:parameter>
            </layer>
          </layout>
          
          <layout name="admin">
            <layer name="content" />
            <layer name="decorator">
              <ae:parameter name="directory">%core.template_dir%
              </ae:parameter>
              <ae:parameter name="template">AdminMaster</ae:parameter>
            </layer>
          </layout>         
          
          <!-- another example layout that has an intermediate wrapper layer in 
          between content and decorator -->
          <!-- it also shows how to use slots etc -->
          <layout name="wrapped">
            <!-- content layer without further params. this means the standard 
            template is used, i.e. the one with the same name as the current view -->
            <layer name="content" />
            <layer name="wrapper">
              <!-- use CurrentView.wrapper.php instead of CurrentView.php as 
              the template for this one -->
              <ae:parameter name="extension">.wrapper.php</ae:parameter>
            </layer>
            <!-- decorator layer with the HTML skeleton, navigation etc; set to 
            a specific template here -->
            <layer name="decorator">
              <ae:parameter name="directory">%core.template_dir%
              </ae:parameter>
              <ae:parameter name="template">Master</ae:parameter>
              <!-- an example for a slot -->
              <slot name="nav" module="Default" action="Widgets.Navigation" />
            </layer>
          </layout>
          
          <!-- special layout for slots that only has a content layer to prevent 
          the obvious infinite loop that would otherwise occur if the decorator layer 
          has slots assigned in the layout; this is loaded automatically by 
          ProjectBaseView::setupHtml() in case the current container is run as a slot -->
          <layout name="simple">
            <layer name="content" />
          </layout>
        </layouts>
        
        <ae:parameter name="http_headers">
          <ae:parameter name="Content-Type">text/html; charset=UTF-8
          </ae:parameter>
        </ae:parameter>
        
      </output_type>
    </output_types>
  </ae:configuration>
  
  <ae:configuration environment="production.*">
    <output_types default="html">
      
      <!-- use a different exception template in production envs -->
      <!-- others are defined in settings.xml -->
      <output_type name="html" 
       exception_template="%core.template_dir%/exceptions/web-html.php" />
      
    </output_types>
  </ae:configuration>
</ae:configurations>

Agavi アプリケーションに新しい出力タイプを導入するには、以下の 3 つの基本ステップに従ってください。

ステップ 1: 出力タイプをヘッダー、レンダラー、レイアウトと併せて定義する

ルートと同じく、出力タイプの構成も XML で表現します。あらゆる出力タイプ定義には、一意に決まる名前があります。出力タイプ定義には、HTTP 出力タイプが要求されたときに要求側クライアントに送信する HTTP ヘッダー、レンダラー (Agavi には基本的な PHP の他、Smarty、eZ、TAL レンダラーが組み込まれています)、そして 1 つ以上のレイアウトをオプションで組み込むことができます。

ステップ 2: ルートを新規出力タイプとリンクさせる

アプリケーションのルーティング・テーブルに含まれるすべてのルート定義には、その特定のルートに使用する出力タイプを指定する output_type という属性を使ってマークアップを付けることができます。以下はその一例です。

<route name="show" pattern="^/show$" module="Default" action="Show" output_type="xml" />

あるいは、リクエスト URL に応じて出力タイプを自動的に設定する catch-all ルートを設定するという手段もあります。つまり、.rss で終わる URL リクエストは自動的に RSS 出力タイプを使用するように設定され、.pdf で終わる URL リクエストは自動的に PDF出力タイプを使用するように設定されるといった具合です。リスト 12 に、この手法の一例を示します。

ステップ 3: 新規出力タイプのサポートをビューに追加する

Agavi がビューのリクエストに対応するには、ビューの executeXXX() メソッドを実行します。ここで、XXX は要求されている出力タイプです。つまり、Agavi は HTML リソースのリクエストには executeHtml() を、RSS リソースのリクエストには executeRss() を、XML リソースのリクエストには executeXml() を実行して対応します。今まで作成した各ビュー・クラスに executeHtml() メソッドがあるのは、そのためです。

検索結果を XML で表示する方法

Agavi で出力タイプがどのように処理されるかをよく理解できるように、このセクションでは XML サポートを WASP 検索エンジンに追加します。まず始めに、$WASP_ROOT/app/config/output_types.xml にある出力タイプ構成ファイルを更新して、XML 出力タイプを新しく定義します (リスト 11 を参照)。

リスト 11. XML 出力タイプの定義
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0" 
xmlns="http://agavi.org/agavi/config/parts/output_types/1.0">
  
  <ae:configuration>    
    <output_types default="html">      
      <output_type name="html">
        ...
      </output_type>
      
      <output_type name="xml">
        <ae:parameter name="http_headers">
          <ae:parameter name="Content-Type">text/xml; charset=UTF-8
          </ae:parameter>
        </ae:parameter>
      </output_type>               
    </output_types>    
  </ae:configuration>
  
</ae:configurations>

次にアプリケーションのルーティング・テーブルを更新し、新しく定義した XML 出力タイプを対象とした catch-all ルートを設定します (リスト 12 を参照)。

リスト 12. XML 出力タイプのルート定義
<?xml version="1.0" encoding="UTF-8"?>
<ae:configurations xmlns:ae="http://agavi.org/agavi/config/global/envelope/1.0" 
 xmlns="http://agavi.org/agavi/config/parts/routing/1.0">
  <ae:configuration>
    <routes>
      <!-- handler for .xml requests -->
      <route name="xml" pattern=".xml$" cut="true" stop="false" output_type="xml" />    
      ...  
    </routes>
  </ae:configuration>
</ae:configurations>

ルーティング・テーブルの先頭に配置されるこのルートは、.xml サフィックスで終わるすべてのリクエストと一致し、リクエストで使用する出力タイプを XML に設定します。しかし、それだけではありません。

  • cut 属性は、一致したサブストリング・セグメントが見つかった場合に、そのサブストリング・セグメントをリクエスト URL から削除してから処理を続けるようにするかどうかを指定します。上記の場合、この属性は true に設定されているため、.xml サフィックスが一致すると、このサフィックスがリクエスト URL から削除されます。
  • ルート定義に含まれる stop 属性は、最初の一致が検出された後にルートの処理を続けるかどうかを指定します。上記の例では false に設定されているため、残りのリクエスト URL が突き合わせられて適切なアクションが呼び出されるまで、リストでのリクエストの突き合わせは続きます。

この構成が結局のところどのように影響するかと言うと、Agavi が例えば http://wasp.localhost/listings/display/1.xml というリクエストを受け取ると、ルーティング・テーブルをチェックし、すぐに先頭にある catch-all ルートとの一致を検出します。すると Agavi はリクエスト URL から .xml サフィックスを取り除き、リクエストの出力タイプを XML に設定します。その後、listing.display との一致が見つかるまでリクエストの残りの部分、http://wasp.localhost/listings/display/1 をリストされたルートと照らし合わせ、Listing/DisplayAction を呼び出します。DisplayAction が完了すると、Agavi は選択された DisplayView で executeXml() メソッドを探して実行し、その出力をクライアントに返します。

次のステップは、SearchSuccessView が SearchAction によって生成された結果セットを読み取り、整形式 XML に変換するように更新し、executeXml() メソッドを追加することです。コードをリスト 13 に記載します。

リスト 13. Listing/SearchSuccessView の定義
<?php
class Listing_SearchSuccessView extends WASPListingBaseView
{
  public function executeHtml(AgaviRequestDataHolder $rd)
  {
    $this->setupHtml($rd);
    $this->getContext()->getRequest()->setAttribute('populate', 
     array('fsearch' => true), 'org.agavi.filter.FormPopulationFilter');    
  }
  
  public function executeXml(AgaviRequestDataHolder $rd)
  {
    // get records
    $records = $this->getAttribute('records');
    
    // create document
    $dom = new DOMDocument('1.0', 'utf-8');

    // create root element
    $root = $dom->createElementNS('http://www.melonfire.com/agavi-wasp', 
     'wasp:results');    
    $dom->appendChild($root);
    
    // import to SimpleXML for easier manipulation
    $xml = simplexml_import_dom($dom);
    
    // add result count
    $xml->addChild('count', count($records));
        
    // add results
    foreach ($records as $record) {
      $listing = $xml->addChild('result');      
      $listing->addChild('id', $record['RecordID']);
      $listing->addChild('submissionDate', $record['RecordDate']);
      $listing->addChild('manufacturer', 
       $record['Manufacturer']['ManufacturerName']);
      $listing->addChild('model', ucwords(strtolower($record['VehicleModel'])));
      $listing->addChild('year', $record['VehicleYear']);
      $listing->addChild('color', $record['VehicleColor']);
      $listing->addChild('year', $record['VehicleYear']);
      $listing->addChild('mileage', $record['VehicleMileage']);
      $price = $listing->addChild('price');
      $price->addChild('min', $record['VehicleSalePriceMin']);
      $price->addChild('max', $record['VehicleSalePriceMax']);
      $location = $listing->addChild('location');
      $location->addChild('city', $record['OwnerCity']);
      $location->addChild('country', $record['Country']['CountryName']);        
    }
    
    // return output
    return $xml->asXML();    
  }   
}
?>

リスト 13executeXml() メソッドは、PHP の DOM 拡張機能を使用して XML 文書のスケルトンを生成します。次にこのスケルトンを SimpleXML オブジェクトに変換し、SearchAction が返した結果セットを繰り返し処理することによって、各レコードを XML ノードのコレクションとして表現してスケルトンに入力します。そして SimpleXML で asXML() メソッドを呼び出して、最終的な XML 文書をクライアントに返します。

リスト 14 は、リスト 13 によって生成される XML 出力の例です。

リスト 14. XML にエンコードされた結果セット
<?xml version="1.0" encoding="utf-8"?>
<wasp:results xmlns:wasp="http://www.melonfire.com/agavi-wasp">
  <wasp:count>2</wasp:count>
  <wasp:result>
    <wasp:id>1</wasp:id>
    <wasp:submissionDate>2009-07-03</wasp:submissionDate>
    <wasp:manufacturer>Porsche</wasp:manufacturer>
    <wasp:model>Boxster</wasp:model>
    <wasp:year>2005</wasp:year>
    <wasp:color>Yellow</wasp:color>
    <wasp:year>2005</wasp:year>
    <wasp:mileage>15457</wasp:mileage>
    <wasp:price>
      <wasp:min>35000</wasp:min>
      <wasp:max>40000</wasp:max>
    </wasp:price>
    <wasp:location>
      <wasp:city>London</wasp:city>
      <wasp:country>United Kingdom</wasp:country>
    </wasp:location>
  </wasp:result>
  <wasp:result>
    <wasp:id>7</wasp:id>
    <wasp:submissionDate>2009-06-07</wasp:submissionDate>
    <wasp:manufacturer>Ferrari</wasp:manufacturer>
    <wasp:model>612 Scaglietti</wasp:model>
    <wasp:year>2003</wasp:year>
    <wasp:color>Yellow</wasp:color>
    <wasp:year>2003</wasp:year>
    <wasp:mileage>10974</wasp:mileage>
    <wasp:price>
      <wasp:min>125000</wasp:min>
      <wasp:max>200000</wasp:max>
    </wasp:price>
    <wasp:location>
      <wasp:city>London</wasp:city>
      <wasp:country>United Kingdom</wasp:country>
    </wasp:location>
  </wasp:result>
</wasp:results>

同じようにして SearchErrorView を更新し、入力の検証またはクエリーの実行でエラーが発生した場合には XML にエンコードされたエラー・メッセージを返すようにします。XML にエンコードされたエラー・メッセージを生成するというタスクは、今後何度も実行することになる共通のタスクです。そのため、リスト 15 のコードを WASPBaseView クラスに追加し、このビューを継承するすべての子クラスで使用できるようにするのが理にかなっています。

リスト 15. 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());
  }  
  
  // generate XML-encoded error message
  function getErrorXml($message) {
    $dom = new DOMDocument('1.0');
    $root = $dom->createElementNS('http://www.melonfire.com/agavi-wasp', 
     'wasp:error');    
    $dom->appendChild($root);        
    $xml = simplexml_import_dom($dom);
    $xml->addChild('message', $message);      
    return $xml->asXML();       
  }    
}
?>

継承の魔法により、このメソッドを SearchErrorView (リスト 16) で呼び出すことで、XML 出力タイプの検証エラーとアプリケーション・エラーの両方を処理することができます。

リスト 16. Listing/SearchErrorView の定義
<?php
class Listing_SearchErrorView extends WASPListingBaseView
{
  public function executeHtml(AgaviRequestDataHolder $rd)
  {
    $this->setupHtml($rd);
    // if validation errors, render input template
    if ($this->container->getValidationManager()->getReport()->count()) {
      $this->getLayer('content')->setTemplate('SearchSuccess');   
      $this->getContext()->getRequest()->setAttribute('populate', 
       array('fsearch' => true), 'org.agavi.filter.FormPopulationFilter');    
    }   
  }
  
  public function executeXml(AgaviRequestDataHolder $rd)
  {
    if ($this->container->getValidationManager()->getReport()->count()) {
      return $this->getErrorXml('Validation error');
    } else {
      return $this->getErrorXml('Application error');      
    }
  }   
}
?>

実際の振る舞いを確認するには、http://wasp.localhost/listing/search.xml にアクセスしてください。検索結果は図 3 のように表示されるはずです。

図 3. XML での検索結果
XML での検索結果
XML での検索結果

リクエスト URL の後に検索基準を追加すると、HTML バージョンの場合と同じく、XML 出力はその基準に応じてフィルタリングされます。例えば、http://wasp.localhost/listing/search.xml?color=yellow&year=2005 にアクセスしてみてください。図 4 に示すように、色と製造年の 2 つの基準でフィルタリングされた結果セットが表示されます。

図 4. XML での検索結果
XML での検索結果
XML での検索結果

個々のレコードを XML で表示する方法

検索結果ではなく、個々の自動車のリストを XML で表示するのも同じく簡単で、それぞれの DisplayView に XML 出力を返す executeXml() メソッドを追加して更新するだけのことです。

作業を開始する前に、リスト 17 に記載する DisplayAction の内容を簡単に思い出してください (連載第 2 回での DisplayAction は、その後、承認済みの有効なリストだけを表示するように更新されていることに注意してください)。

リスト 17. Listing/DisplayAction の定義
<?php
class Listing_DisplayAction extends WASPListingBaseAction
{

  public function getDefaultViewName()
  {
    return 'Success';
  }

  public function executeRead(AgaviRequestDataHolder $rd)
  {
    try {
      $id = $rd->getParameter('id');
      $q = Doctrine_Query::create()
            ->from('Listing l')
            ->leftJoin('l.Manufacturer m')
            ->leftJoin('l.Country c')
            ->where('l.RecordID = ?', $id)
            ->addWhere('l.DisplayStatus = 1')
            ->addWhere('l.DisplayUntilDate >= CURDATE()')
            ->orderBy('l.RecordDate DESC');
      $result = $q->fetchArray();
      if (count($result) == 1) {
        $this->setAttribute('listing', $result[0]);
        return 'Success';
      } else {
        return 'Redirect404';
      }
    } catch (Exception $e) {
      $this->setAttribute('error', $e->getMessage());  
      return 'Error';
    }
  }  
}

?>

リスト 18 では DisplaySuccessView を更新し、自動車のリストを表す XML 文書を動的に構成する executeXml() メソッドを追加しています。

リスト 18. 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']);        
    
    // return output
    return $xml->asXML();       
  }    
}
?>

リスト 19 に、上記による出力の一例を記載します。

リスト 19. XML にエンコードされたレコード
<?xml version="1.0" encoding="utf-8"?>
<wasp:result xmlns:wasp="http://www.melonfire.com/agavi-wasp">
  <wasp:id>1</wasp:id>
  <wasp:submissionDate>2009-07-03</wasp:submissionDate>
  <wasp:manufacturer>Porsche</wasp:manufacturer>
  <wasp:model>Boxster</wasp:model>
  <wasp:year>2005</wasp:year>
  <wasp:color>yellow</wasp:color>
  <wasp:mileage>15457</wasp:mileage>
  <wasp:singleOwner>1</wasp:singleOwner>
  <wasp:certified>1</wasp:certified>
  <wasp:certifiedDate>2008-01-01</wasp:certifiedDate>
  <wasp:note></wasp:note>
  <wasp:accessories>
    <wasp:item>Power steering</wasp:item>
    <wasp:item>Power windows</wasp:item>
    <wasp:item>Audio system</wasp:item>
    <wasp:item>Keyless entry system</wasp:item>
  </wasp:accessories>
  <wasp:price>
    <wasp:min>35000</wasp:min>
    <wasp:max>40000</wasp:max>
    <wasp:negotiable>1</wasp:negotiable>
  </wasp:price>
  <wasp:location>
    <wasp:city>London</wasp:city>
    <wasp:country>United Kingdom</wasp:country>
  </wasp:location>
</wasp:result>

リスト 20 に更新後の DisplayErrorView を記載します。

リスト 20. Listing/DisplayErrorView の定義
<?php
class Listing_DisplayErrorView extends WASPListingBaseView
{
  public function executeHtml(AgaviRequestDataHolder $rd)
  {
    $this->setupHtml($rd);
  }
  
  public function executeXml(AgaviRequestDataHolder $rd)
  {
    if ($this->container->getValidationManager()->getReport()->count()) {
      return $this->getErrorXml('Validation error');
    } else {
      return $this->getErrorXml('Application error');      
    }
  }    
}
?>

実際の振る舞いを確認するには、http://wasp.localhost/listing/display/1.xml にアクセスしてください。図 5 のような表示になっているはずです。

図 5. XML での個々のリスト
XML での個々のリスト
XML での個々のリスト

最後の 2 つの例では、アクションのコードにまったく手をつけていないことに注目してください。異なる出力タイプを処理する方法を、アクションの中ではなく、ビューの中で開発者が決定できるようにすることで、Agavi はコードの重複を最小限にすると同時に、MVC の原則と DRY (Don't Repeat Yourself) 原則の両方に従っているというわけです。

まとめ

現時点で、WASP サンプル・アプリケーションは完全に機能するようになり、メイン・メニューのすべてのリンクがアクティブになりました。販売店が自動車の詳細をアップロードすることも、購入者が基準と一致する自動車を検索することもできます。管理者は有効な管理モジュールを使ってリストの編集、変更、承認を行い、開発者は XML インターフェースを使って独自のアプリケーションを構成することができます。残る作業は、このアプリケーションをもう少し使い勝手の良いものにするための変更をいくつか加えることだけです。それについては、この連載の次回 (最終回) の記事で説明します。

この記事で実装したコードはすべて、「ダウンロード」セクションに用意してあります。コードをぜひ入手して、いろいろと試してみてください。実際に新しい機能を追加してみるのも一考です。何も壊れることはありませんので、いい勉強になるはずです。それでは、次回の記事まで、いろいろ試して楽しんでください!


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


関連トピック


コメント

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

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=XML, Open source, Web development
ArticleID=431445
ArticleTitle=Agavi による MVC プログラミング入門: 第 4 回 XML、RSS、SOAP をはじめとする複数の出力タイプに対応する Agavi 検索エンジンを作成する
publish-date=09152009