Реализация подхода Event-Sourcing / CQRS в api-платформе

На официальном веб-сайте Api-Platform есть страница " Общие вопросы дизайна".

Наконец, что не менее важно, для создания систем на основе Event Sourcing, удобный подход:

  • сохранить данные в хранилище событий, используя пользовательские данные сохраняются
  • создавать проекции в стандартных таблицах или представлениях RDBMS (Postgres, MariaDB...)
  • сопоставить эти проекции с классами сущностей Doctrine только для чтения и пометить эти классы с помощью @ApiResource

После этого вы можете воспользоваться встроенными фильтрами Doctrine, сортировкой, разбиением на страницы, авто-соединениями и т. Д., Предоставляемыми API Platform.

Итак, я попытался реализовать этот подход с одним упрощением (используется одна БД, но с раздельными операциями чтения и записи).

Но не удалось... есть проблема, которую я не знаю, как решить, поэтому просим вас о помощи!

Я создал User Сущность доктрины и аннотированные поля, которые я хочу раскрыть @Serializer\Groups({"Read"}), Я опущу это здесь, поскольку это очень общее.

User ресурс в формате yaml для api-платформы:

# config/api_platform/entities/user.yaml

App\Entity\User\User:
    attributes:
        normalization_context:
            groups: ["Read"]
    itemOperations:
        get: ~
    collectionOperations:
        get:
            access_control: "is_granted('ROLE_ADMIN')"

Итак, как показано выше User Доктрина сущности только для чтения, как только GET методы определены.

Затем я создал CreateUser DTO:

# src/Dto/User/CreateUser.php

namespace App\Dto\User;

use App\Validator as AppAssert;
use Symfony\Component\Validator\Constraints as Assert;

final class CreateUser
{
    /**
     * @var string
     * @Assert\NotBlank()
     * @Assert\Email()
     * @AppAssert\FakeEmailChecker()
     */
    public $email;
    /**
     * @var string
     * @Assert\NotBlank()
     * @AppAssert\PlainPassword()
     */
    public $plainPassword;
}

CreateUser ресурс в формате yaml для api-платформы:

# config/api_platform/dtos/create_user.yaml

App\Dto\User\CreateUser:
    itemOperations: {}
    collectionOperations:
        post:
            access_control: "is_anonymous()"
            path: "/users"
            swagger_context:
                tags: ["User"]
                summary: "Create new User resource"

Итак, здесь вы можете увидеть, что только один POST Метод определен именно для создания нового пользователя.

А вот что показывает роутер:

$ bin/console debug:router
---------------------------------- -------- -------- ------ -----------------------
Name                               Method   Scheme   Host   Path
---------------------------------- -------- -------- ------ -----------------------
api_create_users_post_collection   POST     ANY      ANY    /users
api_users_get_collection           GET      ANY      ANY    /users.{_format}
api_users_get_item                 GET      ANY      ANY    /users/{id}.{_format}

Я тоже добавил кастом DataPersister обрабатывать POST в /users, В CreateUserDataPersister::persist Я использовал сущность Doctrine для записи данных, но для этого случая это не имеет значения, так как Api-платформа ничего не знает о том, как DataPersister будет их писать. Итак, от концепции - это разделение чтения и записи.

Чтения выполняются Доктрины DataProvider поставляется с Api-платформой, а запись выполняется на заказ DataPersister,

# src/DataPersister/CreateUserDataPersister.php

namespace App\DataPersister;

use ApiPlatform\Core\DataPersister\DataPersisterInterface;
use App\Dto\User\CreateUser;
use App\Entity\User\User;
use Doctrine\ORM\EntityManagerInterface;

class CreateUserDataPersister implements DataPersisterInterface
{
    private $manager;

    public function __construct(EntityManagerInterface $manager)
    {
        $this->manager = $manager;
    }

    public function supports($data): bool
    {
        return $data instanceof CreateUser;
    }

    public function persist($data)
    {
        $user = new User();
        $user
            ->setEmail($data->email)
            ->setPlainPassword($data->plainPassword);

        $this->manager->persist($user);
        $this->flush();

        return $user;
    }

    public function remove($data)
    {

    }
}

Когда я выполняю запрос на создание нового пользователя:

POST https://{{host}}/users
Content-Type: application/json

