Примеры шаблонов проектирования или как написать свой PHP framework. Часть 3: Контроллер
Написать свой PHP framework становиться всё проще и проще, ведь я публикую очередную статейку об этом. Сейчас мы поговорим об архитектуре web-приложений, а точнее об одном из основных аспектов организации обработок команд пользователя. Конечно же, я буду обговаривать некоторые шаблоны проектирования. Здесь мы затронем такие паттерны как модель-представление-контроллер (Model View Controller), контроллер страниц (Page Controller), контроллер запросов (Front Controller), команда (Command), сценарий транзакции (Transaction Script) и некоторые другие.
Итак, с чего начнем? А начнем мы с того, что все только и твердят об MVC (Model View Controller) хотя многие понятия не имеют, что это такое и как это работает. Не смотря на то, знаете ли вы или нет, что скрывается за этими словами, я все же попытаюсь кратко объяснить суть, так как на основе последней будет строиться дальнейшее изложение. Конечно же, в нашем контексте я говорю, что MVC представляет собой шаблон проектирования. Но так как шаблоны проектирования как таковые могут абстрагировать довольно различные по охвату мысли и масштабу реализации какого-то функционала, то сказать, что MVC просто шаблон проектирования равносильно тому, что говорить о компьютере, как о простом наборе микросхем. Паттерн MVC не просто типовое решение - он скрывает в себе один из фундаментальных принципов проектирования программного обеспечения - разделение основных частей приложения, благодаря которому приложение легче адаптируется, масштабируется, тестируется, сопровождается и конечно же реализуется. Разделение происходит именно в тех местах, где это наиболее выгодно для всех только что перечисленных действий. Конкретно происходит отделение интерфейсной части приложения от логической. В нашем же случае - случае web-приложения, происходит разделение последнего на три составляющие: модель, представление и контроллер. Модель представляет собой бизнес-логику приложения. Представление характеризуется внешним видом, которое непосредственно наблюдается пользователем. Ну а контроллер управляет всем этим делом. Сегодня речь пойдет о контроллере и вариантах его реализации, на одном из которых мы и остановимся.
На данный момент в 90 процентах случаев взаимодействие пользователя с web-приложением проходит посредством переходов по ссылкам. Посмотрите сейчас на адресную строку браузера - по этой ссылке вы получили этот текст. По другим ссылкам, например, находящимся справа на этой странице, вы получите другое содержимое. Таким образом, значение ссылки - комбинация символов в ней - определяет конкретную команду web-приложению. Надеюсь, вы уже успели заметить, что у разных сайтов могут быть совершенные разные форматы построения адресной строки. Где-то можно выделить явные названия файлов, где-то нельзя, а где-то - вообще ничего вроде и не меняется. Каждый формат может отображать архитектуру web-приложения. Хотя это и не всегда так, но в большинстве случаев это явный факт. К чему я веду? Я просто хочу показать на примере адресной строки различие двух подходов в реализации (сразу скажем это слово) контроллера. Допустим, имеется два варианта адресной строки, по которым показывается какой-то текст:
http://www.domain1.com/article.php?id=3
http://www.domain2.com/index.php?article=3
Соответственно имеются также два варианта для показа профиля пользователя:
http://www.domain1.com/user.php?id=4
http://www.domain2.com/index.php?user=4
Как видно из примеров, логика обращения к web-приложению является разной. Для сайта domain1.com каждый сценарий отвечает за выполнение определённой команды, а для сайта domain2.com все обращения происходят в одном сценарии index.php. То есть для первого случая каждая страница сервера выполняет конкретное действие на сайте, а для второго - все действия реализуются как бы одним скриптом. За реальными примерам далеко ходить не надо. Подход с множеством точек взаимодействия вы можете наблюдать на любом форуме с движком phpBB (например, www.php.ru/forum): просмотр форума происходит через сценарий viewforum.php, просмотр топика через viewtopic.php и т. д. Второй же подход, с доступом через один физический файл сценария, можно наблюдать на этом сайте: все обращения проходят через index.php. Чтобы просмотреть статью нужен адрес http://itdumka.com.ua/index.php?cmd=shownode&node=1, а чтобы, допустим, зарегистрироваться нужно пройти по ссылке http://itdumka.com.ua/index.php?cmd=showregisterform. Как видите, это совершенно два различных подхода, которые, как это ни странно, находят отражение в контексте наших с вами шаблонов проектирования. Первый подход характерен для шаблона контроллер страниц (Page Controller), а второй подход реализуется паттерном контроллер запросов (Front Controller).
Контроллер страниц хорошо применять для сайтов с достаточно простой логикой. Форум phpBB имеет очень простую логику, поэтому контроллер страниц хорошо подошел для его реализации. В свою очередь, контроллер запросов объединяет все действия по обработке запросов в одном месте, что даёт ему дополнительные возможности, благодаря которым можно реализовать более трудные задачи, чем обычно решаются контроллером страниц. Я не буду вдаваться в подробности реализации контроллера страниц, а скажу лишь, что в рамках нашей задачи в роли контроллера будет разработан именно контроллер запросов. Мы же делаем фреймоврк, который должен решать большинство задач, поэтому контроллер страниц, к сожалению, тут не уместен. Но, я думаю, мы еще затронем его.
Вернемся немного к архитектуре MVC. Что же будет делать наш контроллер запросов? В первую очередь, он будет просматривать параметры запроса пользователя, и исходя из их значений, обращаться к соответствующим моделям приложения. Существуют разные способы реализации контроллера запросов, но все они основаны на схеме, в которой взаимодействуют две сущности: обработчик и действие. Обработчик извлекает из URL необходимую информацию, после чего решает, какое действие необходимо инициировать. После выбора конкретного действия он передает ему управление. То есть, в большинстве случаев обработчик является довольно простой программой, функции которой заключаются в выборе нужного действия. Действие представляет собой чистой воды шаблон проектирования, реализуемый в виде сценария транзакции (Transaction Script) или команды (Command).
В чем отличие между сценарием транзакции (Transaction Script) и командой (Command)? Эти два типовых решения практически одинаковы и решают одну и ту же задачу - обеспечить "логическую изоляцию" логики работы приложения по процедурам. Но, в связи с различной структурой реализации, эти типовые решения начинают приобретать различные свойства, обеспечивающие им довольно сильные отличия друг от друга, которые способствуют различным вариантам применения. Фактически, происходит реализация логически завершенных процедур, но конструктивно разными способами. Сценарий транзакций подразумевает расположения каждого логического действия в отдельной процедуре, которые могут существовать как разрознено, так и в пределах одного класса. Это уже решает сам разработчик исходя из своих собственных убеждений. Можно объединить в одном классе сценарии транзакции, реализующие более родственные операции. Но это, еще раз повторюсь, решает конкретный разработчик. В рамках контроллера запросов web-приложения я обычно не решаю такие вопросы, а использую шаблон проектирования команда. В отличие от сценария транзакции каждая логическая процедура реализуется отдельным классом, в котором присутствует полиморфный метод запуска процедуры. Этот метод объявлен в абстрактном классе и реализуется, в наследниках:
Listing №1 (PHP)
/**
* Абстрактное действие
*/
abstract class Action {
/**
* Запускает действие
* с определенными параметрами
* @param ArrayList $Params
*/
abstract function Run(ArrayList $Params);
}
В соответствие с шаблоном проектирования команда наследник реализует конкретное действие. Таким образом, наследник реализует одну процедуру бизнес-логики приложения - фактически это будет реализация модели. Например, модель регистрации пользователя может выглядеть так:
Listing №2 (PHP)
/**
* Объявляем класс действия
* наследуемого от Action
*/
class regAction extends Action {
/**
* Реализуем логику модели в методе Run
* @param ArrayList $Params
* @return mixed
*/
public function Run(ArrayList $Params)
{
// Создаем контейнер ошибок
$Errors = new ArrayList();
// Проверяем новые логин и пароль
$Errors->LoginError = User::TestLogin(Request::Post('login'));
if(!count($Errors) && User::Exists('Login', Request::Post('login'))) {
$Errors->LoginError = 'Такой логин уже используется';
}
$Errors->PasswordError = User::TestPswd(Request::Post('password'));
// Если нет ошибок создаём нового пользователя
if(!count($Errors)) {
$User = new User();
$User->Login = Request::Post('login');
$User->Password = Request::Post('password');
// Пытаемся сохранить его
if ($User->Save()) {
// Если все прошло удачно,
// то возвращаем сообщение об
// успешной регистрации
$View = new View();
$View->SetTemplete('message');
$View->Message = $User->Login . ' зарегистрирован';
return $View;
}
else {
// Возвращаем ошибку если
// не можем сохранить пользователя
$Errors->UnknownError = 'Ошибка регистрации';
return $Errors;
}
}
else {
// Возвращаем ошибки входных данных
return $Errors;
}
}
}
Как видите все очень просто. Класс конкретного действия, один метод - и обработчик готов. Данный паттерн называют по-разному - команда (Сommand), действие (Action) и иногда путают со сценарием транзакции (Transaction Script). Чаще всего я встречал название "команда" хотя, как видите, употребляю также слово "действие". Это происходит потому, что реализация самого шаблона проектирования чаще связана со слово Action. Думаю, ничего страшного не будет, если я буду использовать оба названия.
Что же такого хорошего в команде? Применяя это типовое решение, мы получаем следующие результаты:
Команда разрывает связь между объектом, инициирующим операцию, и объектом, имеющим информацию о том, как ее выполнить. То есть наш контроллер запросов будет как бы выполнять команды и ничего не знать о том, что конкретно в них происходит. Это позволяет добиваться высокой гибкости и динамически подменять команды
Команды - это самые настоящие объекты. Мы можем манипулировать ими и расширять точно так же, как в случае с любыми другими объектами
Из простых команд можно собирать составные
Добавлять новые команды очень легко, поскольку никакие существующие классы изменять не нужно. Необходимо только создать новый класс конкретной команды, реализовав в методе запуска соответствующую логику
Для завершения реализации контроллера запросов осталось дописать обработчик. Напомню, что он всего лишь должен выбрать нужную команду и запустить её. Тут стоит уточнить, что под словом "выбрать" подразумевается создание объекта команды, более модными словами - инстанцирование. Обработчик не должен знать, какой именно экземпляр команды он создаёт, но он точно знает название метода запуска команды. Ведь оно диктуется абстрактным классом, в нашем случае классом Action.
Итак, обработчик, назовем его диспетчер (Dispatcher), будет принимать на вход имя команды, которое будет совпадать с частью имени класса команды в нижнем регистре. К этой части имени мы прибавим строку 'Action' для констатации класса как класса команды (действия). Пример вы видели в листинге 2. Далее осуществляется полет фантазий и каждый летает, как хочет. Конечно же, более умные люди, скорее всего, опираются на задачу, но мы полетим довольно простым способом. Мы можем разделить команды по модулям, привязав название модулей к соответствующим директориям. В принципе, это довольно распространенный подход. Он осуществляется в основном с целью расположить классы команд более удобным способом и таким образом формировать непосредственно модули самого приложения. Таким образом, путь к классу конкретной команды будет выглядеть следующим образом:
Формат пути к классу команды:
/корневой путь/модуль родственных команд/конкретнаяКоманда.php
Как видно у нас будет всегда один и тот же корневой путь. Наверно мы будем по традиции инициализировать наш диспетчер им:
Listing №3 (PHP)
class Dispatcher {
/**
* Корневой путь
* к модулям
* @static
* @var string
*/
private static $_Path = '';
/**
* Задает путь к модулям приложения
* @static
* @param string $Path
*/
public static function Init($Path)
{
self::$_Path = $Path;
}
Теперь реализуем два основных метода. Один будет создавать конкретный экземпляр команды, а второй запускать команду на исполнение:
Listing №4 (PHP)
/**
* Возвращает инстанцированный
* объект команды
* @param string $ActionName
* @param string $ModuleName
* @return Action
*/
private function _CreateAction($ActionName, $ModuleName)
{
//Формируем имя и путь подключаемого файла с классом команды
$FileName = self::$_Path . $ModuleName . DIRECTORY_SEPARATOR .
$ActionName . 'Action' . PHP_EXT;
if(is_file($FileName)) {
require_once $FileName;
$ActionClassName = $ActionName . 'Action';
return new $ActionClassName();
}
else {
//Генерируем исключение если файл не найден
throw new Exception('Action file "' . $FileName . '" does not exists');
}
}
/**
* Запускает команду и
* возвращает результат ее действия
* @param Action $Action
* @param ArrayList $Params
* @return mixed
*/
private function _RunAction(Action $Action, ArrayList $Params)
{
return $Action->Run($Params);
}
Как видим, последний метод не пропустит ничего кроме класса с типом Action, что декларирует обязательную реализацию интерфейса объектов команд через их абстрактного предка.
Но что делать, если пользователь будет запускать всё что не лень? Для ограничения его возможностей мы будет регистрировать в диспетчере те команды, которые может выполнить данный пользователь. Дописываем оставшиеся методы:
Listing №5 (PHP)
class Dispatcher {
/**
* Зарегистрированные
* команды
* @var array
*/
private $_Actions;
public function __construct()
{
$this->_Actions = array();
}
/**
* Регистрирует команду модуля
* @param string $ActionName
* @param string $ModuleName
*/
public function RegisterAction($ActionName, $ModuleName)
{
//Приводим переменные к нижнему регистру
$ActionName = Str::Strtolower($ActionName);
$ModuleName = Str::Strtolower($ModuleName);
$this->_Actions[$ModuleName . ' ' . $ActionName] = TRUE;
}
/**
* Возвращает TRUE если команда зарегистрирована
* @param string $ActionName
* @param string $ModuleName
* @param bool $InDefault FALSE
* @return bool
*/
public function IsActionRegistered($ActionName, $ModuleName)
{
//Приводим переменные к нижнему регистру
$ActionName = Str::Strtolower($ActionName);
$ModuleName = Str::Strtolower($ModuleName);
return (isset($this->_Actions[$ModuleName . ' ' . $ActionName]));
}
/**
* Возвращает результат выполнения команды
* или NULL если команда не зарегистрирована
* @param string $ActionName
* @param string $ModuleName
* @param ArrayList|NULL $Params
* @return mixed
*/
public function GetResponse($ActionName, $ModuleName, $Params = NULL)
{
//Создаем пустые параметры команды если они не заданы
if(!$Params) {
$Params = new ArrayList();
}
//Приводим переменные к нижнему регистру
$ActionName = Str::Strtolower($ActionName);
$ModuleName = Str::Strtolower($ModuleName);
if($this->IsActionRegistered($ActionName, $ModuleName)) {
//Создаем неизвестный экземпляр команды
$Action = $this->_CreateAction($ActionName, $ModuleName);
//Запускаем команду и возвращаем результат
return $this->_RunAction($Action, $Params);
}
else {
return NULL;
}
}
Я думаю вы со мной согласитесь, что как то глупо просто возвращать NULL если сценарий запустился без параметров. Давайте реализуем запуск команды по умолчанию. Она будет запускаться для конкретного модуля. Если эта команда не будет найдена, то будем перенаправлять пользователя по адресу, заданному при создании диспетчера. Для этого изменим наш код. Чтобы не путаться я привожу окончательный вариант класса диспетчера:
Listing №6 (PHP)
class Dispatcher {
/**
* Корневой путь
* к модулям
* @static
* @var string
*/
private static $_Path = '';
/**
* Зарегистрированные
* команды
* @var array
*/
private $_Actions;
/**
* Команды по умолчанию
* @var array
*/
private $_Default;
/**
* Модуль по умолчанию
* @var string
*/
private $_DefModule;
/**
* Адрес редиректа
* @var string
*/
private $_RedirectUrl;
/**
* Задает путь к модулям приложения
* @static
* @param string $Path
*/
public static function Init($Path)
{
self::$_Path = $Path;
}
/**
* @param string $DefModule
* @param string $RedirectUrl
*/
public function __construct($DefModule, $RedirectUrl = '/')
{
$this->_Actions = array();
$this->_Default = array();
$this->_RedirectUrl = Str::Strtolower($RedirectUrl);
$this->_DefModule = Str::Strtolower($DefModule);
}
/**
* Регистрирует команду модуля
* @param string $ActionName
* @param string $ModuleName
*/
public function RegisterAction($ActionName, $ModuleName)
{
//Приводим переменные к нижнему регистру
$ActionName = Str::Strtolower($ActionName);
$ModuleName = Str::Strtolower($ModuleName);
$this->_Actions[$ModuleName . ' ' . $ActionName] = TRUE;
}
/**
* Регистрирует команду по умолчанию для модуля
* @param string $ActionName
* @param string $ModuleName
*/
public function RegisterDefault($ActionName, $ModuleName)
{
//Приводим переменные к нижнему регистру
$ActionName = Str::Strtolower($ActionName);
$ModuleName = Str::Strtolower($ModuleName);
$this->_Default[$ModuleName] = $ActionName;
}
/**
* Возвращает TRUE если команда зарегистрирована
* @param string $ActionName
* @param string $ModuleName
* @param bool $InDefault FALSE
* @return bool
*/
public function IsActionRegistered($ActionName, $ModuleName)
{
//Приводим переменные к нижнему регистру
$ActionName = Str::Strtolower($ActionName);
$ModuleName = Str::Strtolower($ModuleName);
return (isset($this->_Actions[$ModuleName . ' ' . $ActionName]));
}
/**
* Возвращает команду по умолчанию для модуля
* или NULL если её нет
* @param string $ModuleName
* @return mixed
*/
public function GetDefaultAction($ModuleName)
{
//Приводим переменную к нижнему регистру
$ModuleName = Str::Strtolower($ModuleName);
return (isset($this->_Default[$ModuleName])) ? $this->_Default[$ModuleName] : NULL;
}
/**
* Возвращает результат выполнения команды
* @param string $ActionName
* @param string $ModuleName
* @param ArrayList|NULL $Params
* @return mixed
*/
public function GetResponse($ActionName, $ModuleName, $Params = NULL)
{
//Создаем пустые параметры команды если они не заданы
if(!$Params) {
$Params = new ArrayList();
}
//Если не указан модуль устанавливаем тот что по умолчанию
if(!$ModuleName) {
$ModuleName = $this->_DefModule;
}
//Ищем команду в зарегистрированных
if($this->IsActionRegistered($ActionName, $ModuleName)) {
//Создаем неизвестный экземпляр команды
$Action = $this->_CreateAction($ActionName, $ModuleName);
//Запускаем команду и возвращаем результат
return $this->_RunAction($Action, $Params);
}
//Ищем команду в зарегистрированных по умолчанию
elseif (NULL !== $ActionName = $this->GetDefaultAction($ModuleName)) {
//Создаем неизвестный экземпляр команды
$Action = $this->_CreateAction($ActionName, $ModuleName);
//Запускаем команду по умолчанию и возвращаем результат
return $this->_RunAction($Action, $Params);
}
else {
//Перенаправляем пользователя
header('Location: ' . $this->_RedirectUrl);
exit(0);
}
}
/**
* Возвращает инстанцированный
* объект команды
* @param string $ActionName
* @param string $ModuleName
* @return Action
*/
private function _CreateAction($ActionName, $ModuleName)
{
//Приводим переменные к нижнему регистру
$ActionName = Str::Strtolower($ActionName);
$ModuleName = Str::Strtolower($ModuleName);
//Формируем имя и путь подключаемого файла с классом команды
$FileName = self::$_Path . $ModuleName . DIRECTORY_SEPARATOR .
$ActionName . 'Action' . PHP_EXT;
if(is_file($FileName)) {
//Подключаем файл класса
require_once $FileName;
//Формируем имя класса команды (действия)
$ActionClassName = $ActionName . 'Action';
//Инстанцируем экземпляр команды и возвращаем его
return new $ActionClassName();
}
else {
//Генерируем исключение если файл не найден
throw new Exception('Action file "' . $FileName . '" does not exists');
}
}
/**
* Запускает команду и
* возвращает результат ее действия
* @param Action $Action
* @param ArrayList $Params
* @return mixed
*/
private function _RunAction(Action $Action, ArrayList $Params)
{
return $Action->Run($Params);
}
}
Да, не особо простой оказался диспетчер. Некоторые методы я чуть изменил, но суть осталась та же: проверяем команду, если нет, то смотрим, установлена ли какая-либо по умолчанию, и, если и таковой нет, то выполняем редирект на адрес. Не остывая от кода, сразу напишем небольшой пример, в котором в зависимости от входных параметров, будет выполняться определенная команда. Сперва мы инициализируем некоторые константы и классы. Последние были разработаны в предыдущих частях №1 и №2. Затем зарегистрируем несколько команд и запустим диспетчер:
Listing №7 (PHP)
// Константа расширения для файлов команд
// да и для всех интерпретируемых файлов
define('PHP_EXT', '.php');
require_once './framework/Action' . PHP_EXT;
require_once './framework/ArrayList' . PHP_EXT;
require_once './framework/Dispatcher' . PHP_EXT;
require_once './framework/Request' . PHP_EXT;
require_once './framework/Str' . PHP_EXT;
//Инициализируем классы
Str::Init(Str::MODE_MULTIBYTE, 'UTF-8');
Request::Init();
Dispatcher::Init('./actions/');
//Посылаем заголовок
header('Content-type: text/html; charset="' . Str::GetCharset() . '"');
//Создаем диспетчер
$Dispatcher = new Dispatcher('standart');
//Регистрируем команду "login" в модуле "standart"
$Dispatcher->RegisterAction('login', 'standart');
//Регистрируем команду "reg" в модуле "standart"
$Dispatcher->RegisterAction('reg', 'standart');
//Регистрируем команду "user" в модуле "standart"
$Dispatcher->RegisterAction('user', 'standart');
//Также регистрируем ее как команду по умолчанию
$Dispatcher->RegisterDefault('user', 'standart');
//Запускаем диспетчер
$Response = $Dispatcher->GetResponse(Request::Get('cmd'), Request::Get('module'));
//Выводим результат
echo $Response;
Вот так с помощью шаблона проектирования контроллер запросов (Front Controller) мы объединяем все действия по обработке запросов в одном месте, распределяя их выполнение посредством диспетчера. Благодаря применению шаблона проектирования команда (Command) в составе контроллера запросов, мы с легкостью можем добавлять новые действия без особых усилий. Стоит лишь создать класс конкретной команды и подкинуть ее в директорию модуля. Давайте это и сделаем, добавив действия описанные в листинге7:
Listing №8 (PHP)
<?php //авторизация: файл ./actions/standart/loginAction.php
class loginAction extends Action {
public function Run(ArrayList $Params)
{
return 'Запущена команда авторизации';
}
}
?>
<?php //регистрация: файл ./actions/standart/regAction.php
class regAction extends Action {
public function Run(ArrayList $Params)
{
return 'Запущена команда регистрации';
}
}
?>
<?php //пользователь: файл ./actions/standart/userAction.php
class userAction extends Action {
public function Run(ArrayList $Params)
{
return 'Запущена команда пользователя';
}
}
?>
Надеюсь, с путями вы разберетесь. Конечно, данный диспетчер не претендует на идеальность, но я старался сделать как можно проще, чтобы показать суть решения. Естественно, вы можете допилить его до нужной кондиции - рефакторинг никогда не помешает!
Ах да, забыл я о классе ArrayList. Не хочется вас разочаровывать, но мы обсудим его в следующей статье вместе с некоторым шаблоном проектирования. Вы же можете создать фиктивный класс, благо в коде мы его почти нигде не использовали:
Listing №9 (PHP)
class ArrayList {}
Можете считать, что перед вами зародыш фиктивной службы (Service Stub).
Было бы неплохо увидеть ваши комментарии! Продолжение следует...