Обзор

INVO-это небольшое приложение, которое позволяет пользователям создавать счета-фактуры, управлять клиентами и продуктами, а также регистрироваться и входить в систему. Он демонстрирует, как определенные задачи решаются Phalcon. На стороне клиента для пользовательского интерфейса используется Bootstrap. Приложение не генерирует фактические счета-фактуры, но служит примером того, как эти задачи реализуются с помощью Phalcon.

Примечание: рекомендуется открыть приложение в вашем любимом редакторе, чтобы вам было легче следовать этому руководству.

Примечание: приведенный ниже код был отформатирован для повышения читаемости

Структура проекта.

После клонирования проекта в корне документа вы увидите следующую структуру:

└── invo
    ├── app
    │   ├── config
    │   ├── controllers
    │   ├── forms
    │   ├── library
    │   ├── logs
    │   ├── models
    │   ├── plugins
    │   └── views
    ├── cache
    │   └── volt
    ├── docs
    │── public
    │   ├── css
    │   ├── img
    │   ├── index.php
    │   └── js
    └── schemas

 

Поскольку Phalcon не навязывает определенную структуру каталогов, эта структура является просто нашей реализацией. Вам нужно будет настроить свой веб-сервер с помощью инструкций на странице настройки веб-сервера.

Как только вы открываете приложение в вашем браузере http://localhost/invo вы увидите нечто подобное:

Приложение разделено на две части: frontend и backend. Фронтэнд-это публичная зона, где посетители могут получить информацию об INVO и запросить контактную информацию. Серверная часть-это административная область, где зарегистрированные пользователи могут управлять своими продуктами и клиентами.

Маршрутизация

INVO использует стандартный маршрут, встроенный в компонент Router. Эти маршруты соответствуют следующему шаблону: /:controller/:action/:params. Это означает, что первая часть URI является контроллером, вторая - действием контроллера, а остальные-параметрами.

Следующий маршрут /session/register выполняет контроллер SessionController и его действие registerAction.

Конфигурация

INVO имеет файл конфигурации, который устанавливает общие параметры в приложении. Этот файл находится в app/config/config.ini и загружается в самых первых строках приложения bootstrap (public/index.php):

<?php

use Phalcon\Config\Adapter\Ini as ConfigIni;

// ...

// Чтение конфигурации
$config = new ConfigIni(
    APP_PATH . 'app/config/config.ini'
);

Phalcon Config (Phalcon\Config) позволяет управлять файлом объектно-ориентированным способом. В этом примере мы используем ini-файл для конфигурации, но Phalcon имеет адаптеры и для других типов файлов. Файл конфигурации содержит следующие параметры:

[database]
host     = localhost
username = root
password = secret
name     = invo

[application]
controllersDir = app/controllers/
modelsDir      = app/models/
viewsDir       = app/views/
pluginsDir     = app/plugins/
formsDir       = app/forms/
libraryDir     = app/library/
baseUri        = /invo/

Phalcon не имеет каких-либо предопределенных настроек. Разделы помогают нам организовать нужные. В этом файле есть два раздела, которые будут использоваться позже: application и database.

Автозагрузчики

Вторая часть, которая появляется в файле начальной загрузки (public/index.php) - автозагрузчик:

<?php

/**
 * Конфигурация авто-загрузчика 
 */
require APP_PATH . 'app/config/loader.php';

Автозагрузчик регистрирует набор каталогов, в которых приложение будет искать классы, которые в конечном итоге нужны.

<?php

$loader = new Phalcon\Loader();

// Мы регистрируем набор каталогов, взятых из файла конфигурации
$loader->registerDirs(
    [
        APP_PATH . $config->application->controllersDir,
        APP_PATH . $config->application->pluginsDir,
        APP_PATH . $config->application->libraryDir,
        APP_PATH . $config->application->modelsDir,
        APP_PATH . $config->application->formsDir,
    ]
);

$loader->register();

Примечание: риведенный выше код зарегистрировал каталоги, которые были определены в файле конфигурации. Единственный каталог, который не зарегистрирован, - это viewsDir , поскольку он содержит HTML + PHP файлы, но не классы.

Примечание: мы используем константу под названием APP_PATH. Эта константа определяется в bootstrap (public/index.php) tчтобы у нас была ссылка на корень нашего проекта:

<?php

// ...

define(
    'APP_PATH',
    realpath('..') . '/'
);

Регистрация сервисов.

Другой файл, который требуется в bootstrap - (app/config/services.php). Этот файл позволяет нам организовать сервисы, которые использует INVO.

<?php

/**
 * Load application services
 */
require APP_PATH . 'app/config/services.php';

 Регистрация сервиса осуществляется с помощью замыканий для отложенной загрузки необходимых компонентов:

<?php

use Phalcon\Url;

$container->set(
    'url',
    function () use ($config) {
        $url = new Url();

        $url->setBaseUri(
            $config->application->baseUri
        );

        return $url;
    }
);

Мы обсудим этот файл более подробно позже.

Обработка запроса.

Если пропустить до конца файла (public/index.php),запрос, наконец, обрабатывается Phalcon\Mvc\Application, который инициализирует и выполняет все, что необходимо для запуска приложения:

<?php

use Phalcon\Mvc\Application;

// ...

$application = new Application($container);

$response = $application->handle(
    $_SERVER["REQUEST_URI"]
);

$response->send();

Инъекция Зависимостей.

В первой строке блока кода выше конструктор класса приложения получает переменную $di в качестве аргумента. Какова цель этой переменной? Phalcon очень слабосвязанный фреймворк, поэтому нам нужен компонент который действует как клей для того чтобы заставить все работать совместно.  Этотим компонентом является Phalcon\Di. Это контейнер службы, который также выполняет внедрение зависимостей и размещение служб, создавая экземпляры всех компонентов по мере их востребованности приложением.

