Виджеты данных Yii2 и DTO

от автора

Базово Yii2 из коробки предлагает нам архитектуру приложения по шаблону MVC (модель, представление, контроллер). Для более сложного приложения прибегаем к чистой архитектуре (можно посмотреть данную статью для общего представления) и в рамках неё необходимо отказаться от Active Record в шаблонах (представлениях), т.к. AR это часть слоя по работе с базой данных, о которой другим слоям знать не нужно. Предполагаем, что мы хотим продолжить использовать встроенные виджеты по отображению данных в представлениях: DeatilView, ListView и GridView. Последние два используют ActiveDataProvider, который в себе содержит Active Record модели — цель данной статьи избавиться от них и использовать только DTO.

Архитектура приложения

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

Путь запроса:

  1. Входящий запрос с данными от пользователя

  2. Контроллер получает данные, подготавливает их (форматирование и первичная валидация) и передает в сервис

  3. Сервис получает данные от контроллера и выполняет некую бизнес-логику

  4. Если сервису необходимы данные из БД, он обращается к репозиторию

  5. Репозиторий формирует SQL запрос, получает данные из базы данных, упаковывает в DTO и возвращает сервису

  6. Сервис после исполнения бизнес-логики возвращает данные контроллеру

  7. Контроллер используя View Render (представления) подготавливает HTML и возвращает пользователю

В пункте 6, мы позволяем сервису возвращать данные в ActiveDataProvider (в принципе любую реализацию интерфейса DataProviderInterface), но только хранящиеся в нём данные это не Active Record модели, а Data Transfer Object (DTO).

Слои выглядят следующим образом:

  1. User Interface: контроллер (входящие данные, подготовка их, передача в сервис, исходящие данные преобразованные через представления)

  2. Business Logic: сервисы с бизнес-логикой

  3. 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

<?php  namespace app\services\article\repositories;  /**  * @property int    $id         Идентификатор  * @property string $title      Название  * @property string $text       Текст статьи  * @property string $created_at Дата создания  */ class ArticleActiveRecord extends \yii\db\ActiveRecord {     public const ATTR_ID         = 'id';     public const ATTR_TITLE      = 'title';     public const ATTR_TEXT       = 'text';     public const ATTR_CREATED_AT = 'created_at';      /**      * @inheritDoc      */     public static function tableName(): string     {         return '{{%articles}}';     } }

ArticleDto.php

<?php  namespace app\services\article\dtos;  class ArticleDto {     public const ATTR_ID         = 'id';     public const ATTR_TITLE      = 'title';     public const ATTR_TEXT       = 'text';     public const ATTR_CREATED_AT = 'createdAt';      public function __construct(         readonly public int $id,         readonly public string $title,         readonly public string $text,         readonly public \DateTimeInterface $createdAt     ) {     } }

Строитель

ArticleDtoBuilder.php. Простой строитель DTO из Active Record объекта(ов).

<?php  namespace app\services\article\builders;  use app\services\article\dtos\ArticleDto; use app\services\article\repositories\ArticleActiveRecord;  class ArticleDtoBuilder {     public static function buildFromActiveRecord(ArticleActiveRecord $activeRecord): ArticleDto     {         return new ArticleDto(             $activeRecord->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. Перейдем к репозиторию, в нём как раз происходит несколько основных моментов:

  1. Задаем карту атрибутов для сортировки, где ключи — названия свойств из DTO. Так мы сможем обращаться далее в наших виджетах именно к свойствам DTO, а не Active Record. Это так же позволит виджету для сортировки в названии столбцов использовать названия свойств DTO, а не реальные названия столбцов из таблиц БД и в класс Sort передавать именно их, а он на основе данной карты сам поймет, что добавить в SQL запрос.

  2. Мы перезаписываем все AR объекты (в рамках текущей выборки, текущей страницы и пр.) на наши DTO с помощью строителя.

<?php  namespace app\services\article\repositories;  use app\services\article\builders\ArticleDtoBuilder; use app\services\article\dtos\ArticleDto; use yii\data\ActiveDataProvider;  class ArticleRepository {     public function findAllAsDataProvider(int $pageSize = 20): ActiveDataProvider     {         # Создаем Data Provider с картой для сортировки         $dataProvider = new ActiveDataProvider([             'query'      => 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. Код сервиса в данном примере нам не важен и какую-то бизнес-логику опустим и просто сразу обратимся к репозиторию за данными. Плюс именно здесь мы применяем описанные выше упрощения (интерфейс для репозитория, плюс данные пересекают границы слоя и пр.).

<?php  namespace app\services\article;  use app\services\article\repositories\ArticleRepository; use yii\data\ActiveDataProvider;  class ArticleService {     public function __construct(         readonly private ArticleRepository $repository     ) {     }      public function getAllAsDataProvider(): ActiveDataProvider     {         // Дополнительная логика, например закэшировать. В текущем примере ничего не делаем.         return $this->repository->findAllAsDataProvider();     } }

Контроллер

ArticleController.php. Имеет 3 метода для каждого из виджетов. Для упрощения для DeatilView виджета, один элемент возьмем прям из провайдера данных.

<?php  namespace app\controllers;  use app\services\article\ArticleService; use app\services\article\dtos\ArticleDto; use yii\data\ActiveDataProvider; use yii\web\Controller;  class ArticleController extends Controller {     public function __construct($id, $module, readonly private ArticleService $articleService, $config = [])     {         parent::__construct($id, $module, $config);     }      public function actionGrid(): string     {         return $this->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.

<?php  use app\services\article\dtos\ArticleDto; use yii\data\ActiveDataProvider; use yii\grid\GridView; use yii\helpers\StringHelper; use yii\web\View;  /**  * @var View               $this  * @var ActiveDataProvider $dataProvider  */ ?>  <?= 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.

<?php  use yii\data\ActiveDataProvider; use yii\web\View; use yii\widgets\ListView;  /**  * @var View               $this  * @var ActiveDataProvider $dataProvider  */ ?>  <?= ListView::widget([     'dataProvider' => $dataProvider,     'itemView'     => 'list_item', ]); ?>

app/views/list_item.php

<?php  use app\services\article\dtos\ArticleDto; use yii\web\View;  /**  * @var View       $this  * @var ArticleDto $model  */ ?>  <h1><?= $model->title ?></h1> <div><?= $model->text ?></div>

DetailView

app/views/detail.php (Controller::actionDetail())

В представление сразу передается DTO и этот же объект в виджет (как параметр model).

<?php  use app\services\article\dtos\ArticleDto; use yii\web\View; use yii\widgets\DetailView;  /**  * @var View       $this  * @var ArticleDto $article  */ ?>  <?= 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


ссылка на оригинал статьи https://habr.com/ru/post/677408/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *