Сервис на Yii2: Первый рефакторинг

С момента написания предыдущей статьи мы произвели несколько дополнений.

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

Предыдущие части
Исходники проекта на GitHub

Именование классов и методов

Изначально мы взяли модели и действия для регистрации, входа и сброса пароля из yii2-app-advanced и добавили некоторые свои. В итоге получилась бы такая структура моделей:

modules
    main
        models
            ContactForm
    user
        models
            EmailConfirmForm.php
            LoginForm.php
            ChangePasswordForm.php
            PasswordResetRequestForm.php
            ResetPasswordForm.php
            SignupForm.php
            User.php

Неоднозначность возникает с именованием моделей для работы с паролем:

ChangePasswordForm.php
PasswordResetRequestForm.php
ResetPasswordForm.php

и использующих их действий в контроллерах:

public function actionChangePassword()
{
    ...
}
 
public function actionRequestPasswordReset()
{
    ...
}
 
public function actionResetPassword($token)
{
    ...
}

Здесь действия и модели названы то по логике PasswordResetRequest, то RequestPasswordReset, что вызывает путаницу. Постоянно приходится вспоминать, где, что и как названо.

Ресурс человеческого мозга небезграничен в течение дня. Если очень сильно думать, то он устаёт уже за три часа, и весь оставшийся рабочий день пройдёт насмарку. К тому же, если программный код сложный, то вместо самой работы можно вхолостую растратить половину энергии только на сам его разбор. Сложный компьютер и простой лист бумаги умеют помнить, но не умеют думать. А человек умеет делать и то, и то. Поэтому разумнее освободить себя от посторонних воспоминаний, чтобы свободнее было думать.

Вроде бы звучит как банальность. Но так ли это в реальной жизни? И как мы этим пользуемся?

Когда среднестатистический телезритель смотрит фантастический фильм про хакеров, то видит, что там они взламывают Пентагоны, перемещая курсор силой мысли и сидя за неземными галографическими 3D дисплеями. А настоящий программист знает, что это делается банальными запросами в консоли через туннель в виде какого-нибудь PHP-скрипта, подложенного через дыру в старом Вордпрессе, или через SQL Injection, вбивая SQL-запросы в форму поиска на сайте.

А галографические и прозрачные стеклянные дисплеи жутко неудобны, так как светятся и мигают во все стороны, как новогодняя ёлка, и мелкий шрифт из огоньков на них вообще не прочтёшь. И Фотошопы взламываются трассировкой ассемблерных вызовов в дебагере и заменой байт EXE-шников в HEX-редакторе. В общем, в реальности это декомпилятор, отладчик, пять мегабайт ассемблера и вообще никакой романтики.

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

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

А опытные программисты настолько бережливы к своему разуму (можно сказать, ленивы) и настолько не любят тратить его на постороннюю рутину и выискивание ошибок, что всегда пишут код, который настолько примитивен, что понятен с первого взгляда. И который вряд ли может просто так сломаться, а если и сломается, то в нём можно найти ошибку наугад за полсекунды.

В этом и главное отличие хорошего кода от плохого. Плохой код непонятен, неудачно скучкован, зависим... То есть требует сильного напряжения мозга при его использовании и изменении. А хороший упрощён, удобно разложен по смыслу и однообразно назван.

Как только мы встречаемся с какой-нибудь неочевидной мелочью вроде разницы в названиях переменных, классов или методов, это нас сразу же сильно отвлекает. Если в проекте число неочевидностей перевалит за сотню в нескольких проектах, то необходимость помнить о них превратит нашу жизнь в ад. Так что от разнообразия способов именования в любом проекте нужно сразу избавляться. Поможем этому чуваку и изменим мир к лучшему:

У программистов есть некая договорённость именовать переменные, методы и классы по шаблону предмет-действие-остальное. Это как предложения строить по шаблону подлежащее-сказуемое-дополнения по убыванию важности. Если же у нас в названии несколько предметов, то двигаться по иерархии от общего к частному.

То есть, как назвать метод для запроса на восстановление пароля пользователя? Разложим фразу на составляющие:

запрос
восстановление
пароль
пользователь

Самый общий субъект здесь – пользователь. Так названа сама модель или даже сам модуль. Потом у пользователя мы берём пароль. У пароля мы запрашиваем восстановление. У восстановления может быть любые этапы вроде запрос и подтверждение. В итоге после сортировки по иерархии мы получаем:

пользователь
пароль
восстановление
запрос

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

/user/password/reset/request
/user/password/reset/change
/user/password/reset/success
/user/password/reset/error

и саму модель запроса назвать так:

namespace app\models
 
class UserPasswordResetRequestForm extends Model {
    ...
}

Но так как модель у нас находится в модуле user, можно префикс User вначале не указывать:

namespace app\modules\user\models
 
class PasswordResetRequestForm extends Model {
    ...
}

В итоге в соответствии с таким именованием мы можем наши разрозненные модели:

LoginForm
ChangePasswordForm
PasswordResetRequestForm
ResetPasswordForm
SignupForm

назвать одинаковым образом:

modules
    user
        models
            EmailConfirmForm.php
            LoginForm.php
            PasswordChangeForm.php
            PasswordResetForm.php
            PasswordResetRequestForm.php
            ProfileUpdateForm.php
            SignupForm.php
            User.php

