Сервис на Yii2: Публикация расширений на GitHub и Packagist

В прошлой части мы написали удобный гибридный AuthManager, который можем захотеть использовать в других проектах. Да и в обратную связь поступало много вопросов, как подготовить какой-нибудь компонент для совместного использования в разных приложениях. Так что попробуем сделать из своего менеджера авторизации полноценное переносимое расширение, которое выгрузим на GitHub и опубликуем для подключения через Composer.

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

До изобретения пакетных менеджеров вроде Composer и публичных хранилищ вроде GitHub мало кто задумывался о переносимости своих наработок. Обычно всё ограничивалось копированием кода с переписыванием его строк под свой новый проект.

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

Это ограничение стимулировало разработчиков использовать наследование, делегирование, события и прочие приёмы для изменения сторонних компонентов без изменения исходного кода оригиналов. Рассмотрим в своём экспериментальном проекте некоторые из приёиов, применяемых при подготовке компонентов перед выкладыванием их в публичный доступ.

Пока у нас имеется некий класс AuthManager, который мы использовали вместо стандатного PhpManager для хранения роли в таблице пользователя в базе. Окинем его взглядом:

namespace app\components;
 
use app\modules\user\models\User;
use yii\rbac\Assignment;
use yii\rbac\PhpManager;
use Yii;
 
class AuthManager extends PhpManager
{
    public function getAssignments($userId)
    {
        if ($userId && $user = $this->getUser($userId)) {
            $assignment = new Assignment();
            $assignment->userId = $userId;
            $assignment->roleName = $user->role;
            return [$assignment->roleName => $assignment];
        }
        return [];
    }
 
    public function getAssignment($roleName, $userId)
    {
        if ($userId && $user = $this->getUser($userId)) {
            if ($user->role == $roleName) {
                $assignment = new Assignment();
                $assignment->userId = $userId;
                $assignment->roleName = $user->role;
                return $assignment;
            }
        }
        return null;
    }
 
    public function getUserIdsByRole($roleName)
    {
        return User::find()->where(['role' => $roleName])->select('id')->column();
    }
 
    public function assign($role, $userId)
    {
        if ($userId && $user = $this->getUser($userId)) {
            $assignment = new Assignment([
                'userId' => $userId,
                'roleName' => $role->name,
                'createdAt' => time(),
            ]);
            $this->setRole($user, $role->name);
            return $assignment;
        }
        return null;
    }
 
    public function revoke($role, $userId)
    {
        if ($userId && $user = $this->getUser($userId)) {
            if ($user->role == $role->name) {
                $this->setRole($user, null);
                return true;
            }
        }
        return false;
    }
 
    public function revokeAll($userId)
    {
        if ($userId && $user = $this->getUser($userId)) {
            $this->setRole($user, null);
            return true;
        }
        return false;
    }
 
    /**
     * @param integer $userId
     * @return null|\yii\web\IdentityInterface|User
     */
    private function getUser($userId)
    {
        $webUser = Yii::$app->get('user', false);
        if ($webUser && !$webUser->getIsGuest() && $webUser->getId() == $userId) {
            return $webUser->getIdentity();
        } else {
            return User::findOne($userId);
        }
    }
 
    /**
     * @param User $user
     * @param string $roleName
     */
    private function setRole(User $user, $roleName)
    {
        $user->role = $roleName;
        $user->updateAttributes(['role' => $roleName]);
    }
}

Сейчас он сильно связан с нашим сайтом и не совсем повторяет оригинальный PhpManager, так как наш класс:

  • Напрямую использует модель User и её поле role.
  • Сам занимается записью роли в модель User её методом updateAttributes.
  • Не поддерживает гипотетическую возможность привязки нескольких ролей к пользователю.

Первое и второе делает класс зависимым и негибким. Он делает лишнюю работу, влезая во внутренности User. О третьем поговорим ниже.

От публикации такого расширения будет не очень много толка, так как...

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

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

Итак, приступим...

Отвязка от проекта

Сначала попробуем избавиться от привязки к нашей модели. Дадим возможность разработчику изменять имя класса User, добавив параметр $modelClass и используя $this->modelClass вместо оригинального класса User в нашем коде:

namespace app\components;
 
use yii\rbac\Assignment;
use yii\rbac\PhpManager;
use yii\db\ActiveRecord;
use Yii;
 
class AuthManager extends PhpManager
{
    public $modelClass = 'app\models\User';
 
    ...
 
    private function getUser($userId)
    {
        $webUser = Yii::$app->get('user', false);
        if ($webUser && !$webUser->getIsGuest() && $webUser->getId() == $userId) {
            return $webUser->getIdentity();
        } else {
            /** @var \yii\db\ActiveRecord $class */
            $class = $this->modelClass;
            return $class::findOne($userId);
        }
    }
 
    ...
 
    /**
     * @param ActiveRecord $user
     * @param string $roleName
     */
    private function setRole(ActiveRecord $user, $roleName)
    {
        $user->role = $roleName;
        $user->updateAttributes(['role' => $roleName]);
    }
}

Теперь воспользуемся этим параметром в своём конфигурационном файле:

'authManager' => [
    'class' => 'app\components\AuthManager',
    'modelClass' => 'app\modules\user\models\User',
],

Так уже лучше, но осталась привязка к ActiveRecord и полю role модели:

class AuthManager extends PhpManager
{
    public function getAssignments($userId)
    {
        if ($userId && $user = $this->getUser($userId)) {
            ...
            $assignment->roleName = $user->role;
            ...
        }
        return [];
    }
 
    ...
 
