Приветствую всех! На текущем проекте мы используем Yii2 и в процессе разработки понадобилась некая сущность как модуль.

В Yii2 уже реализована модульная система, но есть один минус в том что модуль не позволяет выводить один модуль в другом модуле, а использования виджетов тоже не подходит, т.к. это часть вида и не умеет обрабатывать действия, например входящий POST-запрос (хотя одно время мы использовали виджеты так с некими костылями).

Мы создадим модуль, который будет содержать вложенные модули, которые можно будет использовать в любом контроллере.

В итоге мы получим:

  • Модули выводятся в любом контроллере
  • Управление через БД состоянием модуля (включен/выключен) и его позицией
  • Обработка входящих запросов
  • Вывод только на определенных страницах нужные модули

За идею взято реализация модулей в CMS OpenCart.

Начнем с создания модуля dispatcher через gii и подключим его в конфиге web.php

'dispatcher' => [
     'class' => 'app\modules\dispatcher\Module',
],

В директории модуля app\modules\dispatcher создадим класс BasicModule, который наследуется от \yii\base\Module.

BasicModule.php
<?php

namespace app\modules\dispatcher;

use app\modules\dispatcher\components\Controller;
use app\modules\dispatcher\models\LayoutModule;

/**
 *
 * Class Module
 * @package app\modules\dispatcher\components
 *
 */
class BasicModule extends \yii\base\Module
{
    const POSITION_HEADER = 'header';
    const POSITION_FOOTER = 'footer';
    const POSITION_LEFT = 'left';
    const POSITION_RIGHT = 'right';

    /**
     * @var array of positions
     */
    static protected $positions = [
        self::POSITION_HEADER,
        self::POSITION_FOOTER,
        self::POSITION_LEFT,
        self::POSITION_RIGHT,
    ];

    /**
     * @var string controller name
     */
    public $defaultControllerName = 'DefaultController';

    /**
     * @var string dir of modules catalog
     */
    public $modulesDir = 'catalog';

    /**
     * @var string modules namespace
     */
    private $_modulesNamespace;

    /**
     * @var string absolute path to modules dir
     */
    public $modulePath;

    /**
     *
     * @throws \yii\base\InvalidParamException
     */
    public function init()
    {
        parent::init();

        $this->_setModuleVariables();

        $this->loadModules();
    }

    /**
     * Load modules from directory by path
     * @throws \yii\base\InvalidParamException
     */
    protected function loadModules()
    {
        $handle = opendir($this->modulePath);

        while (($dir = readdir($handle)) !== false) {
            if ($dir === '.' || $dir === '..') {
                continue;
            }

            $class = $this->_modulesNamespace . '\\' . $dir . '\\Module';

            if (class_exists($class)) {
                $this->modules = [
                    $dir => [
                        'class' => $class,
                    ],
                ];
            }
        }

        closedir($handle);
    }

    /**
     * @param $layout
     * @param array $positions
     * @return array
     * @throws \yii\base\InvalidConfigException
     */
    public function run($layout, array $positions = [])
    {
        $model = $this->findModel($layout, $positions);

        $data = [];

        foreach ($model as $item) {
            if ($controller = $this->findModuleController($item['module'])) {
                $data[$item['position']][] = \Yii::createObject($controller, [$item['module'], $this])->index();
            }
        }

        return $data;
    }

    /**
     * @param $layout_id
     * @param array $positions
     * @return array|\yii\db\ActiveRecord[]
     * @internal param $layout
     */
    public function findModel($layout_id, array $positions = [])
    {
        if (empty($positions)) {
            $positions = self::$positions;
        }

        return LayoutModule::find()
            ->where([
                'layout_id' => $layout_id,
                'position' => $positions,
                'status' => LayoutModule::STATUS_ACTIVE,
            ])->orderBy([
                'sort_order' => SORT_ASC
            ])->asArray()->all();
    }

    /**
     * @param $name
     * @return null|string
     */
    public function findModuleController($name)
    {
        $className = $this->_modulesNamespace . '\\' . $name . '\controllers\\' . $this->defaultControllerName;

        return is_subclass_of($className, Controller::class) ? $className : null;
    }

    /**
     * Set modules namespace and path
     */
    private function _setModuleVariables()
    {
        $class = new \ReflectionClass($this);
        $this->_modulesNamespace = $class->getNamespaceName() . '\\' . $this->modulesDir;
        $this->modulePath = dirname($class->getFileName()) . DIRECTORY_SEPARATOR . $this->modulesDir;
    }
}


Унаследуем класс модуля app\modules\dispatcher\Module от BasicModule

Module.php
<?php

namespace app\modules\dispatcher;

/**
 * dispatcher module definition class
 */
class Module extends BasicModule
{
    /**
     * @inheritdoc
     */
    public function init()
    {
        parent::init();
    }
}


Создадим и выполним миграцию:

Миграция
    public $table = '{{%layout_module}}';

    public function safeUp()
    {
        $tableOptions = null;
        if ($this->db->driverName === 'mysql') {
            $tableOptions = 'CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE=InnoDB';
        }

         $this->createTable($this->table, [
            'id' => $this->primaryKey(),
            'layout_id' => $this->integer()->notNull(), // id страницы для вывода нашего модуля
            'module' => $this->string(150)->notNull(),  //название модуля
            'status' => $this->boolean()->defaultValue(true),
            'position' => $this->string(30)->notNull(),
            'sort_order' => $this->integer()->defaultValue(1),
        ], $tableOptions);
    }

    public function safeDown()
    {
        $this->dropTable($this->table);
    }

Заполним созданную таблицу:

INSERT INTO `layout_module` VALUES ('1', '1', 'test', '1', 'header', '1');
INSERT INTO `layout_module` VALUES ('2', '1', 'test', '1', 'footer', '1');
INSERT INTO `layout_module` VALUES ('3', '1', 'test', '1', 'left', '1');

В корне нашего модуля dispatcher добавим директорию components. Создадим класс Controller который будет наследовать \yii\web\Controller. Переопределим в нем метод render().

Controller.php
<?php

namespace app\modules\dispatcher\components;

/**
 *
 * Class Controller
 * @package app\modules\dispatcher\components
 */
class Controller extends \yii\web\Controller
{
    /**
     * @param string $view
     * @param array $params
     * @return string
     * @throws \yii\base\InvalidParamException
     * @throws \yii\base\ViewNotFoundException
     * @throws \yii\base\InvalidCallException
     */
    public function render($view, $params = [])
    {
        $controller = str_replace('Controller', '', $this->module->defaultControllerName);

        $path = '@app/modules/dispatcher/' . $this->module->modulesDir . '/' . $this->id . '/views/' . $controller;

        return $this->getView()->render($path . '/' . 'index', $params, $this);
    }
}


В корне модуля dispatcher добавим директорию catalog — это родительская директория для наших модулей.

Дальше мы создаем наш первый модуль, который по своей структуре ничем не отличается от обычно модуля Yii2. Создаем директорию test, в ней создаем класс Module:

Module.php
<?php
namespace app\modules\dispatcher\catalog\test;

/**
 * test module definition class
 */
class Module extends \yii\base\Module
{
    /**
     * @inheritdoc
     */
    public $controllerNamespace = 'app\modules\dispatcher\catalog\test\controllers';
}


Создаем директорию controllers и в ней класс DefaultController который наследуем от нашего app\modules\dispatcher\components\Controller.

DefaultController.php
<?php

namespace app\modules\dispatcher\catalog\test\controllers;

use app\modules\dispatcher\components\Controller;

/**
 * Default controller for the `test` module
 */
class DefaultController extends Controller
{
    /**
     * Renders the index view for the module
     * @return string
     * @throws \yii\base\InvalidParamException
     * @throws \yii\base\ViewNotFoundException
     * @throws \yii\base\InvalidCallException
     */
    public function index()
    {
        return $this->render('index');
    }
}


Важно: чтоб работал наш модуль он всегда должен наследоваться от app\modules\dispatcher\components\Controller и содержать метод index

Создадим директории для представления views/default и файл нашего представления:

index.php
<div class="dispatcher-default-index">
    <p>
        You may customize this page by editing the following file:<br>
        <code><?= __FILE__ ?></code>
    </p>
</div>


Почти все готово, осталось только сделать вызов наших модулей. Для этого создадим компонент Dispatcher в app\modules\dispatcher\components:

Dispatcher.php
<?php

namespace app\modules\dispatcher\components;

use yii\base\Object;

class Dispatcher extends Object
{
    /**
     * @var \app\modules\dispatcher\Module
     */
    private $_module;

    public $module = 'dispatcher';

    /**
     * Dispatcher constructor.
     * @param array $config
     */
    public function __construct(array $config = [])
    {
        parent::__construct($config);

        $this->_module = \Yii::$app->getModule($this->module);
    }

    /**
     * Get modules by layout
     *
     * @param $layout
     * @param array $positions
     * @return array
     * @throws \yii\base\InvalidConfigException
     */
    public function modules($layout, array $positions = [])
    {
        return $this->_module->run($layout, $positions);
    }
}


Теперь надо подключить наш компонент в web.php

        'dispatcher' => [
            'class' => 'app\modules\dispatcher\components\Dispatcher',
        ],

Не забываем что компонент надо добавить в массив components.

В любом контроллере, например SiteController, в методе actionIndex() добавим

	/* @var $modules Dispatcher */
	$modules = \Yii::$app->dispatcher->modules(1);

	return $this->render('index', compact('modules'));

Осталось только добавить в наше представление позиции для вывода модулей views/site/index.php:

index.php
<?php

/* @var $this yii\web\View */

$this->title = 'My Yii Application';

use app\modules\dispatcher\Module;