Как бонус, теперь все похожие классы будут находиться рядом при сортировке файлов в папке по алфавиту. И теперь нам ни разу не придётся задумываться о том, как мы назвали каждый класс.

Модели форм

На предыдущем шаге мы переименовали модели. Хоть и модели форм отличаются от моделей ActiveRecord по суффиксу Form, но они оказались в одной куче в одной папке. Помимо них у нас могут появиться и другие модели. Целесообразно добавить поддиректорию forms и переместить их туда, оставив в models только модель User:

modules
    user
        forms
            EmailConfirmForm.php
            LoginForm.php
            PasswordChangeForm.php
            PasswordResetForm.php
            PasswordResetRequestForm.php
            ProfileUpdateForm.php
            SignupForm.php
        models
            User.php

Теперь при окидывании папки быстрым взглядом не будет путаницы среди моделей.

Представления писем

При создании модулей и перемещении внутрь них моделей, представлений и контроллеров мы забыли про представления писем в папке mail.

Нам нужно для удобства перенести шаблоны писем emailConfirm и passwordReset в modules/user/mails, а сами макеты mail/layouts оставить на месте. Теперь нужно изменить код форм, которые эти письма рендерят, а именно поменять в них пути на новые. Для этого открываем классы SignupForm и PasswordResetRequestForm и строки рендеринга писем:

Yii::$app->mailer->compose(['text' => 'emailConfirm'], ['user' => $user])
    ->...

меняем на новые, указывая полный путь к представлениям:

Yii::$app->mailer->compose(['text' => '@app/modules/user/mails/emailConfirm'], ['user' => $user])
    ->...

На этом с письмами пока всё.

Консольные команды

Далее переносим консольный контроллер commands/UsersController в папку внутри модуля modules/user/commands, не забыв поменять его пространство имён:

namespace app\modules\user\commands;
 
use yii\console\Controller;
...
 
/**
 * Interactive console user manager
 */
class UsersController extends Controller
{
   ...
}

Но просто перенос нам не поможет, так как если выполнить

php yii

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

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

Для самого приложения мы их задаём явно в config/console.php:

return [
    'id' => 'app-console',
    'controllerNamespace' => 'app\commands',
];

и аналогично указывам в модулях:

namespace app\modules\user;
 
class Module extends \yii\base\Module
{
    public $controllerNamespace = 'app\modules\user\controllers';
}

Но можно его для модуля и не указывать, так как иначе он установится таким же автоматически, судя по коду в базовом классе yii\base\Module:

namespace yii\base;
 
class Module extends ServiceLocator
{
    public $controllerNamespace;
 
    ...
 
    public function init()
    {
        if ($this->controllerNamespace === null) {
            $class = get_class($this);
            if (($pos = strrpos($class, '\\')) !== false) {
                $this->controllerNamespace = substr($class, 0, $pos) . '\\controllers';
            }
        }
    }
 
    ...
}

У нас есть два варианта, как можно помочь фреймворку находить наши консольные контроллеры.

Можно помещать консольные контроллеры в папку controllers, но в данном случае там уже может оказаться контроллер с таким же названием. И, вместе с этим, они могут мешать друг другу. Более удобный вариант – держать консольные и веб-контроллеры в разных папках и при запуске в консоли переключать controllerNamespace с controllers на commands.

Для этого можно в web-конфигурации подключить модули как обычно:

'modules' => [
    'admin' => [
        'class' => 'app\modules\admin\Module',
    ],
    'main' => [
        'class' => 'app\modules\main\Module',
    ],
    'user' => [
        'class' => 'app\modules\user\Module',
    ],
],

а в консольной конфигурации вручную указать нужные пути:

'modules' => [
    'admin' => [
        'class' => 'app\modules\admin\Module',
        'controllerNamespace' => 'app\modules\admin\commands',
    ],
    'main' => [
        'class' => 'app\modules\main\Module',
        'controllerNamespace' => 'app\modules\main\commands',
    ],
    'user' => [
        'class' => 'app\modules\user\Module',
        'controllerNamespace' => 'app\modules\user\commands',
    ],
],

Но вместо этого в методе инициализации модуля можно просто добавить проверку, не в консольном ли режиме мы запускаемся:

namespace app\modules\user;
 
use yii\console\Application as ConsoleApplication;
use Yii;
 
class Module extends \yii\base\Module
{
    public $controllerNamespace = 'app\modules\user\controllers';
 
    public function init()
    {
        parent::init();
        if (Yii::$app instanceof ConsoleApplication) {
            $this->controllerNamespace = 'app\modules\user\commands';
        }
    }
}

Этот код теперь помещаем в init() каждого модуля.

Теперь если запустим:

php yii

то в списке помимо стандартных команд увидим наши, расположенные уже в модуле:

The following commands are available:

- asset                         Allows you to combine and compress your JavaScript and CSS files.
    asset/compress (default)    Combines and compresses the asset files according to the given configuration.
    asset/template              Creates template of configuration file for [[actionCompress]].

...

- user/users                    Interactive console user manager
    user/users/activate         Activates user
    user/users/change-password  Changes user password
    user/users/create           Creates new user
    user/users/delete           Removes user by username

и теперь можно запустить их по этим путям. Например:

php yii user/users/create

Файлы переводов