Существует множество способов регистрации сервисов в контейнере. В INVO большинство сервисов зарегистрировано с использованием анонимных функций/замыканий. Благодаря этому экземпляры объектов создаются в режиме ожидания, что сокращает объем ресурсов, необходимых приложению.

Например, в следующем фрагменте регистрируется служба сеанса. Анонимная функция будет вызываться только в том случае, если приложению требуется доступ к данным сеанса:

<?php

use Phalcon\Session\Manager;
use Phalcon\Session\Adapter\Stream;

$container->set(
    'session',
    function () {
        $session = new Manager();
        $files   = new Stream(
            [
                'savePath' => '/tmp',
            ]
        );
        $session->setAdapter($files);

        $session->start();

        return $session;
    }
);

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

Запрос может использовать несколько служб, и регистрация каждой службы по отдельности может быть трудоемкой задачей. По этой причине фреймворк предоставляет вариант Phalcon\Di называемый Phalcon\Di\FactoryDefault , задача которого состоит в регистрации всех служб, необходимых фреймворку по максимому.

<?php

use Phalcon\Di\FactoryDefault;

// ...

// Инжектор зависимостей FactoryDefault автоматически регистрирует правильные сервисы, 
// предоставляющие полную инфраструктуру стека
$container = new FactoryDefault();

Если какие-либо службы должны быть перезаписаны, нам нужно переопределить определение какого-либо сервиса, мы могли бы просто установить его снова, как мы сделали это выше с помощью session или url. Это и есть причина существования переменной $container.

Вход в приложение.

Вход в систему ( log in ) позволит нам работать с бэкэнд-контроллерами. Разделение между backend контроллерами и frontend контроллерами логично. Все контроллеры находятся в одном каталоге (app/controllers/).

Чтобы войти в систему, пользователи должны иметь действительное имя пользователя и пароль. Пользователи хранятся в таблице users в базе данных invo.

Прежде чем мы сможем начать сеанс, нам нужно настроить соединение с базой данных в приложении. Служба с именем  db настраивается в контейнере служб с информацией о соединении. Как и в случае с автозагрузчиком, мы снова берем параметры из файла конфигурации для настройки службы:

<?php

use Phalcon\Db\Adapter\Pdo\Mysql as DbAdapter;

// ...

// Подключение к базе данных создается на основе параметров, определенных в файле конфигурации
$di->set(
    'db',
    function () use ($config) {
        return new DbAdapter(
            [
                'host'     => $config->database->host,
                'username' => $config->database->username,
                'password' => $config->database->password,
                'dbname'   => $config->database->name,
            ]
        );
    }
);

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

Следующая простая форма (app/views/session/index.volt) просит логин и пароль. Мы удалили некоторые HTML-код, чтобы сделать пример более кратким:

{{ form('session/start') }}
    <fieldset>
        <div>
            <label for='email'>
                Username/Email
            </label>

            <div>
                {{ text_field('email') }}
            </div>
        </div>

        <div>
            <label for='password'>
                Password
            </label>

            <div>
                {{ password_field('password') }}
            </div>
        </div>

        <div>
            {{ submit_button('Login') }}
        </div>
    </fieldset>
{{ endForm() }}

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

Функция SessionController::startAction  (app/controllers/SessionController.php) имеет задачу проверки данных, введенных в форме, включая проверку допустимого пользователя в базе данных:

<?php

class SessionController extends ControllerBase
{
    // ...

    private function _registerSession($user)
    {
        $this->session->set(
            'auth',
            [
                'id'   => $user->id,
                'name' => $user->name,
            ]
        );
    }

    /**
     * Это действие выполняет проверку подлинности и регистрирует пользователя в приложении
     */
    public function startAction()
    {
        if (true === $this->request->isPost()) {
            // Получить данные от пользователя
            $email    = $this->request->getPost('email');
            $password = $this->request->getPost('password');

            // Поиск пользователя в базе данных
            $user = Users::findFirst(
                [
                    "(email = :email: OR username = :email:) AND password = :password: AND active = 'Y'",
                    'bind' => [
                        'email'    => $email,
                        'password' => sha1($password),
                    ]
                ]
            );

            if ($user !== false) {
                $this->_registerSession($user);

                $this->flash->success(
                    'Welcome ' . $user->name
                );

                // Переслать контроллеру "invoices", если пользователь является действительным
                return $this->dispatcher->forward(
                    [
                        'controller' => 'invoices',
                        'action'     => 'index',
                    ]
                );
            }

            $this->flash->error(
                'Неправильный email/пароль'
            );
        }

        // Переслать снова в форму входа
        return $this->dispatcher->forward(
            [
                'controller' => 'session',
                'action'     => 'index',
            ]
        );
    }
}

Для простоты мы использовали sha1 для хранения хэшей паролей в базе данных, однако этот алгоритм не рекомендуется использовать в реальных приложениях, вместо этого используйте bcrypt.

При первом просмотре кода Вы заметите, что в контроллере доступны несколько общих свойств, таких как $this->flash, $this->request или $this->session. Контроллеры в Phalcon автоматически привязываются к контейнеру Phalcon\Di, и в результате все сервисы, зарегистрированные в контейнере, присутствуют в каждом контроллере как свойства с тем же именем, что и имя каждой службы. Если служба доступна в первый раз, она будет автоматически создана и возвращена вызывающему абоненту. Кроме того, эти службы настроены как общие, поэтому один и тот же экземпляр будет возвращен обратно, независимо от того, сколько раз мы обращаемся к свойству/службе в одном запросе. Это Службы, определенные в контейнере служб из предыдущей версии (app/config/services.php) и вы, конечно, можете изменить это поведение при настройке этих сервисов.

