Pull to refresh
0

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

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

В данной статье будет приведен пример как при использовании функционала 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 или веб-интерфейс для тестирования.
Tags:
Hubs:
+4
Comments 11
Comments Comments 11

Articles

Information

Website
ru.trytopic.com
Registered
Employees
2–10 employees
Location
США