Что общего у комментариев к статье на Хабре и дополнительных опций при покупке машины?



С точки зрения моделирования данных, и то, и другое — “вложенные” сущности, которые не имеют самостоятельного значения в отрыве от родительского объекта.

В Yii (php framework) есть Gii — встроенный генератор кода, который позволяет в несколько кликов мышкой создавать базовые CRUD-интерфейсы по модели данных, которые значительно ускоряют разработку, но применимы только для самостоятельных сущностей, как статья или машина в примерах выше.

Было бы здорово, чтобы можно было сгенерировать что-то подобное для “вложенных” объектов данных, верно? Теперь — можно, добро пожаловать под кат за подробностями.

Для самых нетерпеливых в конце статьи дана инструкция по быстрому старту.

А для интересующихся в статье рассмотрены аспекты от бизнес-применения до внутреннего устройства:

  • Бизнес-кейс: публикация сообщений по темам
    • Список тем на главной
    • Список сообщений по теме
  • Под капотом: генератор gii на базе CRUD
    • Шаблон генератора Gii
    • Базовый класс виджета
    • Встроенный контроллер-фасад
  • Быстрый старт
    • О поддержке и развитии

Бизнес-кейс: публикация сообщений по темам


Возможно, комментарии на хабре и плохой пример, т.к. часто бывают полезнее самой статьи, но, в любом случае, при разработке приложения часто встречается ситуации, когда определенный объект модели данных мало интересен пользователю, как самостоятельная сущность.

Рассмотрим упрощенную бизнес-задачу: сделать сайт для публикации сообщений, сгруппированных по различным темам.

Сайт должен иметь следующие интерфейсы:

  1. Главная страница — должна в будущем поддерживать различные виджеты, но на текущем этапе реализации только один: список тем, отфильтрованных по какому-то критерию.
  2. Полный список тем — полный список тем в табличном виде;
  3. Страница темы — информация о теме и список сообщений, опубликованных в ней.

Довольно стандартно, верно?

Посмотрим на модель данных:



Тоже никаких сюрпризов. Два класса моделей будут содержать нашу бизнес-логику:

  • Класс Topic — данные по теме, валидация, список постов в ней, а также отдельный метод, возвращающий список тем, отфильтрованных по критерию для виджета на главной странице.
  • Класс Post — только данные и валидация.

Приложение будет обслуживаться двумя контроллерами:

  • SiteController — стандартные страницы (о нас, контакты и т.п.), авторизация (не требуется по ТЗ, но мы-то знаем) и index — главная страница. Т.к. мы предполагаем в будущем множество разнообразных виджетов, главную страницу имеет смысл оставить в этом контроллере, а не переносить в специфичный для одной модели.
  • TopicController — стандартный набор действий: список, создание, редактирование, просмотр и удаление тем.

Потенциально можно также сгенерировать PostController — для целей администрирования и/или копи-паста кусков кода в кастомные виджеты, но оставим это за рамками данной статьи.
До текущего момента большую часть кода можно сгенерировать с помощью gii, что ускоряет разработку и снижает риски (меньше ручного кода = меньше шансов допустить ошибку).

Остаются два вопроса:

  1. Как вывести отфильтрованный список тем на главной странице?
  2. Как вывести список сообщений по теме?

Если удастся решить их с помощью автоматического генератора — это будет солидным достижением.

Список тем на главной


Главная страница, обслуживаемая адресом site/index, должна содержать список тем, отфильтрованных по заранее определенному критерию. Критерий фильтрации, как часть бизнес-логики, мы включили в модель.

Для отображения же есть несколько вариантов реализации.

Первый, грязный и быстрый — все сделать прямо в файле представления (views/site/index.php):

  1. Создать ActiveDataProvider;
  2. Заполнить его данными из модели Topic;
  3. Отобразить с использованием стандартного виджета ListView / GridView, указав необходимые поля вручную.

Можно пойти немного дальше и упаковать все это в отдельный файл представления, что-то вроде views/site/_topic-list-widget.php, вызвав его рендер из главного файла. Это даст немного больше управляемости и расширяемости, но все равно выглядит довольно грязно.

Большинство из нас, скорее всего, создадут отдельный виджет по всем правилам, в отдельном пространстве имен (app\widgets или app\components для шаблона basic — в зависимости от версии, которую вы используете), где инкапсулируют создание ActiveDataProvider по модели и отображение в самостоятельном представлении. Дальше останется только вызвать этот виджет с главной страницы. Это решение наиболее верное с точки зрения декомпозиции классов, управляемости и расширяемости кода.

