Пример использования standalone actions в Yii2

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

В данной статье будет приведен пример как при использовании функционала Standalone actions фреймворка Yii2 красиво организовать единообразную архитектуру, которую можно использовать во всех частях приложения.

Коротко, что это: возможность создать один раз реализацию action-а и привязывать их к произвольным контроллерам. Так базовый SiteController приложения на основе basic application template реализует два action-а — для обработки ошибок и проверки captcha:

прикрепление action-ов к SiteController
<?php

namespace app\controllers;

use Yii;
use yii\web\Controller;

class SiteController extends Controller
{
    public function actions()
    {
        return [
            'error' => [
                'class' => 'yii\web\ErrorAction',
            ],
            'captcha' => [
                'class' => 'yii\captcha\CaptchaAction',
                'fixedVerifyCode' => YII_ENV_TEST ? 'testme' : null,
            ],
        ];
    }
}



Что нам нужно

  1. ListAction — standalone action, реализует связь между запросами и моделями поиска.
  2. DataProvider — прослойка над запросами, реализует постраничную навигацию, сортировку.
  3. Search Model — Модель поиска, принимает входящие данные, производит валидацию и создаёт DataProvider с нужным запросом.

Реализацию двух последних вещей можно увидеть в стандартных CRUD-ах генерируемых gii. Возможно покажется излишним вынос выборки данных в отдельный класс, когда как это можно было бы реализовать методом в самих моделях AR (как это было в yii1). Но как мне кажется, разделение ответственности и вынос функционала в отдельный класс даёт больше гибкости.

Реализация ListAction

Представляет собой класс с методом run, вызываемый при запросе к action-у. Класс наследуется от yii\base\Action. Action-ы можно настраивать при привязке к его к контроллеру, изменяя его свойства. В наш action мы передаем модель поиска, наследованный от базового абстрактного класса и прочие опционально настройки action-а, такие как кастомное представление (view), способ проставки данных, метод получения пагинации и т.п.

реализация класса с комментариями
<?php

namespace app\modules\shop\actions;

use Yii;
use yii\base;
use yii\web\Response;
use app\modules\shop\components\FilterModelBase;
use yii\widgets\LinkPager;

class ListAction extends base\Action
{

    /**
     * Модель поиска
     * @var FilterModelBase
     */
    protected $_filterModel;

    /**
     * Анонимная-функция запускаемая в случае ошибки валидации модели поиска
     * @var callable
     */
    protected $_validationFailedCallback;

    /**
     * Метод вставки данных из запроса,
     * Если true, то данные в запросе должны быть в под-массиве e.g. $_GET/$_POST[SearchModel][attribute]
     * @var bool
     */
    public $directPopulating = true;

    /**
     * Метод получение пагинации, если true, то получаем уже готовый html пагинации,
     * нужно для AJAX запросов
     * @var bool
     */
    public $paginationAsHTML = false;

    /**
     * Тип запроса
     * @var string
     */
    public $requestType = 'get';

    /**
     * Пусть до представления
     * @var string
     */
    public $view = '@app/modules/shop/views/list/index';

    public function run()
    {
        if (!$this->_filterModel) {
            throw new base\ErrorException('Не указана модель поиска');
        }

        $request = Yii::$app->request;

        if ($request->isAjax) {
            Yii::$app->response->format = Response::FORMAT_JSON;
        }

        // Проставляем данные
        $data = (strtolower($this->requestType) === 'post' && $request->isPost) ? $_POST : $_GET;
        $this->_filterModel->load(($this->directPopulating) ? $data : [$this->_filterModel->formName() => $data]);

        // Производим выборку в модели поиска
        $this->_filterModel->search();

        // Если при поиске произошла ошибка валидации
        if ($this->_filterModel->hasErrors()) {

            /**
             * В зависимости от запроса решаем что делать,
             * если ajax то сбрасываем ошибку, иначе если входящих данных нет, очищаем ошибки
             */
            if ($request->isAjax){
                return (is_callable($this->_validationFailedCallback))
                    ? call_user_func($this->_validationFailedCallback, $this->_filterModel)
                    : [
                        'error' => current($this->_filterModel->getErrors())
                    ];
            }

            if (empty($data)) {
                $this->_filterModel->clearErrors();
            }

        }

        if (!($dataProvider = $this->_filterModel->getDataProvider())) {
            throw new base\ErrorException('Не проинициализирован DataProvider');
        }

        if ($request->isAjax) {
            // Возвращаем корректно сформированную коллекцию объектов
            return [
                'list' => $this->_filterModel->buildModels(),
                'pagination' => ($this->paginationAsHTML)
                        ? LinkPager::widget([
                                'pagination' => $dataProvider->getPagination()
                            ])
                        : $dataProvider->getPagination()
            ];
        }

        return $this->controller->render($this->view ?: $this->id, [
                'filterModel' => $this->_filterModel,
                'dataProvider' => $dataProvider,
                'requestType' => $this->requestType,
                'directPopulating' => $this->directPopulating
            ]);

    }