Далее в коментариях предложили для удобства переноса модулей не держать все переводы в одном файле в корневой папке messages, а перенести их в подпапки messages каждого модуля. При этом в корне оставить только то, что ни к какому модулю не относится.

Так и сделаем. Разнесём всё по папкам:

messages
    en
        app.php
    ru
        app.php
modules
    admin
        messages
            en
                module.php
            ru
                module.php
    main
        messages
            en
                module.php
            ru
                module.php
    user
        messages
            en
                module.php
            ru
                module.php

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

Но в конфигурации config/common.php наши переводы пока заданы примерно в таком виде:

'components' =>
    'i18n' => [
        'translations' => [
            'app*' => [
                'class' => 'yii\i18n\PhpMessageSource',
                'forceTranslation' => true,
                'basePath' => '@app/messages',
                'fileMap' => [
                    'app' => 'app.php',
                ],
            ],
        ],
    ],
],

и, соответственно, они не найдут переводы из наших модулей.

Мы можем подключить новые источники прямо здесь:

'components' =>
    'i18n' => [
        'translations' => [
            'app*' => [
                'class' => 'yii\i18n\PhpMessageSource',
                'forceTranslation' => true,
                'basePath' => '@app/messages',
                'fileMap' => [
                    'app' => 'app.php',
                ],
            ],
            'modules/admin/*' => [
                'class' => 'yii\i18n\PhpMessageSource',
                'forceTranslation' => true,
                'basePath' => '@app/modules/admin/messages',
                'fileMap' => [
                    'modules/admin/module' => 'module.php',
                ],
            ],
            'modules/main/*' => [
                'class' => 'yii\i18n\PhpMessageSource',
                'forceTranslation' => true,
                'basePath' => '@app/modules/main/messages',
                'fileMap' => [
                    'modules/main/module' => 'module.php',
                ],
            ],
            'modules/user/*' => [
                'class' => 'yii\i18n\PhpMessageSource',
                'forceTranslation' => true,
                'basePath' => '@app/modules/user/messages',
                'fileMap' => [
                    'modules/user/module' => 'module.php',
                ],
            ],
        ],
    ],
],

Но при копировании этого из проекта в проект нужно будет делать это также вручную в каждом проекте.

Добавление источников мы можем поместить в метод init() каждого модуля:

namespace app\modules\user;
 
use yii\console\Application as ConsoleApplication;
use Yii;
 
class Module extends \yii\base\Module
{
    public $controllerNamespace = 'app\modules\user\controllers';
 
    public function init()
    {
        parent::init();
        if (Yii::$app instanceof ConsoleApplication) {
            $this->controllerNamespace = 'app\modules\user\commands';
        }
        Yii::$app->i18n->translations['modules/user/*'] = [
            'class' => 'yii\i18n\PhpMessageSource',
            'sourceLanguage' => 'en-US',
            'forceTranslation' => true,
            'basePath' => '@app/modules/user/messages',
            'fileMap' => [
                'modules/user/module' => 'module.php',
            ],
        ];
    }
}

Но это будет работать только при заходе в модуль. Не будет возможности в одном модуле использовать переводы из другого, не загрузив оригинал. Например, в admin мы не сможем получить переводы полей модели User, не подключив сам модуль вызовом Yii::$app->getModule('user').

Третий вариант – сделать модули автозагружаемыми. Для этого в классах каждого имплеменируем BootstrapInterface, добавляя требуемый интерфейсом метод bootstrap($app):

namespace app\modules\user;
 
use yii\base\BootstrapInterface;
 
class Module extends \yii\base\Module implements BootstrapInterface
{
    public function bootstrap($app)
    {
        $app->i18n->translations['modules/user/*'] = [
             'class' => 'yii\i18n\PhpMessageSource',
             'sourceLanguage' => 'en-US',
             'forceTranslation' => true,
             'basePath' => '@app/modules/user/messages',
             'fileMap' => [
                 'modules/user/module' => 'module.php',
             ],
         ];
    }
}

Теперь нужно сказать нашему приложению загружать этот модуль перед стартом. В конфигурации добавляем в одноимённую секцию bootstrap помимо компонента log список наших модулей:

return [
    ...
    'basePath' => dirname(__DIR__),
    'bootstrap' => [
        'log',
        'admin',
        'main',
        'user',
    ],
    'modules' => [ ... ],
    ...
],

Но есть нюанс. Записи admin и main сработают, но user не подтянется. Это всё потому, что вместо него по имени user автозагрузится компонент Yii::$app->user, так как ищутся сначала компоненты с таким именем.

Чтобы загружались именно наши элементы мы должны указать классы:

'bootstrap' => [
    'log',
    'app\modules\admin\Module',
    'app\modules\main\Module',
    'app\modules\user\Module',
],

но при этом система запустит их как просто отдельные объекты, а не как модули. Вместо этого нужно сделать небольшой костыль:

'bootstrap' => [
    'log',
    function () { return Yii::$app->getModule('admin'); },
    function () { return Yii::$app->getModule('main'); },
    function () { return Yii::$app->getModule('user'); },
],

Автозагрузка теперь сработает. Но давайте подумаем, что у нас получилось. При таком подходе куда бы мы на сайте не зашли сразу запустятся ВСЕ модули. Сработают их конструкторы и методы init(). Подключатся поведения, обработчики событий и всё, что с ними связано. Если там находятся громоздкие коды инициализации, то сработают они все.

