PHP on Rails

Руководство программиста

Содержание
1

Вступление............................................................................................................................4
1.1
1.1.1

1.2

2

Компоненты .........................................................................................................................................5

Установка ..................................................................................................................................6

Пример создания приложения...........................................................................................8
2.1
2.1.1
2.1.2
2.1.3

2.2
2.2.1
2.2.2
2.2.3
2.2.4
2.2.5
2.2.6
2.2.7
2.2.8
2.2.9

Начальные приготовления ..................................................................................................10
Модель данных ..................................................................................................................................10
Создание базы данных ......................................................................................................................10
Конфигурирование приложения ......................................................................................................10

Управление товарами ...........................................................................................................12
Создание таблицы products...............................................................................................................12
Создание приложения для управления товарами ...........................................................................12
Модель................................................................................................................................................14
Контроллер.........................................................................................................................................16
Представление ...................................................................................................................................19
Проверка данных ...............................................................................................................................19
Улучшение интерфейса.....................................................................................................................22
Удаление товара.................................................................................................................................26
Добавление нового атрибута ............................................................................................................27

2.3

Каталог товаров .....................................................................................................................30

2.4

Создание покупательской корзины ...................................................................................33

2.4.1
2.4.2
2.4.3
2.4.4
2.4.5

2.5
2.5.1
2.5.2

2.6
2.6.1
2.6.2
2.6.3

2.7
2.7.1
2.7.2
2.7.3
2.7.4
2.7.5
2.7.6

3

Описание ...................................................................................................................................5

Сессии.................................................................................................................................................33
Новая модель данных ........................................................................................................................33
Создание покупательской корзины..................................................................................................35
Пример обработки ошибок ...............................................................................................................41
Пример рефакторинга .......................................................................................................................41

Покупка ...................................................................................................................................43
Оформление заказа ............................................................................................................................43
Вывод содержимого корзины при оформлении заказа ..................................................................49

Отправка заказов...................................................................................................................52
Просмотр новых заказов ...................................................................................................................52
Связь «один-ко-многим»...................................................................................................................55
Отправка заказов ...............................................................................................................................56

Управление администраторами..........................................................................................57
Создание администратора.................................................................................................................57
Просмотр администраторов..............................................................................................................61
Вход в систему...................................................................................................................................61
Ограничение доступа ........................................................................................................................65
Удаление администраторов ..............................................................................................................66
Выход из системы..............................................................................................................................67

Многоязычные приложения .............................................................................................69
3.1
3.1.1
3.1.2
3.1.3

3.2
3.2.1
3.2.2
3.2.3
3.2.4
3.2.5

Установка и конфигурация .................................................................................................70
Установка ...........................................................................................................................................70
Создание базы данных ......................................................................................................................70
Конфигурирование базы данных .....................................................................................................71

Создание приложения ...........................................................................................................73
Структура ...........................................................................................................................................73
Переводчик.........................................................................................................................................74
Многоязычные шаблоны...................................................................................................................75
Выбор текущего языка ......................................................................................................................78
Перевод приложения .........................................................................................................................78

2

3.2.6
3.2.7

Кэширование......................................................................................................................................83
Визуальное редактирование переводов ...........................................................................................84

3

1 Вступление

4

1.1 Описание
PHP on Rails - это развивающаяся многоуровневая фреймворк система,
предназначенная для создания средних и сложных веб-приложений, работающих с базами
данных и использующих архитектуру Модель-Представление-Контроллер (MVC). PHP on
Rails – это объектно-ориентированная библиотека, написанная на языке PHP версии 5.

1.1.1 Компоненты
PHP on Rails включает в себя реализацию следующих компонент:
1. Архитектура MVC (Модель-Представление-Контроллер) позволяет создавать
хорошо структурированные приложения; в качестве представления используется
генератор шаблонов Smarty;
2. Абстрактный доступ к базе данных избавляет разработчиков от необходимости
изучать API для работы с различными СУБД и делает приложение более
переносимым (текущая версия проверена только для MySQL);
3. ORM (Object Relationship Mapping) делает удобным работу с объектами базы
данных, пряча использование языка SQL;
4. Многоязычный интерфейс; PHP on Rails позволяет создавать веб-приложения с
многоязычным интерфейсом, используя в качестве хранилища переводов базу
данных; простота создания многоязычного интерфейса достигается за счёт
расширения возможностей Smarty;
5. Встроенный конструктор сайта избавляет разработчика от рутинной работы,
генерируя многие типичные сценарии.

5

1.2 Установка
Для начала необходимо установить нужное программное обеспечение: веб-сервер,
PHP 5 и СУБД MySQL. Как устанавливать эти продукты в данном руководстве не
объясняется. Разработка и запуск приложений из примеров производились на следующей
конфигурации системы:
1. веб-сервер Apache 1.3.29
2. PHP 5.1.5, установленный как модуль веб-сервера mod_php
3. MySQL 5.0.22
Фреймворк PHP on Rails поддерживает различные реляционные базы данных,
однако в последующих примерах используется СУБД MySQL.
Замечание! Текущая реализация PHP on Rails поддерживает только MySQL.
Поддержку остальных СУБД можно включить, используя PDO. Эта возможность в
данном руководстве не рассматривается, т.к. ещё не была протестирована.
Теперь установите PHP on Rails на вашем компьютере. Для этого необходимо
распаковать содержимое инсталляционного архива в какую-либо директорию. Назовите
эту директорию rails_demo. Она включает в себя саму фреймворк систему и каркас
будущего приложения. Внутри неё должны находиться следующие файлы и директории.
В директории app содержаться все компоненты MVC
архитектуры данного приложения: модели, представления и
контроллеры,- а также дополнительные классы и скрипты.
Очень важно! Веб-сервер должен иметь права записи в
поддиректории app/views/cache и app/views/templates_c.
Директория config содержит конфигурационные файлы
приложения.
В директорию log пишутся все логи приложения, в том
числе и ошибки уровня PHP. Веб-сервер должен иметь права
записи в эту директорию.
Директория public содержит все ресурсы веб-приложения,
которые должны быть доступны извне, т.е. некоторые PHP
скрипты, файлы html, изображения и т.д. Только эта
директория должна быть доступна извне (по протоколу
http(s))!
Директория system – это «сердце» системы, непосредственно сама библиотека PHP
on Rails.
Файлы app.init.php и app.finalize.php выполняются соответственно в начале и в
конце запуска сценария приложения.
Для удобства создаваемое приложение будет располагаться по адресу http://rails.loc
на вашем локальном компьютере. Для этого нужно прописать в файле hosts строку
6

127.0.0.1 rails.loc

, а в конфигурационный файл Apache добавить следующую запись
NameVirtualHost 127.0.0.1:80
<VirtualHost rails.loc:80>
ServerAdmin webmaster@rails.loc
DocumentRoot <www path>/rails_demo/public
ServerName rails.loc
ErrorLog logs/rails-error_log
CustomLog logs/rails-access_log common
</VirtualHost>

Перезапустите свой веб-сервер и наберите в браузере URL http://rails.loc/. Если всё
установлено правильно, то вы должны увидеть следующую страницу. При её запуске не
выполняется никаких полезных действий. Таблица, которую вы видите, содержит
отладочную информацию.

7

2 Пример создания приложения

8

В этой главе объясняется, как при помощи PHP on Rails быстро создать небольшое
приложение, и демонстрируются основные возможности фреймворка. В качестве
демонстрации было выбрано приложение Depot из книги «Agile Web Development with
Rails», где главным инструментом разработки является фреймворк Ruby on Rails.
Данное приложение – это несложный Интернет магазин. Клиент просматривает
каталог товаров на главной странице сайта, добавляет понравившиеся ему товары в
Покупательскую корзину и после всего оформляет заказ. Администратор магазина имеет
возможность добавлять, изменять и удалять товары, а также принимать заказы,
оформленные покупателями.

9

2.1 Начальные приготовления
2.1.1 Модель данных
Для начала надо определить, какие объекты данных необходимы. Из поставленной
задачи ясно, что большинство операций производится над товарами. Поэтому одним из
типов объектов будет Product. Каждый продукт характеризуется именем, подробным
описанием и ценой.
Товары добавляются в Покупательскую корзину Cart. Корзина должна содержать
следующие данные: товар, цена, по которой он был куплен, и количество единиц данного
товара. Возникает вопрос, зачем дублировать информацию о цене товара: она хранится
как атрибут товара и в Покупательской корзине. Это сделано для того, чтобы если цена
товара изменится уже после оформления заказа, то это не отразится на стоимости всего
заказа. Ведь никому не понравится заказать товар по одной цене, а при его получении
заплатить больше.
Поскольку уже несколько раз говорилось о заказах, то, очевидно, что это ещё один
претендент в модель данных - Order. Заказ содержит информацию о покупателе и оплате.
Начальная модель данных представлена на диаграмме.

2.1.2 Создание базы данных
Как минимум в приложении понадобится база данных для хранения всех товаров.
Назовите её depot.
CREATE DATABASE depot

Помещайте все SQL команды в файл app/sql/structure.sql.

2.1.3 Конфигурирование приложения
Вся конфигурация приложения хранится в файлах, расположенных в папке config:
config.php и application.cfg.xml.
В файле config.php определены две переменные: $DEBUG и $BASE_URL. Во время
разработки и отладки приложения значение переменной $DEBUG рекомендуется
устанавливать в true. Это включает вывод таблицы с отладочной информацией и
10

сообщений об ошибках. Переменная $BASE_URL должна соответствовать базовому URL
приложения с символом “/” на конце. В случае данного приложения это http://rails.loc/.
Файл application.cfg.xml представляет собой XML-документ, содержащий
настройки базы данных и ORM. В первоначальном варианте он включает две записи – две
конфигурации соединений к базам данных.
<?xml version="1.0" encoding="UTF-8"?>
<Databases>
<!-This database is used by Site Builder
Do not delete it
-->
<Database name="server">
<Driver>MySQL</Driver>
<Host>localhost</Host>
<Port>3306</Port>
<User>root</User>
<Password></Password>
</Database>
<Database name="database">
<Driver>MySQL</Driver>
<Host>localhost</Host>
<Port>3306</Port>
<User>root</User>
<Password></Password>
<Database>database</Database>
</Database>
<Mapping>
<MapDir>./config/ormmapping/</MapDir>
</Mapping>
</Databases>

Первую запись лучше не трогать, как это указано в комментариях. Только если
пароль для пользователя root у вас не пустой, то укажите его между соответствующих
тегов. Вторую запись измените так, чтобы она соответствовала параметрам соединения с
базой depot. Она должна выглядеть следующим образом.
<Database name="depot_db">
<Driver>MySQL</Driver>
<Host>localhost</Host>
<Port>3306</Port>
<User>depot_user</User>
<Password>paroli</Password>
<Database>depot</Database>
</Database>

Каждый элемент Database в корне XML документа имеет атрибут name, который
является индексом базы данных, чем-то вроде уникального имени базы внутри
приложения. Не может быть нескольких элементов Database с одинаковыми
индексами. Индекс базы данных depot равен “depot_db”.
На этом конфигурация приложения закончена.

11

2.2 Управление товарами
2.2.1 Создание таблицы products
Основной моделью магазина является Товар (Product). Для хранения товаров
создайте таблицу products со структурой, отвечающей приведённой ранее модели данных.
На языке SQL определение таблицы выглядит следующим образом.
CREATE TABLE `products` (
`id` int(10) unsigned NOT NULL auto_increment,
`title` varchar(100) NOT NULL,
`description` text NOT NULL,
`price` decimal(5,2) NOT NULL,
PRIMARY KEY (`id`)
)

Данная таблица содержит название товара, описание и цену, заданную с точностью
до сотых. Также в определении таблицы присутствует ещё одно поле: id. Это поле
является первичным ключом таблицы и уникально для каждой записи. Желательно, чтобы
каждый объект базы данных имел первичный ключ. Также желательно пока точно
следовать наименованиям таблиц и полей в приводимых примерах.

2.2.2 Создание приложения для управления товарами
Каждое приложение PHP on Rails содержит раздел, называемый
конструктором сайта Site Builder. Он расположен по адресу http://rails.loc/builder/. Этот
раздел предназначен для генерации кода приложения. Чтобы конструктор сайтов
работал, нужно, чтобы веб-сервер имел права записи в директории проекта
(rails_demo) и её поддиректориях. Иначе он не сможет записать в них генерируемые
файлы. Это необходимо только на платформе разработчика.
Зайдите по адресу конструктора сайта и выберите в левом меню пункт “Scaffold”.
“Scaffold” – это леса, на которых вы можете построить приложение. Чтобы создать часть
приложения, управляющую товарами, вам достаточно заполнить появившуюся на
странице форму и нажать кнопку “Generate”. Заполните форму следующим образом.

12

Сгенерируйте код и наберите в браузере адрес http://rails.loc/admin/. Вы увидите
страницу с таблицей просмотра товаров и ссылку “Add new product” («Добавить новый
товар»).

Перейдите по ссылке «Добавить новый товар», заполните форму и нажмите кнопку
“Save” (Сохранить).

После сохранения вы снова попадёте на страницу просмотра товаров. На этот раз
таблица содержит одну строку. В этой строке имеются две ссылки: “Edit” (Редактировать)
и “Remove” (Удалить) соответственно для изменения товара и удаления из базы данных.

13

Как видите, не написав ни строчки кода, вы создали работающее приложение.
Конструктор сайта выполнил за вас следующие действия:
1. создал модель Product (Товар),
2. создал контроллер admin,
3. добавил в контроллер методы для просмотра, добавления, редактирования и удаления
объектов модели Product.
Понятия модель и контроллер в PHP on Rails следует обсудить подробнее.

2.2.3 Модель
Вспомните, как вы заполняли форму “Scaffold”. Перед вами был выбор: создать
новую модель или использовать уже существующую.

Следуя указаниям этого руководства, вы выбрали первый вариант. Чтобы создать
новую модель, вы выбрали индекс базы данных (depot_db), в которой находится таблица,
лежащая в основе модели, саму таблицу (products) и указали имя класса, реализующего
модель (Product).
“Scaffold” сгенерировал два файла, необходимых модели Product: «карту»
Product.hbm.xml и класс Product.class.php.

