Основным способом для обработки запросов в Symfony являются контроллеры. С возможностью описывать роутинг непосредственно в контроллерах (аннотациями) жизнь стала еще лучше. Но использование аннотаций при наследовании контроллеров вызывает проблемы — для каждого дочернего контроллера необходимо заново прописывать роутинг. А ведь в crud-приложения типична ситуация, когда есть базовый контроллер и множество дочерних, которые наследуют логику базового контроллера, и роуты для которых можно было бы генерировать автоматически.

Попыткой улучшить стандартные контроллеры Symfony стал DunglasActionBundle. Этот бандл предполагает, что у контроллера есть только один метод __invoke(), что позволяет использовать имя класса в качестве имени роута, что весьма удобно. Например, можно генерировать url следующим образом:

$route->generate(MyController::class, ['paramName' => 'value']);

Но проблему с наследованием контроллеров этот бандл не решает. Поэтому был написан собственный бандл, который решает все эти задачи — PaEndpointBundle

Возможности бандла:

  • Простой интерфейс контроллера — только один метод execute(Request $request): Response
  • Удобная генерация url по имени класса — $router->url(SomeEndpoint::class)
  • Удобство рефакторинга — при переименовании или перемещении контроллера не требуется дополнительных действий, например смена имени роута
  • Не нужно задавать имя роута (но можно при необходимости)

Основным понятием бандла является Endpoint — точка входа в приложение (в терминологии symfony — контроллер с единственным action). Пример простого Endpoint:

use PaLabs\EndpointBundle\EndpointInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class TextEndpoint implements EndpointInterface {

    public function routes()
    {
       return new Route('/cool_message');
    }
    
 
    public function execute(Request $request): Response
    {
       return new Response('Hello, world');
    }
}

Этот endpoint обрабатывает запросы по адресу your.server/cool_message и возвращает ответ с текстом 'Hello, world'.

Чтобы endpoint заработал, достаточно зарегистрировать его как сервис в symfony. Больше ничего делать не нужно — бандл сам обнаружит endpoint и добавит его роуты в общий список роутов приложения.

При необходимости задать собственное имя роута это можно сделать следующим образом:

public function routes()
    {
       return new EndpointRoute('my_route_name', new Route('/cool_message'));
    }

, после чего его можно использовать со стандартным роутером symfony, или в шаблонах.

Поскольку метод routes() в endpoint — обычный метод класса, то он может использовать любую логику для генерирования роутов, например, генерировать роуты в зависимости от каких-то настроек системы, включать или выключать роуты по необходимости, создавать коллекцию роутов.

Для генерации url-ов используется класс EndpointRouter. Пример использования:

class SomeService { 
  protected $router;

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

  public function doWork() {
    // ...
    $url = $endpointRouter->url(TextEndpoint::class);
  }
}