Например, здесь мы вызываем службу session , а затем сохраняем идентификатор пользователя в переменной auth:

<?php

$this->session->set(
    'auth',
    [
        'id'   => $user->id,
        'name' => $user->name,
    ]
);

Примечание: для получения дополнительной информации о службах Di, пожалуйста, проверьте документ инъекции зависимостей.

Метод startAction сначала проверяет, были ли данные представлены с помощью POST. Если нет, то пользователь будет перенаправлен снова к той же форме. Мы проверяем, если форма была отправлена ​​черезPOST с помощью метода isPost() объекта запроса.

<?php

if ($this->request->isPost()) {
    // ...
}

Затем мы извлекаем опубликованные данные из запроса. Это текстовые поля, которые используются для отправки формы, когда пользователь нажимает кнопку Log In. Мы используем объект request и метод getPost().

<?php

$email    = $this->request->getPost('email');
$password = $this->request->getPost('password');

Теперь мы должны проверить, есть ли один пользователь с тем же именем пользователя или электронной почтой и паролем:

<?php

$user = Users::findFirst(
    [
        "(email = :email: OR username = :email:) " .
        "AND password = :password: " .
        "AND active = 'Y'",
        'bind' => [
            'email'    => $email,
            'password' => sha1($password),
        ]
    ]
);

Обратите внимание, что использование "связанных параметров", заполнителей :email: и :password: размещаются там, где должны быть значения, затем значения "привязываются" с помощью параметра bind. Это безопасно заменяет значения для этих столбцов без риска SQL-инъекции.

When searching for the user in the database, we are not searching for the password directly using clear text. The application stores passwords as hashes, using the sha1 method. Although this methodology is adequate for a tutorial, you might want to consider using a different algorithm for a production application. The Phalcon\Security component offers convenience methods to strengthen the algorithm used for your hashes.

Если пользователь является действительным, мы регистрируем его в сессии и направляем его на информационную панель:

<?php

if ($user !== false) {
    $this->_registerSession($user);

    $this->flash->success(
        'Добро пожаловать' . $user->name
    );

    return $this->dispatcher->forward(
        [
            'controller' => 'invoices',
            'action'     => 'index',
        ]
    );
}

Если пользователь не существует, мы перенаправляем его обратно в действие, где отображается форма:

<?php

return $this->dispatcher->forward(
    [
        'controller' => 'session',
        'action'     => 'index',
    ]
);

Защита админки.

Админка - это частная область, доступ к которой имеют только зарегистрированные пользователи. Поэтому надо проверить, что только зарегистрированные пользователи имеют доступ к этим контроллерам. Если вы не вошли в приложение и пытаетесь получить доступ, например, к контроллеру продуктов (который является частным), вы увидите экран, подобный этому:

Каждый раз, когда кто-то пытается получить доступ к любому контроллеру/действию, приложение проверяет, что текущая роль (в сеансе) имеет доступ к нему, в противном случае оно отображает сообщение, подобное приведенному выше, и перенаправляет поток на домашнюю страницу.

Теперь давайте узнаем, как приложение выполняет это. Первое, что нужно знать, что есть компонент под названием Dispatcher. Он информируется о маршруте, найденном компонентом маршрутизации. Затем он отвечает за загрузку соответствующего контроллера и выполняет соответствующий метод действия.

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

<?php

use Phalcon\Mvc\Dispatcher;

// ...

/**
 * MVC диспетчер
 */
$di->set(
    'dispatcher',
    function () {
        // ...

        $dispatcher = new Dispatcher();

        return $dispatcher;
    }
);

Теперь у нас есть полный контроль над диспетчером, используемым в приложении. Многие компоненты платформы инициируют события, которые позволяют изменять внутренний поток операций. Поскольку компонент инжектора зависимостей действует как клей для компонентов, новый компонент под названием EventsManager позволяет нам перехватывать события, произведенные компонентом, направляя события слушателям.

Управление событиями.

EventsManager  позволяет нам присоединять слушателей к определенному типу событий. Тип события, к которому мы привязываемся, - это dispatch. Приведенный ниже код присоединяет прослушиватели к событиям beforeExecuteRoute и beforeException. Мы используем эти события для проверки 404 страниц, а также выполняем проверку разрешенного доступа в нашем приложении.

<?php

use Phalcon\Mvc\Dispatcher;
use Phalcon\Events\Manager as EventsManager;

$di->set(
    'dispatcher',
    function () {
        // Создание менеджера событий
        $eventsManager = new EventsManager();

        // Прослушивание событий, созданных в диспетчере с помощью плагина безопасности
        $eventsManager->attach(
            'dispatch:beforeExecuteRoute',
            new SecurityPlugin()
        );

        // Обработка исключений и не найденных исключений с помощью NotFoundPlugin
        $eventsManager->attach(
            'dispatch:beforeException',
            new NotFoundPlugin()
        );

        $dispatcher = new Dispatcher();

        // Назначить менеджера по обработке событий в диспетчере
        $dispatcher->setEventsManager($eventsManager);

        return $dispatcher;
    }
);

Когда событие  beforeExecuteRoute срабатывает следующий плагин будет уведомлен:

<?php

/**
 * Проверить, разрешен ли пользователю доступ к определенным действиям с помощью SecurityPlugin
 */
$eventsManager->attach(
    'dispatch:beforeExecuteRoute',
    new SecurityPlugin()
);

При срабатывании beforeException вызывается другой плагин:

<?php

/**
 * Обработка исключений и не найденных исключений с помощью NotFoundPlugin
 */
$eventsManager->attach(
    'dispatch:beforeException',
    new NotFoundPlugin()
);

