При разработке сайта неотъемлемую часть занимает получение коллекции данных. Выборка по определённым условиям, пагинация. Каждый раз писать реализацию в контроллерах весьма занудно. Когда как можно один раз сделать расширяемую реализацию часто используемого функционала.
В данной статье будет приведен пример как при использовании функционала Standalone actions красиво организовать единообразную архитектуру, которую можно использовать во всех частях приложения.
Коротко, что это: возможность создать один раз реализацию action-а и привязывать их к произвольным контроллерам. Так базовый SiteController приложения на основе basic application template реализует два action-а — для обработки ошибок и проверки captcha:
<?php
namespace appcontrollers;
use Yii;
use yiiwebController;
class SiteController extends Controller
{
public function actions()
{
return [
'error' => [
'class' => 'yiiwebErrorAction',
],
'captcha' => [
'class' => 'yiicaptchaCaptchaAction',
'fixedVerifyCode' => YII_ENV_TEST ? 'testme' : null,
],
];
}
}
Что нам нужно
- ListAction — standalone action, реализует связь между запросами и моделями поиска.
- DataProvider — прослойка над запросами, реализует постраничную навигацию, сортировку.
- Search Model — Модель поиска, принимает входящие данные, производит валидацию и создаёт DataProvider с нужным запросом.
Реализацию двух последних вещей можно увидеть в стандартных CRUD-ах генерируемых gii. Возможно покажется излишним вынос выборки данных в отдельный класс, когда как это можно было бы реализовать методом в самих моделях AR (как это было в yii1). Но как мне кажется, разделение ответственности и вынос функционала в отдельный класс даёт больше гибкости.
Реализация ListAction
Представляет собой класс с методом run, вызываемый при запросе к action-у. Класс наследуется от yiibaseAction. Action-ы можно настраивать при привязке к его к контроллеру, изменяя его свойства. В наш action мы передаем модель поиска, наследованный от базового абстрактного класса и прочие опционально настройки action-а, такие как кастомное представление (view), способ проставки данных, метод получения пагинации и т.п.
<?php
namespace appmodulesshopactions;
use Yii;
use yiibase;
use yiiwebResponse;
use appmodulesshopcomponentsFilterModelBase;
use yiiwidgetsLinkPager;
class ListAction extends baseAction
{
/**
* Модель поиска
* @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 baseErrorException('Не указана модель поиска');
}
$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 baseErrorException('Не проинициализирован 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 yiiwidgetsActiveForm;
use yiihelpersHtml;
/**
* @var yiiwebView $this
* @var yiidataDataProviderInterface $dataProvider
* @var appmodulesshopcomponentsFilterModelBase $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 yiigridGridView::widget([
'dataProvider' => $dataProvider,
'filterModel' => $filterModel
]);
В данном представлении по умолчанию реализована форма поиска по безопасным атрибутам модели поиска и вывод результатов поиска с помощью виджета GridView. Безопасными атрибуты являются если они указаны в сценарии или же у них имеются правила валидации.
Базовая модель поиска
Представляет собой абстрактный класс, от которого должны наследоваться модели поиска передаваемые в ListAction. Реализует базу для взаимодействия модели и ListAction-а. Логика выборки реализуется в наследуемых моделях.
<?php
namespace appmodulesshopcomponents;
use yiibaseModel;
use yiidataDataProviderInterface;
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 appmodulesshopmodelssearch;
use appmodulesshop;
use yiidataActiveDataProvider;
use yiidataPagination;
class ProductSearch extends shopcomponentsFilterModelBase
{
/**
* Принимаемые моделью входящие данные
*/
public $price;
public $page_size = 20;
/**
* Правила валидации модели
* @return array
*/
public function rules()
{
return [
// Обязательное поле
['price', 'required'],
// Только числа, значение как минимум должна равняться единице
['page_size', 'integer', 'integerOnly' => true, 'min' => 1]
];
}
/**
* Реализация логики выборки
* @return ActiveDataProvider|yiidataDataProviderInterface
*/
public function search()
{
// Создаём запрос на получение продуктов вместе категориями
$query = shopmodelsProduct::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 shopmodelsProduct $product
*/
foreach ($this->_dataProvider->getModels() as $product) {
$result[] = array_merge($product->getAttributes(), [
'categories' => $product->categories
]);
}
return $result;
}
}
Осталось прикрепить ListAction к контроллеру и передать ему модель поиска продуктов:
<?php
namespace appmodulesshopcontrollers;
use yiiwebController;
use appmodulesshopactionsListAction;
use appmodulesshopmodelssearchProductSearch;
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 или веб-интерфейс для тестирования.
Автор: Zhandos