Даже, если в TextEndpoint было задано собственное имя роута, $router->url(TextEndpoint::class) все равно будет работать. Это достигается за счет кэширования роутов. Конечно, если для endpoint было задано больше одного роута, сгенерировать url по имени класса не получится и придется использовать имя роута.

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

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


  1. pbatanov
    16.02.2018 13:08

    Возможности бандла:

    Простой интерфейс контроллера — только один метод execute(Request $request): Response
    Удобная генерация url по имени класса — $router->url(SomeEndpoint::class)
    Удобство рефакторинга — при переименовании или перемещении контроллера не требуется дополнительных действий, например смена имени роута
    Не нужно задавать имя роута (но можно при необходимости)


    1. Давно есть в нативном symfony через `__invoke()` и controller as a service symfony.com/doc/current/controller/service.html
    2. Как вы ниже пишете по тексту работает не всегда. Чаще всего это не работает, когда один и тот же контроллер доступен по нескольим роутам с разными дефолтными значениями, такие ситуации далеко не редкость. Мне кажется весьма неприятным попасть в ситуацию, когда придется полноситью переделать роутинг контроллера ради того, чтобы навесить на него еще один роут
    3. Опять же, не всегда работает, т.к. не всегда работает п. 2
    4. И снова не всегда работает.

    Какие преимущества данного подхода над controller as a service?

    Недостаток я вижу один и довольно существенный — вы привязываете контроллер не только к http-kernel, но и к кастомному роутингу и бандлу, что снижает возможность его переиспользования, особенно если вы пишите компонент, предоставляющий контроллеры, типа админок и всяких вьюеров


    1. lewbor Автор
      16.02.2018 17:52

      Основное преимущество — программная генерация роутов. Это важно, в частности, для наследования контроллеров. Но также полезно, если есть сложная логика генерации. В сторонних библиотеках этот бандо лучше не использовать.


      1. pbatanov
        16.02.2018 18:06

        Возможно статья не демонстрирует преимуществ при наследовании, не хватает примеров.

        У меня есть подозрение, что функцию `routes` можно объявить как static, иначе создается иллюзия, что `routes` может возвращать разные значения в зависимости от runtime. Но это далеко не так из-за кэширования


        1. lewbor Автор
          16.02.2018 18:46

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


          1. pbatanov
            16.02.2018 18:55

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


            1. lewbor Автор
              16.02.2018 19:12

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


              1. pbatanov
                16.02.2018 19:16

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


                1. lewbor Автор
                  16.02.2018 19:24

                  Согласен, надо подумать на этот счет, возможно, создать интерфейс для загрузчика, и в бандле получать все загрузчики, так же, как сейчас endpoint-ы.
                  Основная причина, почему routes() нестатический — чтобы при наследовании можно было использовать абстрактные методы.


      1. Disparity
        17.02.2018 18:28

        Основное преимущество — программная генерация роутов

        оставлю это здесь: https://symfony.com/doc/master/bundles/FOSRestBundle/5-automatic-route-generation_single-restful-controller.html


  1. VolCh
    16.02.2018 16:06

    Чтобы endpoint заработал, достаточно зарегистрировать его как сервис в symfony. Больше ничего делать не нужно — бандл сам обнаружит endpoint и добавит его роуты в общий список роутов приложения.

    На этапе генерации кеша или в рантайме?


    1. lewbor Автор
      16.02.2018 18:36

      На этапе генерации кэша. Но теоретически можно брать конфигурацию из БД и на ее основе генерировать роуты (на целевом сервере). Правда, это все будет статически.


  1. oxidmod
    16.02.2018 18:09

    Тащить целый бандл, чтобы не писать пары строк аннотаций\конфигов? оО


  1. wendel
    16.02.2018 18:33

    А вот у меня вопрос, как решилась проблема с наследованием? Ведь сейчас все равно нужно переписывать весь метод routes() в дочернем методе копируя сигнатуру из родителя, то же самое что переписать аннотацию?


    1. lewbor Автор
      16.02.2018 18:43

      Проблема решается примерно так:

      <?php
      use Doctrine\ORM\EntityManagerInterface;
      use PaLabs\EndpointBundle\EndpointInterface;
      use Symfony\Component\HttpFoundation\Request;
      use Symfony\Component\HttpFoundation\Response;
      use Symfony\Component\Routing\Route;
      
      abstract class BaseEntityListController implements EndpointInterface {
          
          protected $em;
          
          public function __construct(EntityManagerInterface $em)
          {
              $this->em = $em;
          }
      
          protected abstract function className(): string;
          
          public function routes()
          {
              return new Route(sprintf("/rest/%s/list", $this->sectionName()));
          }
      
          public function execute(Request $request): Response
          {
              // do work
              $this->em->getRepository($this->className())->findAll();
          }
          
          protected function sectionName(): string {
              $reflection = new \ReflectionClass($this->className());
              return $reflection->getShortName();
          }
      }
      
      class SomeEntityController extends BaseEntityListController {
      
          protected function className(): string
          {
              return MyEntity::class;
          }
      }
      


      1. qRoC
        16.02.2018 20:30

        А что Вы будете делать, если в вашем crud-приложении один из дочерних классов потребует специфическую реализацию? Вангую что Вы полезете в BaseEntityListController, и перенесёте кусок кода из уже немалого метода execute в отдельный метод, для того что бы в дальнейшем переопределить его в дочернем. А что если в дальнейшем появится ещё такая задача, и кусок кода будет в новосозданном методе, вы ещё и его дробить начнёте? Не много ли отвественности на базовом контроллере при Вашем подходе?


        1. lewbor Автор
          16.02.2018 20:57

          Все зависит от модели. Если логика сильно меняется от класса к классу, то возможно не стоит создавать базовый контроллер. Обычно хватает 2-3 методов в качестве точек расширения.


  1. oxidmod
    16.02.2018 18:39

    Еще и тестов нет…


  1. railsfun
    17.02.2018 22:42

    Просьба владельцам хаба переименовать дескрипшн ведь Симфони 4 уже не на PHP5 а на PHP7.1 написана и строго требует его.