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

f378d35f97eea6d7707f7bb6de564247

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

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. Перейдем к репозиторию, в нём как раз происходит несколько основных моментов:

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

  2. Мы перезаписываем все 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.



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



 $dataProvider,
    'itemView'     => 'list_item',
]); ?>

app/views/list_item.php



title ?>

text ?>

DetailView

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

В представление сразу передается DTO и этот же объект в виджет (как параметр 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

© Habrahabr.ru