В данном посте я бы хотел рассказать о том, как нужно правильно выстраивать RESTfull API для AngularJS и других фронтенд фреймворков с бекендом на Symfony.
И, как вы уже наверное догадались, я буду использовать FOSRestBundle — замечательный bundle, который и поможет нам реализовать backend.
Здесь не будет примеров как работать именно с Ангуляром, я буду описывать исключительно только работу с Symfony FosRestBundle.

Для работы нам так же понадобится JMSSerializerBundle для сериализации данных из Entity в JSON или другие форматы, исключения некоторых полей для той или иной сущности (например пароль для API метода получения списка пользователей) и многое другое. Подробнее можете почитать в документации.


Установка и конфигурирование
1)Загружаем нужные зависимости в нашем composer.json

"friendsofsymfony/rest-bundle": "^1.7",
"jms/serializer-bundle": "^1.1"


2)Конфигурирование
// app/AppKernel.php
class AppKernel extends Kernel
{
    public function registerBundles()
    {
        $bundles = array(
            // ...
            new JMS\SerializerBundle\JMSSerializerBundle(),
            new FOS\RestBundle\FOSRestBundle(),
        );

        // ...
    }
}


А теперь редактируем наш config.yml
Для начала будем настраивать наш FOSRestBundle
fos_rest:
    body_listener: true
    view:
      view_response_listener: true
    serializer:
        serialize_null: true
    body_converter:
        enabled: true
        format_listener:
        rules:
            - { path: '^/api',  priorities: ['json'], fallback_format: json, exception_fallback_format: html, prefer_extension: true }
            - { path: '^/', priorities: [ 'html', '*/*'], fallback_format: html, prefer_extension: true }

body_listener включает EventListener для того, чтобы отслеживать какой формат ответа нужен пользователю, основываясь на его Accept-* заголовках
view_response_listener — эта настройка позволяет просто вернуть View для того или иного запроса
serializer.serialize_null — эта настройка говорит о том, что мы так же хотим, чтобы NULL сериализовывался, как и все, если её не установить или установить как false, тогда все поля, что имеют null — просто напросто не будут отображаться в ответе сервера.
P.S.: спасибо, что напомнил lowadka
body_converter.rules — содержит массив для настроек, ориентированный на тот или иной адрес, в данном примере мы для всех запросов, которые имеют префикс /api, будем возвращать JSON, во всех остальных случаях — html.

Теперь начнем настройку нашего JMSSerializeBundle
jms_serializer:
    property_naming:
        separator:  _
        lower_case: true

    metadata:
        cache: file
        debug: "%kernel.debug%"
        file_cache:
            dir: "%kernel.cache_dir%/serializer"
        directories:
            FOSUserBundle:
                namespace_prefix: FOS\UserBundle
                path: %kernel.root_dir%/config/serializer/FosUserBundle
            AppBundle:
                namespace_prefix: AppBundle
                path: %kernel.root_dir%/config/serializer/AppBundle
        auto_detection: true


Здесь имеет смысл остановиться на моменте с jms_serializer.metadata.directories, где мы говорим serializer-у о том, что конфигурация для того или иного класса (сущности) находится там-то или там-то :)
Условимся, что нам требуется вывести весь список пользователей, я лично использую FosUserBundle в своих проектах и вот моя сущность:
<?php

namespace AppBundle\Entity;

use JMS\Serializer\Annotation\Expose;
use JMS\Serializer\Annotation\Groups;
use JMS\Serializer\Annotation\Exclude;
use JMS\Serializer\Annotation\VirtualProperty;
use JMS\Serializer\Annotation\ExclusionPolicy;

use Doctrine\ORM\Mapping as ORM;
use FOS\UserBundle\Model\User as BaseUser;
use FOS\UserBundle\Model\Group;

/**
 * User
 *
 * @ORM\Table(name="user")
 * @ORM\Entity(repositoryClass="AppBundle\Repository\UserRepository")
 * @ExclusionPolicy("all")
 */
class User extends BaseUser
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     * @Exclude
     */
    protected $id;

    /**
     * @ORM\Column(type="integer")
     * @Groups({"user"})
     * @Expose
     */
    private $balance = 0;

    /**
     * Set balance
     *
     * @param integer $balance
     *
     * @return User
     */
    public function setBalance($balance)
    {
        $this->balance = $balance;

        return $this;
    }

    /**
     * Get balance
     *
     * @return integer
     */
    public function getBalance()
    {
        return $this->balance;
    }
}