    /**
     * @param integer $userId
     * @return null|\yii\db\ActiveRecord
     */
    private function getUser($userId)
    {
        ...
        } else {
            $class = $this->modelClass;
            return $class::findOne($userId);
        }
    }
 
    ...
 
    private function setRole(ActiveRecord $user, $roleName)
    {
        $user->role = $roleName;
        $user->updateAttributes(['role' => $roleName]);
    }
}

Поле в разных проектах может называться по-разному. Мы можем добавить второй параметр $roleField и заменить везде:

$user->role

на код:

$user->{$this->roleField}

Так мы избавимся от жёстко вписанного поля role. Но есть нюанс...

Всё хорошо, но так мы сможем работать только с ActiveRecord, вызывая её метод updateAttributes. В других местах может понадобиться вызывать $model->save() для срабатывания в модели валидации и обработчиков вроде beforeSave. А для сайтов, работающих порой без ActiveRecord, наш класс будет абсолютно бесполезен.

Унылая тоска приходит в наш код. Как же использовать методы, если не во всех классах они присутствуют?

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

Так что давайте вместо прямой работы с низкоуровневым updateAttributes придумаем более понятные методы, которые нам нужно потребовать от модели. Например, мы бы хотели работать с ней так:

class AuthManager extends PhpManager
{
    public function getAssignments($userId)
    {
        if ($userId && $user = $this->getUser($userId)) {
            ...
            $assignment->roleName = $user->getAuthRoleName();
            ...
        }
        return [];
    }
 
    ...
 
    public function getUserIdsByRole($roleName)
    {
        $class = $this->modelClass;
        return $class::findAuthIdsByRoleName($roleName);
    }
 
    public function assign($role, $userId)
    {
        if ($userId && $user = $this->getUser($userId)) {
            $user->setAuthRoleName($role->name);
            return true;
        }
        retrn false;
    }
 
    public function revoke($role, $userId)
    {
        if ($userId && $user = $this->getUser($userId)) {
            if ($role->name == $user->getAuthRoleName()) {
                $user->setAuthRoleName(null);
                retrn true;
            }
        }
        retrn false;
    }
 
    public function revokeAll($userId)
    {
        if ($userId && $user = $this->getUser($userId)) {
            $user->setAuthRoleName(null);
            return true;
        }
        retrn false;
    }
 
    private function getUser($userId)
    {
        ...
        } else {
            $class = $this->modelClass;
            return $class::findAuthRoleIdentity($userId);
        }
    }
}

Здесь нам уже не пригодится метод setRole. Мы напрямую ищем модель методом findAuthRoleIdentity, получаем роль через getAuthRoleName и устанавливаем вызовом setAuthRoleName.

Теперь достаточно попросить разработчиков добавлять эти методы в свой класс модели User. Но разработчики ленивы и README не читают, поэтому могут что-нибудь забыть. Да и сама идея непонятна без документации. Лучше всегда указывать всё явно, чтобы нельзя было ошибиться.

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

namespace app\components;
 
interface AuthRoleModelInterface
{
    public static function findAuthRoleIdentity($id);
 
    public static function findAuthIdsByRoleName($roleName);
 
    public function getAuthRoleName();
 
    public function setAuthRoleName($roleName);
}

И у себя в менеджере будем использовать эти методы. Или начинаем ругаться, если нам передали объект не этого типа (или забыли добавить в модель интерфейс):

private function getUser($userId)
{
    $webUser = Yii::$app->get('user', false);
    if ($webUser && !$webUser->getIsGuest() && $webUser->getId() == $userId && $webUser->getIdentity() instanceof AuthRoleModelInterface) {
        return $webUser->getIdentity();
    } else {
        /** @var AuthRoleModelInterface $class */
        $class = $this->modelClass;
        $identity = $class::findAuthRoleIdentity($userId);
        if ($identity && !$identity instanceof AuthRoleModelInterface) {
            throw new InvalidValueException('The identity object must implement AuthRoleInterface.');
        }
        return $identity;
    }
}

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

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

class User extends ActiveRecord implements IdentityInterface, AuthRoleModelInterface
{
    ...
 
    public static function findAuthRoleIdentity($id)
    {
        return static::findOne($id);
    }
 
    public static function findAuthIdsByRoleName($roleName)
    {
        return static::find()->where(['role' => $roleName])->select('id')->column();
    }
 
    public function getAuthRoleName()
    {
        return $this->role;
    }
 
    public function setAuthRoleName($roleName)
    {
        $this->updateAttributes(['role' => $this->role = $roleName]);
    }
}

Для нас сойдёт и так. Но, как мы уже сказали, нужно продумать и предусмотреть различные варианты использования.

Доработка функциональности

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

Хоть привязка нескольких ролей к одному пользователю не очень логична (противоречива с точки зрения оригинальной концепции RBAC) и редко применима, но мы всё-таки своей «однорольностью» урезаем возможности оригинального PhpManager.

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

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

  • Переименуем в интерфейсе метод getAuthRoleName на getAuthRoleNames.
  • Вместо простого setAuthRoleName сделаем методы для добавления, удаления и очистки.
  • Добавим методы для обработки глобального удаления или переименования ролей.

Итак, приступим. Пока не будем думать об оптимизации. Просто добавим все нужные методы в наш интерфейс:

namespace app\components;
 
interface AuthRoleModelInterface
{
    /**
     * @param mixed $id
     * @return AuthRoleModelInterface
     */
    public static function findAuthRoleIdentity($id);
 
    /**
     * @param string $roleName
     * @return array
     */
    public static function findAuthIdsByRoleName($roleName);
 
    /**
     * @param string $oldRoleName
     * @param string $newRoleName
     */
    public static function updateAuthGlobalRoleName($oldRoleName, $newRoleName);
 