Может оказаться, что только ради переводов запускать подряд все модули при каждом запросе – это слишком громоздко и медленно. Этого желательно избегать.

Вместо запуска всего класса модуля мы можем вынести загрузочный код в лёгкий отдельный класс, который так и назвать Bootstrap и положить рядом:

namespace app\modules\user;
 
use yii\base\BootstrapInterface;
use Yii;
 
class Bootstrap implements BootstrapInterface
{
    public function bootstrap($app)
    {
        $app->i18n->translations['modules/user/*'] = [
            'class' => 'yii\i18n\PhpMessageSource',
            'forceTranslation' => true,
            'basePath' => '@app/modules/user/messages',
            'fileMap' => [
                'modules/user/module' => 'module.php',
            ],
        ];
    }
}

и в конфигурации config/common.php указать эти отдельные классы:

'bootstrap' => [
    'log',
    'app\modules\admin\Bootstrap',
    'app\modules\main\Bootstrap',
    'app\modules\user\Bootstrap',
],

Которые теперь будут инициализировать переводы при загрузке приложения и делать это независимо.

Оставим это всё пока так.

При распространении своего модуля через Composer теперь можно будет поместить путь к автозагружаемому классу в секцию bootstrap файла composer.json своего пакета:

"type": "yii2-extension",
...
"extra": {
    "bootstrap": "my\\user\\Bootstrap",
    "asset-installer-paths": {
        "npm-asset-library": "vendor/npm",
        "bower-asset-library": "vendor/bower"
    }
}

и это избавит всех от необходимости прописывать его вручную в своём файле конфигурации.

В итоге, сами классы модулей оставим примитивными. Добавим в них статический метод t:

namespace app\modules\user;
 
use yii\console\Application as ConsoleApplication;
use Yii;
 
class Module extends \yii\base\Module
{
    public $controllerNamespace = 'app\modules\user\controllers';
 
    public function init()
    {
        parent::init();
        if (Yii::$app instanceof ConsoleApplication) {
            $this->controllerNamespace = 'app\modules\user\commands';
        }
    }
 
    public static function t($category, $message, $params = [], $language = null)
    {
        return Yii::t('modules/user/' . $category, $message, $params, $language);
    }
}

чтобы теперь во всём нашем приложении вместо кода:

Yii::t('app', 'USER_EMAIL')

вызывать перевод уже из этого метода:

Module::t('module', 'USER_EMAIL')

а в конфигурации приложения оставим только настройки глобальных переводов Yii::t('app', ...):

'components' =>
    'i18n' => [
        'translations' => [
            'app' => [
                'class' => 'yii\i18n\PhpMessageSource',
                'forceTranslation' => true,
            ],
        ],
    ],
],

Теперь искать и переносить переводы в отдельных файлах стало проще.

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

Перенос параметров в модуль

До сих пор наша модель User использует параметр user.passwordResetTokenExpire из глобальных параметров приложения:

public static function isPasswordResetTokenValid($token)
{
    if (empty($token)) {
        return false;
    }
    $expire = Yii::$app->params['user.passwordResetTokenExpire'];
    $parts = explode('_', $token);
    $timestamp = (int) end($parts);
    return $timestamp + $expire >= time();
}

А именно из файла config/params.php:

return [
    'adminEmail' => '',
    'supportEmail' => '',
    'user.passwordResetTokenExpire' => 3600,
];

В Yii2 же более распространена практика хранения конфигурации в самом объекте модуля по аналогии с конфигурациями компонентов приложения и прочих элементов:

'modules' => [
    'user' => [
        'class' => 'app\modules\user\Module',
        'passwordResetTokenExpire' => 3600,
    ],
],

Добавляем публичное поле в наш класс модуля:

namespace app\modules\user;
 
use yii\console\Application as ConsoleApplication;
use Yii;
 
class Module extends \yii\base\Module
{
    public $controllerNamespace = 'app\modules\user\controllers';
    /**
     * @var int
     */
    public $passwordResetTokenExpire = 3600;
 
    public function init()
    {
        parent::init();
        if (Yii::$app instanceof ConsoleApplication) {
            $this->controllerNamespace = 'app\modules\user\commands';
        }
    }
 
    public static function t($category, $message, $params = [], $language = null)
    {
        return Yii::t('modules/user/' . $category, $message, $params, $language);
    }
}

Теперь мы можем получать это значение любым способом:

// Для контроллера
$expire = $this->module->passwordResetTokenExpire;
 
// Из конкретного загруженного модуля
$expire = \app\modules\user\Module::getInstance()->passwordResetTokenExpire;
 
// Из любого модуля
$expire = Yii::$app->getModule('user')->passwordResetTokenExpire;

Но второй вариант не загружает модуль, если он ещё не загружен. При попытке получить доступ к соседнему модулю он вернёт null.

Расставлять код вроде Yii::$app->getModule('user') в наши модели мы не будем, так как такой подход создаёт лишние зависимости. Модель должна работать сама по себе. Иначе все зависимости выворачиваются наизнанку, когда мы из модели дёргаем модуль. Вместо этого попробуем внедрить наши параметры в модели напрямую.