14

2.2.3.1 «Карта» модели
Файл Product.hbm.xml находится в каталоге config/ormmapping. Этот файл
представляет собой «карту», описывающую таблицу products, класс Product и связь
между ними. Именно благодаря этой «карте» PHP on Rails знает, что экземпляр класса
Product надо извлекать из таблицы products, знает о её структуре и автоматически
конструирует SQL запросы для манипулирования объектом. Файлы *.hbm.xml были
позаимствованы из ORM библиотеки для языка Java – Hibernate (hbm в названии файла об
этом и говорит).
«Карта» имеет структуру XML документа, которая ниже рассматривается более
подробно.
<?xml version="1.0" encoding="utf-8" ?>
<hibernate-mapping>
<class name="Product" database="depot_db" table="products">
<id name="id" column="id" type="integer"/>
<property name="title" column="title" type="string" not-null="true"/>
<property name="description" column="description" type="string" notnull="true"/>
<property name="price" column="price" type="float" not-null="true"/>
</class>
</hibernate-mapping>

В корне документа находится элемент class. Его атрибуты name, table и database
определяют соответственно имена класса, таблицы и индекса базы данных. Очень
важно!!! Атрибут name элемента Database из файла конфигурации должен быть
равен атрибуту database элемента class из «карты». Индекс базы данных применяется
для того, чтобы в случае её переименования, не пришлось изменять атрибут database во
всех «картах», которых в приложении могут быть десятки. Достаточно будет изменить
имя базы данных в конфигурационном файле application.cfg.xml.
Таким образом, атрибуты элемента class говорят, что экземпляры класса Product
извлекаются из базы данных с индексом depot_db, из таблицы products.
Дочерними элементами элемента class являются id и property. Элемент id
описывает первичный ключ таблицы. Его атрибуты name, column и type задают имя
атрибута, название столбца таблицы и тип данных, поддерживаемый ORM. В текущей
реализации PHP on Rails имя атрибута name и название столбца column должны
быть равны. Атрибут type имеет 6 значений:
1.
2.
3.
4.
5.
6.

integer –целые числа,
float – действительные числа,
date дата,
time - время,
datetime – дата и время,
string – строковый массив.

В приведённой ниже таблице дано соответствие между типами данных ORM,
СУБД MySQL и PHP.

ORM

MySQL
15

PHP

1
2
3
4
5
6

integer

integer, smallint, int, tinyint, mediumint, case, bool,
bigint, timestamp, year
float
numeric, decimal, float, real, double, dec
date
date
time
time
datetime datetime
string
char, varchar, tinyblob, tinytext, blob, text, mediumblob,
mediumtext, longblob, longtext, enum, set

integer
double
class DateTime
class DateTime
class DateTime
string

Элементы «карты» с именем property описывают столбцы таблицы (свойства
класса). Атрибуты name, type и column у них имеют то же значение, что и у элемента id.
Атрибут not-null равен true или false и определяет, может ли данное поле быть пустым, то
есть равным NULL в таблице.

2.2.3.2 Класс модели
При работе с моделью Product разработчик манипулирует экземплярами класса
Product, определённого в файле app/models/Product.class.php. PHP on Rails реализует
концепцию ORM, идея которой состоит в том, что программист работает не с объектами
базы данных, а с объектами языка программирования, использует не язык СУБД (SQL), а
методы, определённые в обычных классах.
Для каждого своего атрибута (id, title, description, price) класс Product имеет пару
set/get методов. Методы load(), save(), remove(), getList() предназначены для извлечения,
сохранения и удаления объектов из базы данных. Как их использовать, будет описано
дальше.

2.2.3.3 Автозагрузка классов
В PHP on Rails реализован принцип автозагрузки определений классов в
приложение. Для этого используется функция __autoload(), которая автоматически
вызывается, когда в коде упоминается неопределённый ранее класс. Имя этого класса
будет передано в качестве единственного аргумента этой функции. В PHP on Rails
функция __autoload() ищет определение класса в файле с именем <имя класса>.class.php.
Поэтому класс Product определён в файле Product.class.php, расположенном в директории
app/models. В этой директории приложение хранит классы модели данных.

2.2.4 Контроллер
Контроллер предназначен для реализации бизнес-логики приложения. Так вся
логика, связанная с управлением товарами, размещена в контроллере admin. В PHP on
Rails контроллер представляет собой класс. Например, контроллер admin реализован в
классе AdminController.
Изначально в приложении доступно три контроллера: app (класс AppController),
root (RootController) и builder (BuilderController). Класс AppController является предком
для всех контроллеров приложения, в свою очередь, являясь потомком класса Controller.
Класс RootController обслуживает все запросы пользователя по URL http://rails.loc/. Класс
BuilderController реализует контроллер конструктора сайта Site Builder. Он включён в
каждое приложение. Все три контроллера можно увидеть в директории app/controllers/.
При генерации контроллера admin конструктор сайта сделал две вещи:
16

1. создал класс AdminController,
2. в директории public создал поддиректорию admin с файлом index.php, который
вызывается веб-сервером на запросы по URL http://rails.loc/admin/.
Директория public/builder/ не должна быть доступна на рабочем сайте! Не
перемещайте её туда с платформы разработчика.

2.2.4.1 Запуск контроллера
Страница для просмотра товаров находится по адресу http://rails.loc/admin/. Из
этого адреса можно почерпнуть некоторые важные сведения. После базового URL
приложения (http://rails.loc/) указано имя контроллера admin. Запрос обрабатывается
файлом PHP public/admin/index.php. Откройте его.
<?PHP
require_once('../../app.init.php');
require_once(CONTROLLERS_DIR . 'AdminController.class.php');
require_once(SYSTEM_UTILS_DIR . 'Request.class.php');
try {

$controller = new AdminController();
$controller->run(Request::get('action', 'listProducts'));

}
catch (Exception $exception) {
………
}

require_once('../../app.finalize.php');
?>

Обратите внимание на операторы внутри блока try. В нём создаётся объект класса
AdminController и вызывается его метод run() с единственным параметром
Request::get(‘action’, ‘listProducts’). Метод get() класса Request возвращает элемент
массива $_GET[‘action’], если он задан, или значение по умолчанию ‘listProducts’.
Значение параметра action определяет, какую страницу должен обработать контроллер.
Фактически метод run() принимает в качестве единственного аргумента имя страницы,
запрошенной пользователем.

2.2.4.2 Методы контроллера
Теперь загляните внутрь контроллера admin, определённого в файле
app/controllers/AdminController.class.php. В нём кроме конструктора присутствуют методы
actionListProducts, actionAddProduct, actionEditProduct, actionRemoveProduct. Методы,
начинающиеся на “action”, обрабатывают запросы к контроллеру admin.
Таким образом, URL http://rails.loc/[controller name]/index.php?action=[page name]
обрабатывается методом [Controller name]Controller::action[Page name]().
Например, запрос
http://rails.loc/admin/index.php?action=editProduct&productId=2

приведёт к вызову метода actionEditProduct().
17

Внутри метода actionListProducts() находится следующий код.
public function actionListProducts() {
$pagination = new ItemPagination(Product::getClassName(),
Product::getManager(), null, array('id'), 10, 10,
Request::integerParam('page', 1));

}

/**
* Display template
*/
$this->smarty->assign('pagination', $pagination);
$this->smarty->assign('message', $this->getFlash('message'));
$this->smarty->display('listProducts.tpl');

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

Конструктор класса ItemPagination получает следующие параметры:
1. название класса выводимых объектов;
2. менеджер объектов, который отвечает за извлечение, изменение и удаление объектов
из базы данных;
3. условие выборки объектов (выражение WHERE в SQL запросе); в данном примере
оно равно NULL, т.е. не установлено;
4. выражение ORDER BY; в данном примере список из одного поля id;
5. количество объектов, отображаемых на одной странице;
6. количество страниц в одном диапазоне, т.е. 1-10, 11-20 (см. рисунок);
7. номер текущей страницы; в данном примере берётся из параметра запроса page и
равен 1 по умолчанию.
18

После этого метод контроллера вызывает объект представления.

2.2.5 Представление
После комментария Display template контроллер передаёт объекту представления
$this->smarty данные и указывает, какой шаблон использовать для отображения.
Для отображения страниц в браузере используется компилирующий обработчик
шаблонов Smarty (http://smarty.php.net). Он позволяет добиться отделения прикладной
логики и данных от представления. Если вы не знакомы с данной технологией, то
сначала ознакомьтесь с ней, а потом продолжайте чтение данного руководства.
Объект класса AdminController имеет свойство $this->smarty, которое
инициализируется в конструкторе. Это свойство является экземпляром класса
AdminSmarty (подкласс Smarty), который был сгенерирован вместе с контроллером. Он
определён в файле app/views/smarty/AdminSmarty.class.php. В конструкторе класса
AdminSmarty указываются директории, в которых хранятся шаблоны страниц и куда
сохраняются откомпилированные шаблоны. Библиотека PHP on Rails определяет для
этого соответственно директории app/views/templates и app/views/templates_c. Веб-сервер
должен иметь права на запись в последнюю директорию.
Шаблон страницы “listProducts” хранится в файле app/views/templates/admin/
listProducts.tpl. Для отображения данных ему был передан объект $pagination.
Код шаблон содержит один нестандартный плагин Smarty: link. Он отвечает за
формирование ссылок (тегов <A>) в пределах приложения. В качестве аргументов он
принимает имя контроллера и имя страницы (action), также другие дополнительные
параметры URL. Если необходимо задать атрибуты HTML тега <A>, то они записываются
со знаком “_” спереди.
Например, функция
{link action="editProduct" productId=$product->getId() _id="tagId"}
edit
{/link}

выводит текст
<a href="index.php?action=editProduct&productId=2" id="tagId">edit</a>

2.2.6 Проверка данных
Попробуйте создать новый товар. Идите по ссылке “Add new product”. Если вы
нажмёте кнопку “Save” («Сохранить») в появившейся форме, не заполнив все поля, то
товар будет добавлен в таблицу с пустым наименование, пустым описанием и нулевой
ценой.
Очевидно, это не то, что вы хотели. Скорее всего, вам нужен механизм проверки
формы перед сохранением объекта. Библиотека PHP on Rails позволяет задавать
ограничения на свойства объектов. Для этого снова используется XML «карта».

19

Откройте файл config/ormmaping/Product.hbm.xml. Чтобы задать ограничения,
нужно в корень XML документа добавить новый элемент validator. Его дочерние
элементы задают ограничения свойств.
Например, следующий код
<?xml version="1.0" encoding="utf-8" ?>
<hibernate-mapping>
<class name="Product" database="depot_db" table="products">
………
</class>
<validator>
<required property="title">Field title is required</required>
<required property="description">Field description is required</required>
<required property="price">Field price is required</required>
<numeric property="price">Price must be numeric</numeric>
<nonzero property="price">Price is nonzero</nonzero>
</validator>
</hibernate-mapping>

задаёт ограничения:
1. все свойства: title, description и price - обязательны к заполнению (required),
2. свойство price является числовым (numeric),
3. свойство price ненулевое (nonzero).
Каждый элемент, имеет атрибут property, указывающий свойство, на которое
накладывается данное ограничение, и содержит сообщение об ошибке.
<ограничение property=”имя свойства”>сообщение об ошибке</ограничение>

Теперь, пока форма не будет правильно заполнена, новый товар не будет сохранён
в базе данных.
Проверка формы реализуется в методе actionEditProduct() класса AdminController,
который вызывается также методом actionAddProduct().
public function actionAddProduct() {
$this->actionEditProduct();
}
public function actionEditProduct() {
$productId = Request::param('productId');
if (is_null($productId)) {
$product = new Product();
}
else {
$product = Product::load($productId);
}
if (Request::post('save')) {
$product->setProperties($_POST);
$errors = array();
$result = Product::getManager()->validate($_POST,
Product::getClassName());
if ($result !== true) {

20

}

}

}

$errors = array_merge($errors, $result);

if (count($errors) == 0) {
$product->save();
$this->setFlash('message', 'Product was saved');
Response::locate(array('action' => 'listProducts'));
}

/**
* Display template
*/
$this->smarty->assign('product', $product);
if (isset($errors)) {
$this->smarty->assign('errors', $errors);
}
$this->smarty->display('editProduct.tpl');

В этом методе создаётся объект $product. Он либо извлекается из базы данных,
если приложению передаётся параметр productId, либо создаётся новый экземпляр класса
Product. Для извлечения объекта из базы данных используется метод load(), в который
передаётся единственный аргумент – первичный ключ товара в таблице products. Если
товар с таким первичным ключом не найден, то метод load() выбрасывает исключение
типа ItemNotFound.
Метод Request::post() возвращает значение параметра, переданного методом POST,
т.е. берёт его из массива $_POST. Внутри составного оператора if (Request::post('save')) {}
определяются операторы, выполняющиеся, если контроллеру передан параметр с именем
save ($_POST[‘save’]), что соответствует нажатой кнопке “Save”.
При этом значения атрибутов объекта $product заполняются параметрами из
массива $_POST, что соответствует строке
$product->setProperties($_POST);

После этого происходит проверка, чтобы все обязательные поля формы были
заполнены, а поле price имело числовое ненулевое значение.
$result = Product::getManager()->validate($_POST, Product::getClassName());

Менеджер объектов, получаемый методом Product::getManager(), проверяет, чтобы
элементы массива $_POST, являющиеся атрибутами класса Product (id, title, description,
price) удовлетворяли описанным в «карте» ограничениям.
В случае успешной проверки метод validate() менеджера объектов возвращает true,
иначе массив ошибок, который потом помещается в массив errors.
Если проверка прошла успешно, то объект сохраняется в базе данных. Для этого
используется метод save(). После чего пользователь пересылается вновь на страницу
просмотра товаров, где видит сообщение “Product was saved” (Товар был сохранён). Для
переадресации страницы используется метод Response::locate(), который посылает
браузеру заголовок Location со значением, равным адресу страницы “listProducts”.

21

Аргумент метода locate – это либо строка URL, либо массив. Элементы массива
задают контроллер, страницу и дополнительные параметры GET. В данном случае
используется тот же контроллер, поэтому он не задан явно. Ключ action указывает на
страницу просмотра товаров.

2.2.6.1 Флеш-параметры
Перед тем как перезагрузить страницу приложение устанавливает флеш-параметр
message.
$this->setFlash('message', 'Product was saved');

Этот параметр сохраняется в сессии приложения (каждое приложение PHP on Rails
имеет сессию по умолчанию). Время жизни флеш-параметра такое, что он доступен
только при следующем показе страницы приложения. Отредактируйте товар и вы увидите
это сообщение. Обновите данную страницу ещё раз. Сообщение пропало.
Для задания флеш-параметра используется метод Controller::setFlash(), в который
передаются два аргумента: имя и значение параметра.
Для получения значения флеш-параметра используется метод Controller::getFlash(),
который получает только один аргумент – имя параметра. Если запрашивается
несуществующий параметр, то возвращается NULL.

2.2.7 Улучшение интерфейса
На данный момент приложение depot выглядит очень непривлекательно. Так что
настало время придать ему более презентабельный вид. Общая идея дизайна изображена
на рисунке ниже.

Данный дизайн использует css-файл, который необходимо разместить по адресу
public/css/style.css, поскольку он может (и будет) использоваться многими контроллерами.
public/css/style.css
/***** Page *****/

22

body {
background:#636958;
color:#333;
font:70% Verdana,Tahoma,Arial,sans-serif;
margin:0;
padding:0;
}
/**** Links ****/
A:link { text-decoration: underline; color: #003077; }
A:visited { text-decoration: underline; color: #205090;}
A:active { text-decoration: underline; color: #FF9900;}
A:hover { text-decoration: none; color: #FF9900;}
/***** Wrapper *****/
#wrapper {
background:#f5f5f5;
border-left:5px solid #53584A;
border-right:5px solid #53584A;
color:#000;
margin:0 auto;
padding:0;
width:550px;
}
/***** Top *****/
#top{

}

background:#69C;
color:#fff;
height:40px;
margin:0;
padding:0;

/***** Navigation *****/
#navigation ul,#navigation li {
margin:0;
padding:0;
}
#navigation {
background:#71A70B;
color:#fff;
font-size:1em;
height:2em;
line-height:2em;
}
#navigation li {
float:left;
list-style:none;
white-space:nowrap;
}
#navigation li a {
background:inherit;
color:#fff;
display:block;
font-weight:bold;

23

}

