Введение в создание MVC-приложений при помощи Agavi: Часть 5. Возможности загрузки файлов и специализированных валидаторов в приложение на основе Agavi

Узнайте о принципах создания масштабируемых Web-приложений на основе Agavi

Эта статья завершает серию из пяти статей, написанную для разработчиков PHP-приложений, интересующихся гибкой и масштабируемой инфраструктурой с открытым кодом под названием Agavi. В ней рассказывается о добавлении в ваше приложение таких функций, как загрузка файлов на сервер, хранение пользовательских данных в сессии, интеграция со сторонними библиотеками, а также создание специализированных валидаторов.

Введение

После прочтения первых четырех статей серии у вас уже есть полнофункциональное Web-приложение, включающее в себя модуль администрирования, систему поиска и возможность вывода информации в формате XML. Не исключено, что вы задаетесь вопросом, зачем возвращаться к этому приложению после того, как были удовлетворены все функциональные требования WASP (Web Automobiles Sales Platform).

В последней статье мы рассмотрим несколько дополнительных понятий и методов, которые пригодятся вам в будущем при создании Web-приложений. Они включают как элементарные вещи, например, поддержку постраничного вывода и сортировки записей, выбранных из базы данных, так и более сложные функции, в частности, загрузку файлов на сервер через Web-форму и создание собственных валидаторов. При этом все эти задачи объединяет то, что Agavi предоставляет встроенные средства, которые делают ваши решения проще, быстрее и безопаснее. Итак, приступим!


Сортировка записей, выбранных из базы данных

