Сериализация данных напрямую в MySQL (5.7)

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

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

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

Сущности

Начнем пожалуй с сущности, давайте представим что мы собираем HTML страницы объявлений. Сущность Page будет иметь следующий вид:

<?php

namespace App\Entity;

use App\Entity\Location\City;
use Doctrine\ORM\Mapping as ORM;
use Mash\MysqlJsonSerializer\Annotation as MysqlJSON;
use Mash\MysqlJsonSerializer\Annotation\Table;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ORM\Table(name="page", indexes={
 *     @ORM\Index(columns={"pge_status"}),
 *     @ORM\Index(columns={"pge_site"}),
 * })
 *
 * @ORM\Entity(repositoryClass="App\Repository\PageRepository")
 *
 * @Table(alias="pge")
 */
class Page implements LockedResourceInterface
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(name="pge_id", type="integer")
     *
     * @MysqlJSON\Expose
     */
    private $id;

    /**
     * @ORM\Column(name="pge_status", type="integer")
     *
     * @MysqlJSON\Expose
     */
    private $status;

    /**
     * @ORM\Column(name="pge_type", type="string")
     *
     * @MysqlJSON\Expose
     */
    private $type;

    /**
     * @ORM\Column(name="pge_category", type="string")
     *
     * @MysqlJSON\Expose
     */
    private $category;

    /**
     * @ORM\Column(name="pge_url", type="string")
     *
     * @MysqlJSON\Expose
     */
    private $url;

    /**
     * One Page has one Lock.
     *
     * @ORM\OneToOne(targetEntity="App\Entity\Lock\PageLock", mappedBy="resource")
     *
     * @MysqlJSON\Expose
     */
    private $lock;

    /**
     * @ORM\Column(name="pge_body", type="blob", nullable=true)
     *
     * @Assert\Type(type="string")
     *
     * @MysqlJSON\Expose(groups={"Default", "page_full"}, type="Mash\MysqlJsonSerializer\Wrapper\Type\Blob")
     */
    private $body;

    /**
     * Many Pages have one Style.
     *
     * @ORM\ManyToOne(targetEntity="App\Entity\Style", inversedBy="pages", cascade={"persist"})
     * @ORM\JoinColumn(name="pge_style", referencedColumnName="stl_id", nullable=true)
     *
     * @MysqlJSON\Expose(groups={"page_full"})
     */
    private $style;

    /**
     * Many Pages have one City.
     *
     * @var City
     *
     * @ORM\ManyToOne(targetEntity="App\Entity\Location\City", cascade={"persist"})
     * @ORM\JoinColumn(name="pge_city", referencedColumnName="cit_id")
     *
     * @Assert\NotBlank
     *
     * @MysqlJSON\Expose(groups={"page_full"})
     */
    private $city;

    /**
     * Many Pages have one Site.
     *
     * @var Site
     *
     * @ORM\ManyToOne(targetEntity="App\Entity\Site", cascade={"persist"})
     * @ORM\JoinColumn(name="pge_site", referencedColumnName="site_id")
     *
     * @Assert\NotBlank
     *
     * @MysqlJSON\Expose(groups={"page_full"})
     */
    private $site;

    //....
}

Вы наверное заметили аннотацию @MysqlJSON\Expose — это и есть та магия, которая позволяет сериализовать поле, но чтобы таблица вошла под контроль сериализатора, вам нужно добавить аннотацию @Table на класс сущности, так же необходимо задать алиас для таблицы, делается это так @Table(alias="pge"), это необходимо для формирования запросов, а так же FieldWrapper который “заворачивает” все в огромный SQL запрос, который руками будет очень долго писать…

Итак, сущность есть, теперь надо бы создать контроллер, а вот и он:

Контроллер

<?php

namespace App\RestController;

use App\Annotation\Lock;
use App\Entity\Lock\PageLock;
use App\Entity\Page;
use App\Entity\Site;
use App\Form\PageType;
use FOS\RestBundle\Controller\AbstractFOSRestController;
use FOS\RestBundle\Controller\Annotations as Rest;
use Mash\MysqlJsonSerializer\QueryBuilder\SQL\SQL;
use Mash\MysqlJsonSerializer\Service\QueryBuilderFactory;
use Nelmio\ApiDocBundle\Annotation\ApiDoc;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
use Symfony\Component\HttpFoundation\Request;

/**
 * Class PageController.
 *
 * @Rest\RouteResource("page", pluralize=false)
 */
class PageController extends AbstractFOSRestController
{
    /**
     * @var QueryBuilderFactory
     */
    private $queryBuilderFactory;