    /**
     * @param string $roleName
     */
    public static function removeAuthGlobalRoleName($roleName);
 
    /**
     * On all roles removing
     */
    public static function removeAuthGlobalRoleNames();
 
    /**
     * On all assignments removing
     */
    public static function removeAuthGlobalAssignments();
 
    /**
     * @return array
     */
    public function getAuthRoleNames();
 
    /**
     * @param string $roleName
     */
    public function addAuthRoleName($roleName);
 
    /**
     * @param string $roleName
     */
    public function removeAuthRoleName($roleName);
 
    /**
     * Removes all roles
     */
    public function clearAuthRoleNames();
}

И наш менеджер перестроим на работу с массивами имён ролей, вводя для них in_array и foreach и переопределяя заодно методы вроде updateItem:

class AuthManager extends PhpManager
{
    public $modelClass = 'app\models\User';
 
    public function getAssignments($userId)
    {
        $assignments = [];
        if ($userId && $user = $this->getUser($userId)) {
            foreach ($user->getAuthRoleNames() as $roleName) {
                if ($role = $this->getRole($roleName)) {
                    $assignment = new Assignment();
                    $assignment->userId = $userId;
                    $assignment->roleName = $role->name;
                    $assignments[$assignment->roleName] = $assignment;
                }
            }
        }
        return $assignments;
    }
 
    public function getAssignment($roleName, $userId)
    {
        if ($userId && $user = $this->getUser($userId)) {
            if (in_array($roleName, $user->getAuthRoleNames())) {
                $assignment = new Assignment();
                $assignment->userId = $userId;
                $assignment->roleName = $roleName;
                return $assignment;
            }
        }
        return null;
    }
 
    public function getUserIdsByRole($roleName)
    {
        /** @var AuthRoleModelInterface $class */
        $class = $this->modelClass;
        return $class::findAuthIdsByRoleName($roleName);
    }
 
    protected function updateItem($name, $item)
    {
        if (parent::updateItem($name, $item)) {
            if ($item->name !== $name) {
                /** @var AuthRoleModelInterface $class */
                $class = $this->modelClass;
                $class::updateAuthGlobalRoleName($name, $item->name);
            }
            return true;
        }
        return false;
    }
 
    public function removeItem($item)
    {
        if (parent::removeItem($item)) {
            /** @var AuthRoleModelInterface $class */
            $class = $this->modelClass;
            $class::removeAuthGlobalRoleName($item->name);
            return true;
        }
        return false;
    }
 
    public function removeAll()
    {
        parent::removeAll();
        /** @var AuthRoleModelInterface $class */
        $class = $this->modelClass;
        $class::removeAuthGlobalRoleNames();
    }
 
    public function removeAllAssignments()
    {
        parent::removeAllAssignments();
        /** @var AuthRoleModelInterface $class */
        $class = $this->modelClass;
        $class::removeAuthGlobalAssignments();
    }
 
    public function assign($role, $userId)
    {
        if ($userId && $user = $this->getUser($userId)) {
            if (in_array($role->name, $user->getAuthRoleNames())) {
                throw new InvalidParamException("Authorization item '{$role->name}' has already been assigned to user '$userId'.");
            } else {
                $assignment = new Assignment([
                    'userId' => $userId,
                    'roleName' => $role->name,
                    'createdAt' => time(),
                ]);
                $user->addAuthRoleName($role->name);
                return $assignment;
            }
        }
        return false;
    }
 
    public function revoke($role, $userId)
    {
        if ($userId && $user = $this->getUser($userId)) {
            if (in_array($role->name, $user->getAuthRoleNames())) {
                $user->removeAuthRoleName($role->name);
                return true;
            }
        }
        return false;
    }
 
    public function revokeAll($userId)
    {
        if ($userId && $user = $this->getUser($userId)) {
            $user->clearAuthRoleNames();
            return true;
        }
        return false;
    }
 
    ...
}

И добавим примитивную реализацию этих методов интерфейса в нашу модель:

class User extends ActiveRecord implements IdentityInterface, AuthRoleModelInterface
{
    ...
 
    public static function findAuthRoleIdentity($id)
    {
        return static::findOne($id);
    }
 
    public static function findAuthIdsByRoleName($roleName)
    {
        return static::find()->where(['role' => $roleName])->select(['id'])->column();
    }
 
    public static function updateAuthGlobalRoleName($oldRoleName, $newRoleName)
    {
        self::updateAll(['role' => $newRoleName], ['role' => $oldRoleName]);
    }
 
    public static function removeAuthGlobalRoleName($roleName)
    {
        self::updateAll(['role' => null], ['role' => $roleName]);
    }
 
    public static function removeAuthGlobalRoleNames()
    {
        self::updateAll(['role' => null]);
    }
 
    public static function removeAuthGlobalAssignments()
    {
        self::updateAll(['role' => null]);
    }
 
    public function getAuthRoleNames()
    {
        return (array)$this->role;
    }
 
    public function addAuthRoleName($roleName)
    {
        $this->updateAttributes(['role' => $this->role = $roleName]);
    }
 
    public function removeAuthRoleName($roleName)
    {
        $this->updateAttributes(['role' => $this->role = null]);
    }
 
    public function clearAuthRoleNames()
    {
        $this->updateAttributes(['role' => $this->role = null]);
    }
 
    ...
}

Модель у нас ещё та же самая «одноролевая». Но если ваша база данных на вашем хостинге вдруг научится поддерживать массивы в полях (или если Вы освоите сериализацию или Json::encode и Json::decode), то Вы уже догадываетесь, что можно с этим делать:

class User extends ActiveRecord implements IdentityInterface, AuthRoleModelInterface
{
    ...
 