SecurityPlugin класс, расположенный в (app/plugins/SecurityPlugin.php). Этот класс реализует метод beforeExecuteRoute. Это то же имя, что и одно из событий, созданных в Диспетчере:

use Phalcon\Di\Injectable;
use Phalcon\Events\Event;
use Phalcon\Mvc\Dispatcher;

class SecurityPlugin extends Injectable
{
    // ...

    public function beforeExecuteRoute(
        Event $event, 
        Dispatcher $containerspatcher
    ) {
        // ...
    }
}

Методы событий всегда получают фактическое событие в качестве первого параметра. Это объект Phalcon\Events\Event, который будет содержать информацию о событии, такую как его тип и другую связанную информацию. Для этого конкретного события вторым параметром будет объект, который произвел само событие ($containerspatcher). Не обязательно, чтобы классы плагинов расширяли класс Phalcon\Di\Injectable, но, делая это, они получают более легкий доступ к сервисам, доступным в приложении.

Теперь у нас есть структура, чтобы начать проверку роли в текущей сессии. Мы можем проверить, имеет ли пользователь доступ к ACL. Если у пользователя нет доступа, мы перенаправим его на главный экран.

<?php

use Phalcon\Di\Injectable;
use Phalcon\Events\Event;
use Phalcon\Mvc\Dispatcher;

class SecurityPlugin extends Plugin
{
    // ...

    public function beforeExecuteRoute(
        Event $event, 
        Dispatcher $containerspatcher
    ) {
        $auth = $this->session->get('auth');

        if (!$auth) {
            $role = 'Guests';
        } else {
            $role = 'Users';
        }

        $controller = $containerspatcher->getControllerName();
        $action     = $containerspatcher->getActionName();

        $acl = $this->getAcl();

        $allowed = $acl->isAllowed($role, $controller, $action);
        if (true !== $allowed) {
            $this->flash->error(
                "You do not have access to this module"
            );

            $containerspatcher->forward(
                [
                    'controller' => 'index',
                    'action'     => 'index',
                ]
            );

            return false;
        }
    }
}

Сначала мы получаем значение auth из службы session. Если мы вошли в систему, то это уже было установлено для нас в процессе входа в систему. Если нет, то мы просто гости.

Following that we get the name of the controller and the action, and also retrieve the Access Control List (ACL). Мы проверяем, разрешен ли пользователь isAllowed используя комбинацию role - controller - action. Если да, то метод завершит обработку.

Если у нас нет доступа, то метод вернет false остановив выполнение, сразу после того, как мы переадресуем пользователя на домашнюю страницу.

Получение списка ACL.

В приведенном выше примере мы получили ACL, используя метод $this->getAcl(). Этот метод также реализован в плагине. Теперь мы собираемся объяснить шаг за шагом, как мы составили список управления доступом (ACL):

<?php

use Phalcon\Acl\Enum;
use Phalcon\Acl\Role;
use Phalcon\Acl\Adapter\Memory as AclList;

$acl = new AclList();

$acl->setDefaultAction(
    Enum::DENY
);

$roles = [
    'users'  => new Role('Users'),
    'guests' => new Role('Guests'),
];

foreach ($roles as $role) {
    $acl->addRole($role);
}

Сначала мы создадим новый объект Phalcon\Acl\Adapter\Memory. Хотя по умолчанию доступ запрещен DENY, мы все равно устанавливаем его в нашем списке с помощью setDefaultAction(). После этого мы должны установить наши роли. Для INVO у нас есть guests (пользователи, которые не вошли в систему) и users. Мы регистрируем эти роли с помощью addRole в списке.

Теперь, когда роли установлены, нам нужно установить компоненты для списка. Компоненты ACL сопоставляются с областями нашего приложения (controller/action). Таким образом, мы можем контролировать, какая роль может получить доступ к какому компоненту.

<?php

use Phalcon\Acl\Component;

// ...

$privateComponents = [
    'companies'    => [
        'index', 
        'search', 
        'new', 
        'edit', 
        'save', 
        'create', 
        'delete',
    ],
    'products'     => [
        'index', 
        'search', 
        'new', 
        'edit', 
        'save', 
        'create', 
        'delete',
    ],
    'producttypes' => [
        'index', 
        'search', 
        'new', 
        'edit', 
        'save', 
        'create', 
        'delete',
    ],
    'invoices'     => [
        'index', 
        'profile',
    ],
];

foreach ($privateComponents as $componentName => $actions) {
    $acl->addComponent(
        new Component($componentName),
        $actions
    );
}

$publicComponents = [
    'index'    => [
        'index',
        ],
    'about'    => [
        'index',
        ],
    'register' => [
        'index',
        ],
    'errors'   => [
        'show404', 
        'show500',
    ],
    'session'  => [
        'index', 
        'register', 
        'start', 
        'end',
    ],
    'contact'  => [
        'index', 
        'send',
    ],
];

foreach ($publicComponents as $componentName => $actions) {
    $acl->addComponent(
        new Component($componentName),
        $actions
    );
}

Как было показано выше, сначала мы регистрируем частные области нашего приложения (backend), а затем публичные (frontend). Созданные массивы имеют ключ в качестве имени контроллера, а значения-соответствующие действия. То же самое мы делаем и с общественными компонентами.

Теперь, когда роли и компоненты зарегистрированы, мы должны связать их так, чтобы ACL был завершен. Роль Users имеет доступ к открытым (frontend) и частным (backend) компонентам, в то время как Guests имеют доступ только к открытым (frontend) компонентам.

<?php

foreach ($roles as $role) {
    foreach ($publicComponents as $resource => $actions) {
        $acl->allow(
            $role->getName(),
            $resource,
            '*'
        );
    }
}

foreach ($privateComponents as $resource => $actions) {
    foreach ($actions as $action) {
        $acl->allow(
            'Users',
            $resource,
            $action
        );
    }
}