padding:0 15px;
text-decoration:none;
text-transform:uppercase;

* html #navigation a {width:1%;}
#navigation .selected,#navigation a:hover {
background:#81C00C;
color:#fff;
text-decoration:none;
}
/***** Content *****/
#content {
background:#fff;
float:left;
padding:15px 10px;
width:450px;
color:#000;
}
/***** Footer *****/
#footer {
background:#71A70B;
clear:both;
color:#fff;
font-size:.9em;
height:1.8em;
line-height:1.8em;
padding:0;
text-align:center;
}

HTML код страницы можно разделить на три логические части: заголовок с меню,
содержательную и нижнюю. Для всех страниц контроллера admin первая и последняя
части одинаковы, поэтому их можно поместить в отдельные файлы: header.tpl и footer.tpl.
app/views/templates/admin/header.tpl
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
<meta http-equiv="content-type" content="text/html; windows-1251" />
<link rel="stylesheet" type="text/css" href="../css/style.css"
media="screen" />
<title>Администрирование магазина</title>
</head>
<body>
<div id="wrapper">
<div id="top" align="center">
<h1>Администрирование магазина</h1>
</div>
<div id="navigation">
<ul>
<li>

24

{link controller="admin" action="listProducts" _class="selected"}
Просмотр товаров
{/link}
</li>
</ul>
</div>
<div id="content">

app/views/templates/admin/footer.tpl
</div>

<div id="footer">&copy; 2006 Depot</span></div>
</div>
</body>
</html>

Теперь остаётся только подключить эти шаблоны к уже созданным страницам. Для
этого добавьте в начало файлов listProducts.tpl и editProduct.tpl строку
{include file="header.tpl"}

, а в конец
{include file="footer.tpl"}

Теперь посмотрите, как выглядит приложение Depot после изменения дизайна.

25

Старайтесь выделять общие фрагменты дизайна в отдельные файлы, как это было
сделано с header.tpl и footer.tpl, чтобы их легко можно было использовать повторно.

2.2.8 Удаление товара
Запрос на удаление товара обслуживается методом actionRemoveProduct()
контроллера admin.
public function actionRemoveProduct() {
$product = Product::load(Request::param('productId'));
$product->remove();
$this->setFlash('message', 'Product was removed');
Response::locate2referer();
}

Он очень простой. Сначала загружается объект $product по первичному ключу,
указанному в URL, а потом вызывается метод remove(), который удаляет объект из базы
данных. После этого задаётся значение флеш-параметра message и браузеру посылается
заголовок перейти на предыдущую страницу. Метод Response:: locate2referer() аналогичен
вызову метода locate() с параметром, равным $_SERVER[‘HTTP_REFERER’].
26

Метод actionRemoveProduct не использует представление, и поэтому для него нет
шаблона removeProduct.tpl.

2.2.9 Добавление нового атрибута
Как только администратор создаёт новый вид товара, используя разработанный
выше интерфейс, то этот товар сразу становится доступен покупателю. Это не всегда то,
что хочет продавец. Логично было бы указывать дату, начиная с которой товар поступает
в продажу. Для этого необходимо добавить в таблицу products новое поле. Это легко
сделать при помощи следующей SQL-команды.
ALTER TABLE products ADD COLUMN availableDate DATE
Чтобы приложение знало о новом поле в таблице products и умело с ним работать,
нужно добавить в «карту» config/ormmapping/Product.hbm.xml строку
<property name="availableDate" column="availableDate" type="date"/>
, которая определяет атрибут типа date, а в класс Product - два новых метода.
public function setAvailableDate(DateTime $value) {
$this->__set('availableDate', $value);
}
public function getAvailableDate() {
return $this->__get('availableDate');
}

Для хранения атрибута availableDate используется класс DateTime.
Теперь нужно, чтобы страницы просмотра, добавления и редактирования товаров
отображали новое поле.

2.2.9.1 Просмотр товаров
Ниже приведён фрагмент код шаблона app/views/templates/admin/listProducts.tpl.
Красным цветом в нём показаны изменённые, а синим новые строки.
{include file="header.tpl"}
………
<tr align="center">
<TD bgcolor="#90C090" colspan="7">
<STRONG>Products</STRONG>
………
</TD>
</tr>
{if $message}
<tr align="center">
<TD bgcolor="#FFFFFF" colspan="7">
………
</TD>
</tr>
{/if}
<tr align="center">

27

</tr>

………
<TD bgcolor="#90C090"><STRONG>Price</STRONG></TD>
<TD bgcolor="#90C090"><STRONG>Available Date</STRONG></TD>
………

{foreach from=$products item=product}
<tr valign="top">
………
<TD bgcolor="#FFFFFF">
{$product->getPrice()|escape}
</TD>
<TD bgcolor="#FFFFFF">
{if $product->isNull('availableDate')}
не указана
{else}
{assign var="availableDate" value=$product->getAvailableDate()}
{$availableDate->getTimestamp()|date_format:"%A, %B %e, %Y"}
{/if}
</TD>
………
</tr>
{foreachelse}
<tr>

</tr>

<TD bgcolor="#FFFFFF" colspan="7" align="center">
No products
</TD>

{/foreach}
<tr>

</tr>

<TD bgcolor="#FFFFFF" colspan="7" align="center">
{assign var="currentRange" value=$pagination->getCurrentRange()}
{foreach from=$pagination->getRanges() item=range}
………
{/foreach}
</TD>

</table>
{include file="footer.tpl"}

Если дата не указана, то вместо неё выводится соответствующее сообщение. В
противном случае отображается отформатированная строка.

2.2.9.2 Добавление и редактирование товара
Ниже приведён фрагмент файла app/views/templates/admin/editProduct.tpl. Цветовые
обозначения остаются те же.
{include file="header.tpl"}
<FORM method="post">
<table border="0" cellpadding="4" cellspacing="1" align="center"
bgcolor="#90C090">
………
<TR>
<TD bgcolor="#90C090"><STRONG>Price</STRONG></TD>
<TD bgcolor="#FFFFFF">

28

</TR>
<TR>

<INPUT type="text" name="price" value="{$product->getPrice()|escape}"/>
</TD>

<TD bgcolor="#90C090"><STRONG>Available date</STRONG></TD>
<TD bgcolor="#FFFFFF">
{if $product->isNull('availableDate')}
{html_select_date field_array="availableDate"}
{else}
{assign var="availableDate" value=$product->getAvailableDate()}
{html_select_date field_array="availableDate" time=$availableDate>getTimestamp()}
{/if}
</TD>
</TR>
………
</table>
</FORM>
{include file="footer.tpl"}

29

2.3 Каталог товаров
На первом этапе разработки был создан интерфейс для управления товаром
магазина. Следующей задачей является отображение каталога товаров посетителям сайта потенциальным покупателям. Они могут видеть только те товары, у которых дата начала
продаж меньше текущей.
Логично разместить каталог товаров на главной странице приложения http://rails.loc/. Как уже упоминалось, все страницы, расположенные по этому URL
обслуживаются контроллером root. Класс RootController изначально имеет метод
actionIndex, который соответствует значению по умолчанию параметра action. Именно
этот метод и будет реализовывать просмотр каталога товаров. Шаблон представления
данной страницы находится в файле app/views/templates/root/index.tpl.
Контроллер должен передать представлению список товаров. Список товаров будет
предоставлен моделью. Вот как выглядит метод actionIndex контроллера RootController.
public function actionIndex() {

}

/**
* Display template
*/
$this->smarty->assign('products', Product::salableItems());
$this->smarty->display('index.tpl');

Этот код неработоспособный. Сначала необходимо определить в классе Product
статический метод salableItems(), который возвращает список товаров, доступных для
продажи.
public static function salableItems() {
return self::getList('availableDate <= NOW()', 'availableDate DESC');
}

Метод getList() возвращает массив объектов класса Product. Его первый параметр
определяет условие выборки товаров из базы данных. Он может быть строкой, массивом
пар поле => значение или экземпляром класса Expression.
Если это строка, то она должна удовлетворять SQL синтаксису выражения
WHERE. Если это массив, то условие выборки будет иметь следующий вид
(ключ1 = значение1) AND (ключ2 = значение2) AND (ключ3 = значение3)

Второй параметр определяет порядок сортировки: первыми отображаются более
новые товары.
Ниже приведён код шаблона представления. Он отображает простой список
товаров с указанием их наименования, цены и ссылки «Добавить в корзину». Эта ссылка
пока не работает, но она предназначена для добавления товара в Покупательскую
корзину. Заметьте также, что в данном шаблоне используется вызов файла footer.tpl из
контроллера admin, в котором содержится код нижней части интерфейса.
app/views/templates/root/index.tpl
30

{include file="header.tpl" pageTitle='Каталог товаров'}
<ul>
{foreach from=$products item=product}
<li>
<strong>{$product->getTitle()|escape}</strong>
<br/>
{$product->getDescription()|escape}
<br/>
Цена: {$product->getPrice()|string_format:"%0.2f"} $
{link action="add2cart" productId=$product->getId()}
Добавить в корзину
{/link}
<br/><br/>
</li>
{/foreach}
</ul>
{include file="../admin/footer.tpl"}

app/views/templates/root/header.tpl
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
<meta http-equiv="content-type" content="text/html; windows-1251" />
<link rel="stylesheet" type="text/css" href="../css/style.css"
media="screen" />
<title>Интернет- магазин</title>
</head>
<body>
<div id="wrapper">
<div id="top" align="center">
<h1>{$pageTitle|escape|default:"Интернет-магазин"}</h1>
</div>
<div id="navigation">
<ul>
<li>
{link _class="selected"}
Каталог товаров
{/link}
</li>
</ul>
</div>
<div id="content">

Шаблон header.tpl содержит переменную $pageTitle, значение которой передаётся
как параметр функции include.
А вот результат работы.

31

Просмотр каталога товаров готов.

32

2.4 Создание покупательской корзины
В предыдущем разделе был создан интерфейс, дающий возможность покупателям
просматривать товары. Однако эти товары необходимо ещё продавать. Поэтому
следующий шаг разработки – это реализация функций для работы с Покупательской
корзиной.

2.4.1 Сессии
Просматривая каталог товаров, пользователь выбирает некоторые из них для
покупки. Выбранные товары заносятся в виртуальную Покупательскую корзину. Корзина
– это список товаров, их количество и цена. Веб-приложение должно как-то сохранять
содержимое Покупательской корзины во время навигации пользователя по сайту. Для
этого отлично подходит сессия. Сессия, открытая для первого запроса пользователя,
доступна и для последующих запросов. Здесь не описывается механизм работы сессий в
PHP. Предполагается, что читатель знаком с ним.
В PHP on Rails для работы с сессией используется класс Session
(app/system/com/utils/Session.class.php). Этот класс базируется на обычной сессии языка
PHP, реализуя более удобный интерфейс использования и механизм защиты от воровства
чужих сессий, благодаря привязке сессии к IP адресу и другим параметрам клиента.
Каждый контроллер PHP on Rails имеет сессию по умолчанию Controller::
appSession. Это так называемая сессия приложения. Для сохранения значений
переменных в сессии и получении их обратно используются методы set() и get(). Метод
flash() аналогичен set(), но сохраняет в сессии флеш-параметры. Метод контроллера
setFlash() на самом деле вызывает соответствующий метод сессии приложения.
Чтобы получить в контроллере root экземпляр Покупательской корзины,
реализуйте следующий метод.
private function findCart() {
if (!$this->appSession->_isset('cart')) {
$this->appSession->set('cart', new Cart());
}
return $this->appSession->get('cart');
}

Если Покупательская корзина ещё не создана, то этот метод создает и сохраняет в
сессии приложения новый экземпляр класса Cart, а потом возвращает его в качестве
результата.

