Использование 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:
Вывод
Благодаря классам DTO мы можем обеспечить согласованность и целостность данных для каждого отдельного запроса, а прослушиватель преобразователя параметров реализован таким образом, что все конечные точки, внедряющие класс DTO, будут десериализованы и проверены. Это экономит массу часов работы по внедрению сложных и запутанных проверок для каждой полезной нагрузки в каждой конечной точке.
И вы также можете использовать DTO для объектов ответа. Таким образом, любому разработчику, реализующему клиентский интерфейс, нужно только взглянуть на определение конечной точки Swagger и очень легко получить структуру данных и типы данных возвращаемого json.
Когда вы создаете объект ответа, вы используете класс DTO для сериализации объекта ответа, таким образом применяя всю необходимую проверку и гарантируя, что каждый раз возвращается допустимый объект класса DTO.
И снова мы только что обнаружили, что разработка с использованием PHP Symfony и лучших практик полностью объектно-ориентированного программирования значительно сократит время разработки для создания программных приложений корпоративного класса.