    public function getAuthRoleNames()
    {
        return $this->roles;
    }
 
    public function addAuthRoleName($roleName)
    {
        $this->roles[$roleName] = $roleName;
        $this->updateAttributes(['roles' => $this->roles]);
    }
 
    public function removeAuthRoleName($roleName)
    {
        unset($this->roles[$roleName]);
        $this->updateAttributes(['roles' => $this->roles]);
    }
 
    public function clearAuthRoleNames()
    {
        $this->roles = [];
        $this->updateAttributes(['roles' => $this->roles]);
    }
 
    ...
}

Здесь роли мы спокойно добавляем в массив.

А может зря мы отвязывались от ActiveRecord, ведь наша модель всё равно ей является? Но если подумать более глобально, то...

В ходе избавления от лишней связанности с проектом мы вынесли ответственность по записи роли из менеджера в модель. Мы сделали класс полностью совместимым с оригинальным yii\rbac\ManagerInterface и для организации стыковки любой модели к нашему классу ввели промежуточный интерфейс. Теперь код менеджера практически никогда не придётся менять для смены способы хранения ролей. Интерфейс можно подключить к любому классу, а не только к ActiveRecord-модели, что даёт нам полную совместимость с любыми проектами и хранилищами данных.

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

Оптимизация

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

Разделим методы на обязательные и не очень. В интерфейсе мы используем и конкретно относящиеся к модели методы:

findAuthRoleIdentity
findAuthIdsByRoleName
getAuthRoleNames
addAuthRoleName
removeAuthRoleName
clearAuthRoleNames

и глобальные:

updateAuthGlobalRoleName
removeAuthGlobalRoleName
removeAuthGlobalRoleNames
removeAuthGlobalAssignments

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

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

Поэтому глобальные методы можно безболезненно удалить из интерфейса, оставив только самые нужные:

interface AuthRoleModelInterface
{
    /**
     * @param mixed $id
     * @return AuthRoleModelInterface
     */
    public static function findAuthRoleIdentity($id);
 
    /**
     * @param string $roleName
     * @return array
     */
    public static function findAuthIdsByRoleName($roleName);
 
    /**
     * @return array
     */
    public function getAuthRoleNames();
 
    /**
     * @param string $roleName
     */
    public function addAuthRoleName($roleName);
 
    /**
     * @param string $roleName
     */
    public function removeAuthRoleName($roleName);
 
    /**
     * Removes all roles
     */
    public function clearAuthRoleNames();
}

Для остальных мы просто обозначаем события:

class AuthManager extends PhpManager
{
    const EVENT_RENAME_ROLE = 'renameRole';
    const EVENT_REMOVE_ROLE = 'removeRole';
    const EVENT_REMOVE_ALL = 'removeAll';
    const EVENT_REMOVE_ALL_ASSIGNMENTS = 'removeAllAssignments';
 
    ...
}

Для оповещения об удалении и переименовании роли нужно передавать имена ролей в сам объект события. У обычного класса yii\base\Event есть поле data, куда можно помещать свои данные:

$this->trigger(self::EVENT_RENAME_ROLE, new Event(['data' => [
    'oldRoleName' => $oldName;
    'newRoleName' => $newName;
])

и извлекать их оттуда же в методе-обработчике:

function onRenameRole(Event $event) {
    echo $event->data['newRoleName']);
}

Но это неудобно тем, что нужно постоянно смотреть исходный код, чтобы понять, какие там есть поля.

Для удобства можно создать два отдельных класса для событий удаления:

namespace app\components\events;
 
use yii\base\Event;
 
class RemoveRoleEvent extends Event
{
    public $roleName;
}

и переименования:

use yii\base\Event;
 
class RenameRoleEvent extends Event
{
    public $oldRoleName;
    public $newRoleName;
}

чтобы в обработчиках использовать нужное поле из самого объекта:

public static function onRenameRole(RemoveRoleEvent $event)
{
    echo $event->newRoleName;
}

Также добавим пустые классы событий для методов removeAll и removeAllAssignments:

class RemoveAllEvent extends Event
{
 
}
 
class RemoveAllAssignmentsEvent extends Event
{
 
}

Можно обойтись и без этих двух, оставив для этих ситуаций стандартный класс yii\base\Event. Но наличие этих классов в папке компонента сразу избавит разработчика от путаницы, какие классы компонент использует для своих событий.

Теперь перепишем менеджер на вызов $this->trigger() вместо вызова методов модели:

class AuthManager extends PhpManager
{
    const EVENT_RENAME_ROLE = 'renameRole';
    const EVENT_REMOVE_ROLE = 'removeRole';
    const EVENT_REMOVE_ALL = 'removeAll';
    const EVENT_REMOVE_ALL_ASSIGNMENTS = 'removeAllAssignments';
 
    protected function updateItem($name, $item)
    {
        if (parent::updateItem($name, $item)) {
            if ($item->name !== $name) {
                $this->trigger(self::EVENT_RENAME_ROLE, new RenameRoleEvent([
                    'oldRoleName' => $name,
                    'newRoleName' => $item->name,
                ]));
            }
            return true;
        }
        return false;
    }
 
    public function removeItem($item)
    {
        if (parent::removeItem($item)) {
            $this->trigger(self::EVENT_REMOVE_ROLE, new RemoveRoleEvent([
                'roleName' => $item->name,
            ]));
            return true;
        }
        return false;
    }
 
    public function removeAll()
    {
        parent::removeAll();
        $this->trigger(self::EVENT_REMOVE_ALL, new RemoveAllEvent());
    }
 
    public function removeAllAssignments()
    {
        parent::removeAllAssignments();
        $this->trigger(self::EVENT_REMOVE_ALL_ASSIGNMENTS, new RemoveAllAssignmentsEvent());
    }
 
    ...
}

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

class User extends ActiveRecord implements IdentityInterface, AuthRoleModelInterface
{
    ...
 
    public static function findAuthRoleIdentity($id)
    {
        return static::findOne($id);
    }
 
    public static function findAuthIdsByRoleName($roleName)
    {
        return static::find()->where(['role' => $roleName])->select('id')->column();
    }
 
    public function getAuthRoleNames()
    {
        return (array)$this->role;
    }
 
    public function addAuthRoleName($roleName)
    {
        $this->updateAttributes(['role' => $this->role = $roleName]);
    }
 
    public function removeAuthRoleName($roleName)
    {
        $this->updateAttributes(['role' => $this->role = null]);
    }
 
    public function clearAuthRoleNames()
    {
        $this->updateAttributes(['role' => $this->role = null]);
    }
}

А если вдруг нужно будет обрабатывать любое глобальное переименование или удаление, то просто создаём методы-обработчики в любом классе:

class User extends ActiveRecord implements IdentityInterface, AuthRoleModelInterface
{
    ...
 
    public static function onRenameRole(RenameRoleEvent $event)
    {
        self::updateAll(['role' => $event->newRoleName], ['role' => $event->oldRoleName]);
    }
 
    public static function onRemoveRole(RemoveRoleEvent $event)
    {
        self::updateAll(['role' => null], ['role' => $event->roleName]);
    }
 
    public static function onRemoveAll(RemoveAllEvent $event)
    {
        self::updateAll(['role' => null]);
    }
 
    public static function onRemoveAllAssignments(RemoveAllAssignmentsEvent $event)
    {
        self::updateAll(['role' => null]);
    }
}

и навешиваем их на компонент в конфигурационном файле:

'authManager' => [
    'class' => 'app\components\AuthManager',
    'modelClass' => 'app\models\User',
    'on renameRole' => ['app\models\User', 'onRenameRole'],
    'on removeRole' => ['app\models\User', 'onRemoveRole'],
    'on removeAll' => ['app\models\User', 'onRemoveAll'],
    'on removeAllAssignments' => ['app\models\User', 'onRemoveAllAssignments'],
],

Всё. Обязательных методов в интерфейсе стало меньше. На остальные необязательные действия при желании можно подписывать свои обработчики.

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

Публикация расширения

Вот мы и переделали наш гибридный AuthManager так, чтобы не зависеть от наших моделей.

У каждого компонента в папке vendor должно быть своё корневое пространство имён, чтобы Composer понимал, откуда подключать его классы. Так что стандартное пространство имён app\modules\... нам не подойдёт. Это должен быть отдельный пакет без лишних файлов. Для этого вынесем его из приложения в отдельный проект.

Создадим где-нибудь папку hybrid, и в её подпапку hybrid/src перенесём наш класс и интерфейс, заменив пространство имён на новое:

namespace elisdn\hybrid;

И туда же в подпапку hybrid/src/events перенесём классы событий.

В саму папку hybrid добавим файл .gitignore с игнорированием неинтересных Git-у файлов:

/vendor
/phpunit.xml
/composer.lock

и запускаем инициализацию нового репозитория и создание первого коммита с пустыми заготовками:

git init
touch LICENCE.md
touch README.md
git add LICENCE.md
git add README.md
git commit -m 'Initial commit'

Добавляем рядом пустой файл composer.json. В нём и будет содержаться полная информация для Composer о нашем расширении.

Назовём наш компонент, например, как elisdn/yii2-hybrid-authmanager и заполним остальные поля:

{
    "name": "elisdn/yii2-hybrid-authmanager",
    "description": "Hybrid RBAC AuthManager for Yii2 Framework.",
    "type": "yii2-extension",
    "keywords": ["yii2", "yii 2", "authmanager"],
    "license": "BSD-3-Clause",
    "authors": [
        {
            "name": "Dmitriy Yeliseyev",
            "email": "mail@elisdn.ru",
            "homepage": "http://www.elisdn.ru"
        }
    ],
    "support": {
        "issues": "https://github.com/ElisDN/yii2-hybrid-authmanager/issues?state=open",
        "source": "https://github.com/ElisDN/yii2-hybrid-authmanager"
    },
    "require": {
        "yiisoft/yii2": "~2.0"
    },
    "require-dev": {
        "phpunit/phpunit": "4.*"
    },
    "autoload": {
        "psr-4": {
            "elisdn\\hybrid\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "elisdn\\hybrid\\tests\\": "tests/"
        }
    },
    "extra": {
        "asset-installer-paths": {
            "npm-asset-library": "vendor/npm",
            "bower-asset-library": "vendor/bower"
        }
    }
}

Мы указали имя, описание и метки, по которым наше расширение будет отображаться и искаться на сайте packagist.org.

Желательно указать тип yii2-extension, чтобы наше расширение после установки спарсилось фреймворком и было видно в списке Yii::$app->extensions.

В секции require мы прописали зависимость от фреймворка. В секции autoload сделали подсказку для Composer, в какой папке нужно искать наши классы при автозагрузке. Аналогично мы добавили *-dev секции для зависимостей и путей, которые нужно устанавливать только в режиме разработки. При подключении нашего компонента к проектам эти секции подключаться не будут. В *-dev у нас прописано подключение PHPUnit и указана отдельная папка tests для хранения тестовых классов в отдельном пространстве имён.

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

Устанавливаем прямо в папке hybrid:

composer install

В hybrid создастся папка vendor и фреймворк со своими зависимости установится туда. Переживать об этом не стоит, так как при реальном использовании компонента в чужом проекте сюда ничего устанавливаться не будет (всё установится как обычно в vendor самого проекта).

Теперь напишем тесты для проверки нашего кода.

Сам сайт мы тестируем с Codeception, но такой большой пакет здесь нам не нужен, так что компоненты проще тестировать с PHPUnit. Создадим в корне файл phpunit.xml.dist со стандартным содержимым:

<?xml version="1.0" encoding="utf-8"?>
<phpunit bootstrap="./tests/bootstrap.php"
         colors="true"
         convertErrorsToExceptions="true"
         convertNoticesToExceptions="true"
         convertWarningsToExceptions="true"
         stopOnFailure="false">
    <testsuites>
        <testsuite name="Test Suite">
            <directory>./tests</directory>
        </testsuite>
    </testsuites>
    <filter>
        <whitelist>
            <directory suffix=".php">./src/</directory>
        </whitelist>
    </filter>
</phpunit>

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

PHPUnit считывает и файл phpunit.xml.dist, и phpunit.xml (если он есть). Так что можно обойтись только одним phpunit.xml.dist. Любой разработчик может создать рядом свой отдельный phpunit.xml, если захочет что-то перенастроить.

Создадим теперь эту папку tests и в неё поместим файл bootstrap.php. В нём разместим минимальный код подключения фреймворка:

<?php
 
defined('YII_DEBUG') or define('YII_DEBUG', true);
defined('YII_ENV') or define('YII_ENV', 'test');
 
require(__DIR__ . '/../vendor/autoload.php');
require(__DIR__ . '/../vendor/yiisoft/yii2/Yii.php');

и там же напишем первый тест:

namespace elisdn\hybrid\tests;
 
class DemoTest extends \PHPUnit_Framework_TestCase
{
    public function testTrueIsTrue()
    {
        $this->assertTrue(true);
    }
}

Здесь мы используем «голый» PHPUnit без использования каких либо дополнений вроде Codeception\Verify.

Проверяем, всё ли работает. Запускаем:

php vendor/bin/phpunit

И, если всё получилось, видим:

PHPUnit 4.8.23 by Sebastian Bergmann and contributors.
.

Time: 745 ms, Memory: 6.00Mb

OK (1 test, 1 assertion)

Точка – это наш успешно пройденный тест. Если поменять true на false и запустить снова, то увидим вместо точки букву F (что будет значить неудачу Fail) и простыню с описанием типа и места ошибки.

В итоге, в папке расширения получилась следующая структура:

hybrid
├── src
│   ├── events
│   │   ├── RemoveRoleEvent.php
│   │   ├── RemoveRoleEvent.php
│   │   ├── RemoveAllEvent.php
│   │   └── RenameAllAssignmentsEvent.php
│   ├── AuthManager.php
│   └── AuthRoleContainerInterface.php
├── tests
│   ├── DemoTest.php
│   └── bootstrap.php
├── .gitignore
├── composer.json
├── composer.lock
├── LICENCE.md
├── README.md
├── phpunit.xml.dist
└── vendor

Теперь нам нужно протестировать наш компонент. Для этого придётся поднимать всё приложение на фреймворке, так как наш класс зависит от Yii.

Чтобы не вписывать запуск приложения в методы setUp и tearDown каждого тестового класса добавим базовый класс TestCase, который будет пересоздавать наше приложение для каждого теста. А потом просто будем наследоваться от него:

namespace elisdn\hybrid\tests;
 
use yii\console\Application;
 
abstract class TestCase extends \PHPUnit_Framework_TestCase
{
    protected function setUp()
    {
        parent::setUp();
        $this->mockApplication();
    }
 
    protected function tearDown()
    {
        $this->destroyApplication();
        parent::tearDown();
    }
 
    protected function mockApplication()
    {
        new Application([
            'id' => 'testapp',
            'basePath' => __DIR__,
            'vendorPath' => dirname(__DIR__) . '/vendor',
            'runtimePath' => __DIR__ . '/runtime',
        ]);
    }
 
    protected function destroyApplication()
    {
        \Yii::$app = null;
    }
}

Здесь мы для нашего тестового приложения укажем свою папку tests/runtime. Мы настроим потом всё так, чтобы файлы items.php и rules.php наш менеджер генерировал именно в ней. Поэтому создадим эту папку и добавим в неё файл .gitignore с содержимым:

*
!.gitignore

Чтобы Git игнорировал в ней всё, кроме самого файла .gitignore.

Теперь нужно написать сами тесты. Но мы не пишем новый компонент, а дополняем уже имеющийся PhpManager. Поэтому можем спокойно использовать уже готовые.

И из папки тестов для RBAC фреймворка скопируем себе ManagerTestCase, PhpManagerTest, ExposedPhpManager и AuthorRule, меняя в каждом пространство имён на своё:

namespace elisdn\hybrid\tests;

И в классе ExposedAuthManager заменим базовый класс на свой:

namespace elisdn\hybrid\tests;
 
use elisdn\hybrid\AuthManager;
 
class ExposedAuthManager extends AuthManager
{
    ...
}

Для проверки не забудем создать там же в папке tests тестовую модель User, эмулирующую работу настоящей:

namespace elisdn\hybrid\tests;
 
use elisdn\hybrid\AuthRoleModelInterface;
use elisdn\hybrid\events\RemoveRoleEvent;
use elisdn\hybrid\events\RenameRoleEvent;
use Yii;
 
class User implements AuthRoleModelInterface
{
    /**
     * @var self[]
     */
    public static $users;
 
    public $id;
    public $roles = [];
 
    private function __construct($id)
    {
        $this->id = $id;
    }
 
    public static function reset()
    {
        return self::$users = [];
    }
 
    public static function findAuthRoleIdentity($id)
    {
        if (!isset(self::$users[$id])) {
            self::$users[$id] = new self($id);
        }
        return self::$users[$id];
    }
 
    public static function onRemoveAll()
    {
        self::$users = [];
    }
 
    public static function onRemoveAllAssignments()
    {
        self::$users = [];
    }
 
    public static function onRenameRole(RenameRoleEvent $event)
    {
        foreach (self::$users as $user) {
            if (isset($user->roles[$event->oldRoleName])) {
                unset($user->roles[$event->oldRoleName]);
                $user->roles[$event->newRoleName] = $event->newRoleName;
            }
        }
    }
 
    public static function onRemoveRole(RemoveRoleEvent $event)
    {
        foreach (self::$users as $user) {
            if (isset($user->roles[$event->roleName])) {
                unset($user->roles[$event->roleName]);
            }
        }
    }
 
    public static function findAuthIdsByRoleName($roleName)
    {
        $ids = [];
        foreach (self::$users as $user) {
            if (in_array($roleName, $user->roles)) {
                $ids[] = $user->id;
            }
        }
        return $ids;
    }
 
    public function getAuthRoleNames()
    {
        return array_values($this->roles);
    }
 
    public function addAuthRoleName($roleName)
    {
        $this->roles[$roleName] = $roleName;
    }
 
    public function removeAuthRoleName($roleName)
    {
        unset($this->roles[$roleName]);
    }
 
    public function clearAuthRoleNames()
    {
        $this->roles = [];
    }
}

Она даже не наследует ActiveRecord. Это не проблема, так как наш менеджер совместим теперь со всеми классами и хранилищами. Мы будем хранить список пользователей с их ролями в статическом поле User::$users. Нужно будет очищать этот список перед каждым тестом, но об этом поговорим ниже.

Этот класс мы как раз и будем использовать для проверки менеджера. Перепишем в классе AuthManagerTest метод-фабрику createManager() на использование нашей модели и на подключение наших обработчиков:

class AuthManagerTest extends ManagerTestCase
{
    ...
 
    protected function createManager()
    {
        return new ExposedAuthManager([
            'modelClass' => 'elisdn\hybrid\tests\User',
            'on renameRole' => ['elisdn\hybrid\tests\User', 'onRenameRole'],
            'on removeRole' => ['elisdn\hybrid\tests\User', 'onRemoveRole'],
            'on removeAll' => ['elisdn\hybrid\tests\User', 'onRemoveAll'],
            'on removeAllAssignments' => ['elisdn\hybrid\tests\User', 'onRemoveAllAssignments'],
            'itemFile' => $this->getItemFile(),
            'assignmentFile' => $this->getAssignmentFile(),
            'ruleFile' => $this->getRuleFile(),
        ]);
    }
 
    ...
}

Во взятом из оригинальных тестов базовом классе ManagerTestCase присутствует метод prepareData(), который дёргается для переинициализации ролей перед каждым тестом. Добавим в него упомянутую выше очистку списка пользователей в тестовой модели с помощью вызова User::reset():

abstract class ManagerTestCase extends TestCase
{
    ...
 
    protected function prepareData()
    {
        User::reset();
        $rule = new AuthorRule;
        $this->auth->add($rule);
        ...
    }
}

И изменим тесты, которые напрямую обращаются к $this->auth->assignments, на обращение к нашей модели:

class AuthManagerTest extends ManagerTestCase
{
    ...
 
    public function testSaveAssignments()
    {
        $this->auth->removeAll();
        $role = $this->auth->createRole('Admin');
        $this->auth->add($role);
        $this->auth->assign($role, 13);
        $this->assertContains('Admin', User::findAuthRoleIdentity(13)->getAuthRoleNames());
        $role->name = 'NewAdmin';
        $this->auth->update('Admin', $role);
        $this->assertContains('NewAdmin', User::findAuthRoleIdentity(13)->getAuthRoleNames());
        $this->auth->remove($role);
        $this->assertNotContains('NewAdmin', User::findAuthRoleIdentity(13)->getAuthRoleNames());
    }
}

Теперь пробуем всё запустить:

php vendor/bin/phpunit

И если всё получилось, то видим результат:

PHPUnit 4.8.23 by Sebastian Bergmann and contributors.

.....................

Time: 1.02 seconds, Memory: 18.00Mb

OK (21 tests, 67 assertions)

Всё работает, значит наш класс полностью совместим со стандартным PhpManager.

Если у нас установлен XDebug после главы о тестировании, то можно попробовать запустить анализ покрытия:

php vendor/bin/phpunit --coverage report

и после завершения посмотреть HTML-отчёт в папке report.

Публикуем на GitHub и Packagist

Composer ищет компоненты на своём сайте Packagist. Но компоненты там только регистрируются. Сами исходники хранятся в публичных репозиториях на сайтах вроде GitHub или BitBucket.

Соответственно, нам нужно создать репозиторий и зарегистрировать его на Packagist.

Создаём репозиторий на GitHub:

Подключаем его адрес в своей консоли как репозиторий origin и отправляем все наши коммиты туда:

git remote add origin git@github.com:ElisDN/yii2-hybrid-authmanager.git
git push -u origin master

Теперь этот пакет публикуем на Packagist. Там достаточно зарегистрироваться и нажать на видном месте ссылку Submit. Откроется форма, в которой нужно указать адрес нашего репозитория:

Готово. Всё остальное произойдёт автоматически. Сервис считает нашу информацию из файла composer.json и выведет на экране. Но пока информация не автообновляема:

При добавлении новых коммитов на GitHub сервис об этом не узнает и информацию не обновит. Нужно настроить автоматическую синхронизацию.

Заходим на GitHub в настройки репозитория и переходим в Webhooks & services:

Добавляем сервис Packagist:

Нас попросят ввести свой секретный ключ. Так что возвращаемся, берём свой API Token:

И вставляем в форму:

Если видим OK, то всё прошло успешно. Сразу пробуем протестировать:

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

Ну и теперь мы всё дописали, протестировали и опубликовали. Сейчас у нас имеется такой набор файлов:

hybrid
├── src
│   ├── events
│   │   ├── RemoveRoleEvent.php
│   │   └── RenameRoleEvent.php
│   │   └── RemoveAllEvent.php
│   │   └── RemoveAllAssignmentsEvent.php
│   ├── AuthManager.php
│   └── AuthRoleModelInterface.php
├── tests
│   ├── runtime
│   │   └──.gitignore
│   ├── bootstrap.php
│   ├── AuthorRule.php
│   ├── AuthManagerTest.php
│   ├── ExposedAuthManager.php
│   ├── ManagerTestCase.php
│   ├── TestCase.php
│   └── User.php
├── .gitignore
├── composer.json
├── composer.lock
├── LICENCE.md
├── README.md
├── phpunit.xml.dist
└── vendor

Можно публиковать релиз. Для этого пишем инструкцию README, коммитим всё недокоммиченное, ставим тег версии 1.0.0 и закидываем вместе с тегом на GitHub:

git add .
git commit -m 'Added project files'
git tag 1.0.0
git push --tag

Наш хук должен сработать, и на странице расширения должен отобразиться новый релиз 1.0.0.

Подключение к проекту

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

composer require elisdn/yii2-hybrid-authmanager

В конфигурационном файле переключаемся на наш новый класс:

'authManager' => [
    'class' => 'elisdn\hybrid\AuthManager',
    'modelClass' => 'app\modules\user\models\User',
],

Аналогично исправляем use в модели на новый:

namespace app\modules\user\models;
 
use elisdn\hybrid\AuthRoleModelInterface;
...
 
class User extends ActiveRecord implements IdentityInterface, AuthRoleModelInterface
{
    ...
}

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

Если расширение уже подключено к проекту через Composer, то для упрощения разработки без постоянного использования composer update можно добавить символическую ссылку с папки vendor/elisdn/yii2-hybrid-authmanager на оригинальную папку расширения или определить локильный репозиторий с типом path.

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

Лишние зависимости

Сейчас с компонентом всё хорошо, но появилась новая неучтённая зависимость в приложении. Наша модель User включает в себя реализацию AuthRoleModelInterface. Это автоматически делает невозможным распространение модуля user без этого компонента (а он не во всех проектах нужен).

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

Да и класс модели выглядит перегруженным кучей методов для двух интерфейсов.

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

namespace app\components;
 
use app\modules\user\models\User;
use elisdn\hybrid\AuthRoleModelInterface;
 
class UserIdentity extends User implements AuthRoleModelInterface
{
    public static function findAuthRoleIdentity($id)
    {
        return static::findOne($id);
    }
 
    public static function findAuthIdsByRoleName($roleName)
    {
        return static::find()->where(['role' => $roleName])->select(['id'])->column();
    }
 
    public function getAuthRoleNames()
    {
        return [$this->role];
    }
 
    public function addAuthRoleName($roleName)
    {
        $this->updateAttributes(['role' => $this->role = $roleName]);
    }
 
    public function removeAuthRoleName($roleName)
    {
        $this->updateAttributes(['role' => $this->role = null]);
    }
 
    public function clearAuthRoleNames()
    {
        $this->updateAttributes(['role' => $this->role = null]);
    }
}

и сконфигурируем его использование для целей аутентификации в config/web.php:

'components' => [
    'user' => [
        'identityClass' => 'app\components\UserIdentity',
        'enableAutoLogin' => true,
        'loginUrl' => ['user/default/login'],
    ],
    ...
],

и авторизации в config/common.php:

'components' => [
    'authManager' => [
        'class' => 'elisdn\hybrid\AuthManager',
        'modelClass' => 'app\components\UserIdentity',
    ],
    ...
],

И из модели User удалим весь код от AuthRoleModelInterface. С двумя чистыми классами всё равно удобнее работать, чем с одним сложным и перегруженным.

Теперь ни компонент AuthManager, ни модуль user никак не связаны технически и друг о друге не знают. Их можно использовать независимо друг от друга.

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

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

Не забудьте подписаться на рассылку статей в сайдбаре или вместе с нами записаться и «позажигать» на бесплатных вебинарах. До встречи!

Комментарии

 

Санжар

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

Спасибо вам большое, Дмитрий, как только освобожусь от текущих проектов, обязательно начну писать более гибко и глобально, всегда хотелось так работать, но не было примеров и хорошего видения, как это реализуемо, но сейчас все стало довольно понятно!

Ответить

 

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

> начну писать более гибко и глобально...

Только не злоупотребляйте, а то это будет слишком долго :)

Ответить

 

Oleg Knyazev

Добрый день Дмитрий.

Спасибо Вам за статьи ...

Они крайне интересные и могут часто подсказывать что сделать лучше ) ... но у меня вот есть один вопросик: я щас в своем небольшом проекте использую yii2-advanced и хочу весь код утащить в модули (т.к. думаю что они более функциональны), но куда пихать эти модули? в common? или в отдельную вообще папку?

Ответить

 

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

Обычно в в common/modules, если на фронте и бэке одновременно нужны. Стандартов здесь нет.

Ответить

 

Александр

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

Ответить

 

Юлия

Дмитрий, спасибо огромное за статьи!

Ответить

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

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


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



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