DTO в Symfony

DTO (Data Transfer Objects) — это особый тип объектов, которые используются для переноса данных между процессами. Они содержат не только данные, но и структуру данных и типы данных каждого из элементов объекта. Вы можете использовать их в качестве контракта между двумя сторонами для обеспечения согласованности и целостности данных.

Если вы работаете с PHP Symfony Framework и реализуете REST API, то рекомендуется использовать DTO.

Реализация

Реализуется DTO следующим образом:

<?php

namespace App\UsersBundle\Api\Dtos;

use App\ApiBundle\Dtos\ApiResponsesInterface;
use DateTime;
use JMS\Serializer\Annotation as JMS;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * Class BasicUser
 */
class BasicUser implements ApiResponsesInterface
{
    /**
     * @var string valid Email
     * @Assert\NotNull
     * @Assert\NotBlank(message="Email Address required")
     * @Assert\Email(message="Invalid Email Address")
     * @JMS\Type("string")
     * @JMS\SerializedName("email")
     */
    public $email;
    
    /**
     * @var string First Name
     * @Assert\NotNull
     * @Assert\NotBlank(message="First name cannot be blank")
     * @Assert\Length(min="2", max="256")
     * @JMS\Type("string")
     * @JMS\SerializedName("firstName")
     */
    public $firstName;
    
    /**
     * @var string Last Name
     * @Assert\Length(min="2", max="256")
     * @JMS\Type("string")
     * @JMS\SerializedName("lastName")
     */
    public $lastName;
    
    /**
     * @var string title
     * @JMS\Type("string")
     * @JMS\SerializedName("title")
     */
    public $title;
    
    /**
     * @var bool Customer isActiveUser. Valid values are true, false
     * @JMS\Type("boolean")
     * @JMS\SerializedName("isActiveUser")
     */
    public $isActiveUser;
    
    /**
     * @var string roleName
     * @Assert\Length(min="2", max="256")
     * @JMS\Type("string")
     * @JMS\SerializedName("roleName")
     */
    public $roleName;
    
    /**
     * @var string phone
     * @JMS\Type("string")
     * @JMS\SerializedName("phone")
     */
    public $phone;
    
    /**
     * @var string extension
     * @JMS\Type("string")
     * @JMS\SerializedName("extension")
     */
    public $extension;
    
