Виджеты данных Yii2 и DTO
Базово Yii2 из коробки предлагает нам архитектуру приложения по шаблону MVC (модель, представление, контроллер). Для более сложного приложения прибегаем к чистой архитектуре (можно посмотреть данную статью для общего представления) и в рамках неё необходимо отказаться от Active Record в шаблонах (представлениях), т.к. AR это часть слоя по работе с базой данных, о которой другим слоям знать не нужно. Предполагаем, что мы хотим продолжить использовать встроенные виджеты по отображению данных в представлениях: DeatilView
, ListView
и GridView
. Последние два используют ActiveDataProvider
, который в себе содержит Active Record модели — цель данной статьи избавиться от них и использовать только DTO.
Архитектура приложения
Необходимо несколько слов сказать об архитектуре, которая у нас получается, прежде чем перейти к коду.
Путь запроса:
Входящий запрос с данными от пользователя
Контроллер получает данные, подготавливает их (форматирование и первичная валидация) и передает в сервис
Сервис получает данные от контроллера и выполняет некую бизнес-логику
Если сервису необходимы данные из БД, он обращается к репозиторию
Репозиторий формирует SQL запрос, получает данные из базы данных, упаковывает в DTO и возвращает сервису
Сервис после исполнения бизнес-логики возвращает данные контроллеру
Контроллер используя View Render (представления) подготавливает HTML и возвращает пользователю
В пункте 6, мы позволяем сервису возвращать данные в ActiveDataProvider
(в принципе любую реализацию интерфейса DataProviderInterface
), но только хранящиеся в нём данные это не Active Record модели, а Data Transfer Object (DTO).
Слои выглядят следующим образом:
User Interface: контроллер (входящие данные, подготовка их, передача в сервис, исходящие данные преобразованные через представления)
Business Logic: сервисы с бизнес-логикой
Data Access: репозитории для работы с данными (частная реализация это репозиторий по работе с базой данных через Active Record)
Код
Подготовка
Для упрощения, опустим некоторые архитектурные моменты (например, что мы должны работать с интерфейсом репозитория, а не конкретной реализацией, что данные между каждым слоям, это свой DTO и пр.).
В качестве примера создаем приложение «блог» со статьями.
Файловая структура:
app
- - controllers
- - - - ArticleController.php
- - views
- - - - article
- - - - - - grid.php
- - - - - - list.php
- - - - - - list_item.php
- - - - - - detail.php
- - services
- - - - article
- - - - - - builders
- - - - - - - - ArticleDtoBuilder.php
- - - - - - dtos
- - - - - - - - ArticleDto.php
- - - - - - repositories
- - - - - - - - ArticleActiveRecord.php
- - - - - - - - ArticleDbRepository.php
- - - - - - ArticleService.php
Сущности (AR и DTO)
При описывании классов сущности (Active Record и DTO) свойства так же продублируем константами, они нам пригодятся когда необходимо обращаться к названию свойств в виде строк, плюс при рефакторинге так можно обнаружить все использования. Далее будет наглядно понятно.
Важное отличие, что в Active Record имена свойств = названия столбцов в базе данных (в SnakeCase), а в DTO имена свойств в lowerCamelCase.
ArticleActiveRecord.php
ArticleDto.php
Строитель
ArticleDtoBuilder.php. Простой строитель DTO из Active Record объекта (ов).
id,
$activeRecord->title,
$activeRecord->text,
new \DateTimeImmutable($activeRecord->created_at),
);
}
/**
* @param ArticleActiveRecord[] $activeRecords
*
* @return ArticleDto[]
*/
public static function buildFromActiveRecords(array $activeRecords): array
{
$dtos = [];
foreach ($activeRecords as $activeRecord) {
if (!($activeRecord instanceof ArticleActiveRecord)) {
continue;
}
$dtos[] = self::buildFromActiveRecord($activeRecord);
}
return $dtos;
}
}
Репозиторий
ArticleDbRepository.php. Перейдем к репозиторию, в нём как раз происходит несколько основных моментов:
Задаем карту атрибутов для сортировки, где ключи — названия свойств из DTO. Так мы сможем обращаться далее в наших виджетах именно к свойствам DTO, а не Active Record. Это так же позволит виджету для сортировки в названии столбцов использовать названия свойств DTO, а не реальные названия столбцов из таблиц БД и в класс
Sort
передавать именно их, а он на основе данной карты сам поймет, что добавить в SQL запрос.Мы перезаписываем все AR объекты (в рамках текущей выборки, текущей страницы и пр.) на наши DTO с помощью строителя.
ArticleActiveRecord::find(),
'pagination' => [
'pageSize' => $pageSize ?: false,
],
'sort' => [
'defaultOrder' => [ArticleDto::ATTR_CREATED_AT => SORT_DESC],
'attributes' => [
ArticleDto::ATTR_ID => [
'asc' => [ArticleActiveRecord::ATTR_ID => SORT_ASC],
'desc' => [ArticleActiveRecord::ATTR_ID => SORT_DESC],
'default' => SORT_ASC,
],
ArticleDto::ATTR_TITLE => [
'asc' => [ArticleActiveRecord::ATTR_TITLE => SORT_ASC],
'desc' => [ArticleActiveRecord::ATTR_TITLE => SORT_DESC],
'default' => SORT_ASC,
],
ArticleDto::ATTR_TEXT => [
'asc' => [ArticleActiveRecord::ATTR_TEXT => SORT_ASC],
'desc' => [ArticleActiveRecord::ATTR_TEXT => SORT_DESC],
'default' => SORT_ASC,
],
ArticleDto::ATTR_CREATED_AT => [
'asc' => [ArticleActiveRecord::ATTR_CREATED_AT => SORT_ASC],
'desc' => [ArticleActiveRecord::ATTR_CREATED_AT => SORT_DESC],
'default' => SORT_ASC,
],
],
],
]);
$dataProvider->setModels(ArticleDtoBuilder::buildFromActiveRecords($dataProvider->getModels()));
return $dataProvider;
}
}
Сервис
ArticleService.php. Код сервиса в данном примере нам не важен и какую-то бизнес-логику опустим и просто сразу обратимся к репозиторию за данными. Плюс именно здесь мы применяем описанные выше упрощения (интерфейс для репозитория, плюс данные пересекают границы слоя и пр.).
repository->findAllAsDataProvider();
}
}
Контроллер
ArticleController.php. Имеет 3 метода для каждого из виджетов. Для упрощения для DeatilView виджета, один элемент возьмем прям из провайдера данных.
render('grid', ['dataProvider' => $this->getDataProvider()]);
}
public function actionList(): string
{
return $this->render('list', ['dataProvider' => $this->getDataProvider()]);
}
public function actionDetail(): string
{
/** @var ArticleDto[] $articles */
$articles = $this->getDataProvider()->getModels();
return $this->render('detail', ['article' => array_shift($articles)]);
}
private function getDataProvider(): ActiveDataProvider
{
return $this->articleService->getAllAsDataProvider();
}
}
Виджеты
GridView
app/views/grid.php (Controller: actionGrid ())
В $dataProvider у нас содержится наш провайдер с нашими DTO. В виджете теперь мы оперируем названиями свойств именно DTO и полностью забываем об Active Record. Когда необходимо что-то сделать над значением, то в анонимную функцию передается объект класса ArticleDto
.
= GridView::widget([
'dataProvider' => $dataProvider,
'columns' => [
[
'attribute' => ArticleDto::ATTR_ID,
'label' => Yii::t('app', 'ID'),
],
[
'attribute' => ArticleDto::ATTR_TITLE,
'label' => Yii::t('app', 'Заголовок'),
'format' => 'raw',
'value' => function (ArticleDto $article) {
return StringHelper::truncate($article->title, 50);
},
],
[
'attribute' => ArticleDto::ATTR_TEXT,
'label' => Yii::t('app', 'Текст'),
'format' => 'raw',
'value' => function (ArticleDto $article) {
return StringHelper::truncate($article->title, 200);
},
],
[
'attribute' => ArticleDto::ATTR_CREATED_AT,
'label' => Yii::t('app', 'Дата создания'),
'value' => function (ArticleDto $article) {
return $article->createdAt->format('Y-m-d');
},
],
],
]); ?>
ListView
app/views/list.php (Controller: actionList ())
Входящие данные такие же, отличие лишь в использовании самого виджета и что в отдельное представление отвечающее за отрисовку 1 записи передается DTO.
= ListView::widget([
'dataProvider' => $dataProvider,
'itemView' => 'list_item',
]); ?>
app/views/list_item.php
= $model->title ?>
= $model->text ?>
DetailView
app/views/detail.php (Controller: actionDetail ())
В представление сразу передается DTO и этот же объект в виджет (как параметр model).
= DetailView::widget([
'model' => $article,
'attributes' => [
[
'attribute' => ArticleDto::ATTR_ID,
'label' => Yii::t('app', 'Идентификатор'),
],
ArticleDto::ATTR_TITLE,
ArticleDto::ATTR_TEXT . ':html',
[
'label' => Yii::t('app', 'Дата создания'),
'value' => $article->createdAt->format('Y-m-d'),
],
],
]) ?>
Итог
Коротко получается:
необходимо заменить все Active Record объекты в провайдере данных нашими DTO
построить карту атрибутов для сортировки (GridView) и для оперирования в виджетах названием свойств DTO, а не Active Record
в виджетах при указании названий атрибутов, используется название свойств DTO