Как мы говорили выше, параметр passwordResetTokenExpire используется в методе isPasswordResetTokenValid() модели User. Изменим метод так, чтобы передавать параметр $timeout снаружи как аргумент этого метода:

class User extends ActiveRecord implements IdentityInterface
{
    /**
     * Finds user by password reset token
     *
     * @param string $token password reset token
     * @param integer $timeout
     * @return static|null
     */
    public static function findByPasswordResetToken($token, $timeout)
    {
        if (!static::isPasswordResetTokenValid($token, $timeout)) {
            return null;
        }
        return static::findOne([
            'password_reset_token' => $token,
            'status' => self::STATUS_ACTIVE,
        ]);
    }
 
    /**
     * Finds out if password reset token is valid
     *
     * @param string $token password reset token
     * @param integer $timeout
     * @return bool
     */
    public static function isPasswordResetTokenValid($token, $timeout)
    {
        if (empty($token)) {
            return false;
        }
        $parts = explode('_', $token);
        $timestamp = (int) end($parts);
        return $timestamp + $timeout >= time();
    }
}

Теперь исправим и остальные классы, которые используют эти методы. Изменим конструктор модели PasswordResetForm:

class PasswordResetForm extends Model
{
    ...
 
    public function __construct($token, $timeout, $config = [])
    {
        if (empty($token) || !is_string($token)) {
            throw new InvalidParamException('Password reset token cannot be blank.');
        }
        $this->_user = User::findByPasswordResetToken($token, $timeout);
        if (!$this->_user) {
            throw new InvalidParamException('Wrong password reset token.');
        }
        parent::__construct($config);
    }
 
    ...
}

и добавим конструктор с сохранением $timeout в PasswordResetRequestForm:

class PasswordResetRequestForm extends Model
{
    public $email;
 
    private $_user = false;
    private $_timeout;
 
    public function __construct($timeout, $config = [])
    {
        $this->_timeout = $timeout;
        parent::__construct($config);
    }
 
    ...
 
    public function validateIsSent($attribute, $params)
    {
        if (!$this->hasErrors() && $user = $this->getUser()) {
            if (User::isPasswordResetTokenValid($user->$attribute, $this->_timeout)) {
                $this->addError($attribute, Module::t('module', 'ERROR_TOKEN_IS_SENT'));
            }
        }
    }
}

Теперь перепишем код создания этих моделей в контроллере, дополнив передачей $this->module->passwordResetTokenExpire в каждую модель:

namespace app\modules\user\controllers;
 
...
 
class DefaultController extends Controller
{
    /**
     * @var \app\modules\user\Module
     */
    public $module;
 
    public function actionPasswordResetRequest()
    {
        $model = new PasswordResetRequestForm($this->module->passwordResetTokenExpire);
 
        ...
    }
 
    public function actionPasswordReset($token)
    {
        try {
            $model = new PasswordResetForm($token, $this->module->passwordResetTokenExpire);
        } catch (InvalidParamException $e) {
            throw new BadRequestHttpException($e->getMessage());
        }
 
        ...
    }
}

После перехода к явному внедрению параметров нам не придётся использовать вызов конструкций вроде Yii::$app->getModule('user'), добавляющих лишнюю связанность. К тому же, теперь можно легко тестировать каждую модель, передавая разные параметры в каждом тестовом сценарии.

В этой части пока всё. Дабы ничего не пропустить подписывайтесь на новые статьи, на видеоуроки и на наш паблик ВКонтакте. И оставляйте свои предложения или вопросы здесь в комментариях.

Заранее спасибо и до встречи в следующей статье Автоудаление неактивных пользователей.

Другие статьи

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

В прошлой части мы немного изменили структуру нашей системы. Фактически это были банальные операции по переносу файлов и частей кода с места на место. А сейчас добавим что-то новое. В комментариях предложили вычищать из базы пользователей, не активировавших свой адрес при регистрации. Рассмотрим. как это можно осуществить.

Часто встречаю вопрос о том, что же это за странные блоки комментариев постоянно генерируются в представлениях, в ActiveRecord-моделях и перед всеми методами в коде? Что они обозначают и зачем они нужны? Это какой-то особый синтаксис объявления переменных в PHP или что?

В этот раз порассуждали о понятиях и реализациях различных подходов к авторизации, аутентификации и контроле доступа на основе ролей в Yii2. Рассмотрели нюансы, сравнили друг с другом различные подходы к реализации RBAC.

Комментарии

 

Евгений Левачев

спасиб!!

Ответить

 

Alex

Как всегда на высоте, спасибо большое. Будет ли до НГ выбинар ?

Ответить

 

Дмитрий Елисеев

А до НГ его кто-нибудь смотреть будет? :)

Ответить

 

Akulenok

Я даже 31-го декабря готов его смотреть )

Спасибо многое стало ясно,
а если решу добавить статическую папку для картинок как в advanced, /static
где и как ее прописать? мне вот про имена не понятно, я например в коде неймспейсы пишу начиная с app\module\... а можно убрать слово app?
еще не понятно как после установок сторонних расширений, получается путь до их классов, реально он такой, а в коде другой
пример: \vendor\sjaakp\yii2-taggable\TaggableBehavior.php
а в модели я указываю такой use sjaakp\taggable\TaggableBehavior;

Ответить

 

Дмитрий Елисеев

1) Если это на поддомене, то просто в конфиге вначале дописываете:

Yii::setAlias('@staticroot', __DIR_ . '/../static');
Yii::setAlias('@static', 'http://static.site.com/static');

и спользуете вместо @web и @webroot.

2) Автозагрузчик Сomposer'а работает только в папке vendor. А всё что снаружи действует через автозагрузчик классов самого фреймворка, работающего по своим псевдонимам.

В методе preInit() приложения устанавливается значение basePath из конфига (обычно это корень проекта):

Yii::setAlias('@app', $this->getBasePath());

В итоге классы, начинающиеся с app, ищутся в самой папке приложения.

В yii2-app-advanced в конфиге common/config/bootstrap.php устанавливаются вручную новые псевдонимы с путями:

Yii::setAlias('common', dirname(__DIR__));
Yii::setAlias('frontend', dirname(dirname(__DIR__)) . '/frontend');
Yii::setAlias('backend', dirname(dirname(__DIR__)) . '/backend');
Yii::setAlias('console', dirname(dirname(__DIR__)) . '/console');

Так что теперь можно спокойно использовать класс frontend\models\Post и автозагрузчик Yii2 его легко найдёт.

По аналогии в своём конфиге в yii2-app-basic Вы можете указать любой свой псевдоним с путём и спользовать его. Например так:

Yii::setAlias('models', dirname(dirname(__DIR__)) . '/models');

чтобы использовать просто models\Post без app.

3) В файле /vendor/sjaakp/yii2-taggable/composer.json имеется секция autoload:

"autoload": {
    "psr-4": {
        "sjaakp\\taggable\\": ""
    }
}

показывающая, что всё из sjaakp\taggable нужно брать из его корневой папки. А в своём /vendor/composer/autoload_psr4.php можете найти закешированные соответствия пространств имён их путям, построенным по секциям autoload.

Ответить

 

Akulenok

Спасибо большое!

Ответить

 

Akulenok

Указываю в config/main.php

Yii::setAlias('modules', dirname(dirname(__DIR__)) . '/modules');

раньше писал так:

use app\modules\post\models\Post;

теперь должно работать так:

use modules\post\models\Post;

но не работает!

Class 'modules\post\models\Post' not found

Что не правильно сделал?

Ответить

 

Дмитрий Елисеев

А в самом классе namespace переименовали?

Ответить

 

Akulenok

Еще по поводу переводов, чем плох такой вариант?
в main конфиге

 'i18n' => require(__DIR__ . '/i18n.php'),
 'db' => require(__DIR__ . '/db.php'),
 'urlManager' => require(__DIR__ . '/url.php'),

.....и все переводы описаны в i18n.php

Ответить

 

Дмитрий Елисеев

Просто вынесли в файл. Разницы никакой.

Ответить

 

Akulenok

Хотелось бы статью о том как создаются расширения

Ответить

 

Евгений

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

Ответить

 

Дмитрий

Спасибо за статью. Не планируете ли написать по поводу работы с очередями? Т.е. с отложенными задачами (какие то долгие задачи, например парсинг страниц сайта, или пакетная отправка писем).
Недавно делал небольшой внутренний сервис по SEO задачам и использовал Laravel 5. Там все это достаточно просто и встроено в фреймворк. Для Yii1 нашел пакет, но чтобы он нормально заработал пришлось многое там удалить/переписать.
Есть ли какое то решение для Yii2?

Ответить

 

Дмитрий Елисеев

Официального расширения yiisoft/yii2-queue, думаю, не дождёмся. Есть самописы вроде wayhood/yii2-queue, работающие сами по себе без серверов очередей.

А если хочется по-серьёзному с тем же RabbitMQ или ещё с чем-нибудь, то выполняем

composer require videlalvaro/php-amqplib:"2.5.*"

И в бой как в туториале:

use PhpAmqpLib\Connection\AMQPStreamConnection;
use PhpAmqpLib\Message\AMQPMessage;

$connection = new AMQPStreamConnection('localhost', 5672, 'guest', 'guest');
$channel = $connection->channel();
$channel->queue_declare('task_queue', false, true, false, false);

$msg = new AMQPMessage('Hello World!');

$channel->basic_publish($msg, '', 'task_queue');
$channel->close();
$connection->close();

Оборачиваем для удобства этот код и код воркера в компонент вроде как VK здесь. И далее по мануалу вставляем код воркера и вызываем из консольного контроллера.

И потом используем:

Yii::$app->queue->push($message);

ограничиваясь только своей фантазией.

Ответить

 

Akulenok

Сделал как в статье, переводы в модуле, но у мен9 ошибка

Unable to locate message source for category 'module'.
....
       throw new InvalidConfigException("Unable to locate message source for category '$category'.")

вызывю так 'created_at' => Yii::t('module', 'created_at')

в модуле у мен9 так

namespace app\modules\pm;

use Yii;

class Module extends \yii\base\Module
{
    public $controllerNamespace = 'app\modules\pm\controllers';

    public function init()
    {
        parent::init();

        if (Yii::$app instanceof ConsoleApplication) 
        {
            $this->controllerNamespace = 'app\modules\pm\commands';
        }

        Yii::$app->i18n->translations['modules/pm/*'] = 
        [
            'class' => 'yii\i18n\PhpMessageSource',
            'sourceLanguage' => 'en-US',
            'forceTranslation' => true,
            'basePath' => '@app/modules/pm/messages',
            'fileMap' => [
                'modules/pm/module' => 'module.php',
            ],
        ];
    }
}
Ответить

 