    /**
     * @var bool isOnlySso
     * @JMS\Type("boolean")
     * @JMS\SerializedName("isOnlySso")
     */
    public $isOnlySso;
    
...

Благодаря возможностям Symfony Annotations и PHP Reflection Class очень легко работать с DTO.

В Dto, показанном выше, используется JMS Serializer Package , который, работает довольно хорошо.

Вы можете использовать собственный сериализатор Symfony, доступный в последних версиях Framework, начиная с Symfony 4.

Использование DTO в REST API

Клиент отправляет запрос REST на конечную точку PHP Symfony Api. Если это POST или PUT, он должен содержать полезную нагрузку json в теле запроса:

{
  "id": 810,
  "email": "fred.flinstone@warnerbros.com",
  "password": null,
  "firstName": "Fred",
  "lastName": "Flinstone",
  "title": null,
  "isActiveUser": true,
  "isConfirmed": true,
  "isOnlySso": false,
...
  "userDomainRoles": [
    {
      "domainId": 2,
      "domainName": "COOL_APP_1",
      "roles": [
        {
          "id": 59,
          "name": "SYSTEM_ADMINISTRATOR"
        }
      ]
    },
    {
      "domainId": 4,
      "domainName": "COOL_APP_2",
      "roles": [
        {
          "id": 90,
          "name": "SYSTEM_ADMINISTRATOR"
        }
      ]
    }
  ],
  "phone": "",
  "extension": ""
...

Мы реализовал прослушиватель Symfony под названием param convertor , который является очень мощным инструментом при правильном использовании. Мы используем DTO и один преобразователь параметров в каждой мини-службе. Этот преобразователь параметров работает следующим образом:В классе контроллера есть метод действия для каждой конечной точки. В аргументах функции вместо внедрения объекта запроса мы вводим объект DTO. То, как мы сообщаем Symfony, что используем DTO, — это приведение типов:

 /**
     * @Route("/{page}/v1/user", options={"url_name":"users_crud", "menu_display_name": "Users", "menu_link": "", "menu_section":"Main", "is_user_navigation": false}, name="updateUserEndpoint", methods={"PUT"}, defaults={"_format": "json"}, requirements={"page"="(api|internal)"})
     * @OA\Put(
     *     summary="Update a User ",
     *     description="Update a User providing all its id and fields that want to be updated.",
     *     operationId="updateUserEndpoint",
     *     @OA\Parameter(
     *      name="body",
     *     in="query",
     *      description="User object",
     *      required=true,
     *      @Model(type=DTO\User::class)
     *      ),
     *     @OA\Response(
     *         response=200,
     *         description="successful operation",
     *         @Model(type=ApiResponse::class)
     *         ),
     *     @OA\Response(
     *         response=412,
     *         description="Precondition Failed. Mostly due to mal formed json or data validation errors",
     *         @OA\JsonContent(
     *             type="string"
     *             )
     *         ),
     *     @OA\Response(
     *         response="500",
     *         description="There was an Internal Server Error."
     *         ),
     *     ),
     * @Security(name="api_key")
     *     }
     * )
     * @param DTO\User $request
     * @param UsersService $usersService
     * @return JsonResponse
     * @throws Exception
     */
    public function updateUser(DTO\User $request, UsersService $usersService)
    {
        $result = $usersService->updateUser($request);
        
        return new JsonResponse($result, $result->httpStatus ?? ErrorCodes::HTTP_200);
    }

Затем мы объявили прослушиватель преобразователя параметров следующим образом services.yaml:

api_request_param_converter:
        class: App\ApiBundle\Utilities\ApiRequestParamConverter
        tags:
            - { name: request.param_converter }

Это говорит Symfony, что для каждого клиентского запроса загружайте преобразователь параметров:

<?php

namespace App\ApiBundle\Utilities;

use App\ApiBundle\Constants\AppConstants;
use App\ApiBundle\Exceptions\JsonException;
use App\ApiBundle\Exceptions\PreconditionFailedException;
use App\ApiBundle\Services\CustomValidationServiceProvider;
use JMS\Serializer\SerializerBuilder;
use JMS\Serializer\SerializerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Sensio\Bundle\FrameworkExtraBundle\Request\ParamConverter\ParamConverterInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Validator\Validator\ValidatorInterface;

/**
 * Class ApiRequestParamConverter
 */
class ApiRequestParamConverter implements ParamConverterInterface
{

    /*
     * @var SerializerBuilder
     */
    private SerializerInterface $serializer;

    /*
     * @var ValidatorInterface
     */
    private ValidatorInterface $validator;

    /*
     * @var validationErrors
     */
    private array $validationErrors;

    /**
     * @var CustomValidationServiceProvider
     */
    private CustomValidationServiceProvider $customValidationServiceProvider;

    /**
     * ApiRequestParamConverter constructor.
     * @param ValidatorInterface $validator
     * @param CustomValidationServiceProvider $customValidationServiceProvider
     */
    public function __construct(ValidatorInterface $validator, CustomValidationServiceProvider $customValidationServiceProvider)
    {
        $this->serializer = SerializerBuilder::create()->build();
        $this->validator = $validator;
        $this->customValidationServiceProvider = $customValidationServiceProvider;
        $this->validationErrors = [];
    }

    /**
     * Checks if the object is supported.
     *
     * @param ParamConverter $configuration Should be an instance of ParamConverter
     *
     * @return bool True if the object is supported, else false
     */
    public function supports(ParamConverter $configuration)
    {
        $namespace = str_replace(AppConstants::APP_ROOT_FOR_BUNDLES, '', $configuration->getClass());
        $bundleName = substr($namespace, 0, strpos($namespace, '\\', 0));
        return strpos($namespace, $bundleName . AppConstants::API_REQUEST_DTOS_ROUTE) === 0;
    }

    /**
     * Stores the object in the request.
     *
     * @param Request $request The request
     * @param ParamConverter $configuration Contains the name, class and options of the object
     *
     * @return void True if the object has been successfully set, else false
     */
    public function apply(Request $request, ParamConverter $configuration)
    {
        $json = $request->getContent();
        $class = $configuration->getClass();

        //Try to deserialize the request dto
        try {
            $dto = $this->serializer->deserialize($json, $class, AppConstants::API_SERIALIZE_FORMAT);
        } catch (\Throwable $e) {
            $jsonMessage = $this->customValidationServiceProvider->getJsonErrorMessage(json_last_error());
            throw new JsonException($e->getMessage() . ' more info:' . $jsonMessage);
        }

        //If there are validation errors, throw validation exception
        $errors = $this->validator->validate($dto);
        if (!empty($errors)) {
            foreach ($errors as $error) {
                $this->validationErrors[] = array('field' => $error->getPropertyPath(), ' message' => $error->getMessage());
            }
            if (!empty($this->validationErrors)) {
                throw new PreconditionFailedException($this->serializer->serialize($this->validationErrors, AppConstants::API_SERIALIZE_FORMAT));
            }
        }

        $request->attributes->set($configuration->getName(), $dto);
    }
}

Если объект, внедренный в качестве аргумента в метод действия конечной точки, поддерживается, происходит волшебство:

Преобразователь параметров применит логику к объекту запроса, в этом случае:

  • Попробуйте десериализовать json в класс DTO. Логика пакета JMS Serializer обеспечит полную совместимость структуры json со структурой, объявленной в классе DTO. Это очень полезно при работе со сложными структурами данных и обеспечивает согласованность данных.
  • После того, как полезная нагрузка json была успешно десериализована в соответствующий класс DTO, я прошу валидатор Symfony проверить объект dto, проверяя каждое из ограничений, объявленных как аннотация Assert в классе DTO. Если есть какие-либо ошибки проверки, клиенту будет возвращено исключение предварительного условия 403, сообщающее, какая проверка не удалась.
  • Если все в порядке, то в методе действия конечной точки доступен для передачи для дальнейшей обработки объект dto, содержащий все данные в соответствующей ему структуре. в данном случае это действие пользователя по обновлению, затем я вызываю метод updateUser в службе пользователей:
 /**
     * @param DTO\User $userDto
     * @return ApiResponse
     * @throws Exception
     */
    public function updateUser(DTO\User $userDto): ApiResponse
    {
        $this->em->getConnection()->beginTransaction();// suspend auto-commit and start transaction
        try {
            $userForUpdate = $this->em->getRepository(User::class)->find($userDto->id);
            if ($userForUpdate) {
                $updateUserTransformer = new UpdateUserTransformer($userDto, $userForUpdate, $this->em, $this->customValidation, $this->passwordEncoder);
                $user = $updateUserTransformer->transform();

.....

В классе Transformer я также выполняю некоторую пользовательскую проверку, например, проверяю дублированную электронную почту, а затем преобразовываю объект пользователя Dto в объект пользователя Doctrine для сохранения в базе данных:

...

    
    /**
     * CreateCustomerTransformer constructor.
     * @param DTO\User $users
     * @param User $user
     * @param EntityManagerInterface $em
     * @param CustomValidationServiceProvider $customValidation
     */
    public function __construct(
        DTO\User $users,
        User $user,
        EntityManagerInterface $em,
        CustomValidationServiceProvider $customValidation
    )
    {
        $this->apiUsers = $users;
        $this->user = $user;
        $this->em = $em;
        $this->customValidation = $customValidation;
    }

    /**
     * @return User
     * @throws CustomValidationException
     * @throws Exception
     */
    public function transform(): User
    {
        if ($this->validate()) {
            //Transform Users
            $this->user->setEmail($this->apiUsers->email);
            $this->user->setFirstName($this->apiUsers->firstName);
            $this->user->setLastName($this->apiUsers->lastName);
            $this->user->setTitle($this->apiUsers->title ?? '');
            $this->user->setPhone($this->apiUsers->phone);
            $this->user->setExtension($this->apiUsers->extension);
            $this->user->setUpdatedAt(new DateTime('now'));
            $this->user->setIsActiveUser($this->apiUsers->isActiveUser);
            $this->user->setIsConfirmed($this->apiUsers->isConfirmed);
            $this->user->setIsOnlySso($this->apiUsers->isOnlySso ?? false);

...

Если вы используете Nelmio Api Doc Package и/или Swagger , вы можете отобразить спецификацию Open Api для ваших конечных точек REST API, включая модели, взятые из DTO:

https://phpenterprisesystems.com/images/symfony_framework/sample-dto-used-for-oas.png

Вывод

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

И вы также можете использовать DTO для объектов ответа. Таким образом, любому разработчику, реализующему клиентский интерфейс, нужно только взглянуть на определение конечной точки Swagger и очень легко получить структуру данных и типы данных возвращаемого json.

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

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