Среди php-разработчиков последнее время все сильнее набирает популярность Symfony2. Этот фреймворк позволяет использовать любые модули (в симфони они называются бандлы) для создания базовых фич проекта. По сути стандартная поставка симфони и является набором модулей. Но что если у вас несколько проектов, и вам необходим одинаковый набор функций на них, но подходящего модуля среди открытых нет? Не беда, можно написать свой.

По поводу создания бандла на Хабре есть статья «Создание собственного вендорного бандла в Symfony2», в которой описаны базовые моменты. В своей статье я хотел бы рассказать о некоторых методах работы из внешнего бандла с проектом, на которой он устанавливается. Предложенные мной решения буду показывать на основе своего бандла лайков.

Связь внешних энтити


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

Интерфейсы


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

interface LikeableInterface
{
    public function getId();
    public function addLike(Like $like);
    public function removeLike(Like $like);
    public function getLikes();
}

Мапинг


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

class LikeHelper
{
    /* @var EntityManager */
    private $em;

    protected function checkAssociation(LikeableInterface $entity)
    {
        $metadata = $this->em->getClassMetadata(get_class($entity));
        $mapping = false;

        if ($metadata->hasAssociation('likes')) {
            $mapping = $metadata->getAssociationMapping('likes');
        }

        if (!$mapping || ($mapping['targetEntity'] != 'Undelete\LikesBundle\Entity\Like')) {
            throw new NoLikeAssociationException(
                sprintf('Association with like entity not found in entity %s', get_class($entity))
            );
        }
}

Динамическое создание привязки


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

namespace Undelete\LikesBundle\Mapping;

use Doctrine\ORM\Event\LoadClassMetadataEventArgs;
use Doctrine\ORM\Mapping\ClassMetadataInfo;

class Like
{
    private $userClass;

    public function __construct($userClass)
    {
        $this->userClass = $userClass;
    }

    public function loadClassMetadata(LoadClassMetadataEventArgs $eventArgs)
    {
        /* @var $metadata ClassMetadataInfo */
        $metadata = $eventArgs->getClassMetadata();

        if ($metadata->getName() == 'Undelete\LikesBundle\Entity\Like') {
            $metadata->mapManyToOne([
                'targetEntity' => $this->userClass,
                'fieldName' => 'user',
            ]);
        }
    }
}

Обратная связь (event dispatching) Тэгированные сервисы


Update Как подсказал в комментариях korotovsky, для событий лучше использовать нативный event_dispatcher. Эту часть я оставляю только как пример работы с тэгами.

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

class LikePass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container)
    {
        $definition = $container->getDefinition(
            'undelete.likes.event.dispatcher'
        );

        $taggedServices = $container->findTaggedServiceIds(
            'like_listener'
        );

        foreach ($taggedServices as $id => $tags) {
            $onLike = isset($tags[0]['onLike']) ? $tags[0]['onLike'] : false;
            $onLikeRemove = isset($tags[0]['onLikeRemove']) ? $tags[0]['onLike'] : false;

            $definition->addMethodCall(
                'addListener',
                array(new Reference($id), $onLike, $onLikeRemove)
            );
        }
    }
}

Для работы с этими сервисами сделаем небольшой диспетчер:

class LikeEventDispatcher
{
    private $listeners = [];

    public function addListener($service, $onLike, $onLikeRemove)
    {
        $this->listeners[] = [
            'service' => $service,
            'onLike' => $onLike,
            'onLikeRemove' => $onLikeRemove,
        ];
    }

    public function dispatchEvent($kind, LikeEvent $event)
    {
        foreach ($this->listeners as $listener) {
            $method = false;

            if ($kind == LikeEvent::ON_LIKE) {
                $method = $listener['onLike'];
            } elseif ($kind == LikeEvent::ON_LIKE_REMOVE) {
                $method = $listener['onLikeRemove'];
            }

            if ($method) {
                $listener['service']->$method($event);
            }
        }
    }
}

Front end


Помимо какой-то серверной логики на внешний проект иногда приходится отдавать и файлы для браузера (стили, картинки и javascript). Эти файлы мы храним в папке Resource/public. В симфони есть assets для подключения файлов из бандла. Собственно, его (assets:install) и используем чтобы файлы были доступны в публичной папке.
Для некоторых проектов мы используем assetic как более гибкое решение. Но здесь приходиться мириться с тем, что js и css лежат в публичной части, но не используются.

ЗЫ


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