Arthur Mr

Это должно быть не в модуле, а в бутстрапе. Перечитайте еще раз внимательнее.

Ответить

 

Akulenok

Вы бы самим перечитали внимательнее, я решил использовать второй вариант

Добавление источников мы можем поместить в метод init() каждого модуля:


в статье предлагается три варианта использования

Ответить

 

Дмитрий Елисеев

Ну раз определено как 'modules/pm/module', то относительно Yii нужно вызывать Yii::t('modules/pm/module', 'created_at'). Либо добавить метод t в сам модуль:

class Module extends \yii\base\Module
{
    ...
    public static function t($category, $message, $params = [], $language = null)
    {
        return Yii::t('modules/pm/' . $category, $message, $params, $language);
    }
}

и вызывать уже его как Module::t('module', 'created_at').

Ответить

 

Akulenok

так

'created_at' => Yii::t('modules/pm/module', 'created_at'),

работает, но если

Module::t('module', 'created_at')

то

Invalid Configuration – yii\base\InvalidConfigException
Unable to locate message source for category 'module'.

уж извините что докапался, но реально не работает с приведенным выше кодом

namespace app\modules\pm;

use Yii;

class Module extends \yii\base\Module
{
    public $controllerNamespace = 'app\modules\pm\controllers';

    public function init()
    {
        parent::init();

        if (Yii::$app instanceof ConsoleApplication) 
        {
            $this->controllerNamespace = 'app\modules\pm\commands';
        }

        Yii::$app->i18n->translations['modules/pm/*'] = 
        [
            'class' => 'yii\i18n\PhpMessageSource',
            'sourceLanguage' => 'en-US',
            'forceTranslation' => true,
            'basePath' => '@app/modules/pm/messages',
            'fileMap' => [
                'modules/pm/module' => 'module.php',
            ],
        ];
    }

    public static function t($category, $message, $params = [], $language = null)
    {
        return Yii::t('/modules/pm/' . $category, $message, $params, $language);
    }
}
Ответить

 

Дмитрий Елисеев
return Yii::t('/modules/pm/' . $category, ...)

Здесь первый слеш лишний.

Ответить

 

Akulenok

Все равно у меня ничего не пашет, может вы найдете ошибку.
решил сделать третий вариант с Bootstrap.
есть 2 перевода modules\pm\messages\ru\module.php и modules\pm\messages\en\module.php
содержание такое:

return [
	'CREATED_AT' => 'Created',
];

в function attributeLabels

'created_at' => Yii::t('module', 'CREATED_AT'),

в main конфиге

'bootstrap' => [
    'log',
    'app\modules\pm\Bootstrap',
],

файл app\modules\pm\Bootstrap.php

class Bootstrap implements BootstrapInterface
{
    public function bootstrap($app)
    {
        $app->i18n->translations['modules/pm/*'] = [
            'class' => 'yii\i18n\PhpMessageSource',
            'forceTranslation' => true,
            'basePath' => '@app/modules/pm/messages',
            'fileMap' => [
                'modules/pm/module' => 'module.php',
            ],
        ];
    }
}

и файл modules\pm\Module.php

class Module extends \yii\base\Module
{
    ...

    public static function t($category, $message, $params = [], $language = null)
    {
        return Yii::t('modules/pm/' . $category, $message, $params, $language);
    }
}

Что здесь не правильно или что я забыл?

ибо выдает ошибку

Unable to locate message source for category 'module'.
Ответить

 

Дмитрий Елисеев

Ну раз добавили метод в модуль, то и используйте Module::t('module', 'CREATED_AT').

P.S. Обрамляйте код в своих комментариях в тег pre.

Ответить

 

Akulenok

Вот, теперь заработало

Ответить

 

Akulenok

По консольным командам, у меня если явно не указать в консольном конфиге

 'modules' => [
        'user' => [
            'class' => 'app\modules\user\Module',
            'controllerNamespace' => 'app\modules\user\commands',
        ],
    ],

то в списке команд php yii они не появляются,
а вы утверждаете что достаточно указать в app\modules\user\Module в init

if (Yii::$app instanceof ConsoleApplication) 
        {
            $this->controllerNamespace = 'app\modules\user\commands';
        }
Ответить

 

Дмитрий Елисеев

Да, у меня только в init(). И всё работает.

Ответить

 

slo_nik

В файле

app\modules\user\Module

присутствует строка

use yii\console\Application as ConsoleApplication;

или нет?
Возможно Вы забыли её вписать в файл, поэтому может не работать без явного указания в файле конфигурации.

Ответить

 

Akulenok

Еще один вопрос созрел.
Вот подключил я переводы через Bootstrap, а как теперь из модуля обратится к переводам которые в папке /app/messages или это не реально?
Просто глупо в каждом модуле делать переводы кнопок submit, достаточно сделать в одном месте и вызывать, так можно?

Ответить

 

Дмитрий Елисеев

К глобальным - через Yii::t. К переводам модуля - через Module::t.

Ответить

 

Akulenok

Где-то у меня косяк, через Yii::t перестал работать

