Symfony 2 Internals на практике

    Пост навеян вот этим вопросом. Будем использовать стандартные эвенты Symfony для переопределения вывода контроллера. Итак, как, в общем, всё это будет работать:
    1. Создадим аннотацию Ajax для обработки типа контента контроллера
    2. Будем обрабатывать эту аннотацию через эвенты
    3. Будем переопределять тип контента в соответствии с выбранным типом в аннотации

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

    Итак, преступим.
    Для начала определим класс аннотации:
    namespace SomeNamespace\SomeBundle\Annotations;
    
    
    /** @Annotation */
    class Ajax
    {
        /**
         * @var array @contentType
         */
        public $contentType;
    
        /**
         * @var array @parameters
         */
        public $parameters;
    
        public function __construct($data)
        {
            if (isset($data['value'])) {
                $this->contentType = $data['value'];
            }
            if (isset($data['parameters'])) {
                $this->parameters = $data['parameters'];
            }
        }
    
        /**
         * @param array $contentType
         */
        public function setContentType($contentType)
        {
            $this->contentType = $contentType;
        }
    
        /**
         * @return array
         */
        public function getContentType()
        {
            return $this->contentType;
        }
    }
    
    

    Эта аннотация и определяет тип контента, отдаваемый контроллером.
    Далее создадим слушатель эвентов:
    namespace SomeNamespace\SomeBundle\Event;
    
    use Symfony\Component\HttpKernel\Event\KernelEvent;
    use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent;
    use Doctrine\Common\Annotations\Reader;
    use Symfony\Component\HttpFoundation\Response;
    
    /**
     * Controller Event listener
     */
    class ControllerListener
    {
        /**
         * @var ServiceContainer
         */
        private $container;
    
        /**
         * Parameters of Event Listener
         *
         * @var array
         */
        private $parameters;
    
        /**
         * @var AnnotationsReader
         */
        private $annotationReader;
    
        //В конструкторе мы будем искать контент-тайпы в директории Core/ContentTypes
        public function __construct($c, $a)
        {
    
            $this->container = $c;
            $this->annotationReader = $a;
            //@TODO здесь небольшой быдлокод, по хорошему это нужно делать при обновлении кэша. Также, хотелось бы чтобы контент-тайпы собирались не только из этого бандла, а из всех.
            $classes = array();
            $namespace = 'SomeNamespace\SomeBundle';
            $namespace = str_replace('\\', '/', $namespace);
            $dir = opendir('../src/' . $namespace . '/Core/ContentTypes');
            while ($classes[] = str_replace('.php', '', readdir($dir))) {
                ;
            }
            foreach ($classes as $key => $class) {
                if ($class == '') {
                    unset($classes[$key]);
                    continue;
                }
                if ($class[0] == '.') {
                    unset($classes[$key]);
                }
            }
            $this->parameters['contentTypes'] = $classes;
    
    
        }
    
        /**
         * Controller event listener
         *
         * @param \Symfony\Component\HttpKernel\Event\KernelEvent $event
         */
        public function onKernelController(KernelEvent $event)
        {//это событие возникает при каждом вызове контроллера. Здесь мы будем читать аннотации. Если кто не знает, можно посмотреть мою предыдущую статью, там всё это описано, здесь я не буду на этом останавливаться
            $controller = $event->getController();
            $object = new \ReflectionObject($controller[0]);
            $method = $object->getMethod($controller[1]);
            $annotations = $this->annotationReader->getMethodAnnotations($method);
            $response = new Response();
            $this->parameters['attributes'] = $event->getRequest()->attributes;
            foreach ($annotations as $annotation) {
                if ($annotation instanceof \ITE\JSBundle\Annotations\Ajax) {
                    $this->parameters['annotation'] = $annotation;
                }
            }
            $class = NULL;
            $params = array();
            if (isset($this->parameters['annotation'])) {
                if (isset($this->parameters['annotation']->parameters)) {
                    $params = $this->parameters['annotation']->parameters;
                }
                foreach ($this->parameters['contentTypes'] as $contentType) {
                    $className = '\ITE\JSBundle\Core\ContentTypes\\' . $contentType;
                    $name = $className::getName();
                    if ($name == $this->parameters['annotation']->contentType) {
                        $class = $className;
                    }
                }
                if (!$class) {
                    throw new \ITE\JSBundle\Core\Exception\ContentTypeException(
                        'ContentType "' . $this->parameters['annotation']->contentType . '" is not found!');
                }
                //Создаём объект контент-тайпа и вызываем первый хук. Об этой структуре расскажу ниже.
                $contentType = new $class($this->container, $params);
                $this->parameters['contentType'] = $contentType;
                $contentType->hookPre($event->getRequest());
            }
    
    
        }
    
        /**
         * Controller Response listener
         *
         * @param $event
         */
        public function onKernelResponse($event)
        {// Этот эвент вызывается при каждом ответе контроллера. Здесь я встраиваю свой javascript в страницу, также, как это делает Symfony Profiler. В этом эвенте можно переопределить ответ контроллера
            $response = $event->getResponse();
            $response = $this->addJavascript($response);
            $event->setResponse($response);
        }
    
        /**
         * Controller Request listener
         *
         * @param $event
         */
        public function onKernelRequest($event)
        { // Вызывается при запросе контроллера. Здесь можно переопределить параметры запроса
            $this->generateRoutes();
        }
    
        /**
         * Controller response listener
         *
         * @param GetResponseForControllerResultEvent $event
         */
        public function onKernelView(GetResponseForControllerResultEvent $event)
        {
            // Этот эвент вызывается перед выводом. И соответсвенно перед onKernelResponse
            if (isset($this->parameters['contentType'])) {
                $contentType = $this->parameters['contentType'];
                $response = new Response;
                $response->setContent($contentType->encodeParameters($event->getControllerResult()));
                $response = $contentType->hookPost($response);
                $event->setResponse($response);
            }
    
    
        }
    
        /**
         * Generating route array and move to javascript file
         */
        private function generateRoutes()
        { // По хорошему, это нужно делать при обновлении кэша
            $routeCollection = $this->container->get('router')->getRouteCollection();
            $routes = array();
            foreach ($routeCollection->all() as $route) {
                $r = array();
                $defaults = $route->getDefaults();
                try {
                    $method = new \ReflectionMethod($defaults['_controller']);
                } catch (\Exception $e) {
                    continue;
                }
                $ann = $this->annotationReader->getMethodAnnotations($method);
                foreach ($ann as $a) {
                    if ($a instanceof \Sensio\Bundle\FrameworkExtraBundle\Configuration\Route) {
                        $r[$a->getName()] = $route->getPattern();
                    }
                }
                $routes += $r;
            }
            $path = __FILE__;
            $path = str_replace('Event' . DIRECTORY_SEPARATOR . 'ControllerListener.php', '', $path);
            $path .= 'Resources' . DIRECTORY_SEPARATOR . 'js' . DIRECTORY_SEPARATOR . 'routing_template.js';
            $content = file_get_contents($path);
            $route_string = json_encode($routes);
            $content = str_replace('__routes__', $route_string, $content);
            $kernel = $this->container->get('kernel');
            $params = array(
                'env'       => $kernel->getEnvironment(),
                'debug'     => $kernel->isDebug(),
                'name'      => $kernel->getName(),
                'startTime' => $kernel->getStartTime(),
            );
            $content = str_replace('__params__', json_encode($params), $content);
            $path = str_replace('routing_template', 'routing', $path);
            file_put_contents($path, $content);
        }
    
        /**
         * Adding global Symfony javascript
         *
         * @param $response
         *
         * @return mixed
         */
        private function addJavascript($response)
        {// Добавляем свой яваскрипт в каждую страницу
            $content = $response->getContent();
            $arr = explode('</head>', $content);
            if (count($arr) == 1) {
                return $response;
            }
            $twig = $this->container->get('templating');
            $c = $twig->render('SomeNamespaceSomeBundle:Javascript:js.html.twig');
            $content = $arr[0] . $c . "</head>" . $arr[1];
            $response->setContent($content);
    
            return $response;
        }
    
    }
    

    И зарегистрируем его в системе:
    #SomeBundle\Resources\config\services.yml
    services:
        my.ajax.listener:
            class: "SomeNamespace\SomeBundle\Event\ControllerListener"
            tags: [{name: kernel.event_listener, event: kernel.response, method: onKernelResponse, priority: -128}, {name: kernel.event_listener, event: kernel.request, method: onKernelRequest}, {name: kernel.event_listener, event: kernel.view, method: onKernelView, priority: -128}, {name: kernel.event_listener, event: kernel.controller, method: onKernelController}]
            arguments: [@service_container, @annotation_reader]
    
    

    Обратите внимание на ещё один аргумент: priority. Он задаёт приоритет эвента. Если приводить пример, то мне в голову приходит Drupal. Это аналог веса модуля, только наоборот. В друпале, чем больше вес, тем позже вызовется хук. А в Symfony, чем больше приоритет, тем раньше вызовется эвент.

    Итак, как выглядет структура каждого контент-тайпа:
    Для начала, создадим интерфейс:

    namespace SomeNamespace\SomeBundle\Core;
    
    interface ContentTypeInterface {
    
      /**
       * Get the name of ContentType
       * @abstract
       * @return mixed
       */
      public static function getName();
    
      /**
       * Encoder
       * @abstract
       * @param $data
       * @return mixed
       */
      public function encodeParameters($data);
    
      /**
       * Decoder
       * @abstract
       * @param $data
       * @return mixed
       */
      public function decodeParameters($data);
    
      /**
       * Prepares request
       * @abstract
       * @param Request
       * @return mixed
       */
      public function hookPre($request);
    
      /**
       * Changes response
       * @abstract
       * @param Response
       * @return mixed
       */
      public function hookPost($response);
    
    
    
    }
    

    А теперь расскажу поподробнее:
    • encodeParameters — кодирует входные данные в нужный контент-тайп (например, для JSON это будет json_encode)
    • decodeParameters — декодирует входные данные в нужный контент-тайп (например, для JSON это будет json_decode). Это может пригодиться, если данные приходят вам запакованными этим же контент-тайпом в один параметр POST
    • hookPre — вызывается при запросе к контроллеру, здесь контент-тайп может делать всё что угодно с объектом request
    • hookPost — вызывается при ответе контроллера, здесь контент-тайп может делать всё что угодно с объектом response

    Далее мы создадим класс, который будет реализовывать наш интерфейс, от которого будут наследоваться все контент-тайпы:
    namespace SomeNamespace\SomeBundle\Core;
    
    class ContentType implements ContentTypeInterface
    {
    
      /**
       * @var ServiceContainer
       */
      protected $container;
    
      /**
       * @var array parameters
       */
      protected $parameters;
    
      /**
       * Public constructor
       * @param $container
       */
      public function __construct($container, $params = array()){
        $this->container = $container;
        $this->parameters = $params;
      }
    
    
      /**
       * Get the name of ContentType
       * @return mixed
       */
      public static function getName()
      {
        return 'contentType';
      }
    
      /**
       * Encoder
       * @param $data
       * @return mixed
       */
      public function encodeParameters($data)
      {
        return $data;
      }
    
      /**
       * Decoder
       * @param $data
       * @return mixed
       */
      public function decodeParameters($data)
      {
        return $data;
      }
    
      /**
       * Prepares request
       * @param $data
       * @return mixed
       */
      public function hookPre($request)
      {
    
      }
    
      /**
       * Changes response
       * @param $data
       * @return mixed
       */
      public function hookPost($response)
      {
        return $response;
      }
    
    }
    
    
    

    Как видно, он реализует интерфейс ContentTypeInterface.
    Теперь можно создавать свои контент-тайпы, для примера я приведу свой контент-тайп json:
    namespace SomeNamespace\SomeBundle\Core\ContentTypes;
    use SomeNamespace\SomeBundle\Core\ContentType;
    
    class JSONContentType extends ContentType
    {
    
      private $params;
    
      /**
       * Get the name of ContentType
       * @return mixed
       */
      public static function getName()
      {
        return "json";
      }
    
    
      /**
       * Changes response
       * @param $data
       * @return mixed
       */
      public function hookPost($response)
      {
        return $response;
      }
    
      /**
       * Encoder
       * @param $data
       * @return mixed
       */
      public function encodeParameters($data)
      {
        return json_encode($data);
      }
    
      /**
       * Decoder
       * @param $data
       * @return mixed
       */
      public function decodeParameters($data)
      {
        return json_decode($data);
      }
    }
    
    
    


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

    //SomeBundle\Resources\js\routing_template.js
    (function(){if(typeof SF!='undefined'){SF.sSet('routes',__routes__);SF.parameters = __params__;}})();
    

    А также javascript, который всё это дело сохраняет и использует:
    
    (function () {
    
        SF = function () {
        };
        SF.prototype.fn = SF.prototype;
        SF = new SF();
        SF.fn.Storage = {};
    
        SF.fn.hasValue = function (name) {
            return this.Storage[name] !== undefined;
        };
    
        SF.fn.getValue = function (name) {
            if (this.hasValue(name)) {
                return this.Storage[name];
            } else {
                return void 0;
            }
        };
    
        SF.fn.getAllValues = function () {
            return this.Storage
        };
    
        SF.fn.loggingEnabled = function () {
            return this.parameters.debug;
        };
    
        SF.fn.messagingEnabled = function () {
            return this.parameters.messaging !== undefined && this.parameters.messaging;
        };
    
        SF.fn.getMessages = function () {
            return !this.framework || this.framework.messaging === undefined ? { } : this.framework.messaging;
        };
    
    
        // framework
    
        SF.fn.getLocation = function (name) {
            if (this.hasLocation(name)) {
                return this.framework.ajax[name];
            } else {
                return void 0;
            }
        };
    
        SF.fn.hasLocation = function (name) {
            return this.framework !== null &&
                this.framework.ajax !== undefined &&
                this.framework.ajax[name] !== undefined;
        };
        // Storage setter and getter
        SF.fn.sSet = function (key, val) {
            this.Storage[key] = val;
        };
        SF.fn.sGet = function (key) {
            return this.Storage[key] ? this.Storage[key] : null;
        };
    
        // log function with debug checking
        SF.fn.l = function (a, b) {
            if (!b)
                b = 'log';
            if (this.parameters.debug) {
                switch (b) {
                    case 'log':
                        console.log('[SF]: ', a);
                        break;
                    case 'info':
                        console.info('[SF]: ', a);
                        break;
                    case 'warning':
                        console.warn('[SF]: ', a);
                        break;
                    case 'error':
                        console.error('[SF]: ', a);
                        break;
                }
    
            }
        };
        // SF path function
        SF.fn.path = function (name, arguments) {
            if (this.Storage.routes[name]) {
                var path = this.Storage.routes[name];
                for (var a in arguments) {
                    path = path.replace('{' + a + '}', arguments[a]);
                }
                return path;
            } else {
                this.l('Route "' + name + '" is not found!', 'error');
                return false;
            }
        };
       
    
    
    })(window);
    

    ну а теперь, самое интересное — пример работы. Создадим в контроллере акшн:
    //не забываем использовать use для аннотации, иначе Symfony её не найдёт
    /**
       *
       * @param key string
       * @Route("/ajax/{key}", name="JSBundle_ajax")
       * @Ajax("json")
       * @return array
       */
      public function ajaxAction($key)
      {
            //do some work
            return array('a' => 'b', 'd' => 'c');
      }
    

    Ответ контроллера будет таким:
    {
    a: "b",
    d: "c"
    }
    

    также, пример для javascript:
    
    SF.l(SF.path('JSBundle_ajax', {'key': 'asd'}));
    

    Если у вас в Symfony отключен debug, то в консоль ничего не распечатается, иначе распечатается:
    /ajax/asd

    P.S. дополнения приветствуются. Рад буду услышать умные мысли.
    Метки:
    Поделиться публикацией
    Похожие публикации
    Комментарии 6
    • +1
      return new Response(json_encode(array('a' => 'b', 'd' => 'c')); ??
      • 0
        Конечно, без проблем. Но в данном случае, у меня есть не один тип. У меня ещё есть text, html и xml. И не изменяя тело контроллера, поменяв всего лишь аннотацию с Ajax(«json») на Ajax(«text»), Ajax(«html») или Ajax(«xml»), на выходе получается разный тип контента.
        • 0
          И вообще, целью было не показать, как отвечать JSONом, а как использовать эвенты в Symfony2. Но это и один из способов именно выдавать браузеру JSON.
          • 0
            Неплохая статья для понимание как работает внутри. А на счет предложенной функциональности:
            раз
            два
            • 0
              да, знаю об этом. Собственно, с JsRoutingBundle идея и взята. Но ещё раз говорю, это лишь пример того, как можно использовать. И это не идеал кода.
              • 0
                Я понял, поэтому и отметил, что как пример — хорошо. А как готовый результат можно использовать то, что уже написано, чтобы люди не кинулись копипастить себе в код =)

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