Я привожу в пример именно эту сущность, которая наследуется от основной модели FosUserBundle. Это важно потому что оба класса придется конфигурировать для JmsSerializerBundle отдельно.
Итак, вернемся jms_serializer.metadata.directories:
directories:
            FOSUserBundle:
                namespace_prefix: FOS\UserBundle
                path: %kernel.root_dir%/config/serializer/FosUserBundle
            AppBundle:
                namespace_prefix: AppBundle
                path: %kernel.root_dir%/config/serializer/AppBundle

Здесь мы как раз и указываем, что для AppBundle классов мы будем искать конфигурацию для сериализации в app/config/serializer/AppBundle, а для FosUserBundle — в app/config/serializer/FosUserBundle.
Конфигурация для класса будет находиться автоматически в формате:
Для класса AppBundle\Entity\User — app/config/serializer/AppBundle/Entity.User.(yml|xml|php)
Для класса базовой модели FosUserBundle — app/config/serializer/FosUserBundle/Model.User.(yml|xml|php)

Лично я предпочитаю использовать YAML. Начнем наконец-таки рассказывать JMSSerializer каким образом нам нужно чтобы он настраивал тот или иной класс.
app/config/serializer/AppBundle/Entity.User.yml
AppBundle\Entity\User:
    exclusion_policy: ALL
    properties:
        balance:
            expose: true


app/config/serializer/FosUserBundle/Model.User.yml
FOS\UserBundle\Model\User:
    exclusion_policy: ALL
    group: user
    properties:
        id:
            expose: true
        username:
            expose: true
        email:
            expose: true
        balance:
            expose: true


Вот так просто мы смогли рассказать о том, что хотим видеть примерно следующий формат ответа от сервера при получении данных от 1 пользователя:
{"id":1,"username":"admin","email":"admin","balance":0}


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

Теперь приступим к созданию контроллера
Первым делом создадим роут:
backend_user:
    resource: "@BackendUserBundle/Resources/config/routing.yml"
    prefix:   /api

Обратите внимание на /api — не забывайте добавлять его, а если хотите изменить, то придется менять и конфигурацию для fos_rest в config.yml

Теперь сам BackendUserBundle/Resources/config/routing.yml:
backend_user_users:
  type: rest
  resource: "@BackendUserBundle/Controller/UsersController.php"
  prefix: /v1


Теперь можно приступать к созданию самого контроллера:
<?php

namespace Backend\UserBundle\Controller;

use AppBundle\Entity\User;
use FOS\RestBundle\Controller\FOSRestController;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\Annotations\View;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

/**
 * Class UsersController
 * @package Backend\UserBundle\Controller
 */
class UsersController extends FOSRestController
{
    /**
     * @return \Symfony\Component\HttpFoundation\Response
     * @View(serializerGroups={"user"})
     */
    public function getUsersAllAction()
    {
        $users = $this->getDoctrine()->getRepository('AppBundle:User')->findAll();

        $view = $this->view($users, 200);
        return $this->handleView($view);
    }


    /**
     * @param $id
     * @return \Symfony\Component\HttpFoundation\Response
     * @View(serializerGroups={"user"})
     */
    public function getUserAction($id)
    {
        $user = $this->getDoctrine()->getRepository('AppBundle:User')->find($id);

        if (!$user instanceof User) {
            throw new NotFoundHttpException('User not found');
        }

        $view = $this->view($user, 200);
        return $this->handleView($view);
    }
}



Заметим, что наследуемся мы теперь от FOS\RestBundle\Controller\FOSRestController.
Кстати, вы наверное обратили внимание на аннотацию View(serializerGroups={«user»}).
Дело в том, что т.к. мы мы хотим видеть и данные App\Entity\User и основной модели FosUserBundle, в которой хранятся все остальные поля, мы должны создать определенную группу, в данном случае — «user».

Итак, у нас есть 2 экшена getUserAction и getUsersAllAction. Сейчас вы поймете суть специфики названий методов контроллера.
Сделаем debug всех роутов:
$ app/console debug:route | grep api
Получаем:
get_users_all                              GET        ANY      ANY    /api/v1/users/all.{_format}                         
get_user                                      GET        ANY      ANY    /api/v1/users/{id}.{_format} 


Рассмотрим следующий пример с новыми методами:
<?php
class UsersComment extends Controller
{
    public function postUser($id)
    {} // "post_user_comment_vote" [POST] /users/{id}

    public function getUser($id)
    {} // "get_user"   [GET] /users/{id}

    public function deleteUserAction($id)
    {} // "delete_user" [DELETE] /users/{id}

    public function newUserAction($id)
    {} // "new_user"   [GET] /users/{id}/new