?>
<div class="site-index">

    <?php if (isset($modules[Module::POSITION_HEADER])) { ?>
        <div class="row">
            <?php foreach ($modules[Module::POSITION_HEADER] as $module) {
                echo $module;
            } ?>
        </div>
    <?php } ?>


    <div class="jumbotron">
        <h1>Congratulations!</h1>

        <p class="lead">You have successfully created your Yii-powered application.</p>

        <p><a class="btn btn-lg btn-success" href="http://www.yiiframework.com">Get started with Yii</a></p>
    </div>

    <div class="body-content">

        <div class="row">
            <div class="col-lg-4">
                <h2>Heading</h2>

                <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor
                    incididunt ut
                    labore et
                    dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris
                    nisi
                    ut aliquip
                    ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
                    cillum dolore eu
                    fugiat nulla pariatur.</p>

                <p><a class="btn btn-default" href="http://www.yiiframework.com/doc/">Yii
                        Documentation »</a></p>
            </div>
            <div class="col-lg-4">
                <h2>Heading</h2>

                <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor
                    incididunt ut
                    labore et
                    dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris
                    nisi
                    ut aliquip
                    ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
                    cillum dolore eu
                    fugiat nulla pariatur.</p>

                <p><a class="btn btn-default" href="http://www.yiiframework.com/forum/">Yii
                        Forum »</a>
                </p>
            </div>
            <div class="col-lg-4">
                <h2>Heading</h2>

                <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor
                    incididunt ut
                    labore et
                    dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris
                    nisi
                    ut aliquip
                    ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
                    cillum dolore eu
                    fugiat nulla pariatur.</p>

                <p><a class="btn btn-default" href="http://www.yiiframework.com/extensions/">Yii
                        Extensions »</a></p>
            </div>
        </div>
    </div>

    <?php if (isset($modules[Module::POSITION_FOOTER])) { ?>
        <div class="row">
            <?php foreach ($modules[Module::POSITION_FOOTER] as $module) {
                echo $module;
            } ?>
        </div>
    <?php } ?>
</div>


Рекомендую официальную документацию по модулям.

Весь код выложен на GitHub.
Поделиться с друзьями
-->

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


  1. ErickSkrauch
    19.12.2016 17:50

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


    Если вы столкнулись с тем, что в нескольких местах используется одинаковая логика контроллера, то имеет смысл рассмотреть использование Standalone actions. Если дублируется view, то путь к view можно задавать используя алиасы. Если и то и то, то есть widgets. Хоть вы и сказали, что они вам не подошли, но я склонен думать, что вы просто неправильно пытались их использовать.


    К слову: чем ваше решение отличается от Yii::$app->getModule('moduleName')?


    1. yu-hritsaiy
      19.12.2016 17:58

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

      К слову: чем ваше решение отличается от Yii::$app->getModule('moduleName')?

      Согласен, но тут мы получаем еще некую гибкость в плане управлением состоянием модуля и его позицией на конкретной странице, наш проект это CMS, где пользователю не надо будет лезть в конфиги/контроллер/вид только для того чтоб поменять позицию модуля на странице и т.д. а сделает все через интерфейс в админке сайта.
      Спасибо!


      1. ErickSkrauch
        19.12.2016 18:02

        Хм, наличие переключателя в админке действительно относительно оправдывает написание своего решения, но, с другой стороны, вы могли это сделать написав класс, реализующий BootstrapInterface. Подключаете его в конфигурации и уже в нём выбираете из БД необходимые настройки модулей и инициализируете их.


        1. yu-hritsaiy
          19.12.2016 18:06

          да, согласен с Вами, но у нас уже на bootstrap-е есть некая логика и его перегружать такими фичами — не хотелось, а отдать логику управления модулями страницы на контроллеры. Это действие показалось самым логичным на тот момент.


          1. ErickSkrauch
            19.12.2016 18:10

            Ну количество Bootstrap методов не ограничено ведь.


            Ещё пара идей:


            • Можно переопределить метод getModule() и релизовать внутри необходимый обработчик.
            • Можно в самих модулях, внутри init() методов читать конфигурацию.

            Вариантов то много ;) Впрочем я ни в коем случае не намекаю, что ваше решение плохое. Просто можно было обойтись меньшей кровью.


            1. yu-hritsaiy
              19.12.2016 18:16

              Можно переопределить метод getModule() и релизовать внутри необходимый обработчик.

              не плохая идея, даже было реализовано по началу похоже, есть базовый контроллер на котором был метод со всей логикой, но со временем принялось решение отделить «мух от котлет» :)


              1. ErickSkrauch
                19.12.2016 18:30

                Нет, я имел в виду заменить оригинальный класс \yii\web\Application и заставить его работать на себя. Так делают, это используют.


                1. yu-hritsaiy
                  19.12.2016 18:43

                  у нас приложение полностью состоит из модулей, в каждом модуле есть свою контроллер Controller который наследуется от одного контроллера который в свою очередь уже наследуется от \yii\web\Controller, соответственно на одном уровне абстракции был и реализован данный функционал. Но потом возникла идея реализации модуля по управлению модулями ))

                  огромное спасибо за конкретную критику и идеи! :)


  1. iiifx
    20.12.2016 10:22

    Правильно ли я понимаю, что вы с помощью модулей сделали некий аналог виджетов с привязой к конкретным позициям?


    1. yu-hritsaiy
      20.12.2016 10:23

      да, все верно