    public function __construct(QueryBuilderFactory $queryBuilderFactory)
    {
        $this->queryBuilderFactory = $queryBuilderFactory;
    }

    /**
     * Get Page list.
     *
     * @ApiDoc(
     *     views={"v1"},
     *     section="Page",
     *     description="Get Page list",
     *     filters={
     *         {
     *             "name": "page",
     *             "dataType": "integer",
     *             "required": "false",
     *             "description": "Page number for pagination"
     *         },
     *         {
     *             "name": "limit",
     *             "dataType": "integer",
     *             "required": "false",
     *             "description": "Limit for pagination"
     *         },
     *         {
     *             "name": "site",
     *             "dataType": "integer",
     *             "required": "false",
     *             "description": "Site ID, numeric site identifier"
     *         }
     *     },
     *     requirements={
     *         {
     *             "name": "version",
     *             "dataType": "string",
     *             "requirement": "(v1|v2|v3)",
     *             "description": "API version"
     *         },
     *     },
     *     statusCodes={
     *         "200": "Returned when successful",
     *         "403": "Returned when you haven't permissions",
     *     },
     *     tags={"v1"}
     * )
     *
     * @Rest\View(serializerGroups={"Default", "page_full"})
     *
     * @IsGranted("ROLE_HISTORY_VIEW", statusCode=403, message="Access denied, you have not permissions")
     *
     * @param Request $request
     *
     * @return SQL
     */
    public function getListAction(Request $request)
    {
        $page   = $request->get('page', 1);
        $limit  = $request->get('limit', 20);
        $siteId = $request->get('site', null);
        $status = $request->get('status', null);
        $noLock = $request->get('noLock', null);

        $builder = $this->queryBuilderFactory->getBuilder(Page::class);
        $builder->orderBy('pge.pge_id', 'DESC');

        if (null !== $status) {
            $builder
                ->andWhere('pge.pge_status = :status')
                ->setParameter('status', $status)
            ;
        }

        if (null !== $siteId && 0 !== (int) $siteId) {
            $builder
                ->innerJoin(Site::class, 'pge.pge_site = sit.site_id AND sit.site_id = :site')
                ->setParameter('site', $siteId);
        }

        if ('1' === $noLock) {
            $builder
                ->select('pge.*, lck.lck_id')
                ->leftJoin(
                    PageLock::class,
                    'pge.pge_id = lck.lck_resource AND lck.lck_type = :lock_type'
                )
                ->setParameter('lock_type', 'page')
                ->andWhere('lck.lck_id is NULL')
            ;
        }

        return $builder->jsonPagination($page, $limit);
    }

    /**
     * Get Page by ID.
     *
     * @ApiDoc(
     *     views={"v1"},
     *     section="Page",
     *     description="Get Page by ID",
     *     requirements={
     *         {
     *             "name": "page",
     *             "dataType": "integer",
     *             "requirement": "\d+",
     *             "description": "Page ID"
     *         },
     *         {
     *             "name": "version",
     *             "dataType": "string",
     *             "requirement": "(v1|v2|v3)",
     *             "description": "API version"
     *         },
     *     },
     *     statusCodes={
     *         "200": "Returned when successful",
     *         "404": "Returned when not found",
     *         "403": "Returned when you haven't permissions",
     *     },
     *     output="App\Entity\Page",
     *     tags={"v1"}
     * )
     *
     * @Rest\View(serializerGroups={"Default", "page_full"})
     *
     * @IsGranted("ROLE_HISTORY_VIEW", statusCode=403, message="Access denied, you have not permissions")
     *
     * @param Page $page
     *
     * @return SQL
     */
    public function getAction(Page $page)
    {
        $builder = $this->queryBuilderFactory->getBuilder(Page::class);

        return $builder->jsonObject($page->getId());
    }

    /**
     * Post new Page.
     *
     * @ApiDoc(
     *     views={"v1"},
     *     section="Page",
     *     description="Post new Page",
     *     statusCodes={
     *         "200": "Returned when successful",
     *         "404": "Returned when not found",
     *         "403": "Returned when you haven't permissions",
     *     },
     *     input="App\Form\PageType",
     *     output="App\Entity\Page",
     *     tags={"v1"}
     * )
     *
     * @Rest\View(serializerGroups={"Default", "page_full"})
     *
     * @IsGranted("ROLE_ADMIN", statusCode=403, message="Access denied, you have not permissions")
     *
     * @param Request $request
     *
     * @return array|SQL
     */
    public function postAction(Request $request)
    {
        $page = new Page();

        $form = $this->createForm(PageType::class, $page, ['method' => 'POST'])
            ->handleRequest($request);

        if (false === $form->isSubmitted()) {
            $form->submit([]);
        }

        if (false === $form->isValid()) {
            return ['form' => $form];
        }

        $manager = $this->getDoctrine()->getManager();
        $manager->persist($page);
        $manager->flush();

        $builder = $this->queryBuilderFactory->getBuilder(Page::class);

        return $builder->jsonObject($page->getId());
    }