Мы начнем с функций сортировки записей и их вывода с разбиением на страницы. На данный момент записи на административной странице приложения (http://wasp.localhost/admin/listing/index) выводятся, как показано на рисунке 1.

Рисунок 1. Административная страница приложения WASP со списком объявлений
Screen capture of the WASP listing summary page with details for eight vehicles

Теперь добавим возможность сортировки записей по различным колонкам. Для этого сначала следует изменить шаблон 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; ?>

Часто встречающиеся аббревиатуры

  • API: Application program interface (Интерфейс прикладного программирования).
  • CSS: Cascading stylesheet (Каскадные страницы стилей).
  • HTML: Hypertext Markup Language (Язык разметки гипертекста).
  • MVC: Model-View-Controller (Модель-представление-контроллер).
  • OOP: Object-oriented programming (Объектно-ориентированное программирование)
  • ORM: Object-Relational Mapping (Объектно-реляционное отображение).
  • SQL: Structured Query Language (Структурированный язык запросов).
  • URL: Uniform Resource Locator (Универсальный локатор ресурса).
  • XML: Extensible Markup Language (Расширяемый язык разметки).

Как видно из листинга 1, каждая ссылка включает два параметра:

  • s - указывает по какому полю сортировать;
  • d - указывает направление сортировки (по возрастанию или по убыванию).

Далее измените валидатор обработчика AdminIndexAction для проверки этих параметров, как показано в листинге 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>

После этого отредактируйте метод executeRead() класса AdminIndexAction, который на основе этих параметров должен формировать блок ORDER BY запроса Doctrine (листинг 3).

Листинг 3. Обработчик Listing/AdminIndexAction
<?php
class Listing_AdminIndexAction extends WASPListingBaseAction
{
  public function getDefaultViewName()
  {
    return 'Success';
  }
  
  public function executeRead(AgaviRequestDataHolder $rd) 
  { 
    try {
      // получение входных параметров
      $id = $rd->getParameter('id');
      $sort = $rd->isParameterValueEmpty('s') ? 
       'RecordID' : $rd->getParameter('s');
      $dir = $rd->isParameterValueEmpty('d') ? 
       'asc' : $rd->getParameter('d');

      // формирование запроса
      $q = Doctrine_Query::create()
            ->from('Listing l')
            ->leftJoin('l.Manufacturer m')
            ->leftJoin('l.Country c')
            ->orderBy(sprintf('%s %s', $sort, $dir));
      $result = $q->fetchArray();
      
      // установка переменных представления
      $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. Административная страница с возможностью сортировки объявлений
Screen capture of the WASP listing summary page with sorting functions added to table heading

Постраничный вывод записей базы данных

От приложений, которые работают с крупными наборами данных, часто требуется отображать их на нескольких страницах, чтобы, во-первых, снизить нагрузку на сервер базы данных (он сможет выдавать записи небольшими порциями), а во-вторых, упростить работу пользователя (он может просматривать страницы поочередно, а не все сразу). В отличие от других инфраструктур, Agavi не имеет встроенных средств поддержки постраничного вывода, но эту возможность несложно реализовать при помощи Doctrine.

Doctrine включает объект Doctrine_Pager, через который должны выполняться все операции, связанные с разбиением набора записей на страницы. Он поддерживает два наиболее популярных типа постраничного разбиения (скользящее и скачкообразное), а также позволяет настраивать формат и способ отображения номеров страниц и соответствующих ссылок. Полное описание всех возможностей выходит за рамки этой статьи; ссылка на соответствующую главу руководства по 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 {
      // получение входных параметров
      $id = $rd->getParameter('id');
      $sort = $rd->isParameterValueEmpty('s') 
       ? 'RecordID' : $rd->getParameter('s');
      $dir = $rd->isParameterValueEmpty('d') 
       ? 'asc' : $rd->getParameter('d');
      $page = $rd->getParameter('p');      

      // формирование запроса
      $q = Doctrine_Query::create()
            ->from('Listing l')
            ->leftJoin('l.Manufacturer m')
            ->leftJoin('l.Country c')
            ->orderBy(sprintf('%s %s', $sort, $dir));
            
      // конфигурирование постраничного вывода
      $perPage = 5;
      $numPageLinks = 5;      
      
      // инициализация объекта Pager
      $pager = new Doctrine_Pager($q, $page, $perPage);
      // выполнение запроса для конкретной страницы
      $result = $pager->execute(array(), Doctrine::HYDRATE_ARRAY);            
       
      // настройка разметки ссылок на страницы
      $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);
      
      // сохранение разметки в переменной шаблона
      $pagerLayout->setTemplate(
       '<a href="{%url}&p={%page}">{%page}</a>'
      );
      $pagerLayout->setSelectedTemplate('{%page}');      
      $pagerLayout->setSeparatorTemplate('&nbsp;');
      
      // задание значений для переменных представления
      $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 присутствуют несколько новых элементов, требующих пояснения. Во-первых, в нем инициализируется объект Doctrine_Pager, в конструктор которого передаются три ключевых параметра: SQL-запрос для выборки записей, номер текущей страницы (полученный из параметра p) и число записей в странице (в данном примере оно принимается равным 5). Далее объект Doctrine_Pager выполняет запрос с необходимой конструкцией LIMIT и извлекает только необходимое подмножество записей.

Однако это еще не все – нам необходимо также отображать список номеров страниц, чтобы пользователи могли переходить с одной страницы на другую в произвольном направлении. Для этого служит объект Doctrine_Pager_Layout, который позволяет устанавливать тип постраничного разбиения, базовый URL для ссылки на каждую страницу и число ссылок для отображения. Кроме того, вы можете гибко настраивать формат ссылок на страницы, их внешний вид и то, как они отделяются друг от друга при выводе на экран. Все это конфигурирование выполняется при помощи методов 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>

В результате Web-страница будет выглядеть, как показано на рисунке 3.

Рисунок 3. Административная страница WASP с постраничным выводом объявлений
Screen capture of the WASP listing summary, divided into two pages

Хранение пользовательских данных в сессии

Еще одно небольшое усовершенствование приложения WASP заключается в том, что некоторые административные ссылки (например, ссылку для выхода) можно отображать лишь в том случае, если пользователь был аутентифицирован. В этом нет ничего сложного, потребуется лишь проверить значение, возвращаемое методом AgaviUser::isAuthenticated(). В листинге 7 показано, как следует изменить шаблон AdminMaster.

Листинг 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>

    <!-- заголовок -->
    <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>
    <!-- конец заголовка -->
    
    <!-- основная область -->
    ...
    <!-- конец основной области -->
    
    <!-- нижняя часть -->
    ...
    <!-- конец нижней части -->
  </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 {
      // получение входных параметров
      $u = $rd->getParameter('username');
      $p = $rd->getParameter('password');
      
      // проверка учетных данных
      $q = Doctrine_Query::create()
            ->from('User u')
            ->where('u.Username = ? AND u.Password = PASSWORD(?)', array($u,$p));
      $result = $q->fetchArray();
        
      // установка флага успешной аутентификации
      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';        
    }
  }

}
?>

Шаблон AdminMaster, извлекающий сохраненные данные из сессии и отображающий их на административных страницах, показан в листинге 9.

Листинг 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>
    <!-- заголовок -->
    ...
    <!-- конец заголовка -->
    
    <!-- основная область -->
    <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>
    <!-- конец основной области -->
    
    <!-- нижняя часть -->
    ...
    <!-- конец нижней части -->
  </body>
</html>

В результате административная страница для просмотра списка объявлений будет выглядеть, как показано на рисунке 4.

Рисунок 4. Главное меню административной страницы WASP с новыми ссылками
Screen capture of the WASP administration main menu, with new Listings and Log Out links and username

Загрузка файлов на сервер

До этого момента все объявления, загружаемые продавцами автомобилей, сохранялись в базе данных. Однако, как известно, лучше один раз увидеть, чем сто раз услышать, поэтому продавцы должны иметь возможность загружать фотографии своих автомобилей. К счастью, реализация загрузки изображений достаточно проста.

Вначале создадим директорию $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>

Обратите внимание, что у элемента <form> в листинге 10 появился атрибут enctype, необходимый для отправки форм с файлами.

Возможность загрузки файлов часто используется для атак на Web-приложения, поэтому очень важно тщательно проверять файлы до их сохранения на сервере. К счастью, Agavi включает полноценный валидатор изображений (AgaviImageFileValidator), который проверяет, что указанный параметр действительно представляет собой файл с изображением. Кроме того, он позволяет проверить формат и размеры изображения.

Валидатор CreateAction, использующий AgaviImageFileValidator, показан в листинге 11.

Листинг 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/). Эти действия выполняет модифицированный обработчик CreateAction, показанный в листинге 12.

Листинг 12. Обработчик Listing/CreateAction
<?php
class Listing_CreateAction extends WASPListingBaseAction
{
  public function getDefaultViewName()
  {
    return 'Input';
  }
  
  public function executeWrite(AgaviRequestDataHolder $rd)
  {    
    try {
      // инициализация объявления
      $listing = new Listing();
      
      // заполнение проверенными данными
      $listing->fromArray($rd->getParameters());      
      $listing->VehicleAccessoryBit = 
       array_sum($rd->getParameter('VehicleAccessoryBit'));

      // ручное задание некоторых значений
      $listing->RecordDate = date('Y-m-d', mktime());
      $listing->DisplayStatus = 0;
      
      // сохранение записи и получение идентификатора
      $listing->save();
      $id = $listing->RecordID;   
      
      // переименование и сохранение загруженного изображения
      $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(), служащий для перемещения файла в другую директорию.

Однако загрузка файлов на сервер – это только полдела. Изображения также необходимо отображать на страницах соответствующих объявлений. Для этого следует изменить шаблон DisplaySuccess, как показано в листинге 13.

Листинг 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 с возможностью загрузки фотографии
Screen capture of the WASP listing form that supports upload of three images

Попытки загрузить файлы, не являющиеся изображениями, а также фотографии, не отвечающие требованиям по размерам, закончатся неудачей. Соответствующие сообщения будут сгенерированы классом AgaviImageFileValidator (рисунок 6).

Рисунок 6. Ошибки в случае попытки загрузки недопустимого типа файла
Screen capture of errors caused by invalid image uploads

Если загрузка прошла успешно, то рядом с остальной информацией на странице объявления о продаже должна появиться фотография автомобиля. Пример показан на рисунке 7.

Рисунок 7. Страница объявления с фотографией
Screen capture of the WASP listing detail page, with an image gallery

Необходимо также позаботиться об удалении фотографий с диска после удаления соответствующих им объявлений (листинг 14).

Листинг 14. Обработчик Listing/AdminDeleteAction
<?php
class Listing_AdminDeleteAction extends WASPListingBaseAction
{
  public function getDefaultViewName()
  {
    return 'Success';
  }
  
  public function executeWrite(AgaviRequestDataHolder $rd)
  {
    try {
      // получение идентификатора записи
      $ids = $rd->getParameter('id');
      
      foreach ($ids as $id) {
        // удаление записи из базы данных
        $q = Doctrine_Query::create()
              ->delete('Listing')
              ->addWhere('RecordID = ?', $id);
        $result = $q->execute();       
         
        // удаление изображений
        $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;
  }   
}
?>

Для полноты картины можно аналогичным образом изменить метод executeXml() представления DisplaySuccessView, чтобы он добавлял информацию об изображении в документ XML. Код приведен в листинге 15.

Листинг 15. Представление Listing/DisplaySuccessView
<?php
class Listing_DisplaySuccessView extends WASPListingBaseView
{
  public function executeHtml(AgaviRequestDataHolder $rd)
  {
    $this->setupHtml($rd);
  }
  
  public function executeXml(AgaviRequestDataHolder $rd)
  {
    // выборка записи
    $record = $this->getAttribute('listing');
    
    // создание документа
    $dom = new DOMDocument('1.0', 'utf-8');

    // создание корневого элемента
    $root = $dom->createElementNS('http://www.melonfire.com/agavi-wasp', 
     'wasp:result');    
    $dom->appendChild($root);
    
    // создание объекта SimpleXML для упрощения работы с XML
    $xml = simplexml_import_dom($dom);
    
    // добавление элементов в документ
    $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']);        
            
    // добавление информации об изображении (при необходимости)
    $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));  
    }
        
    // вывод XML
    return $xml->asXML();       
  }  
}
?>

Документы XML, возвращаемые этим методом, теперь выглядят, как показано на рисунке 8.

Рисунок 8. Документ XML с информацией об изображении
An example of the revised XML output

Создание специализированных валидаторов

Как неоднократно отмечалось в предыдущих статьях, Agavi включает широкий набор валидаторов, которые позволяют легко гарантировать, что обработчики будут получать на вход только корректные данные. Стандартного набора валидатора с лихвой хватает для таких заурядных задач, как проверка адресов e-mail или дат. Но что делать в случае, если необходимый алгоритм проверки данных не реализован ни одним стандартным валидатором? Ответ прост: написать собственный валидатор.

Разработчики Agavi отдавали себе отчет в том, что требования к валидации у каждого приложения уникальны, поэтому они максимально упростили задачу написания специализированных валидаторов путем расширения стандартного базового класса AgaviValidator. Специализированные валидаторы описываются так же, как и стандартные — в конфигурационном XML-файле, с указанием обработчика.

Мы продемонстрируем создание специализированного валидатора на следующем примере: допустим, что в форме CreateInput продавец обязан указать верхнюю и нижнюю ценовые границы для своего автомобиля. При этом, разумеется, нижняя граница должна быть меньше верхней. Однако ни один валидатор, входящий в стандартную поставку Agavi, не позволяет делать такую проверку, поэтому на данный момент приложение позволяет указать нижнюю границу цены большую, чем верхнюю (попробуйте и убедитесь сами). Для устранения этого недоразумения мы напишем собственный валидатор.

Вначале измените валидатор 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. Он принимает два входных параметра, минимальную и максимальную цену, а также содержит сообщение, которое будет генерироваться в случае перехвата ошибки типа 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;
  }

}
?>

В центре любого валидатора находится метод validate(), который выполняет проверку данных, возвращая true, если они корректны, и false – если некорректны. Метод, показанный в листинге 17, считывает входные данные при помощи метода getData(), выполняет сравнение ценовых границ и возвращает результат. Если результат проверки отрицательный, метод выбрасывает исключение типа max_min_mismatch, после чего фильтр AgaviFormPopulationFilter отображает сообщение об ошибке на Web-странице.

Этот валидатор должен загружаться автоматически, поэтому добавьте фрагмент, показанный в листинге 18, в файл $WASP_ROOT/app/config/autoload.xml.

Листинг 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. Валидатор ценовых диапазонов в действии
Screen capture of the WASP price range validator and error message for a minimum price that exceeds the maximum price

Функция автозаполнения

В последнее время Web-приложения все чаще включают функции автозаполнения, предлагая значения полей на основе фрагментов, введенных пользователем.

В Web-форме добавления объявления лучшим кандидатом на автозаполнение является поле модели автомобиля. Почему? В основном потому, что число моделей конечно. При этом вероятность того, что пользователь введет модель, которая уже находится в базе данных, растет по мере расширения последней. Благодаря этому можно сэкономить пользователю несколько нажатий на клавиши, предлагая выбрать одну из списка моделей, которые соответствуют введенной части строки.

В настоящее время существует ряд сторонних библиотек, позволяющих быстро добавить возможность автозаполнения в Web-приложение, в частности, PEAR HTML_QuickForm, Dojo Yahoo! User Interface (YUI). Одним из наиболее полных инструментариев обладает библиотека YUI, поэтому именно она будет использоваться в форме добавления объявления о продаже автомобиля.

Вначале следует загрузить интерфейсный компонент (или виджет) AutoComplete, состоящий из файлов CSS и JavaScript (см. раздел Ресурсы). Далее создайте директории $WASP_ROOT/pub/css/yui и $WASP_ROOT/pub/js/yui, после чего скопируйте в них соответственно файлы CSS и JavaScript. Затем измените метод WASPListingBaseView:: setInputViewAttributes(), написанный в третьей статье серии, добавив в него SQL-запрос, выбирающий список неповторяющихся моделей из базы данных (листинг 19).

Листинг 19. Представление Listing/WASPListingBaseView
<?php
/**
 * Базовый класс, который наследуют все представления модуля Listing
 */
class WASPListingBaseView extends WASPBaseView
{
  // задание значений для выпадающих списков
  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, содержащий дополнительный клиентский код, использующий возможности автозаполнения компонента AutoComplete из библиотеки YUI.

Листинг 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 -->
<!-- Этот код базируется на примере по адресу 
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() {
  // Использование LocalDataSource
  var oDS = new YAHOO.util.LocalDataSource(arrayModels);

  // Создание экземпляра AutoCompletes
  var oAC = new YAHOO.widget.AutoComplete("VehicleModel", "ac1", oDS);
  oAC.prehighlightClassName = "yui-ac-prehighlight";
  
  return {
      oDS: oDS,
      oAC: oAC,
  };
}();
</script>

Код на JavaScript, приведенный в конце листинга 20, сначала инициализирует YUI-объект LocalDataSource, а затем заполняет его массивом имен моделей, полученным из переменной шаблона $t['models']. Далее этот объект связывается с объектом AutoComplete, который, в свою очередь, ссылается на элемент VehicleModel Web-формы.

В результате всех этих манипуляций виджет AutoComplete будет сравнивать строку, вводимую пользователем в поле VehicleModel, с элементами массива, хранящегося в объекте LocalDataSource, и выдавать пользователю список подходящих значений. Подсказки автозаполнения выглядят, как показано на рисунке 10.

Рисунок 10. Форма создания объявлений с функцией автозаполнения поля для ввода модели автомобиля
Screen capture of the WASP listing form with the auto-complete function for the Model field

Заключение

На этом наша серия подошла к концу. На протяжении пяти статей рассказывалось об инфраструктуре Agavi и о базовых методах создания масштабируемых Web-приложений на ее основе. Вы могли убедиться, что на данный момент Agavi является одной из наиболее удачных реализаций принципов MVC. Она поддерживает четкое разграничение между моделями, обработчиками и представлениями, строго следует принципам OOP, а также предоставляет широкий набор средств для валидации пользовательских данных, обеспечения безопасности, выполнения аутентификации, интеграции с базами данных, использования разных форматов вывода и конфигурирования. Все это позволяет создавать более безопасные, надежные, гибкие и расширяемые приложения.

Я искренне надеюсь, что эта серия была для вас полезна и заставит серьезно рассмотреть Agavi в качестве основы для вашего следующего Web-приложения. А до тех пор — счастливого программирования!


Загрузка

ОписаниеИмяРазмер
Исходный код текущей версии приложения WASPwasp-05.zip3,881 KБ

Ресурсы

Научиться

Получить продукты и технологии

  • Загрузите Agavi – инфраструктуру для создания приложения на PHP5 по принципам MVC, которая поможет повысить качество и упростит дальнейшее развитие ваших приложений. (EN)
  • Загрузите MySQL - популярную бесплатную СУБД. (EN)
  • Загрузите пакет Doctrine - инфраструктуру объектно-реляционного отображения для PHP. (EN)
  • Загрузите ознакомительные версии продуктов IBM, а также опробуйте средства разработки приложений и промежуточное программное обеспечение IBM, в частности DB2®, Lotus®, Rational®, Tivoli® и WebSphere® на сайте online-тестирования IBM SOA Sandbox. (EN)

Обсудить

Комментарии

developerWorks: Войти

Обязательные поля отмечены звездочкой (*).


Нужен IBM ID?
Забыли Ваш IBM ID?


Забыли Ваш пароль?
Изменить пароль

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Профиль создается, когда вы первый раз заходите в developerWorks. Информация в вашем профиле (имя, страна / регион, название компании) отображается для всех пользователей и будет сопровождать любой опубликованный вами контент пока вы специально не укажите скрыть название вашей компании. Вы можете обновить ваш IBM аккаунт в любое время.

Вся введенная информация защищена.

Выберите имя, которое будет отображаться на экране



При первом входе в developerWorks для Вас будет создан профиль и Вам нужно будет выбрать Отображаемое имя. Оно будет выводиться рядом с контентом, опубликованным Вами в developerWorks.

Отображаемое имя должно иметь длину от 3 символов до 31 символа. Ваше Имя в системе должно быть уникальным. В качестве имени по соображениям приватности нельзя использовать контактный e-mail.

Обязательные поля отмечены звездочкой (*).

(Отображаемое имя должно иметь длину от 3 символов до 31 символа.)

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Вся введенная информация защищена.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=XML, Open source
ArticleID=548158
ArticleTitle=Введение в создание MVC-приложений при помощи Agavi: Часть 5. Возможности загрузки файлов и специализированных валидаторов в приложение на основе Agavi
publish-date=09272010