    public function editUserAction($slug, $id)
    {} // "edit_user"   [GET] /users/{id}/edit

    public function removeUserAction($slug)
    {} // "remove_user" [GET] /users/{slug}/remove
}

Напоминает Laravel Resource Controller, правда?

В комментариях показано по какому адресу и методу запроса будет выполнен тот или иной метод.
В следующий раз я расскажу вам о том, как правильно использовать FOSRestBundle для, например, вывода комментариев определенного пользователя по адресу: "/users/{id}/comments", создавать \ обновлять данные пользователей.

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


  1. lowadka
    28.02.2016 23:35

    Думаю в статье стоит упомянуть про параметр:

    fos_rest:
        serializer:
            serialize_null: true

    Многие сталкиваются с этим, так почему бы не сэкономить другим время


    1. php_freelancer
      29.02.2016 08:18

      А вот кстати да! Не подумал сначала почему-то. Вообще нужная вещица — сериализация null.
      Добавил в статью, спасибо.


      1. Fesor
        29.02.2016 16:17

        Ну не сказать что оно особо нужная.


        1. by25
          01.03.2016 23:08

          Ну не сказал бы, 1С-ники (горите в аду) плачут часто, что нету ключа в json.


  1. lowadka
    28.02.2016 23:43

    Если говорить в целом про бандл, то он наверное хорош, но если вы делаете исключительно json api, то он очень избыточен, как в скорости работы, так и в количестве кода. В реальном проекте такое:

        /**
         * @param $id
         * @return \Symfony\Component\HttpFoundation\Response
         * @View(serializerGroups={"user"})
         */
        public function getUserAction($id)
        {
            $user = $this->getDoctrine()->getRepository('AppBundle:User')->find($id);
    
            if (!$user instanceof User) {
                throw new NotFoundHttpException('User not found');
            }
    
            $view = $this->view($user, 200);
            return $this->handleView($view);
        }

    Превратится в такое:

        /**
         * @Common\Annotation\SerializationGroups({"manager_app__user_details"})
         */
        public function detailsAction(Common\Entity\User $entity)
        {
            return $entity
        }

    Где вся повторяющаяся логика вынесена во всякие listener`ы и шорткаты


    1. Fesor
      29.02.2016 16:20
      +1

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

              $user = $this->getDoctrine()->getRepository('AppBundle:User')->find($id);
      
              if (!$user instanceof User) {
                  throw new NotFoundHttpException('User not found');
              }

      это вообще из коробки идет в виде ParamConverter

              $view = $this->view($user, 200);
              return $this->handleView($view);

      тут опять же непонятно почему бы просто не выплюнуть $user, у нас же все уже описано в аннотациях. Да и одной строчкой это делается.


  1. BOLTIKUS
    29.02.2016 10:08

    Стоит более подробно написать про группы сериализации. О том что их может быть несколько и это позволяет получать разные поля сущности в разных ситуациях.
    Так же можно описать процесс десериализации, опять же группы, парам конвертер для получения и валидации сущностей из запросов клиентов.
    И напоследок упомянуть про то как кастомизировать эксепшены через эксепшен врапперы и стратегию именования полей для сериализации.


  1. Fesor
    29.02.2016 15:09

    Не используйте JMS Serializer. Он привносит больше проблем чем пользы. Есть symfony/serializer на худой конец.


    1. lowadka
      29.02.2016 16:34

      Какие например? Не ради спора, действительно интересно


      1. Fesor
        29.02.2016 17:12
        +2

        Для начала посмотрим пульс пациента — он мертв. Проект активно не поддерживается уже более двух лет.

        1) иногда просто так сериализует массив как хэш-мэпу, лечится кастылями в виде листенеров
        2) надо замэпить json на существующий объект — лепим object creator
        3) inline — работает только в одну сторону, без возможности задать кастомный префикс. бесполезная фича. В принципе очень много фич работающих наполовину.
        4) шаг в право/шаг в лево — пишем свои хэндлеры или листенеры, много лишнего кода, не особо прогнозируется необходимость писать этот код. Много рисков.
        5) версионизация API? переименовали пропертю? Пишите свои хэндлеры, мэппингами вы это так просто уже не разрулите.
        6) в принципе для тупого CRUD сойдет, но чуть сложнее — и все… приплыли к морю кода. Быстрее руками мэппинги сделать.
        7) медленно. У меня были проекты где jms serializer занимал 50% времени генерации респонса.

        Лучше брать symfony/serializer. Он в этом плане намного более грамотная штука. Позволяет сделать все то же что и jms serializer но намного более грамотно (за счет выделения процесса нормализации и отделения от сериализации).