Но не возникает ли ощущение, что код этого виджета будет очень во многом повторять код TopicController в части обработки actionIndex()? И так обидно писать этот код вручную.

Гораздо лучше было бы сгенерировать этот код автоматически и потом просто вызвать готовый виджет:

<?= \app\widgets\TopicControllerWidget::widget([
    'action' => 'index',
    'params' => [
        'query' => app\models\Topic::findBySomeSpecificCriteria()
    ],
]) ?>

Список сообщений по теме

Страница просмотра темы, обслуживаемая адресом topic/view, должна содержать информацию о самой теме и список сообщений, опубликованных в ней. Список сообщений для темы мы получаем в модели автоматически, если у нас правильно настроены связи между таблицами, так что остается только вопрос отображения.

По аналогии с отфильтрованным списком тем у нас есть практически те же самые варианты.

Первый — все сделать в коде файла представления для просмотра темы (views/topic/view.php):

  1. Создать ActiveDataProvider;
  2. Заполнить его данными из модели $model->getPosts();
  3. Отобразить с использованием стандартного виджета ListView / GridView, указав необходимые поля вручную.

Второй — изолировать этот код в отдельный файл представления: views/topic/_posts-list-widget.php, просто, чтобы не мозолил глаза — переиспользовать его где-либо все равно не получится.

Третий — полноценный виджет, который будет во многом дублировать код условного PostController в части actionIndex(), но написанный вручную.

Или сгенерировать код автоматически и вызвать готовый виджет:

<?= app\widgets\PostControllerWidget::widget([
    'action' => 'index',
    'params' => [
        'query' => $model->getPosts(),
    ],
]) ?>

Под капотом: генератор gii на базе CRUD


Бизнес-задача определена, требования к генерируемому виджету обрисованы, разберемся с тем, как именно это будем генерировать. В Gii уже есть генератор для CRUD-контроллера. Для CRUD-виджета нам понадобится создать новый генератор на основе существующего.

Пара ссылок на документацию перед стартом — будет также полезно, если вы решите написать собственное расширение:


Очевидно, вся функциональность упакована в расширение Yii, которое устанавливается через composer и попадает в папку vendor вашего проекта.

Расширение состоит из трех частей:

  1. Директория templates/crud, содержащая шаблон генератора gii;
  2. Файл Controller.php — встроенный контроллер-фасад для вызовов виджетов;
  3. Файл Widget.php — базовый класс для всех генерируемых виджетов.



Шаблон генератора Gii


Расширение должно генерировать код, поэтому центральной его частью является генератор Gii.

Изначально предполагалось, что для реализации расширения будет достаточно написать свой шаблон для встроенного генератора CRUD-Controller. К слову, именно поэтому директория называется templates, а не generators. Но вышло так, что генератор CRUD-Controller проводит весьма интенсивную валидацию введенных данных, которая не позволяла реализовать многие требования, например, изменить класс для наследования. Поэтому расширение содержит полноценный генератор, а не только шаблон.

Генератор gii состоит из следующих частей (все лежат внутри директории templates/crud):

  • Директория default — это шаблон, где и происходит вся магия: каждый файл в этой директории будет соответствовать одному сгенерированному файлу в вашем проекте;
  • Файл form.php — как можно догадаться из названия, это форма для ввода параметров генерации (имена классов и т.п.);
  • Файл Generator.php — оркестратор генерации, который получает данные из формы, проводит их валидацию, а потом последовательно вызывает файлы шаблона для создания результата.

Файлы Generator.php и form.php содержат в основном косметические правки относительно оригинальных из CRUD-генератора: имена файлов, валидация, тексты описаний и подсказок и т.п.

Файлы шаблона отвечают за генерируемое представление и сам код виджета. В первую очередь важен файл templates/crud/default/controller.php, который отвечает за генерацию непосредственно класса виджета, соответствующего классу контроллера из оригинального генератора.

Виджет должен иметь те же действия (actions), что и CRUD-контроллер, но генерируются они немного иначе. В примерах ниже показан результат генерации с комментариями:

  • actionIndex — вместо безусловного вывода всех моделей метод принимает параметр $query;

    public function actionIndex($query)
    {
        $dataProvider = new ActiveDataProvider([
            'query' => $query,
        ]);
    
        return $this->render('index', [
            'dataProvider' => $dataProvider,
        ]);
    }
  • actionCreate и actionUpdate — в случае успеха вместо редиректа просто возвращают код успеха, дальнейшая обработка обеспечивается встроенным контроллером-фасадом;

    public function actionCreate()
    {
        $model = new Post();
    
        if ($model->load(Yii::$app->request->post()) && $model->save()) {
            return 'success';
        }
    
        return $this->render('create', [
            'model' => $model,
        ]);
    }

  • actionDelete — поддерживает GET метод для отображения виджета удаления (по умолчанию — одна кнопка) и POST для выполнения действия; в случае успеха также не выполняет редирект, а возвращает код.

    public function actionDelete($id)
    {
        $model = $this->findModel($id);
    
        if (Yii::$app->request->method == 'GET') {
            return $this->render('delete', [
                'model' => $model,
            ]);
        } else {
            $model->delete();
            return 'success';
        }
    }