Работа с CRUD.

Backends, как правило, предоставляют формы, позволяющие пользователям управлять данными. Продолжая объяснение INVO, теперь мы рассмотрим создание CRUDs, очень распространенную задачу, которую Phalcon облегчит вам использование форм, валидаций, пагинаторов и многого другого.

Большинство опций, которые манипулируют данными в INVO (компании, продукты и типы продуктов), разрабатывались с использованием базового и общего CRUD (создание, чтение, обновление и удаление). Каждый CRUD содержит следующие файлы:

└── invo
    └── app
        ├── controllers
        │   └── ProductsController.php
        ├── forms
        │   └── ProductsForm.php
        ├── models
        │   └── Products.php
        └── views
            └── products
                ├── edit.volt
                ├── index.volt
                ├── new.volt
                └── search.volt

Для других областей (например, компаний) соответствующие файлы (с префиксом Company) можно найти в тех же каталогах, как показано выше.

Каждый контроллер имеет следующие действия:

<?php

class ProductsController extends ControllerBase
{
    public function indexAction();

    public function searchAction();

    public function newAction();

    public function editAction();

    public function createAction();

    public function saveAction();

    public function deleteAction($id);
}
ActionDescription
createAction Создает продукт на основе данных, введенных в новом действии
deleteAction Удаляет существующий продукт
editAction Показывает представление для редактирования существующего продукта
indexAction Действие начать, оно показывает вид поиска
newAction Показывает представление для создания нового продукта
saveAction Обновление продукта на основе данных, введенных в действие изменить
searchAction Выполните поиск на основе критериев, отправленных из index. Возврат пагинатора для получения результатов

Форма поиска.

Каждый CRUD начинается с формы поиска. Эта форма показывает каждое поле таблицы (products), позволяя пользователю создавать критерии поиска для любого поля. Таблица products имеет связь с таблицей products_types. В этом случае мы предварительно запросили записи в этой таблице, чтобы облегчить поиск по этому полю:

<?php

/**
 * Действие запуска, он показывает "search" вид
 */
public function indexAction()
{
    $this->persistent->searchParams = null;

    $this->view->form = new ProductsForm();
}

Экземпляр формы ProductsForm  (app/forms/ProductsForm.php) передается представлению. Эта форма определяет поля, видимые пользователю:

<?php

use Phalcon\Forms\Form;
use Phalcon\Forms\Element\Text;
use Phalcon\Forms\Element\Hidden;
use Phalcon\Forms\Element\Select;
use Phalcon\Validation\Validator\Email;
use Phalcon\Validation\Validator\PresenceOf;
use Phalcon\Validation\Validator\Numericality;

class ProductsForm extends Form
{
    public function initialize($entity = null, $options = [])
    {
        if (!isset($options['edit'])) {
            $element = new Text('id');
            $element->setLabel('Id');
            $this->add($element);
        } else {
            $this->add(new Hidden('id'));
        }

        $name = new Text('name');
        $name->setLabel('Name');
        $name->setFilters(
            [
                'striptags',
                'string',
            ]
        );
        $name->addValidators(
            [
                new PresenceOf(
                    [
                        'message' => 'Имя обязательно',
                    ]
                )
            ]
        );
        $this->add($name);

        $type = new Select(
            'profilesId',
            ProductTypes::find(),
            [
                'using'      => [
                    'id',
                    'name',
                ],
                'useEmpty'   => true,
                'emptyText'  => '...',
                'emptyValue' => '',
            ]
        );

        $this->add($type);

        $price = new Text('price');
        $price->setLabel('Price');
        $price->setFilters(
            [
                'float',
            ]
        );
        $price->addValidators(
            [
                new PresenceOf(
                    [
                        'message' => 'Цена обязательна',
                    ]
                ),
                new Numericality(
                    [
                        'message' => 'Цена обязательна',
                    ]
                ),
            ]
        );
        $this->add($price);
    }
}

 

Форма объявляется с помощью объектно-ориентированной схемы, основанной на элементах, предоставляемых компонентом forms. Каждый элемент имеет почти одинаковую структуру:

<?php

$name = new Text('name');
$name->setLabel('Name');
$name->setFilters(
    [
        'striptags',
        'string',
    ]
);

$name->addValidators(
    [
        new PresenceOf(
            [
                'message' => 'Имя обязательно',
            ]
        )
    ]
);

$this->add($name);

Сначала мы создаем элемент. Затем мы прикрепляем к нему ярлык, прикрепляем фильтры, чтобы мы могли выполнять очистку данных. После этого мы применяем валидаторы к элементу и, наконец, добавляем элемент в форму.

Другие элементы также используются в этой форме:

<?php

$this->add(
    new Hidden('id')
);

// ...

$productTypes = ProductTypes::find();

$type = new Select(
    'profilesId',
    $productTypes,
    [
        'using'      => [
            'id',
            'name',
        ],
        'useEmpty'   => true,
        'emptyText'  => '...',
        'emptyValue' => '',
    ]
);

 

Обратите внимание, что ProductTypes::find() содержит данные, необходимые для заполнения тега SELECT с помощью Phalcon\Tag::select(). После передачи формы в представление ее можно отобразить и представить пользователю:

{{ form('products/search') }}

    <h2>
        Search products
    </h2>

    <fieldset>

        {% for element in form %}
            <div class='control-group'>
                {{ element.label(['class': 'control-label']) }}

                <div class='controls'>
                    {{ element }}
                </div>
            </div>
        {% endfor %}

        <div class='control-group'>
            {{ submit_button('Search', 'class': 'btn btn-primary') }}
        </div>

    </fieldset>

{{ endForm() }}

Это приводит к следующему HTML:

<form action='/invo/products/search' method='post'>

    <h2>
        Search products
    </h2>

    <fieldset>

        <div class='control-group'>
            <label for='id' class='control-label'>Id</label>

            <div class='controls'>
                <input type='text' id='id' name='id' />
            </div>
        </div>

        <div class='control-group'>
            <label for='name' class='control-label'>Name</label>

            <div class='controls'>
                <input type='text' id='name' name='name' />
            </div>
        </div>

        <div class='control-group'>
            <label for='profilesId' class='control-label'>profilesId</label>

            <div class='controls'>
                <select id='profilesId' name='profilesId'>
                    <option value=''>...</option>
                    <option value='1'>Vegetables</option>
                    <option value='2'>Fruits</option>
                </select>
            </div>
        </div>

        <div class='control-group'>
            <label for='price' class='control-label'>Price</label>

            <div class='controls'>
                <input type='text' id='price' name='price' />
            </div>
        </div>

        <div class='control-group'>
            <input type='submit' value='Search' class='btn btn-primary' />
        </div>

    </fieldset>

</form>

Когда форма отправлена, действие search выполняется в контроллере, выполняющем поиск на основе данных, введенных пользователем.

Выполнение поиска.

Действие search имеет два поведения. При доступе через POST он выполняет поиск на основе данных, отправленных из формы, но при доступе через GET перемещает текущую страницу в пагинаторе. Чтобы отличить методы HTTP, мы проверяем его с помощью компонента Request:

<?php

/**
 * Выполнить 'search' на основе критериев, отправленных из 'index'
 * Возврат пагинатора для результатов
 */
public function searchAction()
{
    if ($this->request->isPost()) {
        // Создание условий запроса
    } else {
        // Разбиение на страницы с использованием существующих условий
    }

    // ...
}

С помощью Phalcon\Mvc\Model\Criteria, мы можем интеллектуально создать условия поиска на основе типов данных и значений, отправленных из формы:

<?php

$query = Criteria::fromInput(
    $this->di,
    'Products',
    $this->request->getPost()
);

Этот метод проверяет, какие значения отличаются от " (пустая строка) и null и принимает их во внимание для создания критериев поиска:

  • Если тип данных поля-text или аналогичные (char, varchar, text, и т. д.) Для фильтрации результатов используется оператор SQL like.
  • Если тип данных не текстовый или похожий, то используется оператор =.

Кроме того, Criteria игнорируют все переменные $_POST , которые не соответствуют ни одному полю в таблице. Значения автоматически экранируются с помощью bound parameters.

Теперь мы храним произведенные параметры в сумке сеанса контроллера:

<?php

$this->persistent->searchParams = $query->getParams();

Сумка сеанса-это специальный атрибут контроллера, который сохраняется между запросами, использующими службу сессии. При доступе этот атрибут внедряет экземпляр Phalcon\Session\Bag , независимо в каждом контроллере.

Затем на основе построенных параметров мы выполняем запрос:

<?php

$products = Products::find($parameters);

if (count($products) === 0) {
    $this->flash->notice(
        'В результате поиска не найдено ни одного товара'
    );

    return $this->dispatcher->forward(
        [
            'controller' => 'products',
            'action'     => 'index',
        ]
    );
}

Если поиск не возвращает какой-либо продукт, мы снова перенаправляем пользователя к действию index. Давайте представим результаты поиска, а затем создадим пагинатор для удобной навигации по ним:

<?php

use Phalcon\Paginator\Adapter\Model as Paginator;

// ...

$paginator = new Paginator(
    [
        'data'  => $products,   // Данные для разбиения на страницы
        'limit' => 5,           // Строки на странице
        'page'  => $numberPage, // Активная страница
    ]
);

// Получить активную страницу в пагинаторе
$page = $paginator->getPaginate();

Объект paginator получает результаты, полученные в результате поиска. Мы также устанавливаем лимит (результаты на страницу), а также номер страницы. Наконец, мы вызываем paginate() t, чтобы вернуть соответствующий фрагмент результирующего набора.

Наконец, мы передаем возвращенную страницу для просмотра:

<?php

$this->view->page = $page;

В представлении (app/views/products/search.volt), мы просматриваем результаты, соответствующие текущей странице, показывая пользователю каждую строку на текущей странице:

{% for product in page.items %}
    {% if loop.first %}
        <table>
            <thead>
                <tr>
                    <th>Id</th>
                    <th>Product Type</th>
                    <th>Name</th>
                    <th>Price</th>
                    <th>Active</th>
                </tr>
            </thead>
            <tbody>
    {% endif %}

    <tr>
        <td>
            {{ product.id }}
        </td>

        <td>
            {{ product.getProductTypes().name }}
        </td>

        <td>
            {{ product.name }}
        </td>

        <td>
            {{ '%.2f'|format(product.price) }}
        </td>

        <td>
            {{ product.getActiveDetail() }}
        </td>

        <td width='7%'>
            {{ link_to('products/edit/' ~ product.id, 'Edit') }}
        </td>

        <td width='7%'>
            {{ link_to('products/delete/' ~ product.id, 'Delete') }}
        </td>
    </tr>

    {% if loop.last %}
            </tbody>
            <tbody>
                <tr>
                    <td colspan='7'>
                        <div>
                            {{ 
                                link_to(
                                    'products/search', 
                                    'First'
                                ) 
                            }}
                            {{ 
                                link_to(
                                    'products/search?page=' ~ page.previous, 
                                    'Previous'
                                ) 
                            }}
                            {{ 
                                link_to(
                                    'products/search?page=' ~ page.next, 
                                    'Next'
                                ) 
                            }}
                            {{ 
                                link_to(
                                    'products/search?page=' ~ page.last, 
                                    'Last'
                                ) 
                            }}
                            <span class='help-inline'>
                                {{ page.current }} of 
                                {{ page.total_pages }}
                            </span>
                        </div>
                    </td>
                </tr>
            </tbody>
        </table>
    {% endif %}
{% else %}
    No products are recorded
{% endfor %}

