Всем привет! В прошлой статье я рассказал о нашем опыте в REST API со сборкой на FOSRestBundle + JMSSerializer. Сегодня я поделюсь нашим подходом к разработке REST API на FOSRestBundle + GlavwebDatagridBundle.

Новые задачи.


С ростом количества REST проектов возрастали требования к нашему апи. Часто пользователями нашего апи являются сторонние разработчики, которые имеют те или иные потребности для своих решений. Так, например, не всем было по душе передавать конфигурацию вложенности объектов в запросах. Возможностей фильтрации из коробки так же не хватало.

Но самым весомым основанием к пересмотру подхода к разработке апи были проблемы с производительностью. Причинами этих проблем были либо не оптимально написанный SQL, либо дополнительные затраты на обработку результата доктриной и сериализером.

Если с SQL понятно в какую сторону оптимизировать, то с потерей производительности в доктрине и сериализере сложнее. Вот, как это выглядит после того, как запрос к БД отработал:

получение массива данных из PDO -> преобразования массива в объекты (в доктрине, гидрация) -> вызов листенера для каждого поля каждого объекта в jmsserializer -> преобразование объекта в json в сериализере.

Было решено сократить этот путь до:

получение массива данных из PDO -> преобразования массива в многомерный ассоциативный массив (в доктрине, гидрация) -> по необходимости, преобразование данных сразу в массиве.

Так как мы отказались от гидрации в объекты, пришлось отказаться и от JMSSerializer тоже. JMSSerializer делал много полезного для нашего апи (это я описал в предыдущей статье). Кроме того он выполнял еще одну важную работу — догружал вложенные сущности, если они не были определены в join-ах.

Для того, чтобы закрыть образовавшуюся «дыру» в функциональности, возникшую в результате отказа от jmsserializer, был разработан GlavwebDatagridBundle.

В «двух словах» о GlavwebDatagridBundle


Коротко определить предназначение GlavwebDatagridBundle можно так: получать данные, отформатированные нужным образом, на основе фильтров, лимита и оффсета. Не понятно? Знаю. А теперь обо всем по порядку.

В основе GlavwebDatagridBundle лежат следующие компоненты:
  • DatagridBuilder;
  • Datagrid;
  • Filter;
  • DataSchema + Scope.

DatagridBuilder


DatagridBuilder формирует QueryBuilder используя DataSchema, фильтры и дополнительные параметры запроса:

$datagridBuilder = $this->get('glavweb_datagrid.doctrine_datagrid_builder');
$datagridBuilder
    ->setEntityClassName(Entity::class)
    ->setFirstResult(0)
    ->setMaxResults(100)
    ->setOrderings(['id'=>'DESC'])
    ->setOperators(['field1' => '='])
    ->setDataSchema('entity.schema.yml', 'entity/list.yml')
;

Далее возвращает объект Datagrid:

$datagrid = $datagridBuilder->build($paramFetcher->all());

Filter


Фильтры позволяют задавать дополнительные условия в запрос.

// Define filters
$datagridBuilder
    ->addFilter('field1')
    ->addFilter('field2')
;

Datagrid


Datagrid получает QueryBuilder из DatagridBuilder и позволяет вернуть данные как в виде списка:
$datagrid->getList();

так и в виде одной строки:

$datagrid->getItem();

DataSchema


DataSchema определяет набор данных в формате yaml:

schema:
    class: AppBundle\Entity\Article
    db_driver: orm
    properties:
        id: # integer
        type: # ArticleType
        name: # string
        body: # text
        publish: # boolean
        publishAt: # datetime

Определяет поля и связи:

schema:
    class: AppBundle\Entity\Movie
    db_driver: orm
    properties:
        ...

        tags: # AppBundle\Entity\Tag
            join: left
            properties:
                id: # integer

Дополнительные условия:

schema:
    class: AppBundle\Entity\Article
    db_driver: orm
    conditions: ["{{alias}}.publish = true"]
    properties:
        ....

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

schema:
    class: AppBundle\Entity\Movie
    db_driver: orm
    properties:
        ...

        articles: # AppBundle\Entity\Article
            conditions: ["{{alias}}.publish = true"]
            join: none
            properties:
                ....

DataSchema так же реализует функцию догрузки вложенных сущностей, если они не были сконфигурированы как join:

schema:
    class: AppBundle\Entity\Movie
    db_driver: orm
    properties:
        ...

        articles: # AppBundle\Entity\Article
            join: none
            properties:
                id: # integer

DataSchema используется для построения запроса в DatagridBuilder и для преобразования данных в Datagrid.

Scope


Scope позволяет сузить набор данных, определенных в DataSchema.

Например, с помощью scope можно определить небольшой объем данных для списка записей (article\list.yml):

scope:
    id: 
    name: 

и полный набор данных для просмотра одной записи (article\view.yml):

scope:
    id: 
    type: 
    name: 
    body: 
    publish: 
    publishAt: 
    movies: # AppBundle\Entity\Movie
        id:
        name:


От слов к демо.


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

Демо проект размещен на гитхабе.

Для того, что бы развернуть проект локально нужно сделать 3 простых действия:

1. Создать проект через композер.

composer create-project glavweb/rest-demo-app

2. Выполнить миграции.

php bin/console d:m:m -n

3. Выполнить фикстуры.

php bin/console h:d:f:l -n


Сущности


В наличии следующие сущности:
  • Movie. Фильмы.
  • MovieDetail. Подробная информация о фильме фильма.
  • MovieSession. Сеансы фильмов.
  • MovieGroup. Группы фильмов.
  • MovieComment. Комментарии к фильмам.
  • Image. Изображения, используются в комментариях к фильмам.
  • Article. Статьи.
  • Tag. Тэги.

Между ними имеем следующие связи:
  1. один-ко-многим (сеансы фильма, комментарии);
  2. многие-к-одному (группа фильмов);
  3. многие-ко-многим (теги);
  4. один-к-одному (инфо о фильме).

Дополнительные особенности:
  1. Не опубликованные комментарии доступны только авторам.
  2. Администраторам и модераторам доступны все комментарии.
  3. Комментарий созданный пользователем входящим в группу администраторов или модераторов является опубликованным автоматически.

Пользователи и группы пользователей


Группы пользователей:
  1. Администраторы. Имеют полный доступ ко всем записям в системе администрировния.
  2. Модераторы. Имеют ограниченный доступ в системе администрирования (могут только редактировать и удалять комментарии).
  3. Пользователи. Не имеют доступа к системе администрирования. Через апи приложения могут добавлять новые комментарии, редактировать и удалять только собственные комментарии.

Предустановленный набор пользователей:
  1. Андминистратор. Группа: администраторы. Логин: admin, пароль: qwerty
  2. Модератор. Группа: модераторы. Логин: login: moderator, пароль: qwerty
  3. Пользователь 1. Группа: пользователи. Логин: login: user-1, пароль: qwerty
  4. Пользователь 2. Группа: пользователи. Логин: login: user-2, пароль: qwerty

Сценарии


Сценарий 1.

Пользователь не авторизован. Ему доступны все фильмы и комментарии, подтвержденные модераторами. Пользователь не может создавать комментарии, у него нет доступа к системе администрирования.

Сценарий 2.

Пользователь авторизован. Принадлежит к группе «Пользователь». Ему доступны так же комментарии подтвержденные модераторами и собственные комментарии. Пользователь может создавать комментарии и прикреплять изображения к комментариям, может редактировать и удалять свои комментарии. Система администрирования не доступна.

Сценарий 3.

Пользователь авторизован. Принадлежит к группе «Модератор». Ему доступны все фильмы и комментарии. Модератор может создавать комментарии и прикреплять изображения к комментариям, может редактировать и удалять любые комментарии. Доступны ограниченные возможности системы администрирования — доступ к комментариям.

Сценарий 4.

Пользователь авторизован. Принадлежит к группе «Администратор». Ему доступны все фильмы и комментарии. Может создавать комментарии и прикреплять изображения к комментариям, может редактировать и удалять любые комментарии. Доступны полные возможности системы администрирования.

Система администрирования


Система администрирования доступна админам и модераторам.

URL: /admin

Примеры запросов к Api


URL к документации апи: /api/doc

Специальные параметры


_scope

С помощью этого параметра можно определить скопу отличный от дефолтного. Например, получим сокращенный список фильмов состоящий только из id и name.

GET /api/movies?_scope=list_short

_oprs