Наконец, файлы представления содержат следующие основные правки:

  • Все заголовки переведены в h2 вместо h1;
  • Убран код, отвечающий за вывод title страницы и за хлебные крошки — виджет не должен влиять на эти вещи;
  • Создание и редактирование моделей происходит с помощью модального окна (встроенный виджет Modal);
  • Добавлен шаблон виджета удаления — с одной большой красной кнопкой.

Базовый класс виджета


Когда генератор закончит свою работу, он создаст класс виджета в пространстве имен приложения. Цепочка наследования выглядит так: виджеты, сгенерированные для приложения, наследуются от базового виджета расширения, класса \ianikanov\wce\Widget, который, в свою очередь, наследуется от базового виджета Yii, класса \yii\base\Widget.

Базовый класс виджета расширения решает следующие задачи:

  1. Определяет два основных поля: $action и $params, через которые виджету передается управление из вызывающего представления;
  2. Определяет ряд стандартных параметров, которые можно переопределить в сгенерированном классе, таких как путь к файлам представления виджета, имя и путь к контроллеру-фасаду (о нем ниже) и сообщения об ошибках;
  3. Определяет стандартные параметры при рендере представлений: render и renderFile;
  4. Обеспечивает инфраструктуру событий, аналогичную инфраструктуре контроллера, чтобы работали стандартные фильтры, такие как AccessControl и VerbFilter;
  5. Определяет метод run, который и собирает все это вместе.

Встроенный контроллер-фасад

С отображением данных проблем нет — виджеты для того и предназначены. А вот для редактирования, как ни крути, нужен контроллер. Генерировать уникальный контроллер для каждого виджета — теряется вся его суть. Использовать стандартный CRUD — не всегда актуально, да и не хочется зависеть от дополнительного запуска gii. Поэтому был использован вариант с универсальным, встроенным контроллером-фасадом.

Этот контроллер регистрируется в карте приложения через файл конфигурации и содержит только один метод — actionIndex, который выполняет следующие действия:

  1. Принимает запрос от клиента;
  2. Передает управление соответствующему классу виджета;
  3. Обрабатывает бизнес-ошибки в результате работы виджета;
  4. Осуществляет перенаправление обратно основному приложению.

Пожалуй, важнее указать, чего этот контроллер НЕ делает:

  1. Он не проверяет уровни доступа — эта логика принадлежит конкретным виджетам;
  2. Он не производит никаких манипуляций с вводом — параметры передаются виджету, как есть;
  3. Он не производит никаких манипуляций с выводом, кроме проверки на заранее определенный код успеха.

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

Быстрый старт

Бизнес-задача ясна, готовы начать? Использование расширения состоит из четырех шагов:

  1. Установка;
  2. Конфигурация;
  3. Генерация;
  4. Применение.

Установка расширения производится с помощью composer:

php composer.phar require --prefer-dist ianikanov/yii2-wce "dev-master"

Далее надо внести несколько изменений в файл конфигурации приложения.

Во-первых, добавить указание на генератор gii:

if (YII_ENV_DEV) {    
    $config['modules']['gii'] = [
        'class' => 'yii\gii\Module',      
        'allowedIPs' => ['127.0.0.1', '::1', '192.168.0.*', '192.168.178.20'],  
        'generators' => [ //here
            'widgetCrud' => [
                'class' => '\ianikanov\wce\templates\crud\Generator',
                'templates' => [
                    'WCE' => '@vendor/ianikanov/yii2-wce/templates/crud/default', // template name
                ],
            ],
        ],
    ];
}

Во-вторых, добавить встроенный контроллер-фасад в карту:

$config = [
    ...
    'controllerMap' => [
        'wce-embed' => '\ianikanov\wce\Controller',
    ],
    ...
];

На этом установка и конфигурация завершена.

Чтобы сгенерировать виджет надо:

  1. Открыть gii;
  2. Выбрать «CRUD Controller Widget»;
  3. Заполнить поля формы;
  4. Просмотреть и сгенерировать код.

Далее для использования виджета, его надо вызвать, указав action и params — практически также, как вызывается контроллер.