В приведенном выше примере есть много вещей, которые стоит подробно описать. Прежде всего, активные элементы на текущей странице пересекаются с помощью Volt-макроса  for. Volt предоставляет более простой синтаксис для PHP foreach.

{% for product in page.items %}

Который в PHP совпадает с:

<?php foreach ($page->items as $product) { ?>

Весь блок for обеспечивает следующие:

{% for product in page.items %}
    {% if loop.first %}
        // 1
    {% endif %}

    // 2

    {% if loop.last %}
        // 3
    {% endif %}
{% else %}
    // 4
{% endfor %}
  • 1 - Выполняется перед первым продуктом в цикле
  • 2 - Выполняется для каждого продукта страницы.предметы
  • 3 - Выполняется после того, как последний продукт является циклом
  • 4 - Выполняется, если страница.items не имеет никаких продуктов

Теперь вы можете вернуться к просмотру и узнать, что делает каждый блок. Каждое поле в product печатается соответствующим образом:

<tr>
    <td>
        {{ product.id }}
    </td>

    <td>
        {{ product.productTypes.name }}
    </td>

    <td>
        {{ product.name }}
    </td>

    <td>
        {{ '%.2f'|format(product.price) }}
    </td>

    <td>
        {{ product.getActiveDetail() }}
    </td>

    <td width='7%'>
        {{ link_to('products/edit/' ~ product.id, 'Edit') }}
    </td>

    <td width='7%'>
        {{ link_to('products/delete/' ~ product.id, 'Delete') }}
    </td>
</tr>

Как мы видели перед использованием product.idтак же, как в PHP: $product->id, мы сделали то же самое с product.name и так далее. Другие поля отображаются иначе, например, давайте сосредоточимся на product.productTypes.name. Чтобы понять эту часть, мы должны посмотреть модель Products  (app/models/Products.php):

<?php

use Phalcon\Mvc\Model;

/**
 * Products
 */
class Products extends Model
{
    // ...

    /**
     * Инициализатор продуктов
     */
    public function initialize()
    {
        $this->belongsTo(
            'product_types_id',
            'ProductTypes',
            'id',
            [
                'reusable' => true,
            ]
        );
    }

    // ...
}

Модель может иметь метод initialize(), этот метод вызывается один раз за запрос и служит ORM для инициализации модели. В этом случае 'Products' инициализируется определением того, что эта модель имеет отношение один-ко-многим к другой модели, называемой 'ProductTypes'.

<?php

$this->belongsTo(
    'product_types_id',
    'ProductTypes',
    'id',
    [
        'reusable' => true,
    ]
);

Это означает, что локальный атрибут product_types_id в Products имеет отношение один-ко-многим к модели ProductTypes в  атрибуте  id. Определяя это отношение, мы можем получить доступ к имени типа продукта, используя:

<td>{{ product.productTypes.name }}</td>

Поле price печатается по его форматированию с использованием Volt фильтра:

<td>{{ '%.2f'|format(product.price) }}</td>

В простом PHP это будет:

<?php echo sprintf('%.2f', $product->price) ?>

Вывод, является ли продукт активным или не использует помощник, реализованный в модели:

<td>{{ product.getActiveDetail() }}</td>

Этот метод определен в модели.

Создание и обновление записей.

Теперь давайте посмотрим, как CRUD создает и обновляет записи. Из представлений new и edit данные, введенные пользователем, отправляются в действия create и save , которые выполняют действия продуктов creating и updating соответственно.

В случае создания мы восстанавливаем отправленные данные и назначаем их новому экземпляру Products:

<?php

public function createAction()
{
    if (true !== $this->request->isPost()) {
        return $this->dispatcher->forward(
            [
                'controller' => 'products',
                'action'     => 'index',
            ]
        );
    }

    $form    = new ProductsForm();
    $product = new Products();

    $product->id = $this
        ->request
        ->getPost('id', 'int')
    ;

    $product->product_types_id = $this
        ->request
        ->getPost('product_types_id', 'int')
    ;

    $product->name = $this
        ->request
        ->getPost('name', 'striptags')
    ;

    $product->price = $this
        ->request
        ->getPost('price', 'double')
    ;

    $product->active = $this
        ->request
        ->getPost('active')
    ;

    // ...
}

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

<?php

// ...

$name = new Text('name');
$name->setLabel('Name');
$name->setFilters(
    [
        'striptags',
        'string',
    ]
);

$name->addValidators(
    [
        new PresenceOf(
            [
                'message' => 'Имя обязательно',
            ]
        )
    ]
);

$this->add($name);

При сохранении мы узнаем, соответствуют ли данные бизнес-правилам и проверкам, реализованным в форме ProductsForm  (app/forms/ProductsForm.php):

<?php

// ...

$form = new ProductsForm();

$product = new Products();

// Проверка входных данных
$data = $this->request->getPost();

if (!$form->isValid($data, $product)) {
    $messages = $form->getMessages();

    foreach ($messages as $message) {
        $this->flash->error($message);
    }

    return $this->dispatcher->forward(
        [
            'controller' => 'products',
            'action'     => 'new',
        ]
    );
}

Вызов $form->isValid() вызывает все валидаторы, установленные в форме. Если проверка не проходит, переменная $messages будет содержать соответствующие сообщения о неудачных проверках.

Наконец, если форма не возвращает сообщение о проверке, мы можем сохранить экземпляр продукта:

<?php

// ...

if ($product->save() === false) {
    $messages = $product->getMessages();

    foreach ($messages as $message) {
        $this->flash->error($message);
    }

    return $this->dispatcher->forward(
        [
            'controller' => 'products',
            'action'     => 'new',
        ]
    );
}

$form->clear();

$this->flash->success(
    'Продукт был создан успешно'
);

return $this->dispatcher->forward(
    [
        'controller' => 'products',
        'action'     => 'index',
    ]
);

Мы проверяем результат метода save() на модели, и если ошибки произошли, они будут присутствовать в переменной $messages , и пользователь будет отправлен обратно в действие products/new с отображенными сообщениями об ошибках. Если все в порядке, форма очищается, и пользователь перенаправляется к products/index с соответствующим сообщением об успехе.

Теперь, в случае обновления продукта, мы должны сначала представить пользователю данные, которые в данный момент находятся в редактируемой записи:

<?php

public function editAction($id)
{
    if (true !== $this->request->isPost()) {
        $product = Products::findFirstById($id);

        if (false !== $product) {
            $this->flash->error(
                'Product was not found'
            );

            return $this->dispatcher->forward(
                [
                    'controller' => 'products',
                    'action'     => 'index',
                ]
            );
        }

        $this->view->form = new ProductsForm(
            $product,
            [
                'edit' => true,
            ]
        );
    }
}

Найденные данные привязываются к форме путем передачи модели в качестве первого параметра. Благодаря этому, пользователь может изменить любое значение, а затем отправил его обратно в базу данных через  действие save:

<?php

public function saveAction()
{
    if (true !== $this->request->isPost()) {
        return $this->dispatcher->forward(
            [
                'controller' => 'products',
                'action'     => 'index',
            ]
        );
    }

    $id      = $this->request->getPost('id', 'int');
    $product = Products::findFirstById($id);

    if (false !== $product) {
        $this->flash->error(
            'Product does not exist'
        );

        return $this->dispatcher->forward(
            [
                'controller' => 'products',
                'action'     => 'index',
            ]
        );
    }

    $form = new ProductsForm();
    $data = $this->request->getPost();

    if (true !== $form->isValid($data, $product)) {
        $messages = $form->getMessages();

        foreach ($messages as $message) {
            $this->flash->error($message);
        }

        return $this->dispatcher->forward(
            [
                'controller' => 'products',
                'action'     => 'new',
            ]
        );
    }

    if (false === $product->save()) {
        $messages = $product->getMessages();

        foreach ($messages as $message) {
            $this->flash->error($message);
        }

        return $this->dispatcher->forward(
            [
                'controller' => 'products',
                'action'     => 'new',
            ]
        );
    }

    $form->clear();

    $this->flash->success(
        'Product was updated successfully'
    );

    return $this->dispatcher->forward(
        [
            'controller' => 'products',
            'action'     => 'index',
        ]
    );
}

Пользовательские Компоненты.

Все элементы пользовательского интерфейса и визуальный стиль приложения были достигнуты в основном через Bootstrap. Некоторые элементы, например панель навигации, изменяются в зависимости от состояния приложения. Например, в правом верхнем углу ссылка Log in / Sign Up изменится на Log out , если пользователь вошел в приложение.

Эта часть приложения реализована в компоненте Elements (app/library/Elements.php).

<?php

use Phalcon\Di\Injectable;

class Elements extends Injectable
{
    public function getMenu()
    {
        // ...
    }

    public function getTabs()
    {
        // ...
    }
}

 

Этот класс расширяет Phalcon\Di\Injectable. В этом нет необходимости, но расширение этого компонента позволяет нам получить доступ ко всем службам приложений. Мы собираемся зарегистрировать этот пользовательский компонент в контейнере служб:

<?php

// Регистрация пользовательского компонента
$di->set(
    'elements',
    function () {
        return new Elements();
    }
);

Как контроллеры, плагины или компоненты в представлении, этот компонент также имеет доступ к службам, зарегистрированным в контейнере, и просто получает доступ к атрибуту с тем же именем, что и ранее зарегистрированная служба:

<div class='navbar navbar-fixed-top'>
    <div class='navbar-inner'>
        <div class='container'>
            <a class='btn btn-navbar' 
               data-toggle='collapse' 
               data-target='.nav-collapse'>
                <span class='icon-bar'></span>
                <span class='icon-bar'></span>
                <span class='icon-bar'></span>
            </a>

            <a class='brand' href='#'>INVO</a>

            {{ elements.getMenu() }}
        </div>
    </div>
</div>

<div class='container'>
    {{ content() }}

    <hr>

    <footer>
        <p>© Company {{ date('Y') }}</p>
    </footer>
</div>

Важная часть:

{{ elements.getMenu() }}

Динамическое изменение заголовка.

Когда вы просматриваете между одним вариантом и другим увидите, что заголовок динамически изменяется, указывая, где мы в настоящее время работаем. Это достигается в каждом инициализаторе контроллера:

<?php

class ProductsController extends ControllerBase
{
    public function initialize()
    {
        // Set the document title
        $this->tag->setTitle(
            'Управление типами продуктов'
        );

        parent::initialize();
    }

    // ...
}

Обратите внимание, что метод parent::initialize() также вызывается, он добавляет больше данных в заголовок:

<?php

use Phalcon\Mvc\Controller;

class ControllerBase extends Controller
{
    protected function initialize()
    {
        // Добавьте название приложения к заголовку
        $this->tag->prependTitle('INVO | ');
    }

    // ...
}

Приведенный выше код добавляет название приложения к заголовку

Наконец, заголовок печатается в главном представлении (app/views/index.volt):

<!DOCTYPE html>
<html>
    <head>
        <?php echo $this->tag->getTitle(); ?>
    </head>

    <!-- ... -->
</html>