Базово 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
Комментарии (12)
GoodGod
18.07.2022 09:59Можно ли в методе ArticleRepository::findAllAsDataProvider заменить возвращаемый тип ActiveDataProvider на ArrayDataProvider?
https://www.yiiframework.com/doc/api/2.0/yii-data-arraydataproviderА ссылки на github репозиторий у вас нету?
FoxDev Автор
18.07.2022 21:25Суть в том, что мы работаем именно с ActiveDataProvider, который в свою очередь работает с Active Record и помогает нам формировать запросы (пагинация, сортировка, фильтрация и пр.).
Если нам не нужен Active Record, ничто не мешает создать и ArrayDataProvider (либо любой другой провайдер, ведь GridView ждёт интерфейс DataProviderInterface) и загрузить в него подготовленные объекты/модели, которые уже могут являться DTO. Но тогда мы берем на себя процесс получения этих данных (можно вообще не использовать AR и получить данные в виде массива из БД) и их подготовку (например, фильтрации по страницам, сортировку и пр.).
myks92
20.07.2022 14:06Зачем?) Это очень странно. В Yii2 есть ArrayDataProvider. Вам достаточно было отделить слой работы с моделью изменения от слоя чтения - аналог CQRS. Тогда бы у вас было меньше связанности. А так у вас может утекать какая-то логика в ваши DtoBuilder. Например, в User у нас есть статусы (активный, заблокирован). А в UI мам нужно только isActive чтобы показать что-то или скрыть. Тогда получается что вы эту проверку добавите в DtoBuilder. И как это тестировать?
Пойдём дальше, а что если к User нужно ещё добавить например список его комментариев из другого модуля? У вас уже будут совмещённые DtoBuilder или два билдера как-то объединять данные. А с разными репозиториями на чтение мы бы вызвали два репозитория, которые отдают нам две DTO: User и Comment и уже их бы передали в view.
des1roer
ммм. а зачем?
FoxDev Автор
Не хотим, чтобы слой работы с данными уходил в пользовательский слой, но при этом хотим использовать виджеты Yii. Active Record не должно быть в Представлениях, иначе можно при рендере шаблона начать кидаться запросами в БД. Ладно, допустим $user->delete() разработчик писать не будет, но обратиться к какому-нибудь релейшену или цепочке?
des1roer
Ну по хорошему все эти виджеты - они максимум для CRUD админки, причем простой. Если нужен нормальный фронт - то использовать апи. А уже в апи невозможно релейшн ошибочно загрузить
FoxDev Автор
Согласен, все зависит от кейса и как устроен фронт.
Если мы используем шаблон Advanced от Yii2, который предлагает нам деление на Frontend/Backend, где это клиентское и админское приложения, которые мы строим со вьюхами (т.е. бэк отдает html) + JQuery, то как раз данная статья и подходит. Плюс мы пониманием, что использование данных виджетов уже подразумевает использование того же Bootstrap и JQuery.
Мы можем для упрощения разработки только админку оставить на этой схеме, а клиентскую часть сделать API + полноценный фронт React/Vue.
В последнем кейсе и жил проект, со слоевой архитектурой в которой Active Record не покидал Data Layer, а сервис с бизнес-логикой результат своей деятельности отдавал в виде неких DTO и их коллекций (в рамках Yii, те же дата провайдеры) и в зависимости от того, это API или админка по разному возвращал результат (json, рендер вьюхи и пр.).
michael_v89
С ActiveRecord можно сделать точно так же. Сервис возвращает DataProvider с ActiveRecord, как нужно, так и рендерим.
michael_v89
Вас же не смущает, когда JavaScript во фронтенд-приложении несколько раз обращается к серверу во время рендеринга страницы, почему вдруг с серверным рендерингом той же верстки это проблема?
FanatPHP
Потому что серверный процесс синхронный. Страница рендерится целиком. И результат запроса может влиять на те данные, которые уже отрендерились. Поэтому бизнес-логика должна обрабатывать до начала логики отображения.
Клиентское приложение асинхронное, оно работает с готовым рендером, модифицируя его с каждым запросом.
michael_v89
Если у вас данные меняются во время рендеринга, то это проблема архитектуры вашего приложения, а не ActiveRecord. Они у вас и с фронтенд-приложением будут меняться.
В случае фронтенд-приложения это правило не соблюдается. Подгрузка данных через JavaScript во время рендеринга по определению начинается после начала логики отображения этой страницы.
Если вы переходите с главной страницы на страницу с некоторой таблицей, страница модифицируется практически вся. Даже в уже отрендеренный заголовок подгружается новая информация — количество непрочитанных уведомлений и т.д.
Серверный рендеринг тоже модифицирует готовый рендер, состоящий из пустой строки. То что на фронетнде он не пустой, а там есть несколько общих тегов типа
<html>
, принципиально ничего не меняет.