Бандл лайков можно найти здесь: github.com/UnDeleteRU/LikesBundle

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


  1. korotovsky
    07.04.2015 18:14
    +4

    А почему у вас используется самопальный EventDispatcher а не нативный, который используется всеми компонентами Symfony2?
    Второй вопрос — почему логика ACL возложена на некий LikeHelper а не на стандартный AuthorizationChecker (бывш SecurityContext)?


    1. UnDelete Автор
      07.04.2015 22:49

      Про EventDispatcher, здесь наверно проблема в том что те бандлы, что я смотрел обычно содержали какие-то самопальные диспетчеры, и я подумал, чем я хуже. За наводку спасибо, посмотрю, что смогу сделать с нативным диспетчером.

      Про acl не совсем понял. Метод в LikeHelper проверяет наличие связи в БД у объекта, а не права доступа у этому объекту.


      1. korotovsky
        07.04.2015 22:53

        github.com/UnDeleteRU/LikesBundle/blob/master/Controller/LikeController.php#L37 вот здесь. В вашем посте метод не представлен, а судя по коду на гитхабе, он таки проверяет права.


        1. UnDelete Автор
          07.04.2015 23:04

          Я сейчас смотрю, и думаю что вообще не надо было выделять эти две строчки в Helper
          github.com/UnDeleteRU/LikesBundle/blob/master/Helper/LikeHelper.php#L102

          Думаю, что стоит просто оставить их в контроллере.


          1. korotovsky
            07.04.2015 23:14
            +3

            Дело в том, что на самом деле, не только не стоило их выделять в LikeHelper (у него и так получилась большая зона ответственности) а воспользоваться существующим компонентом Security, для того, чтобы проверить права доступа к ресурсу. Если ваши строчки заменить на:

            $authorizationChecker = $this->get('security.authorization_checker');
            if (false === $authorizationChecker->isGranted('LIKE_TOGGLE', $entity) {
               throw new AccessDeniedException();
            }
            


            Тогда любой, кто бы подключил ваш бандл, могбы накрутить бесконечное количество логики (специфичной для проекта) перед тем, как дать лайкнуть объект, путем имплементации кастомного Voter класса symfony.com/doc/current/cookbook/security/voters_data_permission.html#the-voter-interface у себя в проекте.


            1. korotovsky
              07.04.2015 23:24
              +2

              Нашел еще место для выстрела в ногу: github.com/UnDeleteRU/LikesBundle/blob/master/Helper/LikeHelper.php#L28 ваш хэлпер получился statefull, что само по себе уже не круто, но страшнее другое, в больших приложениях, как показала практика — чень тяжело отследить порой порядок загрузки тех или иных сервисов (их инстанциирование) и вот если ваш LikeHelper вдруг будет инстанциирован до фаервола, то вы там больше никогда не сможете получить объект пользователя в любом из ваших методов. Т.к. в поле класса будет записано null значение, и после инициализации фаервола, далее, после аутентификации пользователя токен будет заполнен, а вот в вашем хэлпере — нет.


              1. UnDelete Автор
                08.04.2015 00:48
                +2

                Интересная особенность, пока не сталкивался с таким в живую, но удалось воспроизвести. Буду работать над этим.
                Спасибо за все ваши комментарии, много полезного для себя почерпнул.


  1. skobkin
    07.04.2015 18:29
    +2

    Полезно.
    Но сумбурно. Мало комментариев с объяснениями, что делает код — например, в «маппинге». Для заметки в личном блоге — нормально. Для обучающей статьи на Хабре — маловато.
    Ну и, если писать продолжения (хотя я бы и тут поправил) — стоит как-то применять меньше неустоявшегося сленга и варьировать слова. То есть, «Symfony» вместо «симфони». «Сущности» вместо «энтити» и т.п.
    В любом случае — спасибо.


    1. UnDelete Автор
      07.04.2015 22:53

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


  1. apian
    07.04.2015 19:02
    -1

    Помимо какой-то серверной логики на внешний проект иногда приходится отдавать и файлы для браузера (стили, картинки и javascript).

    Сразу чувствуется, что пишет backend'ер. Простите.


    1. UnDelete Автор
      07.04.2015 22:57

      Грешен, здесь мне нечего добавить.


    1. LionAlex
      08.04.2015 11:44
      +2

      Вы так говорите, как будто это что-то плохое.


  1. saksmt
    08.04.2015 19:49
    +3

    1. В Symfony есть существует «класс для пользователей», только он не класс, а интерфейс: «Symfony\Component\Security\Core\User\UserInterface» (собственно класс тоже есть, но он не для того).

    2. Грешно писать интерфейсы привязанные к реализации, тем более привязанные к «хранящей прослойке»:
    «Likeable» должен содержать человеческие названия методов, например «like(Like)», «unlike(Like)», «getLikes()», а уж как конкретно хранить и привязывать эти лайки лучше оставить на совесть пользователя.

    3. «Как хранить, где, почему, как привязывать, куда, зачем и т.д.» — об этом думать должен пользователь, ваша задача предоставить адекватный интерфейс взаимодействия. Взгляните на досуге на реализацию «FOSUserBundle», он не требует специфичного хранилища, ему глубоко без разницы как вы будете хранить пользователей, будь то реляционная БД, ОО БД или просто файл. Сделайте таким же образом «Model\StorageAgnostic\Like» и наследуйте конкретные привязки!


    1. UnDelete Автор
      09.04.2015 19:29

      На все эти вопросы я думаю один ответ. Роль сыграло, что бандл разрабатывался под конкретные проекты. Эти проекты имели схожую архитектуру (mysql, orm и доктриновский кодогенератор) и нужно было бы решение, которое бы позволяло без лишних мыслей добавлять функциональность. То есть сразу и шаблоны, и логика, и структура БД. Отсюда и интерфейсы с методами «хранящей прослойки»

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