Пост навеян вот этим вопросом. Будем использовать стандартные эвенты Symfony для переопределения вывода контроллера. Итак, как, в общем, всё это будет работать:
- Создадим аннотацию Ajax для обработки типа контента контроллера
- Будем обрабатывать эту аннотацию через эвенты
- Будем переопределять тип контента в соответствии с выбранным типом в аннотации
Сразу предупрежу, код не претендует на идеальный, не используется кэширование (позднее скажу об этом), но главная идея, думаю, будет понятной. Также, более подробно почитать о 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