Параметр "_oprs" позволяет определить оператор для переданных в фильтр значений. Т.е. если нет возможности передать оператор первым символом в параметре к фильтру, это можно сделать с помощью параметра "_oprs". Например, это полезно для массивов когда, нужно передать «NOT IN»:

GET /api/articles?_oprs[type]=<>&type=photo_report

_sort

С помощью этого параметра можно определить порядок сортировки. Например, получаем все статьи сортированные по имени (от последней до первой буквы) и ID (от меньшего к большему):

GET /api/articles?_sort[name]=DESC&_sort[id]=ASC

_offset

С помощью параметра "_offset" можно определить позицию для списка. Например, для того что бы получить все записи начиная с 11-й, передадим "_offset=10":

GET /api/articles?_offset=10

_limit

Этот параметр определяет лимит для списка. Например, получаем первые 10 статей:

GET /api/articles?_limit=10

Примеры фильтров


Строковые фильтры

Для строковых фильтров по умолчанию поиск осуществляется по вхождению подстроки.

GET /api/articles?name=Dolorem

Если нужно обратное, т.е. найти все записи, в которых нет вхождения данной подстроки, то необходимо передать "!" перед значением:

GET /api/articles?name=!Dolorem

Если нужно полное сравнение, то необходимо поставить знак "=" перед значением:

GET /api/articles?name==Dolorem+eaque+libero+maxime.

Не равно, определяется следующим образом (символы "<>" перед значением):

GET /api/articles?name=<>Dolorem+eaque+libero+maxime.

Enum типы

Enum типы ищуются по полному сравнению (=):

Для того что бы найти статьи с типом news необходимо передать news:

GET /api/articles?type=news

Такой запрос вернет пустой результат:

GET /api/articles?type=new

Массивы

Для того, что бы отфильтровать по массиву значений (IN), необходиом передать массив следующим образом [«name1»,«name2»,«name3'...]. Например, для того, чтобы получить статьи с типом photo_report и news:

/api/articles?type=["photo_report","news"]

Получить статьи все кроме new и photo_report:

/api/articles?_oprs[type]=<>&type=["photo_report",+"news"]

Даты

Операторы больше/меньше доступны как для числовых фильтров, так и для фильров дат. Например, получить статьи позже 2016-06-07:

/api/articles?publishAt=>2016-06-07

Получить статьи раньше 2016-06-07:

/api/articles?publishAt=<2016-06-07

Диапазон дат, можно получить следующим образом:

GET /api/articles?publishAt=[">2016-03-06","<2016-03-13"]


Заключение


Для желающих ознакомиться с тем как это реализовано в коде — ссылка на гитхаб.
Поделиться с друзьями
-->

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


  1. zcasper
    15.06.2016 23:54
    -1

    Клёво, за исключением одного НО, в описываемом «демо» API мало общего имеет с REST и больше тянет на RPC (параметры должны идти не GET а в HEAD) так как URL является идентификатором ресурса, а в заголовках описывается/запрашивается содержимое этого ресурса:

    GET: /api/articles
    type=[«photo_report»,«news»]


    1. Nilov_A
      16.06.2016 00:08

      Не согласен. Олег, идентификатор ресурса в REST это путь ("/api/articles"), а то что после "?" называются параметрами.


      1. zcasper
        16.06.2016 00:17

        не рекламы для, пруф: http://anton.shevchuk.name/php/create-restful-api/, да и в принципе всегда так делали…


        1. zcasper
          16.06.2016 00:22

          сам себе отвечу, и так и так тоже делают…


    1. Fesor
      16.06.2016 01:22

      параметры должны идти не GET а в HEAD


      чта? Вы что-то путаете (http verb HEAD и заголовки)
      так как URL является идентификатором ресурса


      URI является идентификатором ресурса (последняя буква об этом так и кричит), URL это… частный случай URI. По поводу фильтрации все намного проще:
      GET /articles - коллекция ресурсов представляющих статьи
      GET /articles?tags=photos,news - выборка из коллекции ресурсов, представляет собой новую коллекцию со своим URI.


      А что до пруфов — перечитайте еще раз. Вы видимо увидили пример с заголовком Range и подумали что это ко всему относится. А чуть дальше пример с выборкой отдельных полей — что собственно ломает ваше утверждение.


      1. zcasper
        16.06.2016 03:06

        Да, всё верно, ниже в той ветке на которую вы ответили, я отписался что ошибался…