2.4.2 Новая модель данных
В Покупательской корзине должны хранится товары, выбранные клиентом для
покупки. Каждая запись в корзине – это комбинация из товара, количества единиц
данного товара и цены за одну единицу.
Имея эти данные, можно создать новую таблицу line_items.
CREATE TABLE `line_items` (
`id` int(10) unsigned NOT NULL auto_increment,
`productId` int(10) unsigned NOT NULL,
`quantity` tinyint(4) NOT NULL,

33

`unitPrice` decimal(10,2) NOT NULL,
PRIMARY KEY (`id`),
KEY `productId` (`productId`),
CONSTRAINT `productId` FOREIGN KEY (`productId`) REFERENCES `products`
(`id`)
)

Теперь необходимо создать модель для этой таблицы. Для этого используйте
конструктор сайта http://rails.loc/builder/. Зайдите в меню “Models”, выберите ссылку
“Generate *.hbm.xml”, а затем “Generate item” -, как показано ниже. Заполните первую
форму.

В результате вы должны получить «карту» config/ormmaping/LineItem.hbm.xml.
После этого сгенерируйте файл с определением класса app/models/LineItem.class.php.

2.4.2.1 Связь «многие-к-одному»
При создании таблицы line_items был указан внешний ключ на таблицу products.
Эта связь имеет тип «многие-к-одному». Таким образом, каждый объект класса LineItem
ссылается на один объект Product. Необходимо дать PHP on Rails знать об этой связи. Для
этого используется только что созданная «карта». Эта «карта» содержит описание столбца
productId, который используется в качестве внешнего ключа. Библиотека PHP on Rails
делает возможным получить из экземпляра одного класса доступ к связанному с ним
экземпляру другого класса. Замените в файле config/ormmapping/LineItem.hbm.xml
определение атрибута
<property name="productId" column="productId" type="integer" notnull="true"/>

на

34

<many-to-one name="product" column="productId" type="integer" not-null="true"
class="Product"/>

Теперь каждый объект класса LineItem имеет свойство product по имени связи.
echo $lineItem->product->getTitle();

Атрибут productId по-прежнему доступен, т.к. элемент many-to-one кроме связи
автоматически определяет и атрибут класса.
$lineItem->setProductId(1);
echo $lineItem->getProductId();

В текущей реализации PHP on Rails свойство-связь «многие-к-одному» доступно
только для чтения, т.е. строка
$lineItem->product = new Product()

приведёт к выбросу исключения.

2.4.3 Создание покупательской корзины
Написанный код по-прежнему является нерабочим, т.к. не определён класс Cart. В
отличие от предыдущих классов модели данных, которые являются отображением таблиц
базы данных, класс Cart необходим только для сохранения в сессии, а значит, он является
обычным классов и ему не нужна «карта».
Создайте в директории app/models новый класс.
Cart.class.php
<?php
class Cart {
public function getItems() {
return $this->items;
}
public function getTotalAmount() {
return $this->totalAmount;
}

}

private $items = array();
private $totalAmount = 0.0;

?>

В классе Cart определён массив из элементов LineItem и общая стоимость всех
товаров.

2.4.3.1 Добавление товара в корзину
В шаблоне index.tpl контроллера root определена ссылка
35

{link action="add2cart" productId=$product->getId()}
Добавить в корзину
{/link}

, выбрав которую, клиент добавляет товар с указанным идентификатором в
Покупательскую корзину. Необходимо реализовать соответствующий метод контроллера
для осуществления этих действий.
Библиотека PHP on Rails берёт на себя создание нового метода контроллера.
Для этого как всегда используйте конструктор сайта. Зайдите в меню “Controllers” и
выберите ссылку “Add action”. Заполните её ниже указанным способом.

После генерации в классе RootController появился новый метод actionAdd2cart(). Он
пустой, и ваша задача - его реализовать следующим образом.
public function actionAdd2cart() {
$product = Product::load(Request::integerParam('productId'));
$cart = $this->findCart();
$cart->addProduct($product);
Response::locate(array('action'=> 'displayCart'));
}

В данном коде используются неопределённый метод addProduct() класса Cart и
переадресация на метод контроллера displayCart. Реализуйте метод addProduct()
следующим образом.
public function addProduct(Product $product) {
if (isset($this->items[$product->getId()])) {
/*@var $lineItem LineItem*/
$lineItem = $this->items[$product->getId()];
$lineItem->setQuantity($lineItem->getQuantity() + 1);
}
else {
$lineItem = new LineItem();
$lineItem->setProductId($product->getId());
$lineItem->setQuantity(1);
$lineItem->setUnitPrice($product->getPrice());
$this->items[$product->getId()] = $lineItem;
}
$this->totalAmount += $product->getPrice();
}

36

Сначала проверяется, был ли такой же товар уже помещён в корзину. Если да, то
просто увеличивается на единицу его количество. Если же нет, то создаётся новый объект
LineItem и помещается в корзину. После этого стоимость товара прибавляется к общей
стоимости корзины.

2.4.3.2 Просмотр корзины
Остался нереализованным метод контроллера RootController, предназначенный для
обслуживания ссылки index.php?action=displayCart. Добавьте его при помощи
конструктора сайта. И не забудьте, что этот метод использует представление смарти для
отображения корзины.

Для отображения корзины контроллер должен лишь передать представлению
объект класса Cart.
public function actionDisplayCart() {

}

/**
* Display template
*/
$this->smarty->assign('cart', $this->findCart());
$this->smarty->display('displayCart.tpl');

app/views/templates/root/displayCart.tpl
{include file="header.tpl" pageTitle='Покупательская корзина'}
<ul>
{foreach from=$cart->getItems() item=item}
{assign var="product" value=$item->product}
{assign var="unitPrice" value=$item->getUnitPrice()}
{assign var="quantity" value=$item->getQuantity()}
<li>
<strong>{$product->getTitle()|escape}</strong>
<br/>
{$product->getDescription()|escape}
<br/>
<strong>Цена единицы товара</strong>:
{$unitPrice|string_format:"%0.2f"}$
<strong>Количество</strong>:
{$quantity}
<strong>Всего</strong>:

37

{$unitPrice*$quantity|string_format:"%0.2f"}$
<br/><br/>
</li>
{/foreach}
</ul>
<hr/>
<strong>Общая стоимость</strong>:
{$cart->getTotalAmount()|string_format:"%0.2f"}$
{include file="../admin/footer.tpl"}

А вот как выглядит страница.

2.4.3.3 Последние штрихи
Чтобы сделать работу с Покупательской корзиной более удобной, надо добавить в
приложение ещё кое-какой функционал. Во-первых, пользователь должен иметь
возможность просмотреть содержимое корзины с любой страницы сайта, поэтому в
главное меню нужно добавить ссылку «Просмотреть корзину». Во-вторых, на странице
просмотра корзины нужно добавить ссылки «Очистить корзину» и «Оформить заказ».
Чтобы добавить новый пункт в главное меню, нужно изменить файл
app/views/templates/root/header.tpl.
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
………
<body>
………

38

<div id="navigation">
<ul>
<li>
{if !$smarty.request.action ||
$smarty.request.action == 'index'}
{link _class="selected"}
Каталог товаров
{/link}
{else}
{link}
Каталог товаров
{/link}
{/if}
</li>
<li>
{if $smarty.request.action == 'displayCart'}
{link action="displayCart" _class="selected"}
Просмотр корзины
{/link}
{else}
{link action="displayCart"}
Просмотр корзины
{/link}
{/if}
</li>
</ul>
</div>
<div id="content">

Если корзина пустая, то нет смысла отображать её. Логичнее перебросить
пользователя на страницу просмотра каталога товаров и вывести соответствующее
сообщение. Для этого необходимо изменить метод actionDisplayCart() следующим
образом.
public function actionDisplayCart() {
$cart = $this->findCart();
if (count($cart->getItems()) == 0) {
$this->setFlash('message', 'Корзина пустая');
Response::locate(array('action' => 'index'));
}

}

/**
* Display template
*/
$this->smarty->assign('cart', $cart);
$this->smarty->display('displayCart.tpl');

На странице «Каталог товаров» тоже необходимо произвести некоторые изменения
для вывода флеш-параметра message.
app/controllers/RootController.class.php
public function actionIndex() {
/**
* Display template
*/
$this->smarty->assign('message', $this->getFlash('message'));
$this->smarty->assign('products', Product::salableItems()$products);

39

}

$this->smarty->display('index.tpl');

app/views/templates/root/index.tpl
{include file="header.tpl" pageTitle='Каталог товаров'}
{if $message}
<h3 align="left">
<font color="Blue">{$message|escape}</font>
</h3>
{/if}
………
{include file="../admin/footer.tpl"}

Теперь необходимо добавить на страницу просмотра корзины ссылки «Очистить
корзину» и «Оформить заказ».
app/views/templates/root/displayCart.tpl
{include file="header.tpl" pageTitle='Покупательская корзина'}
{link action=emptyCart}Очистить корзину{/link}&nbsp;&nbsp;
{link action=checkout}Оформить заказ{/link}&nbsp;&nbsp;
………
{include file="../admin/footer.tpl"}

Метод actionEmptyCart(), обрабатывающий запрос на очистку корзины, довольно
прост. Он вызывает метод _empty() экземпляра корзины, который очищает её содержимое,
устанавливает флеш-параметр message и пересылает пользователя на страницу просмотра
товаров. Сгенерируйте каркас этого метода при помощи конструктора сайта.

public function actionEmptyCart() {
$cart = $this->findCart();
$cart->_empty();
$this->setFlash('message', 'Корзина теперь пустая');
Response::locate(array('action' => 'index'));
}

app/models/Cart.class.php
<?php

40

class Cart {
………
public function _empty() {
$this->items = array();
$this->totalAmount = 0.0;
}
………
}
?>

Оформление заказов будет реализовано позже.

2.4.4 Пример обработки ошибок
Наберите
в
браузере
следующую
http://rails.loc/index.php?action=add2cart&productId=11111111.

ссылку

На самом деле, это запрос на добавление в корзину товара с первичным ключом,
равным 11111111. Такого продукта не существует. Как результат вы увидите в браузере
сообщение об исключении ItemNotFound. Метод load() выбрасывает это исключение, если
не может извлечь из базы данных объект с первичным ключом, указанным как параметр.
Чтобы обрабатывать такие ошибки, нужно заключить строку, выбрасывающую
исключение, в блок try … catch.
public function actionAdd2cart() {
try {
$product = Product::load(Request::integerParam('productId'));
$cart = $this->findCart();
$cart->addProduct($product);
Response::locate('index.php?action=displayCart');
}
catch (ItemNotFound $e) {
$this->setFlash('message', 'Товар с идентификатором ' .
Request::integerParam('productId').
' не найден');
Response::locate('index.php');
}
}

При возникновении исключения контроллер перебрасывает пользователя на
страницу просмотра каталога товаров с соответствующим сообщением.

2.4.5 Пример рефакторинга
В контроллере RootController в трёх местах выполняется похожий код:
устанавливается значение флеш-параметра message и браузеру шлётся заголовок перейти
на страницу просмотра каталога товаров. Его можно выделить в отдельный private метод
redirect2index(). Вот как выглядят изменённые фрагменты класса RootController после
небольшого рефакторинга.
<?PHP
………
class RootController extends AppController {
………
public function actionAdd2cart() {
try {
}
catch (ItemNotFound $e) {

41

$this->redirect2index('Товар с идентификатором ' .
Request::integerParam('productId').
' не найден');
}

}

public function actionDisplayCart() {
$cart = $this->findCart();
if (count($cart->getItems()) == 0) {
$this->redirect2index('Корзина пустая');
}
………
}
public function actionEmptyCart() {
$cart = $this->findCart();
$cart->_empty();
$this->redirect2index('Корзина теперь пустая');
}
private function redirect2index($message) {
$this->setFlash('message', $message);
Response::locate(array('action' => 'index'));
}
}
?>

Работа с Покупательской корзиной закончена. Время оформлять покупки.

42

2.5 Покупка
После того, как был реализован интерфейс для просмотра каталога товаров и
заполнения Покупательской корзины, необходимо дать возможность клиенту сделать
заказ, а администратору магазина этот заказ принять. При оформлении заказа клиент
должен указать свою контактную информацию и данные об оплате. Реализация какойлибо системы оплаты не является целью этого руководства, поэтому процедура оплаты
будет простой.

2.5.1 Оформление заказа
На странице просмотра корзины есть ссылка «Оформить заказ». По ней клиент
переходит на новую страницу, где он может указать детали своей покупки и заказать
товар.
Заказ – это содержание Покупательской корзины вместе с деталями покупки:
контактной информацией клиента, методом оплаты и т.д. Заказы сохраняются в отдельной
таблице orders. Эта таблица определяется следующей структурой.
CREATE TABLE `orders` (
`id` int(10) unsigned NOT NULL auto_increment,
`name` varchar(100) NOT NULL,
`email` varchar(255) NOT NULL,
`address` text NOT NULL,
`paymentType` char(10) NOT NULL,
PRIMARY KEY (`id`)
)

Поскольку заказ содержит список товаров из Покупательской корзины, т.е.
объекты LineItem, то необходимо добавить в таблицу line_items внешний ключ,
указывающий, какому заказу принадлежит запись о товаре. Измените структуру таблицы
line_items как показано в SQL команде.
ALTER TABLE `line_items` ADD COLUMN `orderId` int(10) unsigned NOT NULL;
CREATE INDEX iOrderId ON `line_items`(`orderId`);
ALTER TABLE `line_items` ADD CONSTRAINT `orderId` FOREIGN KEY (`orderId`)
REFERENCES `orders` (`id`);

Сгенерируйте «карту» для таблицы orders и класс Order.

43

В «карту» LineItem.hbm.xml нужно добавить определение свойства orderId.
config/ormmapping/LineItem.hbm.xml
<?xml version="1.0" encoding="utf-8" ?>
<hibernate-mapping>
<class name="LineItem" database="depot_db" table="line_items">
………
<property name="orderId" column ="orderId" type="integer" not-null="true"/>
</class>
</hibernate-mapping>