    //...
}

Каждый наверное знает, для того чтобы свойство сущности (поле в таблице БД) было сериализовано, необходимо чтобы совпала хотя бы одна группа в @Rest\View(serializerGroups={"Default", "page_full"}) (аннотация на методе контроллера) и @MysqlJSON\Expose(groups={"page_full"}) (аннотация в сущности) — это стандартная схема работы сериализатора в симфони.

Итак, как видите у нас совпала группа page_full — а это значит что? Да, поле будет сериализовано. Но так же я хочу вам показать пару приемов с моим QueryBuilder, помимо стандартных операций у него есть методы, которые возвращают:

  • Спагинированные данные $builder->jsonPagination($page, $limit)
  • Объект $builder->jsonObject($page->getId())
  • Массив объектов $builder->jsonArray()

Если ваш фронт хочет пагинацию то просто верните ее из контроллера, удобно же?

Примеры вывода:

  • пагинация
{"data": [{"id": 216, "url": "http://url", "body": "<html>Some html body</html>", "city": {"id": 41, "cad": "99", "name": "Name", "center": {"latitude": 33.4444, "longitude": 88.9999}, "region": {"id": 41, "cad": "12", "code": "99", "name": "region", "capital": null, "country": {"id": 38, "area": 17100000, "name": "Россия", "capital": null}}, "population": 10000}, "site": {"id": 22, "name": "site_name_7116", "address": "http://test.address"}, "type": "rent", "style": {"id": 21, "hash": "7698daff6f9d6a9947b7773fb3cb90a2b6b7d82238533b41958c047ee4427258"}, "status": 0, "category": "flat"}], "totalItems": 3, "currentPage": 1, "itemsPerPage": 20}
  • Объект
{"id": 216, "url": "http://url", "body": "<html>Some html body</html>", "city": {"id": 41, "cad": "99", "name": "Name", "center": {"latitude": 33.4444, "longitude": 88.9999}, "region": {"id": 41, "cad": "12", "code": "99", "name": "region", "capital": null, "country": {"id": 38, "area": 17100000, "name": "Россия", "capital": null}}, "population": 10000}, "site": {"id": 22, "name": "site_name_7116", "address": "http://test.address"}, "type": "rent", "style": {"id": 21, "hash": "7698daff6f9d6a9947b7773fb3cb90a2b6b7d82238533b41958c047ee4427258"}, "status": 0, "category": "flat"}
  • Массив объектов ( это те же объекты, только внутри [ ])

Так же очень интересный момент, что касается каких-то нестандартных операций при сериализации, есть такие ValueObjects в Doctrine например, та самая Point в MySQL, вот ссылка

Вы наверное заметили в коде, такую вещь @MysqlJSON\Expose(groups={"Default", "page_full"}, type="Mash\MysqlJsonSerializer\Wrapper\Type\Blob") — я называю это CustomType, например в случае с Blob MySQL делает encode в base64 внутри JSON, неудобно, да? Но вы можете написать свой кастомный тип, чтобы это обойти:

<?php

namespace Mash\MysqlJsonSerializer\Wrapper\Type;

class Blob implements CustomTypeInterface
{
    public function convert(string $name, string $alias): string
    {
        return "CONVERT({$alias}.{$name} USING utf8mb4)";
    }
}

Заимплементили CustomTypeInterface и все, а дальше внутри описали логику.

Вот пример для Point

<?php

namespace Mash\MysqlJsonSerializer\Wrapper\Type;

class Location implements CustomTypeInterface
{
    public function convert(string $name, string $alias): string
    {
        return '(SELECT CAST('
            . 'REPLACE('
            . 'REPLACE('
            . 'REPLACE('
            . "ASTEXT({$alias}.{$name}), 'POINT(', '{\"longitude\":'),"
            . "' ', ',\"latitude\":')"
            . ",')', '}') "
            . 'as JSON))';
    }
}

Результат:

{"latitude": 33.4444, "longitude": 88.9999}

Удобно? полное описание в репозитории на гитхабе

P.S.

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

Подпишитесь на рассылку новых статей

Подпишитесь на рассылку свежих статей и присоединяйтесь к 7 остальным подписчикам.