error
yii\i18n\PhpMessageSource::loadMessages
The message file for category 'yii' does not exist:
D:\server\domains\site\vendor\yiisoft\yii2/messages/en-EN/yii.php
Fallback file does not exist as well:
D:\server\domains\site\vendor\yiisoft\yii2/messages/en/yii.php
Ответить

 

Олег

Здравствуйте,

Подскажите, пожалуйста, как правильно пройти авторизацию без внесения каких либо данных в базу. Использую дефолтный каркас yii2-basic.

public function onAuthSuccess($client) {
    $attributes = $client->getUserAttributes();
}

В моём случае $attributes возвращает массив:

Array ( [id] => ссылка на профиль [namePerson/friendly] => никнейм )
Ответить

 

Олег

Как вариант, сделал так.

Контроллер:

public function onAuthSuccess($client) {
	$attributes = $client->getUserAttributes();
	$identity = User::findByOpenId($attributes);
	Yii::$app->user->login($identity);
}

Модель:

/**
 * @inheritdoc
 */
public static function findIdentity($id) {
	if (Yii::$app->getSession()->has($id)) {
		return new self(Yii::$app->getSession()->get($id));
	} else {
		return isset(self::$users[$id]) ? new self(self::$users[$id]) : null;
	}
}
	
/**
 * @inheritdoc
 */
public static function findByOpenId($user) {
	$attributes = [
		'id' => $user['id'],
		'username' => $user['namePerson/friendly'],
	];
	Yii::$app->getSession()->set($user['id'], $attributes);
	return new self($attributes);
}
Ответить

 

Дмитрий Елисеев

Добавил пункт про перенесение параметров из Yii::$app->params в сам модуль.

Ответить

 

Гераклит Вяткин

Вопрос по переносу параметров, которые используются в моделях, в модуль.
Например, у модуля есть параметр, которые указывает на путь где хранятся изображения для конкретной модели - public $imagesUrl = '@web/images'. При этом в модели удобно иметь геттер, который возвращал бы url для картинки. И тут вопрос: либо при вызове этого метода передавать параметр каждый раз извне, что неудобно. Либо вызывать внутри этого метода \app\modules\mymodule\Module::getInstance()->imagesUrl чтобы получить путь к файлу, что делает модель не изолированной. Есть ли более правильный способ?

Ответить

 

Иван Зайцев

Здравствуйте!
Как правильно организовать хранение загружаемых файлов в модуле? (например аватарок пользователя).
Правильно ли будет создать в корне модуля папку, в которую загружать файлы? и как в этом случае задать путь до папки в фреймворке?

Ответить

 

Дмитрий Елисеев

Правильнее, например, в web/uploads/avatars.

Ответить

 

slo_nik

Дмитрий, в статье по поводу консольных команд Вы написали, цитирую:

public function init()
    {
        parent::init();
        if (Yii::$app instanceof ConsoleApplication) {
            $this->controllerNamespace = 'app\modules\user\commands';
        }
    }

Этот код теперь помещаем в init() каждого модуля.

Теперь если запустим:

php yii

то в списке помимо стандартных команд увидим наши, расположенные уже в модуле...

Как я понял, Вы предлагаете вписать этот код в модуль "админ", но модуль Вы закрыли при помощи AccessControl. Получается, что если модуль закрыт, то при выполнении консольной команды php yii users/user/***** вывалится ошибка Exception 'ReflectionException' with message 'Class user does not exist'
Можно ли это как-то обойти, если модуль закрыт?

Ответить

 

Дмитрий Елисеев

Можно не закрывать для консоли:

class Module extends \yii\base\Module
{
    public function behaviors()
    {
        return array_filter([
            'access' => !Yii::$app instanceof \yii\console\Application ? [
                'class' => AccessControl::className(),
                'rules' => [
                    [
                        'allow' => true,
                        'roles' => ['@'],
                    ],
                ],
            ] : false,
        ]);
    }
}
Ответить

 

slo_nik

Но зачем в каждый модуль, может достаточно в модуль "user" ?
В чём смысл?

Ответить

 

Дмитрий Елисеев

Можно не помещать, если там команд нет и не будет.

Ответить

 

Роман

Добрый вечер.
Прошу подсказать.
После переноса большинства моделей в папку /forms PHPStorm стал постоянно ругаться при изменении нэймспейсов в этих моделях.

Выдаёт:

Undefined constant forms.
Undefined class: Declaration of referenced class is not found in built-in library and project files.

Так понимаю, где-то какие-то пути в IDE нужно настраивать нужно настраивать?

Ответить

 

Дмитрий Елисеев

А в этих моделях теперь новые namespace стоят?

Ответить

 

Роман

Да всё прописано.
Как будто найти не может.
Причём перестаёт выдавать ошибку после следующих действий:

Поменял обратный слэш \forms на прямой /forms - ошибка не ушла. Закономерно.
Вернул обратный слэш - ошибка ушла.

???

Ответить

 

Дмитрий Елисеев

Да, иногда подтормаживает. Я обычно фрагмент вырезаю и заново вставляю. А если ничего не поможет, то делайте File -> Invalidate Cache / Restart.

Ответить

 

Роман

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

Ответить

Оставить комментарий

Войти | Завести аккаунт


(никто не увидит)



Можно использовать теги <p> <ul> <li> <b> <i> <a> <pre>