Теперь необходимо добавить в класс LineItem методы set и get для нового атрибута.
А можно просто сгенерировать класс LineItem снова. Для этого достаточно установить
опцию “Rewrite if exists (Перезаписать, если существует)” в конструкторе сайта.

Оформление заказа осуществляется
RootController. Создайте и реализуйте его.

44

методом

actionCheckout()

контроллера

public function actionCheckout() {
$cart = $this->findCart();

}

/**
* Display template
*/
$this->smarty->assign('paymentTypes', Order::$paymentTypes);
$this->smarty->display('checkout.tpl');

Данный метод отображает страницу с формой, которую необходимо заполнить для
оформления заказа. Также в класс Order был добавлен статический элемент
$paymentTypes, содержащий массив методов оплаты.
Order.class.php
<?PHP
class Order extends Item {
………
public static $paymentTypes = array('check' => 'Чек', 'cc' => 'Кредитная
карта');
}
?>

Шаблон,
содержащий
app/views/templates/root/checkout.tpl.

форму

заказа,

хранится

в

файле

{include file="header.tpl" pageTitle='Оформление заказа'}
<form action="{url action='saveOrder'}" method="post">
<table border="0" align="center" cellpadding="4" cellspacing="4">
<tr align="left" valign="top">
<td>Имя</td>
<td>
<input type="text" name="name" value="{$smarty.post.name|escape}"/>
</td>
</tr>
<tr align="left" valign="top">
<td>E-mail</td>
<td>
<input type="text" name="email" value="{$smarty.post.email|escape}"/>
</td>
</tr>
<tr align="left" valign="top">
<td>Адрес</td>
<td>
<textarea name="address" cols="30" rows="4">
{$smarty.post.address|escape}
</textarea>
</td>
</tr>
<tr align="left" valign="top">
<td>Платить</td>
<td>
<select name="paymentType">
<option value="">Выберите метод оплаты</option>
{html_options options=$paymentTypes selected=$smarty.post.paymentType}

45

</select>
</td>
</tr>
<tr>
<td colspan="2" align="center">
<input type="submit" name="checkout" value="Заказать"/>
</td>
</tr>
</table>
</form>
{include file="../admin/footer.tpl"}

Адрес, на который указывает элемент form, задаётся при помощи ещё одной
дополнительной функции Smarty: url. Эта функция формирует URL в пределах
приложения. В качестве аргументов она принимает контроллер controller (можно
опускать, если используется текущий), имя страницы action и другие дополнительные
параметры, передающиеся в URL.
Форма оформления заказа обрабатывается методом actionSaveOrder() контроллера
root. Если форма была заполнена правильно, то заказ сохраняется в базе данных. В
противном случае, метод снова отображает форму, вызывая при этом метод
actionCheckout(), которому в качестве необязательного параметра передаётся массив
ошибок. Так достигается повторное использование кода.
public function actionCheckout($errors = null) {
$cart = $this->findCart();

}

/**
* Display template
*/
$this->smarty->assign('paymentTypes', Order::$paymentTypes);
if (!is_null($errors)) {
$this->smarty->assign('errors', $errors);
}
$this->smarty->display('checkout.tpl');

public function actionSaveOrder() {
$manager = Order::getManager();
$result = $manager ->validate($_POST, Order::getClassName());
if ($result === true) {
try {
$driver = $manager->getDriver();
$driver->startTransaction();
$order = new Order($_POST);
$order->save();
$cart = $this->findCart();
$items = $cart->getItems();
foreach ($items as $item) {
/*@var $item LineItem*/
$item->setOrderId($order->getId());
$item->save();
}
$driver->commit();
$cart->_empty();
$this->redirect2index('Спасибо за покупку');

}
catch (SqlException $e) {
$driver->rollback();

46

}

}

$errors = array('Заказ не был сохранён');

}
else {
$errors = $result;
}
$this->actionCheckout($errors);

Теперь надо задать ограничения в «карте» Order.hbm.xml.
<validator>
<required property="name">Имя обязательно</required>
<required property="email">E-mail обязателен</required>
<email property="email">Неправильный формат e-mail</email>
<required property="address">Адрес обязателен</required>
<required property="paymentType">Выберите метод оплаты</required>
</validator>

В «карте» заданы следующие ограничения:
1. все поля формы (свойства модели) обязательны к заполнению,
2. поле email должно иметь правильный формат e-mail’а.
Если вы сохраняете файл «карты» не в кодировке UTF-8, то укажите правильную
кодировку в заголовке XML документа. Например,
<?xml version="1.0" encoding="windows-1251" ?>

Если форма была заполнена правильно, то создаётся объект Order,
инициализированный данными формы, и сохраняется в базе данных. Если заказ
регистрируется успешно, то корзина очищается, а клиент перебрасывается на страницу
просмотра каталога товаров. Иначе ему выводится сообщение об ошибке и предлагается
оформить заказ снова.
При регистрации заказа в базе данных сначала сохраняется объект $order, чтобы
присвоенный ему первичный автоинкрементный ключ, можно было указать для всех
объектов класса LineItem. Все SQL команды выполняются в рамках одной транзакции. Так
что, если ваша база данных поддерживает транзакции (для этого в СУБД MySQL
создавайте таблицы
типа InnoDB), то в случае провала одной SQL команды,
откатываются все изменения в базе данных.
Объект $driver инкапсулирует соединение с базой данных. Он является
экземпляром класса DatabaseDriver, реализующим уровень абстрактного доступа к СУБД.
Каждый менеджер объектов имеет свой собственный драйвер базы данных. Все созданные
до этого классы модели данных используют один менеджер объектов, а, следовательно,
одно соединение с базой данных.
Последнее, что осталось, это добавить в шаблон представления код, выводящий на
странице сайта сообщения об ошибках. Вот изменённый фрагмент шаблона.
app/views/templates/root/checkout.tpl
{include file="header.tpl" pageTitle='Оформление заказа'}

47

<form action="index.php?action=saveOrder" method="post">
<table border="0" align="center" cellpadding="4" cellspacing="4">
{if isset($errors)}
<tr align="left" valign="top">
<td colspan="2" align="center">
<font color="Red">
{foreach from=$errors item=error}
{$error|iconv:"UTF-8":"WINDOWS-1251"|escape}
<br/>
{/foreach}
</font>
</td>
</tr>
{/if}
<tr align="left" valign="top">
<td>Имя</td>
<td>
<input type="text" name="name" value="{$smarty.post.name|escape}"/>
</td>
</tr>
………
</table>
</form>
{include file="../admin/footer.tpl"}

Сообщения об ошибках выводятся только, если установлен параметр $errors. При
их выводе используется нестандартный модификатор смарти: iconv. Это ещё одно
дополнение PHP on Rails. Действия модификатора iconv аналогично одноименной
функции PHP: текст ошибки преобразуется из кодировки UTF-8 в Windows-1251. Если не
использовать данный модификатор, то текст на русском языке будет отображён
некорректно.

48

2.5.2 Вывод содержимого корзины при оформлении заказа
Клиенту было бы удобно видеть на странице оформления заказа список,
покупаемых им товаров. Для этого достаточно было бы передать представлению
экземпляр корзины, а в шаблон страницы добавить необходимый HTML код. Передать
представлению объект корзины можно, добавив в метод actionCheckout() строку
$this->smarty->assign('cart', $cart);

Чтобы отобразить корзину на странице оформления заказа, можно воспользоваться
шаблоном app/views/templates/root/displayCart.tpl. Часть этого шаблона можно выделить в
отдельный файл cartContent.tpl и использовать его для других страниц. Вот что из этого
получится.
Новый файл шаблона app/views/templates/root/cartContent.tpl
<ul>
{foreach from=$cart->getItems() item=item}
{assign var="product" value=$item->product}
{assign var="unitPrice" value=$item->getUnitPrice()}

49

{assign var="quantity" value=$item->getQuantity()}
<li>
<strong>{$product->getTitle()|escape}</strong>
<br/>
{$product->getDescription()|escape}
<br/>
<strong>Цена единицы товара</strong>:
{$unitPrice|string_format:"%0.2f"}$
<strong>Количество</strong>:
{$quantity}
<strong>Всего</strong>:
{$unitPrice*$quantity|string_format:"%0.2f"}$
<br/><br/>
</li>
{/foreach}
</ul>

app/views/templates/root/displayCart.tpl
{include file="header.tpl" pageTitle='Покупательская корзина'}
{link action=emptyCart}Очистить корзину{/link}&nbsp;&nbsp;
{link action=checkout}Оформить заказ{/link}&nbsp;&nbsp;
{include file="cartContent.tpl"}
<hr/>
<strong>Общая стоимость</strong>:
{$cart->getTotalAmount()|string_format:"%0.2f"}$
{include file="../admin/footer.tpl"}

app/views/templates/root/checkout.tpl
{include file="header.tpl" pageTitle='Оформление заказа'}
{include file="cartContent.tpl"}
<form action="{url action='saveOrder'}" method="post">
………
</form>
{include file="../admin/footer.tpl"}

50

51

2.6 Отправка заказов
После того, как клиент оформил заказ, администратор магазина должен иметь
возможность просмотреть его и переслать товар по указанному адресу после оплаты. В
данном приложении не рассматривается сам процесс оплаты. Считается, что
администратору известно, что покупатель уже расплатился. Он должен лишь пометить
заказ как отправленный.

2.6.1 Просмотр новых заказов
Для этого необходимо добавить в таблицу orders поле, указывающее, что товар был
отправлен. Кроме того, необходимо сохранять дату отправки. Для простоты можно
добавить в таблицу только поле shippedDate, равное NULL, если товар ещё не был
отправлен, либо равное дате отправки.
ALTER TABLE `orders` ADD COLUMN `shippedDate` datetime default NULL

Добавьте свойство shippedDate в «карту» Order.hbm.xml.
<property name="shippedDate" column="shippedDate" type="datetime" notnull="false"/>

Добавьте методы setShippedDate() и getShippedDate() в класс Order.
public function getShippedDate() {
return $this->__get('shippedDate');
}
public function setShippedDate(DateTime $value) {
$this->__set('shippedDate', $value);
}

Теперь необходимо добавить страницу, на которой администратор сможет
просмотреть неотправленные товары. Поскольку речь идёт об управлении магазином, то
эту страницу должен обслуживать контроллер admin. Поэтому добавьте в класс
AdminController метод actionShip(). Т.к. он будет отображать заказы, то укажите, чтобы он
использовал представление смарти.

Реализуйте новый метод.
public function actionShip() {

52

}

/**
* Display template
*/
$this->smarty->assign('pendingOrders', Order::pendingOrders());
$this->smarty->display('ship.tpl');

Теперь реализуйте метод pendingOrders() класса Order.
public static function pendingOrders() {
return self::getManager()->getList(self::getClassName(),
'shippedDate IS NULL');
}

Осталось заполнить шаблон представления. Шаблон должен содержать список
заказов, напротив каждого заказа должна быть кнопка-флажок (checkbox). Ниже списка
должна быть кнопка «Отправить». При нажатии на неё все выбранные заказы помечаются
текущей датой на отправку.
app/views/templates/admin/ship.tpl
{include file="header.tpl"}
<DIV align="center">
<strong>Отправка товаров</strong>
</DIV>
<form action="#" method="post">
<table border="0" cellpadding="4" cellspacing="1" align="center"
bgcolor="#90C090">
{if $message}
<tr align="center">
<TD bgcolor="#FFFFFF" colspan="3">
<FONT size="-2" color="Blue">
<STRONG>
{$message|escape}
</STRONG>
</FONT>
</TD>
</tr>
{/if}
{foreach from=$pendingOrders item=order}
<tr valign="top">
<TD bgcolor="#FFFFFF" align="center" valign="middle">
<input type="checkbox" name="orders[]" value="{$order->getId()}"/>
</TD>
<TD bgcolor="#FFFFFF">
<strong>{$order->getName()|escape}</strong>
<br/>
{$order->getEmail()|escape}
<br/>
<em>Адрес: {$order->getAddress()|escape}</em>
</TD>
<TD bgcolor="#FFFFFF">
<ul>
{foreach from=$order->items item=item}
<li>
{$item->getQuantity()}

53

</tr>

x
{$item->getUnitPrice()|string_format:"%0.2f"}$
{$item->product->getTitle()|escape}
</li>
{/foreach}
</ul>
</TD>

{/foreach}
<tr align="center">
<TD bgcolor="#FFFFFF" colspan="3">
<input type="submit" name="ship" value="Отправить"/>
</TD>
</tr>
</table>
</form>
{include file="footer.tpl"}

Добавьте пункт «Отправка заказов» в главное меню.
app/views/templates/admin/header.tpl
<!DOCTYPE ………>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
………
<body>
………
<div id="navigation">
<ul>
<li>
{if !$smarty.request.action ||
$smarty.request.action == 'listProducts'}
{link controller="admin" action="listProducts" _class="selected"}
Просмотр товаров
{/link}
{else}
{link controller="admin" action="listProducts"}
Просмотр товаров
{/link}
{/if}
</li>
<li>
{if $smarty.request.action == 'ship'}
{link controller="admin" action="ship" _class="selected"}
Отправка заказов
{/link}
{else}
{link controller="admin" action="ship"}
Отправка заказов
{/link}
{/if}
</li>
</ul>
</div>
<div id="content">

54

2.6.2 Связь «один-ко-многим»
Ранее был приведён пример реализации связи «многие-к-одному» в PHP on Rails.
Текст шаблона ship.tpl содержит пример связи «один-ко-многим».
{foreach from=$order->items item=item}
………
{/foreach}

Объект $order имеет атрибут items, ссылающийся на массив объектов класса
LineItem, связанных с данным заказом. Чтобы PHP on Rails смог распознать этот атрибут
правильно, надо добавить в «карту» класса Order определение связи с именем “items”.
<?xml version="1.0" encoding="windows-1251" ?>
………
<class name="Order" database="depot_db" table="orders">
………
<one-to-many name="items" class="LineItem" ref-column="orderId" orderby="id"/>
</class>
</hibernate-mapping>

Элемент, определяющий связь «один-ко-многим», имеет имя “one-to-many”. Его
атрибуты определяют объекты на другом конце связи: class – их класс (LineItem), refcolumn – имя поля в связанной таблице (line_items.orderId), order-by – выражение,
задающее порядок сортировки этих объектов (line_items.id).
Вот как выглядит результат работы PHP on Rails.

55

2.6.3 Отправка заказов
Осталось только реализовать саму отправку заказов. При нажатии на кнопку
«Отправить» приложению передаётся параметр orders, содержащий массив первичных
ключей выбранных заказов. Это достигается благодаря строке
<input type="checkbox" name="orders[]" value="{$order->getId()}"/>
, выводящейся в HTML коде страницы для каждого заказа.
Определив, что кнопка «Отправить» нажата, и имея список выбранных заказов,
можно осуществить процедуру их отправки. Для этого достаточно установить значение
поля shippedDate для каждого заказа равным текущей дате.
public function actionShip() {
if (Request::post('ship')) {
$orders = Request::post('orders');
if ($orders && count($orders) > 0) {
Order::getManager()->getDriver()->startTransaction();
foreach ($orders as $orderId) {
$order = Order::load($orderId);
$order->setShippedDate(new DateTime());
$order->save();
}
Order::getManager()->getDriver()->commit();
$orderCount = count($orders);
if ($orderCount == 1) {
$this->setFlash('message', 'Один заказ был отправлен');
}
elseif ($orderCount >= 2 && $orderCount <= 4) {
$this->setFlash('message',
"$orderCount заказа были отправлены");
}
else {
$this->setFlash('message',
"$orderCount заказов были отправлены");
}
}
else {
$this->setFlash('message', 'Ни один заказ не был выбран');
}
Response::locate2referer();
}

}

/**
* Display template
*/
$this->smarty->assign('message', $this->getFlash('message'));
$this->smarty->assign('pendingOrders', Order::pendingOrders());
$this->smarty->display('ship.tpl');

Класс DateTime предназначен для работы с датой и временем. Его конструктор по
умолчанию создаёт объект, инкапсулирующий текущее время.

56

2.7 Управление администраторами
Теперь, когда был реализован функционал управления магазином, осталось добавить
систему безопасности. Сейчас любой человек может воспользоваться контроллером
admin. Чтобы этого избежать, необходимо ввести систему распознавания пользователей,
которым открыт доступ к функциям администрирования. Эта система проста и основана
на паре значений логин - пароль, определяющей каждого администратора.

2.7.1 Создание администратора
Чтобы хранить список всех администраторов системы, нужна новая таблица.
CREATE TABLE `users` (
`id` int(10) unsigned NOT NULL auto_increment,
`login` varchar(100) NOT NULL,
`password` char(32) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `login` (`login`)
)

Кроме первичного ключа таблица users содержит всего два поля: login,
идентифицирующее пользователя, и password, хранящее хешированный пароль. Логин
является уникальным. Для хеширования пароля будет использоваться алгоритм MD5.
Необходимо реализовать следующие функции, оперирующие пользователями: вход
в систему (login), выход (logout), создание (addUser), просмотр(listUsers) и удаление
(removeUser). Все эти функции имеет смысл реализовать в отдельном контроллере login.
Создайте его при помощи конструктора сайта. Нужно указать имя контроллера, список
методов (имена страниц), которые будут добавлены в новый контроллер, и определить,
будет ли он использовать Smarty.

Теперь нужно создать «карту» и класс User.

57

После того, как была создана новая модель User, можно реализовывать функции
контроллера login. Лучше начать с метода actionAddUser().
public function actionAddUser() {
$user = new User();
if (Request::post('save')) {
$errors = array();
$login = trim(Request::param('login'));
$user->setLogin($login);
$user->setPassword(trim(Request::param('password')));
if (!$login) {
$errors[] = 'Укажите логин пользователя';
}

}

}

if (count($errors) == 0) {
try {
$user->save();
$this->redirect2userList("Пользователь $login был
добавлен");
}
catch (DuplicateEntry $e) {
$errors[] = "Пользователь с именем $login уже
существует";
}
}

/**
* Display template
*/
$this->smarty->assign('errors', @$errors);
$this->smarty->assign('user', $user);
$this->smarty->display('addUser.tpl');

private function redirect2userList($message = null) {
if (!is_null($message)) {

58

}

$this->setFlash('message', $message);
}
Response::locate(array('action' => 'listUsers'));

Метод actionAddUser() выполняет два действия: отображение формы и сохранение
нового пользователя в базе данных.
В первой строке создаётся новый объект User. Затем проверяется, была ли нажата
кнопка «Сохранить». Если кнопка была нажата, то проверяется, был ли указан логин. Если
ошибок найдено не было, то объект сохраняется в базе данных. При этом вызов метода
save() заключён в конструкцию try…catch. Если пользователь с указанным логином уже
существует в базе данных, то будет выброшено исключение DuplicateEntry, которое
является потомком класса SqlException. В этом случае на странице выводится
соответствующее сообщение. Использование исключения DuplicateEntry позволяет
избежать дополнительного запроса SELECT для поиска в таблице users пользователя с
указанным логином.
В случае успешного создания администратора, вызывается метод redirect2userList(),
который аналогичен методу redirect2index() контроллера root.
Теперь нужно заполнить шаблон данной страницы.
app/views/templates/login/addUser.tpl
{include file="../admin/header.tpl"}
<FORM method="post">
<table border="0" cellpadding="4" cellspacing="1" align="center"
bgcolor="#90C090">
<tr align="center">
<TD bgcolor="#90C090" colspan="2">
<strong>Создать пользователя</strong>
</TD>
</tr>
{if count($errors) > 0}
<tr align="center">
<TD bgcolor="#FFFFFF" colspan="2">
<FONT size="-2" color="Red"><STRONG>
{foreach from=$errors item=error}
{$error|escape}<BR/>
{/foreach}
</STRONG></FONT>
</TD>
</tr>
{/if}
<TR>

</TR>
<TR>

<TD bgcolor="#90C090"><STRONG>Логин</STRONG></TD>
<TD bgcolor="#FFFFFF">
<INPUT type="text" name="login" value="{$user->getLogin()|escape}"/>
</TD>
<TD bgcolor="#90C090"><STRONG>Пароль</STRONG></TD>
<TD bgcolor="#FFFFFF">
<INPUT type="password" name="password"/>

59

</TR>

</TD>

<tr align="center">
<TD bgcolor="#FFFFFF" colspan="2">
<INPUT type="submit" name="save" value="Save"/>
</TD>
</tr>
</table>
</FORM>
{include file="../admin/footer.tpl"}

Этот шаблон подключает два шаблона из контроллера admin: header.tpl и footer.tpl.
Результат вы можете увидеть по адресу http://rails.loc/login/?action=addUser.

2.7.1.1 Методы-события
Если вы попробуйте создать администраторов системы и затем посмотрите в
таблицу users, то увидите, что все пароли сохранены в явном виде. Это происходит,
потому что свойству password присваивается значение одноимённого поля формы, и
именно в таком виде оно пишется в базу данных. В PHP on Rails существуют методысобытия, вызываемые в определённые моменты. К ним относятся методы beforeSave() и
afterSave(), которые вызываются менеджером объектов до и после сохранения каждого
объекта в базе данных.
Метод beforeSave() можно использовать для замены пароля на его хешированный
вариант. Просто определите этот метод в классе User.
public function beforeSave() {
if ($this->modified('password')) {
$this->setPassword(md5($this->getPassword()));

60

}

}

Если свойство password было изменено, то считается, что ему было присвоено
новое нехешированное значение. В этом случае применяется функция mp5().

2.7.2 Просмотр администраторов
Нужно создать страницу, на которой выводился бы список всех администраторов
системы со ссылкой на удаление каждого из них. Также она должна содержать ссылку на
только что созданную страницу “addUser”.
public function actionListUsers() {
/**
* Display template
*/
$this->smarty->assign('message', $this->getFlash('message'));
$this->smarty->assign('users', User::getList(null, array('login')));
$this->smarty->display('listUsers.tpl');
}

app/views/templates/login/listUsers.tpl
{include file="../admin/header.tpl"}
{link action="addUser"}Создать{/link}
{if $message}
<div align="center">
<font color="Blue">
{$message|escape}
</font>
</div>
{/if}
<ul>
{foreach from=$users item=user}
<li>
<strong>{$user->getLogin()|escape}</strong>
&nbsp;
{link action="removeUser" userId=$user->getId()}
Удалить
{/link}
</li>
{/foreach}
</ul>
{include file="../admin/footer.tpl"}

2.7.3 Вход в систему
Для обеспечения безопасного входа в систему приложение должно уметь:
1. отображать форму для ввода логина и пароля;
2. если вход в систему прошёл успешно, то запоминать это в сессии для
использования при дальнейшей навигации пользователя;
3. ограничить доступ к административной части приложения, открывая его только
для аутентифицированных пользователей.

61

Эти действия должны быть реализованы в методе actionLogin() контроллера
LoginController. В контроллер был добавлен атрибут $this->adminSession. Это экземпляр
класса Session. Он представляет собой сессию, открываемую после успешного входа в
систему. Факт, что эта сессия открыта, является гарантом правильной аутентификации
при последующей навигации пользователя по административной части приложения.
<?PHP
………
class LoginController extends AppController {
public function __construct() {
parent::__construct();
$this->smarty = new LoginSmarty();
$this->modulesDir = CONTROLLERS_DIR . 'login'.DIRECTORY_SEPARATOR;
$this->adminSession = new Session('ADMIN');
}
public function actionLogin() {
if (Request::isPost()) {
$login = trim(Request::post('login'));
if ($login) {
$password = trim(Request::post('password'));
try {
$user = User::try2login($login, $password);
$this->adminSession->start();
$this->adminSession->set('adminId', $user->getId());
Response::locate(array('action' => 'index'));
}
catch (ItemNotFound $e) {
}
}
}

………

}

/**
* Display template
*/
$this->smarty->display('login.tpl');

/**
* Сессия администратора
*
* @var Session
*/
protected $adminSession = null;

}
?>

Метод Request::isPost() возвращает true, если страница была запрошена методом
POST, что соответствует отсылке формы. Эта даёт такой же эффект, что и проверка,
нажата ли кнопка отправки «Вход».
Если форма была заполнена, то вызывается метод try2login() класса User. Он
пытается найти администратора с указанными логином и паролем. Если такой
администратор найден, то он возвращается в качестве результата, иначе выбрасывается
исключение ItemNotFound.
public static function try2login($login, $password) {

62

}

return self::loadWhere(array('login' => $login,
'password' => md5($password)));

В успешном случае приложение открывает сессию администратора, сохраняя в ней
его первичный ключ, и перебрасывает пользователя на индексную страницу. Это первая
страница, которую администратор видит после входа. В противном случае ему опять
выводится форма для входа в систему.
app/views/templates/login/login.tpl
{include file="../admin/header.tpl" dontShowMenu=1}
<FORM method="post">
<table border="0" cellpadding="4" cellspacing="1" align="center"
bgcolor="#90C090">
<TR>

</TR>
<TR>

</TR>

<TD bgcolor="#90C090"><STRONG>Логин</STRONG></TD>
<TD bgcolor="#FFFFFF">
<INPUT type="text" name="login"/>
</TD>
<TD bgcolor="#90C090"><STRONG>Пароль</STRONG></TD>
<TD bgcolor="#FFFFFF">
<INPUT type="password" name="password"/>
</TD>

<tr align="center">
<TD bgcolor="#FFFFFF" colspan="2">
<INPUT type="submit" name="loginBtn" value="Войти"/>
</TD>
</tr>
</table>
</FORM>
{include file="../admin/footer.tpl"}

В первой строке этого шаблона, как и прежде, вызывается шаблон header.tpl.
Однако ему передаётся параметр dontShowMenu, который говорит, что меню
администратора не должно отображаться на странице «Вход в систему». Вот как этот
параметр проверяется в самом шаблоне header.tpl.
app/views/templates/admin/header.tpl
……..

<div id="top" align="center">
<h1>Администрирование магазина</h1>
</div>
{if !$dontShowMenu}
<div id="navigation">
<ul>
………
</ul>

63

</div>
{/if}
<div id="content">

Теперь черёд создать индексную страницу контроллера. Как правило, она
реализуется методом actionIndex(). Добавьте его в контроллер при помощи конструктора
сайта.

public function actionIndex() {

}

/**
* Display template
*/
$this->smarty->assign('totalOrderCount', Order::count());
$this->smarty->assign('pendingOrderCount', Order::pendingCount());
$this->smarty->display('index.tpl');

В классе Order надо определить новые методы.
public static function count() {
return self::getManager()->count(self::getClassName());
}
public static function pendingCount() {
return self::getManager()->count(self::getClassName(), "shippedDate IS
NULL");
}

А вот так выглядит шаблон страницы.
app/views/templates/login/index.tpl
{include file="../admin/header.tpl"}
<ul>
<li>Всего {$totalOrderCount} заказов</li>
<li>Ждут отправки {$pendingOrderCount} заказов</li>
</ul>
{include file="../admin/footer.tpl"}

64

2.7.4 Ограничение доступа
Аутентификация пользователей на входе в систему была реализована. Однако попрежнему любой человек имеет доступ к административной части сайта. Необходимо
ограничить доступ пользователей к методам контроллеров admin и login.
Как было сказано выше, гарантом того, что пользователь успешно прошёл
аутентификацию, является открытая сессия администратора.
Класс Session имеет два метода для открытия сессии: start() и restore(). Первый
метод либо использует уже существующую сессию с указанным в конструкторе именем,
либо создаёт новую. Второй метод может использовать только уже открытую ранее
сессию. В противном случае он вернёт false.
При этом проверяется правильность сессии: IP адрес и т.д. Если сессия была
открыта клиентом по одному адресу, а после происходит попытка использовать её
клиентом с другого адреса, то метод restore() возвращает false, а метод start() очищает все
данные сессии. Таким образом, гарантируется безопасность данных, хранящихся в
сессиях на сервере.
Типичное использование сессий для аутентификации и ограничения доступа
заключается в вызове сначала метода start() после правильной аутентификации, а потом
restore() для страниц с ограниченным доступом. Если метод restore() возвращает true, то
считается, что сессия была ранее открыта, а значит, аутентификация была пройдена.
Теперь стоит задача, не позволять пользователям заходить на страницы
администратора без предварительной аутентификации. Типичное решение этой задачи –
использование фильтра контроллера.
Каждый контроллер имеет метод-фильтр beforeFilter, принимающий единственный
параметр – имя страницы. Так, например, при обработке запроса index.php?action=listUsers
контроллер сначала вызывает метод beforeFilter(‘listUsers’), а потом только метод
actionListUsers().
65

Фильтр может проверять, открыта ли сессия администратора, и в зависимости от
результата либо разрешать дальнейшую обработку запроса, либо перебрасывать
пользователя на страницу «Вход в систему». Вот его примерная реализация в классе
LoginController.
protected function beforeFilter($action) {
if (strcmp($action, 'login') != 0) {
if (!@$this->adminSession->restore()) {
Response::locate(array('controller' => 'login', 'action' =>
'login'));
}
}
}

Фильтр проверяет для всех страниц, кроме login («Вход в систему»), была ли
открыта сессия администратора. Если нет, то пользователь отсылается на прохождение
процедуры аутентификации.
Теперь, когда ограничен доступ к страницам контроллера login, необходимо
сделать то же самое для всех страниц контроллера admin. Чтобы не определять такой же
метод beforeFilter() и свойство $this->adminSession в классе AdminController, просто
сделайте класс LoginController родительским для класса AdminController.
class AdminController extends LoginController {
………
}

Теперь доступ к администраторской части приложения ограничен.

2.7.5 Удаление администраторов
Процедура удаления администратора из базы данных очень проста. Однако тут
возникает одна проблема. Если удалить всех администраторов, то уже невозможно будет
пользоваться приложением. Поэтому необходимо какое-то ограничение. Одно из решений
– это не удалять никогда администратора с именем (логином) admin. Реализовать это
ограничение легко при помощи методов-событий.
Так же, как и для сохранения, так и для операции удаления объекта существуют два
метода-события: beforeRemove() и afterRemove(). В данном случае нужно использовать
первый метод.
class User extends Item {
………
public function beforeRemove() {
if (strcmp($this->getLogin(), 'admin') == 0) {
throw new OrmException('Can not remove administrator with
name admin');
}
}
}

И не забудьте реализовать метод actionRemoveUser() в контроллере login.
public function actionRemoveUser() {
try {
$user = User::load(Request::param('userId'));

66

$user->remove();
$this->redirect2userList("User was removed");

}

}
catch (OrmException $e) {
$this->redirect2userList($e->getMessage());
}

В случае если приложение не сможет удалить администратора, на странице будет
выведен текст исключения OrmException.

2.7.6 Выход из системы
Для выхода из системы достаточно просто закрыть сессию администратора.
public function actionLogout() {
$this->adminSession->stop();
Response::locate(array('controller' => 'login', 'action' =>
'login'));
}

Добавим оставшиеся ссылки в меню администратора.
app/views/templates/admin/header.tpl
<div id="navigation">
<ul>
<li>
{if !$smarty.request.action ||
$smarty.request.action == 'listProducts'}
{link controller="admin" action="listProducts" _class="selected"}
Товары
{/link}
{else}
{link controller="admin" action="listProducts"}
Товары
{/link}
{/if}
<li>
{if $smarty.request.action == 'ship'}
{link controller="admin" action="ship" _class="selected"}
Заказы
{/link}
{else}
{link controller="admin" action="ship"}
Заказы
{/link}
{/if}
</li>
<li>
{if $smarty.request.action == 'listUsers'}
{link controller="login" action="listUsers" _class="selected"}
Администраторы
{/link}
{else}
{link controller="login" action="listUsers"}
Администраторы
{/link}
{/if}
</li>
<li>

67

</div>

{link controller="login" action="logout"}
Выход
{/link}
</li>
</ul>

На этом разработка приложения завершена.

68

3 Многоязычные приложения

69

3.1 Установка и конфигурация
В данной главе рассматривается создание небольшого приложения, представленного
на двух языках: русском и английском.

3.1.1 Установка
Распакуйте архив с PHP on Rails в какую-либо директорию и настройте ваш вебсервер так, как это было сделано в части 2. Для удобства будем считать, что приложение
доступно по адресу http://translator.loc/. Укажите этот адрес в качестве базового URL в
файле конфигурации config/config.php.

3.1.2 Создание базы данных
Фреймворк PHP on Rails хранит переводы в базе данных. В данном примере снова
используется СУБД MySQL версии 5.
Пусть переводы хранятся в базе translator_demo. Создайте её.
CREATE DATABASE translator_demo

Эта база данных будет состоять из четырёх таблиц. Их структура в виде SQL
находится в файле system/com/translator/sql/sql_without_foreign_keys.sql. Создайте эти
таблицы.
-----

---------------------------Structure of the translator
database
----------------------------

-- ----------------------------- Table structure for languages
-- ---------------------------CREATE TABLE `languages` (
`id` tinyint(3) unsigned NOT NULL auto_increment,
`code` char(2) NOT NULL,
`name` varchar(20) NOT NULL,
`keepingCharset` char(20) NOT NULL,
`displayingCharset` char(20) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- ----------------------------- Table structure for sections
-- ---------------------------CREATE TABLE `sections` (
`id` int(10) unsigned NOT NULL auto_increment,
`name` char(20) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`)
);
-- ----------------------------- Table structure for keywords
-- ---------------------------CREATE TABLE `keywords` (
`id` bigint(20) unsigned NOT NULL auto_increment,
`sectionId` int(11) NOT NULL,

70

`name` char(255) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `section_keyword` (`sectionId`,`name`),
KEY `sectionId` (`sectionId`)
);
-- ----------------------------- Table structure for translations
-- ---------------------------CREATE TABLE `translations` (
`id` bigint(20) unsigned NOT NULL auto_increment,
`keywordId` bigint(20) unsigned NOT NULL,
`languageId` int(10) unsigned NOT NULL,
`text` text NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `keyword_language` (`keywordId`,`languageId`),
KEY `keywordId` (`keywordId`),
KEY `languageId` (`languageId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

В таблице languages
поддерживаемые приложением.

хранятся

все

языки,

Таблица sections содержит список секций, на
которые делится интерфейс. В самом простом случае
секция представляет собой просто страницу. Когда
загружается какая-либо страница, извлекаются переводы из
соответствующей ей секции. В общем случае секция – это
логическая часть интерфейса. Она характеризуется именем.
Имя должно быть уникально.
Каждая секция состоит из ключевых слов,
хранящихся в таблице keywords. Ключевое слово – это та
фраза, перевод которой будет выведен в браузер. Имя
ключевого слова должно быть уникально в пределах
секции.
И, наконец, переводы хранятся в таблице
translations. Каждая запись – это перевод ключевого слова
на указанный язык.

3.1.3 Конфигурирование базы данных
После создания базы данных нужно указать настройки соединения в файле
конфигураций config/application.cfg.xml.
<Database name="translator_demo">
<Driver>MySQL</Driver>
<Host>localhost</Host>
<Port>3306</Port>

71

<User>root</User>
<Password></Password>
<Database>translator_demo</Database>
</Database>

Укажите правильно пользователя и пароль на базу данных.

72

3.2 Создание приложения
3.2.1 Структура
Главная и единственная задача данного приложения – это отображение
информации на нескольких языках.
Сайт содержит две страницы. Первая страница имеет два варианта: по одному на
каждый язык. Это значит, что страница имеет один шаблон, содержание которого
выводится в зависимости от выбранного языка. Поскольку в контроллере root по
умолчанию присутствует страница index, то она и будет использоваться в этих целях. На
второй странице выводится текст на обоих языках сразу.
Для начала нужно создать вторую страницу в контроллере root. Как всегда
используйте конструктор сайта, который расположен по адресу http://translator.loc/builder/.
Выберите меню “Controllers”, ссылку “Add action” и добавьте страницу index2.

Заполните содержимое шаблонов страниц следующим образом.
app/views/templates/root/index.tpl
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; windows-1251" />
<title>Заголовок</title>
</head>
<body>
<div align="center">
[{link action='index' lang='ru'}Русский{/link}]&nbsp;&nbsp;
[{link action='index' lang='en'}English{/link}]&nbsp;&nbsp;
[{link action='index2'}Многоязычная страница{/link}]&nbsp;&nbsp;
</div>
<h2 align="center">
Это текст страницы
</h2>
</body>
</html>

app/views/templates/root/index2.tpl
73

<html>
<head>
<meta http-equiv="Content-Type" content="text/html; windows-1251" />
<title>Заголовок (рус) Title (en)</title>
</head>
<body>
<div align="center">
[{link action='index' lang='ru'}Русский{/link}]&nbsp;&nbsp;
[{link action='index' lang='en'}English{/link}]&nbsp;&nbsp;
</div>
<h2 align="center">
Это текст страницы на русском
</h2>
<h2 align="center">
This is text in english
</h2>
</body>
</html>

Теперь осталось сделать содержание страниц зависимым от выбранного языка.

3.2.2 Переводчик
Переводами, как правило, занимаются профессиональные переводчики. Класспереводчик, используемый в PHP on Rails, настоящий полиглот. Один его экземпляр
может работать одновременно с произвольным количеством языков.
Переводчик реализован в классе Translator. Его
единственный аргумент: параметры соединения с базой данных.

конструктор

получает

Главный метод переводчика – это getString(). Он возвращает перевод ключевого
слова на указанный язык. Его аргументы:
string $section
string $keyword
string $langCode
string $displayingCharset = null

имя секции
ключевое слово, т.е. фраза, которую
необходимо перевести
двухбуквенный код языка (en, ru и т.д.)
кодировка, в которой должен быть
возвращён результат

Таким образом, getString() возвращает перевод ключевого слова $keyword из
секции $section на язык $langCode в кодировке $displayingCharset (если была указана).
Если перевод не был найден, то возвращается перевод на язык по умолчанию, который
задаётся методом
setDefaultLanguageCode($langCode)

Если же язык по умолчанию не задан или для него тоже не был найден перевод, то
метод getString() возвращает null.

74

3.2.3 Многоязычные шаблоны
3.2.3.1 Смарти
Вывод содержимого веб-страницы – это занятие для представления, которое в PHP
on Rails реализовано при помощи сторонней библиотеки Smarty. Для поддержки
многоязычных страниц класс Smarty был расширен классом TranslatorSmarty, который
использует объект-переводчик для перевода содержимого страниц. Его конструктор
принимает единственный аргумент – объект типа Translator. Метод
setCurrentLanguageCode($langCode)

задаёт текущий язык, используемый для вывода информации, а метод
setDefaultLanguageCode($langCode)

задаёт язык по умолчанию, который будет использоваться в случае, если перевод для
текущего языка не был найден (вызывает одноимённый метод переводчика).
Теперь необходимо задать те блоки, которые нужно переводить. Такие блоки
включаются в шаблоны при помощи блоковой функции translate. Её синтаксис:
{translate section=”sectionName”}
keyword
{/translate}

Параметр section задаёт имя секции, а содержимое блока рассматривается как
ключевое слово, при этом удаляются все ведущие и конечные пробельные символы.
Модифицированный Smarty выводит перевод ключевого слова на месте блока или само
ключевое слово, если его перевод не был найден.
Рекомендации! Имена секций и ключевые слова должны помещаться в одну
строку, могут содержать буквы английского алфавита, цифры и знак подчёркивания
“_” и не должны превышать в длину 255 символов.
Чтобы страницы контроллера root выводились на разных языках, его свойство
$this->smarty должно происходить от класса TranslatorSmarty. Для этого достаточно
изменить определение класса RootSmarty так, чтобы он наследовал TranslatorSmarty.
app/views/smarty/RootSmarty.class.php (красным цветом показаны изменённые строки, а
синим - добавленные)
require_once(SYSTEM_TRANSLATOR_DIR . 'TranslatorSmarty.class.php');
class RootSmarty extends TranslatorSmarty {

}

public function __construct(Translator $translator) {
parent::__construct($translator);
$this->template_dir = VIEWS_DIR . 'templates/root/';
$this->compile_dir = VIEWS_DIR . 'templates_c/root/';
$this->cache_dir = VIEWS_DIR . 'cache/root/';
}

Свойство $this->smarty инициализируется в конструкторе класса RootController.
75

app/controllers/RootController.class.php
class RootController extends AppController {
public function __construct() {
parent::__construct();
$this->smarty = new RootSmarty($this->getTranslator());
$this->modulesDir = CONTROLLERS_DIR . 'root' .
DIRECTORY_SEPARATOR;
}
private function getTranslator() {
$config = ItemManagerUtils::getConfig();
$dbParams = $config->getDatabaseParameters('translator_demo');
$translator = new Translator($dbParams);
return $translator;
}
}

При инициализации свойства контроллера $this->smarty было сделано лишь одно
изменение – объект-переводчик был передан как аргумент его конструктору.
Закрытый метод getTranslator() создаёт и возвращает экземпляр класса Translator.
Для этого ему необходимо иметь параметры соединения с базой данных. Класс
ItemManagerUtils предназначен для создания и раздачи менеджеров объектов, т.е.
экземпляров класса ItemManager. Его метод getConfig() возвращает экземпляр класса
Configuration, который инкапсулирует в себе параметры конфигурации приложения, а
точнее содержимое файла config/application.cfg.xml. Метод getDatabaseParameters()
объекта-конфигурации возвращает параметры соединения с базой данных, индекс которой
указан как единственный аргумент. После этого создаётся переводчик, в конструктор
которого передаются параметры соединения, и возвращается в качестве результата
функции.

3.2.3.2 Шаблоны
Теперь необходимо заменить весь переводимый текст в шаблонах на вызов
блоковой функции translate.
Для начала нужно разделить приложение на секции. Самое простое деление:
отдельная страница – отдельная секция. Итого будет две секции. Для простоты назовите
их page1 и page2. Не забывайте рекомендации при назначении имён секциям и ключевым
словам: только буквы английского алфавита, цифры и знак “_”, длина не более 255
символов.
Первая страница содержит заголовок, три ссылки и основной текст. Первые две
ссылки «Русский» и “English” не нуждаются в переводе. Таким образом, нужно лишь три
ключевых слова. Пусть это будут title, multilingual_page_link и content.
Тогда шаблон примет следующий вид.
app/views/templates/root/index.tpl
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; windows-1251" />

76

<title>
{translate section="page1"}
title
{/translate}
</title>
</head>
<body>
<div align="center">
[{link action='index' lang='ru'}Русский{/link}]&nbsp;&nbsp;
[{link action='index' lang='en'}English{/link}]&nbsp;&nbsp;
[{link action='index2'}
{translate section="page1"}
multilingual_page_link
{/translate}
{/link}]&nbsp;&nbsp;
</div>
<h2 align="center">
{translate section="page1"}
content
{/translate}
</h2>
</body>
</html>

Секция page2 содержит всего два ключевых слова: title и content -, а шаблон
соответствующей страницы выглядит следующим образом.
app/views/templates/root/index2.tpl
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; windows-1251" />
<title>
{translate section="page2" lang="ru"}
title
{/translate}
{translate section="page2" lang="en"}
title
{/translate}
</title>
</head>
<body>
<div align="center">
[{link action='index' lang='ru'}Русский{/link}]&nbsp;&nbsp;
[{link action='index' lang='en'}English{/link}]&nbsp;&nbsp;
</div>
<h2 align="center">
{translate section="page2" lang="ru"}
content
{/translate}
</h2>
<h2 align="center">
{translate section="page2" lang="en"}
content
{/translate}
</h2>
</body>
</html>

77

На этой странице заголовок (title) и основной текст (content) отображаются дважды:
на русском и английском языках. Поэтому в блоковую функцию translate передаётся
дополнительный параметр lang, который содержит код языка, на который должно быть
переведено содержимое блока.
Заметьте, что секции page1 и page2 содержат одинаковые ключевые слова: title и
content. Однако поскольку эти ключевые слова находятся в разных секциях, то они не
имеют ничего общего.

3.2.4 Выбор текущего языка
На второй странице выводится текст на обоих языках, поэтому код языка явно
указывается для каждого вызова блоковой функции translate. Однако для первой страницы
код языка передаётся только как параметр в URL.
[{link action='index' lang='ru'}Русский{/link}]&nbsp;&nbsp;
[{link action='index' lang='en'}English{/link}]&nbsp;&nbsp;

Необходимо извлечь его и установить в качестве значения текущего языка
шаблона.
class RootController extends AppController {
………
public function actionIndex() {
$langCode = Request::param('lang', 'ru');

………
}

}

/**
* Display template
*/
$this->smarty->setCurrentLanguageCode($langCode);
$this->smarty->display('index.tpl');

Многоязычное приложение создано.

3.2.5 Перевод приложения
После того, как приложение было разработано, необходимо заполнить базу
переводов. Этот процесс делится на два этапа: добавление языков, секций и ключевых
слов в базу данных и непосредственно перевод. Можно сделать это, воспользовавшись
каким-либо клиентом СУБД. Однако в качестве дополнения к PHP on Rails прилагается
вспомогательное приложение «Переводчик» (“Translator”), которое предоставляет
удобные средства для заполнения базы данных и экспорта и импорта переводов.

3.2.5.1 Приложение «Переводчик»
Скачайте и распакуйте приложение «Переводчик» так же, как вы делали это
раньше. Расположите его по адресу http://admin.translator.loc/. Настройте приложение
следующим образом.
config/config.php
78


$BASE_URL = 'http://admin.translator.loc/';

config/application.cfg.xml
<Database name="translator">
<Driver>MySQL</Driver>
<Host>localhost</Host>
<Port>3306</Port>
<User>root</User>
<Password></Password>
<Database>translator_demo</Database>
</Database>

Как видите, для настройки приложения нужно лишь указать правильный базовый
URL и параметры соединения к базе данных переводов. Это соединение доступно в
приложении по индексу “translator”.
Зайдите по ссылке http://admin.translator.loc/ и вы увидите страницу, содержащую
меню из пяти элементов: languages (языки), sections (секции), keywords (ключевые слова),
export (экспорт) и import (импорт). Первые три раздела меню предназначены для создания,
редактирования и удаления объектов базы данных. Остальные два раздела предоставляют
инструменты для экспорта и импорта базы переводов. По умолчанию активен раздел
«Языки».

3.2.5.2 Языки
Наше приложение поддерживает два языка: английский и русский. Чтобы добавить
язык, нужно выбрать ссылку "Add new language” («Добавить новый язык») в разделе
“Languages” («Языки»).
Появившаяся форма содержит четыре поля, из которых только первые два
являются обязательными.
Code – двухбуквенный код языка, определённый стандартом ISO-639-1 (en, ru и
т.д.). Name – имя языка. Необязательное поле Keeping charset содержит кодировку, в
которой переводы хранятся в базе, т.е. это кодировка таблицы translations. А значит, если
у вас есть несколько языков, кроме английского, для которых вы задаёте значение этого
поля, то это значение должно быть одинаковым для всех этих языков. Для
многоязычных проектов рекомендуется использовать кодировку UTF-8. Второе
необязательное поле Displaying charset содержит кодировку, в которой переводы
возвращаются объектом-переводчиком. Таким образом, если вы зададите для какого-либо
языка оба поля Keeping charset и Displaying charset, а потом запросите перевод ключевого
слова на этот язык у объекта-переводчика, то последний произведёт конвертирование
результата из первой кодировки во вторую.
Таблица translations была создана с кодировкой по умолчанию utf-8.
-- ----------------------------- Table structure for translations
-- ---------------------------CREATE TABLE `translations` (
`id` bigint(20) unsigned NOT NULL auto_increment,
`keywordId` bigint(20) unsigned NOT NULL,
`languageId` int(10) unsigned NOT NULL,

79

`text` text NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `keyword_language` (`keywordId`,`languageId`),
KEY `keywordId` (`keywordId`),
KEY `languageId` (`languageId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8

Проверьте это в своей базе данных командой
SHOW CREATE TABLE translations

В качестве кодировки страницы в шаблонах указана кириллица.
<meta http-equiv="Content-Type" content="text/html; windows-1251" />

Добавьте в базу данных русский язык. Его код – ru. Имя – Russian. Кодировка
хранения – utf-8 как у таблицы translations. А кодировка отображения – windows-1251, т.к.
она используется в HTML-странице.

Добавьте английский язык. Обе кодировки можно не указывать.

3.2.5.3 Секции
Выберите в меню раздел «Секции» (“Sections”). Ссылка «Добавить новую секцию»
(“Add a new sections”) приведёт на страницу с формой из одного поля, в котором надо
указать имя секции. Не забывайте, что имена секций уникальны! Добавьте секции page1 и
page2.

80

3.2.5.4 Ключевые слова
Здесь же в разделе «Секции» напротив имени каждой секции указаны количество
ключевых слов в ней (столбец “Count of keywords”) и ссылка на их список (“List
keywords”).
Откройте список ключевых слов для секции page1. Перейдите по ссылке «Добавить
ключевое слово» (“Add a new keyword”). Добавьте в секцию три ключевых слова: title,
multilingual_page_link и content.
Вернитесь в раздел «Секции» и откройте список ключевых слов для page2.
Добавьте ключевые слова title и content.

3.2.5.5 Переводы
Напротив каждого ключевого слова есть ссылка в колонке «Переводы»
(“Translations”). Перейдите по такой ссылке для ключевого слова title в секции page1. Вы
увидите перевод этого слова для каждого языка или фразу «не найден» (“not found”), если
перевода нет. Выберите ссылку «добавить» (“add”) напротив русского языка и введите в
поле «Текст» (“Text”) содержание заголовка страницы. Например,

Таким способом можно перевести все ключевые слова на все языки. Однако есть
более простой способ. Зайдите в раздел «Экспорт». В этом разделе вы можете
экспортировать переводы в файл формата XML Spreadsheet, который поддерживается MS
Excel. Выберите язык (например, русский), секцию (все-all) и нажмите кнопку «Экспорт»
(“Export”). Сохраните полученный документ на жёстком диске и откройте его.
Этот документ состоит из трёх колонок. Колонка A содержит имя секции, B –
ключевое слово, C – перевод ключевого слова на язык экспортирования. Заполните
колонку C на русском языке.

Сохраните документ. Теперь его можно импортировать обратно. Зайдите в раздел
«Импорт» (“Import”). Выберите русский язык в выпадающем списке и изменённый
документ в поле загрузки файла, нажмите кнопку «Импорт».
81

Повторите эту процедуру для английского языка.

Теперь у вас есть сайт, полностью переведённый на два языка. Вот результаты
ваших трудов.
Страница на русском языке

Страница на английском языке

82

Страница с русским и английским текстом

3.2.6 Кэширование
Для начала нужно определиться, как объект-переводчик извлекает переводы из
базы данных. Здесь становится понятным для чего нужно разделение ключевых слов по
секциям.
Когда вы запрашивайте перевод ключевого слова, вызывая, например, блоковую
функцию смарти translate, это не значит, что будет осуществлён очередной запрос к базе
данных. Объект-переводчик извлекает данные по секциям, т.е., например, при запросе
перевода слова title из секции page1 на русский язык в оперативный кэш переводчика
будут загружены ВСЕ ключевые слова и их переводы на русский язык из секции page1.
Таким образом, если веб-страница содержит ключевые слова только из нескольких
секций, что, как правило, и бывает, то переводчик осуществит к базе данных лишь
несколько запросов.
Из вышесказанного надо запомнить два момента: извлечение переводов
осуществляется по секциям, и оперативный кэш переводчика включён всегда.
Однако при более сложном разделении слов по секциям (не в случае, каждой
странице своя секция) число запросов к базе данных возрастает и может достигнуть
десяти-двадцати. Чтобы снизить нагрузку на базу данных, можно воспользоваться двумя
видами кэширования:
1. кэширование средствами смарти,
2. файловый кэш переводчика.
Кэширование смарти эффективно, но не всегда возможно. Переводчик может
кэшировать перевод каждой секции на определённый язык в файлах. Включить кэш
можно методом
setCachingParams($caching, $cachingTime, $cacheDir)

83

Первый аргумент – это булево значение. Если он равен true, то включается
кэширование, иначе оно отключается, а остальные аргументы игнорируются. Второй
аргумент – это время секундах, в течение которого кэш будет актуальным, третий –
директория кэширования.
Включим файловое кэширование переводчика на один час. Поскольку переводы,
как правило, обновляются не очень часто, то время кэширования можно устанавливать
достаточно большое: часы, сутки.
class RootController extends AppController {
………
private function getTranslator() {
$config = ItemManagerUtils::getConfig();
$dbParams = $config->getDatabaseParameters('translator_demo');
$translator = new Translator($dbParams);
$translator->setCachingParams(true, 60 * 60, VIEWS_DIR . 'cache' .
DIRECTORY_SEPARATOR . 'root' . DIRECTORY_SEPARATOR);
return $translator;
}
}

Директория кэширования – app/views/cache/root/.
Используйте таблицу отладки внизу страницы, чтобы определить, как изменились
время выполнения скрипта и количество запросов к базе данных.

3.2.7 Визуальное редактирование переводов
Процесс перевода страниц можно сделать более лёгким и приятным. Часто
возникает ситуация, когда переводчику не достаточно просто перевести
экспортированный файл с переводами, а необходимо видеть результат своей работы сразу
же, хотя бы для того, чтобы оценить, как переведённый текст укладывается в дизайн. Для
этого существует средство прямого редактирования текста страниц. Чтобы включить его,
нужно выполнить лишь два действия: подключить ко всем страницам, где есть вызов
блоковой функции translate, стороннюю JavaScript библиотеку Overlib и установить
соответствующее свойство-флаг объекта смарти.
Скопируйте директорию system/3side/design/overlib в директорию public. Теперь в
директории public/overlib содержаться все необходимые JavaScript библиотеки.
Подключите их во все шаблоны проекта.
app/views/templates/root/index.tpl
<html>
<head>
………
<!-- Overlib -->
<script type="text/javascript" src="overlib/overlib.js"/>
<script type="text/javascript" src="overlib/overlib_exclusive.js"/>
<script type="text/javascript" src="overlib/overlib_draggable.js"/>
………
</head>
………
</html>

app/views/templates/root/index2.tpl
84

<html>
<head>
………
<!-- Overlib -->
<script type="text/javascript" src="overlib/overlib.js"/>
<script type="text/javascript" src="overlib/overlib_exclusive.js"/>
<script type="text/javascript" src="overlib/overlib_draggable.js"/>
</head>
………
</html>

А теперь просто дайте смарти знать, что нужно включить редактирование
переводов. Это делает метод setEditable() с аргументом true.
class RootController extends AppController {
public function __construct() {
parent::__construct();
$this->smarty = new RootSmarty($this->getTranslator());
$this->smarty->setEditable(true);
$this->modulesDir = CONTROLLERS_DIR . 'root' .
DIRECTORY_SEPARATOR;
}
………
}

Кстати не забудьте отключить файловое кэширование переводов. Просто замените
первый аргумент в методе
$translator->setCachingParams(false, 60 * 60, VIEWS_DIR . ………);

на false. Обновите страницы проекта в браузере.
Чтобы перевести какую-либо фразу на странице или отредактировать её перевод,
достаточно удерживать нажатой клавишу Ctrl и подвести курсор мыши к этой надписи.
Вы увидите popup окно, в котором сможете осуществить перевод. Если вам необходимо
переместить popup окно, то нажмите клавишу Alt и мышью тащите окно за заголовок.

85

На иллюстрации видна ошибка в заголовке страницы. Поскольку заголовок не
может содержать JavaScript, то его использование приведёт к ошибке, а редактировать
перевод, конечно, будет нельзя. То же самое относится к атрибутам alt и title тэга img и
т.д. Поэтому для них логично было бы запретить включать редактирование. Для этого
достаточно передать в блоковую функцию translate параметр edit со значением 0.
<title>

{translate section="page1" edit=0}
title
{/translate}
</title>
<title>

{translate section="page2" lang="ru" edit=0}
title
{/translate}
{translate section="page2" lang="en" edit=0}
title
{/translate}
</title>

Вот и всё о переводах.

86