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

в 9:37, , рубрики: internals, symfony, symfony2, метки: ,

Пост навеян вот этим вопросом. Будем использовать стандартные эвенты Symfony для переопределения вывода контроллера. Итак, как, в общем, всё это будет работать:

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

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

Итак, преступим.
Для начала определим класс аннотации:

namespace SomeNamespaceSomeBundleAnnotations;


/** @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 SomeNamespaceSomeBundleEvent;

use SymfonyComponentHttpKernelEventKernelEvent;
use SymfonyComponentHttpKernelEventGetResponseForControllerResultEvent;
use DoctrineCommonAnnotationsReader;
use SymfonyComponentHttpFoundationResponse;

/**
 * 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 = 'SomeNamespaceSomeBundle';
        $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 SymfonyComponentHttpKernelEventKernelEvent $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 ITEJSBundleAnnotationsAjax) {
                $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 = 'ITEJSBundleCoreContentTypes\' . $contentType;
                $name = $className::getName();
                if ($name == $this->parameters['annotation']->contentType) {
                    $class = $className;
                }
            }
            if (!$class) {
                throw new ITEJSBundleCoreExceptionContentTypeException(
                    '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 SensioBundleFrameworkExtraBundleConfigurationRoute) {
                    $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;
    }

}

И зарегистрируем его в системе:

#SomeBundleResourcesconfigservices.yml
services:
    my.ajax.listener:
        class: "SomeNamespaceSomeBundleEventControllerListener"
        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 SomeNamespaceSomeBundleCore;

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 SomeNamespaceSomeBundleCore;

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 SomeNamespaceSomeBundleCoreContentTypes;
use SomeNamespaceSomeBundleCoreContentType;

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, который используется для генерации маршрутов и параметров:

//SomeBundleResourcesjsrouting_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. дополнения приветствуются. Рад буду услышать умные мысли.

Автор: sam0delkin

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js