Базово 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
<?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. Перейдем к репозиторию, в нём как раз происходит несколько основных моментов:
Задаем карту атрибутов для сортировки, где ключи - названия свойств из DTO. Так мы сможем обращаться далее в наших виджетах именно к свойствам DTO, а не Active Record. Это так же позволит виджету для сортировки в названии столбцов использовать названия свойств DTO, а не реальные названия столбцов из таблиц БД и в класс
Sortпередавать именно их, а он на основе данной карты сам поймет, что добавить в SQL запрос.Мы перезаписываем все 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