Виджет просмотра списка моделей:

<?= app\widgets\PostControllerWidget::widget([
    'action' => 'index',
    'params' => [
        'query' => $otherModel->getPosts(),
    ],
]) ?>

Виджет просмотра одной модели:

<?= app\widgets\PostControllerWidget::widget(['action' => 'view', 'params' => ['id' => $post_id]]) ?>

Виджет создания модели (кнопка + форма, обернутая в Modal):

<?= app\widgets\PostControllerWidget::widget(['action' => 'create']) ?>

Виджет изменения модели (кнопка + форма, обернутая в Modal):

<?= app\widgets\PostControllerWidget::widget(['action' => 'update', 'params'=>['id' => $post_id]]) ?>

Виджет удаления модели (кнопка):

<?= app\widgets\PostControllerWidget::widget(['action' => 'delete', 'params'=>['id' => $post_id]]) ?>

Код виджета и всех представлений принадлежит приложению и может быть легко изменен — все точно также, как при генерации контроллера.

О поддержке и развитии


Пару слов о том, как расширение будет поддерживаться и развиваться. У меня есть основная работа и несколько своих “побочных” проектов (pet-projects). Так вот, это расширение — это побочный проект от моих побочных проектов, так что улучшения к нему я буду разрабатывать только под нужды своих проектов.

В лучших традициях open source, код доступен на гитхабе, и я буду его поддерживать в части исправления багов, а также постараюсь делать своевременные ревью, если кто захочет отправить pull request, так что, кому интересно — присоединяйтесь.

Комментарии (5)


  1. tsukasa_mixer
    06.05.2019 19:58

    А можно перестать баловаться, и просто написать 1 общую админку с шаблонами полей.
    Благо делается не сложно.
    А если ещё подумать, то можно сделать нормальное описание полей в моделях, и собирать админку от полей модели и связных с минимальными корректировка и.

    А если подумать чуть больше то можно нагуглить готовое решение.


    1. JSas Автор
      06.05.2019 20:19

      Видимо, я слишком сильно упростил пример. Речь не идет о стандартной CMS, для которой нужна одна админка с набором полей. Речь идет о проекте, когда пишется полноценный кастомный бэк, со сложной моделью данных и бизнес логикой.
      Например, в одном из моих гейм-дев проектов есть такая структура: основная сущность — квест, он состоит из этапов. Этапы состоят из сообщений. Сообщения могут содержать мини игру. Мини игры могут быть разных типов, один из них — викторины, которые содержат список вопросов. А к каждому вопросу есть список вариантов ответов с одним правильным.


      Если вы можете привести ссылку на готовое решение для такой возможности, буду благодарен.


      1. tsukasa_mixer
        06.05.2019 22:54

        Я вас прекрасно понял, но кастомные схемы тоже не проблема, отзыв выше немного эмоционален получился, но ровно потому, как мы с командой проходили весь этот путь написания своей системы поверх Yii и как показала наша практика, использование коло генерации через Gii не самое лучшее решение.
        Наиболее удобным вариантом получилось завернуть таблицы страниц админки в класс админки с конфигурацией полей.
        И классы форм редактирования и создания.


        1. JSas Автор
          06.05.2019 23:28

          Расскажете подробнее? Просто я видел (и реализовывал) паттерны с конфигурируемыми полями, но пока не могу понять, как за счет таких полей выстроить реляционные отношения в несколько уровней.
          И второй вопрос — почему вы говорите только об админке, на чем вы пишите фронт?
          Если говорить о других примерах вложенности, где можно использовать CRUD-виджеты, могу привести из другого моего проекта: пользователь имеет список записей в балансе, каждая запись имеет список счетов, каждый счет имеет список исторических значений (по одному на каждый месяц) и связи на транзакции (дебет/кредит).
          Здесь полностью приложение написано на Yii. Админка, конечно, есть, но она скорее утилитарная — справочники настроить, какие-то общие параметры и т.п. Соль как раз в интерфейсах для конечных пользователей для отображения и управления такими вложенными сущностями.


          1. tsukasa_mixer
            07.05.2019 11:11

            Принципиальной разницы нет админка\клиентка, просто изначально мы реализовали это для админ части, поэтому так и пишу.

            Если смогу расковырять старый код, чтобы немного анонимизировать, подкину вам.
            Прям вот сейчас увы работа.
            Скрин как мы накидали динамику вам ЛС отправлю.

            Основную идею можно подсмотреть в github.com/phact-cmf/phact
            К сожалению оригинальная разработка недоступна, а это калька с оригинальной идеи.