{
  "email": "test@custom.domain",
  "plainPassword": "123qweQWE"
}

Проблема! Я получаю 400 ответ ... "hydra:description": "No item route associated with the type "App\Dto\User\CreateUser"." ...

Тем не менее, новая запись добавляется в базу данных, так что пользовательский DataPersister работает;)

В соответствии с общими соображениями проектирования разделение операций записи и чтения осуществляется, но не работает должным образом.

Я почти уверен, что мне не хватает чего-то для настройки или реализации. Вот почему это не работает.

Будем рады любой помощи!

Обновление 1:

Проблема в \ApiPlatform\Core\Bridge\Symfony\Routing\RouteNameResolver::getRouteName(), В строках 48-59 он перебирает все маршруты, пытаясь найти подходящий маршрут для:

  • $operationType = 'item'
  • $resourceClass = 'App\Dto\User\CreateUser'

Но $operationType = 'item' определяется только для $resourceClass = 'App\Entity\User\User', поэтому он не может найти маршрут и выдает исключение.

Обновление 2:

Итак, вопрос может звучать так:

Как можно реализовать разделение операций чтения и записи (CQS?) С использованием сущности Doctrine для операций чтения и DTO для операций записи, которые находятся на одном и том же маршруте, но разными методами?

Обновление 3:

Данные сохраняются

  • хранить данные в других постоянных слоях (ElasticSearch, MongoDB, внешние веб-сервисы...)
  • не публично раскрывать внутреннюю модель, сопоставленную с базой данных через API
  • использовать отдельную модель для операций чтения и обновления путем реализации таких шаблонов, как CQRS

Да! Я хочу этого... но как этого добиться в моем примере?

2 ответа

Короткий ответ

Проблема заключается в том, что объект Dto\User\CreateUser сериализуется для ответа, хотя на самом деле вы действительно хотите, чтобы Entity\User был возвращен и сериализован.

Длинный ответ

Когда API Platform сериализует ресурс, они генерируют IRI для ресурса. Поколение IRI - то, где код рвется. Генератор IRI по умолчанию использует маршрутизатор Symfony для фактического построения маршрута на основе маршрутов API, созданных платформой API.

Таким образом, для генерации IRI на объекте необходимо определить операцию элемента GET, поскольку именно этот маршрут будет IRI для ресурса.

В вашем случае у DTO нет операции элемента GET (и не должно быть), но когда API Platform пытается сериализовать вашу DTO, она выдает эту ошибку.

Шаги, чтобы исправить

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

Единственное, что нужно сделать, это установить пакет отладки и запустить сервер дампа bin/console server:dumpи добавьте несколько операторов дампа в WriteListener платформы API: ApiPlatform\Core\EventListener\WriteListener рядом со строкой 53:

dump(["Controller Result: ", $controllerResult]);
$persistResult = $this->dataPersister->persist($controllerResult);
dump(["Persist Result: ", $persistResult]);

Результат контроллера должен быть экземпляром вашего DTO, результат Persist должен быть экземпляром вашего объекта User, но я предполагаю, что он возвращает ваше DTO.

Если он возвращает ваш DTO, вам нужно просто отладить и выяснить, почему DTO возвращается из dataPersister->persist вместо сущности User. Возможно, у вас есть другие данные или что-то в вашей системе, что может вызвать конфликт.

Надеюсь, это поможет!

Работать только в версии 2.4, но очень полезно.

Просто добавь output_class=false для CreateUserDTO и все будет хорошо для POST|PUT|PATCH

Значение false для output_class позволяет обойти операцию get item. Вы можете видеть это в ApiPlatform \ Core \ EventListener # L68.

Вы должны отправить "id" в своем ответе.

Если Пользователь является сущностью Doctrine, используйте:

/**
 * @ORM\Id()
 * @ORM\GeneratedValue()
 * @ORM\Column(type="integer")
 */
private $id;

Если Пользователь не является сущностью Doctrine, используйте:

/**
 * @Assert\Type(type="integer")
 * @ApiProperty(identifier=true)
 */
private $id;

В любом случае, ваш ответ будет таким:

{
  "id": 1, // Your unique id of User
  "email": "test@custom.domain",
  "plainPassword": "123qweQWE"
}

PS: простите за мой английский:)

Другие вопросы по тегам