    public function setFilterModel(FilterModelBase $model)
    {
        $this->_filterModel = $model;
    }

    public function setValidationFailedCallback(callable $callback)
    {
        $this->_validationFailedCallback = $callback;
    }
}


Так же нужно создать представление по умолчанию для вывода данных если это не Ajax запрос.

представление по умолчанию
<?php
use yii\widgets\ActiveForm;
use yii\helpers\Html;
/**
 * @var \yii\web\View $this
 * @var \yii\data\DataProviderInterface $dataProvider
 * @var \app\modules\shop\components\FilterModelBase $filterModel
 * @var ActiveForm: $form
 * @var string $requestType
 * @var bool $directPopulating
 */

// Формируем форму для поиска по safe аттрибутам
if (($safeAttributes = $filterModel->safeAttributes())) {
    echo Html::beginTag('div', ['class' => 'well']);
    $form = ActiveForm::begin([
            'method' => $requestType
        ]);
    foreach ($safeAttributes as $attribute) {
        echo $form->field($filterModel, $attribute)->textInput([
                'name' => (!$directPopulating) ? $attribute : null
            ]);
    }
    echo Html::submitInput('search', ['class' => 'btn btn-default']).
        Html::endTag('div');
    ActiveForm::end();
}


echo \yii\grid\GridView::widget([
        'dataProvider' => $dataProvider,
        'filterModel' => $filterModel
    ]);



В данном представлении по умолчанию реализована форма поиска по безопасным атрибутам модели поиска и вывод результатов поиска с помощью виджета GridView. Безопасными атрибуты являются если они указаны в сценарии или же у них имеются правила валидации.

Базовая модель поиска

Представляет собой абстрактный класс, от которого должны наследоваться модели поиска передаваемые в ListAction. Реализует базу для взаимодействия модели и ListAction-а. Логика выборки реализуется в наследуемых моделях.

реализация абстрактного класса
<?php

namespace app\modules\shop\components;

use yii\base\Model;
use yii\data\DataProviderInterface;

abstract class FilterModelBase extends Model
{
    /**
     * @var DataProviderInterface
     */
    protected $_dataProvider;

    /**
     * @return DataProviderInterface
     */
    abstract public function search();

    /**
     * Получение результатов выборки
     * Этот метод часто переобределяется моделями поиска, например сгруппировать в под-массивы по датам и т.д.
     * @return mixed
     */
    public function buildModels()
    {
        return $this->_dataProvider->getModels();
    }

    public function getDataProvider()
    {
        return $this->_dataProvider;
    }
}


Осталось реализовать модель поиска и прикрепить ListAction для поиска по данной модели в произвольный контроллер. В модели поиска обязательным является реализация выборки данных. Всё остальное зависит требований той или иной модели поиска — валидация, логика компоновки данных и т.п.

Логика компоновки данных переопределяется в методе buildModels.

Ниже с комментариями приведен простой пример модели поиска продуктов:

Модель поиска
<?php

namespace app\modules\shop\models\search;

use app\modules\shop;
use yii\data\ActiveDataProvider;
use yii\data\Pagination;

class ProductSearch extends shop\components\FilterModelBase
{
    /**
     * Принимаемые моделью входящие данные
     */
    public $price;
    public $page_size = 20;

    /**
     * Правила валидации модели
     * @return array
     */
    public function rules()
    {
        return [
            // Обязательное поле
            ['price', 'required'],
            // Только числа, значение как минимум должна равняться единице
            ['page_size', 'integer', 'integerOnly' => true, 'min' => 1]
        ];
    }

    /**
     * Реализация логики выборки
     * @return ActiveDataProvider|\yii\data\DataProviderInterface
     */
    public function search()
    {
        // Создаём запрос на получение продуктов вместе категориями
        $query = shop\models\Product::find()
            ->with('categories');

        /**
         * Создаём DataProvider, указываем ему запрос, настраиваем пагинацию
         */
        $this->_dataProvider = new ActiveDataProvider([
            'query' => $query,
            'pagination' => new Pagination([
                    'pageSize' => $this->page_size
                ])
        ]);

        // Если ошибок нет, фильтруем по цене
        if ($this->validate()) {
            $query->where('price <= :price', [':price' => $this->price]);
        }

        return $this->_dataProvider;
    }

    /**
     * Переопределяем метод компоновки моделей,
     * возвращаем так же категории
     * Это синтетический пример.
     * @return array|mixed
     */
    public function buildModels()
    {
        $result = [];

        /**
         * @var shop\models\Product $product
         */
        foreach ($this->_dataProvider->getModels() as $product) {
            $result[] = array_merge($product->getAttributes(), [
                    'categories' => $product->categories
                ]);
        }

        return $result;
    }
}


