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 и лучших практик полностью объектно-ориентированного программирования значительно сократит время разработки для создания программных приложений корпоративного класса.