Как стать автором
Обновить

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

Время на прочтение7 мин
Количество просмотров4.9K

Базово 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

Теги:
Хабы:
+8
Комментарии13

Публикации

Истории

Работа

PHP программист
157 вакансий

Ближайшие события