Осталось прикрепить ListAction к контроллеру и передать ему модель поиска продуктов:

настройка контроллера для поиска продуктов
<?php

namespace app\modules\shop\controllers;

use yii\web\Controller;
use app\modules\shop\actions\ListAction;
use app\modules\shop\models\search\ProductSearch;

class ProductController extends Controller
{
    public function actions()
    {
        return [
            'index' => [
                'class' => ListAction::className(),
                'filterModel' => new ProductSearch(),
                'directPopulating' => false,
            ]
        ];
    }
}


Так при обращении к action-у посредством Ajax мы получим JSON примерно такого содержания:

результат выборки
{
    "list":
        [
            {
                "id": "7",
                "price": "50",
                "title": "product title #7",
                "description": "product description #7",
                "create_time": "0",
                "update_time": "0",
                "categories":
                    [
                        {
                            "id": "1",
                            "title": "category title #1",
                            "description": "category description #1",
                            "create_time": "0",
                            "update_time": "0"
                        }
                    ]
            }
        ],
    "pagination":
    {
        "pageVar": "page",
        "forcePageVar": true,
        "route": null,
        "params": null,
        "urlManager": null,
        "validatePage": true,
        "pageSize": 20,
        "totalCount": 1
    }
}


При ошибке валидации массив будет содержать описание ошибки. При обычном запросе (не Ajax) мы увидим приблизительно такое:



Для примера был создан небольшой модуль на основе basic application template. Его нужно подключить в настройках приложения Yii2 и запустить миграцию с тестовыми данными

php yii migrate --migrationPath=modules/shop/migrations


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

Как пример из реальности, мы используем этот функционал в API, один action реализует в зависимости от запроса вывод ответа в JSON или веб-интерфейс для тестирования.
Topic
31.13
Компания
Share post

Comments 11

    0
    никогда не понимал зачем вообще нужны отдельные классы-экшены… По сути это часть контроллера. В статье вы говорите о том, что они помогают избежать дублирования кода. Но дублирование кода в контроллерах это нормально (если мы говорим про тонкие контроллеры). Более того, я уже не раз сталкивался с проблемами после таких способов «уменьшить дублирование кода». Особенно это бьет по проектам, которые год нежали себе спокойно и вдруг пришел огромный список чейнджреквестов, при которых все это приходится рассовывать по разным экшенам. Для реюзания кода есть сервисный слой.

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

    По поводу выборок и разделения ответственности — для этого есть сервисный слой. В случае с yii я обычно реализую доступ к базе через статические методы, возвращающие датапровайдер. Причем каждую выборку в отдельный метод. Зачем? опять же удобно в последствии поддерживать и расширять.

    Ну и последнее…
    protected $_filterModel;
    

    к чему вообще эти пережитки php4?
      +4
      Отдельных классы action-ов выполняют не столько задачу по укорачиванию кода, сколько по унификации реализации функционала, используемого в разных частях приложения. По тонким контроллерам — поддерживаю, вся логика реализуется именно в моделях. Action реализует лишь связь между запросом и ответом, как бы это не было очевидно. Если есть множество контроллеров, с типичными в общих чертах реализациями action-ов, я думаю вполне обоснованно их реализовать в виде отдельных классов. Расширяемость класса может решить проблему частных случаев. Если же реализовать определенный функционал в контексте класса action-a не получается, его можно и не использовать.

      Action каптчи, как и action обработки ошибки в основном подключаются в одном единственном контроллере на всё приложение. Для виджета каптчи можно указать адрес обработчика каптчи. В данной статье они приведены как пример механизма standalone action.

      Форм билдер не использует скорее из-за его неочевидности.

      По нижнему подчёркиванию у защищённых свойств классов, да, каюсь, давняя привычка обозначать таким образом видимость свойства.
        0
        так и не понял в чем отличие от Yii 1.x, кроме использования namespace
        так же делаю в 1/x — большинство контроллеров у меня только из списка стандартных actions состоит.
          +1
          В данном коде особо ничем, но если более внимательно следить за развитием ветки Yii2 — различий достаточно.
          Переход на namespace одно из наиболее важных; изменение в ORM (отказ от JOIN'ов); наконец, Twitter Bootstrap с коробки.
            0
            и более двух лет разработки…
              +1
              JOIN'ы вернули на неделе
                0
                А какова причина? Падение производительности?
              –1
              Речь шла об акшенах — и тут изменений в приведенном примере не видно.
              Джойны вернулись, кстати — rmcreative.ru/blog/post/yii2-join-vernulsja
                0
                Согласен, данные приемы с action существовали и раньше.
                А вот на счет нового поведения с Join — отличное решение.
                Появился выбор — делать join или создавать сторонний запрос.

          Only users with full accounts can post comments. Log in, please.