Bom Dia a todos! Surpreendentemente, a menção do padrão "Especificação" no contexto php é extremamente rara. Mas com sua ajuda, você pode não apenas evitar a explosão combinatória dos métodos de repositório , mas também melhorar a reutilização de código . Eu, por sua vez, gostaria de me deter em mais uma oportunidade fornecida por esse padrão. Pode ajudar a resolver um problema que ocorre em quase todos os aplicativos da web. E pessoalmente, eu realmente senti falta desse conhecimento alguns anos atrás.
O que nós fazemos
Vamos supor que estejamos desenvolvendo um rastreador de tarefas. A página principal exibirá uma lista de tarefas. Também precisamos ver uma tarefa separada.
<?php declare(strict_types=1); namespace App\Controller; use App\Entity\Task; use App\Repository\TaskRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; #[Route('/task')] final class TaskController extends AbstractController { #[Route('/', name: 'task_index', methods: ['GET'])] public function index(TaskRepository $taskRepository): Response { return $this->render('task/index.html.twig', [ 'tasks' => $taskRepository->findAll(), ]); } #[Route('/{id}', name: 'task_show', methods: ['GET'])] public function show(Task $task): Response { return $this->render('task/show.html.twig', [ 'task' => $task, ]); } }
Além disso, suponha que temos 3 tipos de usuários:
- Admin - pode trabalhar com todas as tarefas.
- Gerente - só pode trabalhar com as tarefas de seu projeto.
- Desenvolvedor - pode trabalhar apenas com tarefas atribuídas a ele.
Portanto, é necessário criar um sistema de direitos para que cada tipo de usuário tenha acesso apenas às tarefas que lhe são destinadas. Vai parecer algo assim:
namespace App\Controller; use App\Entity\Task; +use App\Entity\User; use App\Repository\TaskRepository; +use App\Security\CurrentUserProvider; +use Doctrine\ORM\QueryBuilder; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; #[Route('/task')] final class TaskController extends AbstractController { + public function __construct(private AuthorizationCheckerInterface $authorizationChecker, private CurrentUserProvider $currentUserProvider) + { + } + #[Route('/', name: 'task_index', methods: ['GET'])] public function index(TaskRepository $taskRepository): Response { + $queryBuilder = $taskRepository->createQueryBuilder('t'); + $this->filter($queryBuilder); + return $this->render('task/index.html.twig', [ - 'tasks' => $taskRepository->findAll(), + 'tasks' => $queryBuilder->getQuery() + ->getResult(), ]); } + private function filter(QueryBuilder $queryBuilder): void + { + if ($this->authorizationChecker->isGranted(User::ROLE_ADMIN)) { + return; + } + + $user = $this->currentUserProvider->getUser(); + + if ($this->authorizationChecker->isGranted(User::ROLE_MANAGER)) { + $queryBuilder->andWhere('t.project in(:projects)') + ->setParameter('projects', $user->getProjects()); + + return; + } + + $queryBuilder->andWhere('t.performedBy = :performedBy') + ->setParameter('performedBy', $user); + } + #[Route('/{id}', name: 'task_show', methods: ['GET'])] public function show(Task $task): Response { + if (!$this->isViewable($task)) { + throw new AccessDeniedHttpException(); + } + return $this->render('task/show.html.twig', [ 'task' => $task, ]); } + + private function isViewable(Task $task): bool + { + if ($this->authorizationChecker->isGranted(User::ROLE_ADMIN)) { + return true; + } + + $user = $this->currentUserProvider->getUser(); + + if ($this->authorizationChecker->isGranted(User::ROLE_MANAGER)) { + return $user->getProjects() + ->contains($task->getProject()); + } + + return $task->getPerformedBy() === $user; + } }
É claro que escrever muito código no controlador não é bom. De uma forma ou de outra, você pode espalhar pelos serviços, use os votantes padrão do symfony. Mas o principal problema com esse código é que nossas regras de negócios são completamente repetidas tanto no método de filtro quanto no método isViewable. E a correção desse fato não parece mais tão óbvia. O que você pode fazer a respeito? Precisamos de uma abstração de regra de negócios que funcione tanto para uma lista de itens quanto para uma única entidade. Isso é o que o modelo de especificação fornece.
Escrevendo uma Especificação
2 , php. Happyr/Doctrine-Specification K-Phoen/rulerz. , symfony 5 . , , .
, . . , , , .
<?php declare(strict_types=1); namespace App\Specification; use Doctrine\ORM\QueryBuilder; use Symfony\Component\PropertyAccess\PropertyAccess; abstract class Specification { abstract public function isSatisfiedBy(object $entity): bool; abstract public function generateDql(string $alias): ?string; abstract public function getParameters(): array; public function modifyQuery(QueryBuilder $queryBuilder): void { } public function filter(QueryBuilder $queryBuilder): void { $this->modifyQuery($queryBuilder); $alias = $queryBuilder->getRootAliases()[0]; $dql = $this->generateDql($alias); if (null === $dql) { return; } $queryBuilder->where($dql); foreach ($this->getParameters() as $field => $value) { $queryBuilder->setParameter($field, $value); } } protected function getFieldValue(object $entity, string $field): mixed { return PropertyAccess::createPropertyAccessorBuilder() ->enableExceptionOnInvalidIndex() ->getPropertyAccessor() ->getValue($entity, $field); } }
. filter query builder. getFieldValue
.
, -, . CompositeSpecification.
<?php declare(strict_types=1); namespace App\Specification; use Doctrine\ORM\QueryBuilder; abstract class CompositeSpecification extends Specification { abstract public function getSpecification(): Specification; public function isSatisfiedBy(object $entity): bool { return $this->getSpecification() ->isSatisfiedBy($entity); } public function generateDql(string $alias): ?string { return $this->getSpecification() ->generateDql($alias); } public function getParameters(): array { return $this->getSpecification() ->getParameters(); } public function modifyQuery(QueryBuilder $queryBuilder): void { $this->getSpecification() ->modifyQuery($queryBuilder); } }
, .
<?php declare(strict_types=1); namespace App\Specification; final class AlwaysSpecified extends Specification { public function isSatisfiedBy(object $entity): bool { return true; } public function generateDql(string $alias): ?string { return null; } public function getParameters(): array { return []; } }
<?php declare(strict_types=1); namespace App\Specification; final class Equals extends Specification { public function __construct(private string $field, private mixed $value) { } public function isSatisfiedBy(object $entity): bool { return $this->value === $this->getFieldValue($entity, $this->field); } public function generateDql(string $alias): ?string { return sprintf('%s.%s = :%2$s', $alias, $this->field); } public function getParameters(): array { return [ $this->field => $this->value, ]; } }
<?php declare(strict_types=1); namespace App\Specification; final class MemberOf extends Specification { public function __construct(private string $field, private object $value) { } public function isSatisfiedBy(object $entity): bool { return $this->getFieldValue($entity, $this->field) ->contains($this->value); } public function generateDql(string $alias): ?string { return sprintf(':%2$s member of %1$s.%2$s', $alias, $this->field); } public function getParameters(): array { return [ $this->field => $this->value, ]; } }
<?php declare(strict_types=1); namespace App\Specification; final class Not extends Specification { public function __construct(private Specification $specification) { } public function isSatisfiedBy(object $entity): bool { return !$this->specification ->isSatisfiedBy($entity); } public function generateDql(string $alias): ?string { return sprintf( 'not (%s)', $this->specification->generateDql($alias) ); } public function getParameters(): array { return $this->specification ->getParameters(); } }
. . .
<?php declare(strict_types=1); namespace App\Specification; use Doctrine\ORM\QueryBuilder; final class Join extends Specification { public function __construct(private string $rootAlias, private string $field, private Specification $specification) { } public function isSatisfiedBy(object $entity): bool { return $this->specification ->isSatisfiedBy($this->getFieldValue($entity, $this->field)); } public function generateDql(string $alias): ?string { return $this->specification ->generateDql($this->field); } public function getParameters(): array { return $this->specification ->getParameters(); } public function modifyQuery(QueryBuilder $queryBuilder): void { $queryBuilder->join(sprintf('%s.%s', $this->rootAlias, $this->field), $this->field); $this->specification ->modifyQuery($queryBuilder); } }
-
, , - . .
<?php declare(strict_types=1); namespace App\Specification\Task; use App\Entity\User; use App\Security\CurrentUserProvider; use App\Specification\AlwaysSpecified; use App\Specification\CompositeSpecification; use App\Specification\Equals; use App\Specification\Join; use App\Specification\MemberOf; use App\Specification\Specification; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; final class IsViewable extends CompositeSpecification { public function __construct(private AuthorizationCheckerInterface $authorizationChecker, private CurrentUserProvider $currentUserProvider) { } public function getSpecification(): Specification { if ($this->authorizationChecker->isGranted(User::ROLE_ADMIN)) { return new AlwaysSpecified(); } $user = $this->currentUserProvider->getUser(); if ($this->authorizationChecker->isGranted(User::ROLE_MANAGER)) { $isProjectMember = new MemberOf('members', $user); return new Join('task', 'project', $isProjectMember); } return new Equals('performedBy', $user); } }
.
namespace App\Controller; use App\Entity\Task; -use App\Entity\User; use App\Repository\TaskRepository; -use App\Security\CurrentUserProvider; -use Doctrine\ORM\QueryBuilder; +use App\Specification\Task\IsViewable; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Routing\Annotation\Route; -use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; #[Route('/task')] final class TaskController extends AbstractController { - public function __construct(private AuthorizationCheckerInterface $authorizationChecker, private CurrentUserProvider $currentUserProvider) + public function __construct(private IsViewable $isViewable) { } @@ -26,7 +23,7 @@ final class TaskController extends AbstractController public function index(TaskRepository $taskRepository): Response { $queryBuilder = $taskRepository->createQueryBuilder('t'); - $this->filter($queryBuilder); + $this->isViewable->filter($queryBuilder); return $this->render('task/index.html.twig', [ 'tasks' => $queryBuilder->getQuery() @@ -34,29 +31,10 @@ final class TaskController extends AbstractController ]); } - private function filter(QueryBuilder $queryBuilder): void - { - if ($this->authorizationChecker->isGranted(User::ROLE_ADMIN)) { - return; - } - - $user = $this->currentUserProvider->getUser(); - - if ($this->authorizationChecker->isGranted(User::ROLE_MANAGER)) { - $queryBuilder->andWhere('t.project in(:projects)') - ->setParameter('projects', $user->getProjects()); - - return; - } - - $queryBuilder->andWhere('t.performedBy = :performedBy') - ->setParameter('performedBy', $user); - } - #[Route('/{id}', name: 'task_show', methods: ['GET'])] public function show(Task $task): Response { - if (!$this->isViewable($task)) { + if (!$this->isViewable->isSatisfiedBy($task)) { throw new AccessDeniedHttpException(); } @@ -64,20 +42,4 @@ final class TaskController extends AbstractController 'task' => $task, ]); } - - private function isViewable(Task $task): bool - { - if ($this->authorizationChecker->isGranted(User::ROLE_ADMIN)) { - return true; - } - - $user = $this->currentUserProvider->getUser(); - - if ($this->authorizationChecker->isGranted(User::ROLE_MANAGER)) { - return $user->getProjects() - ->contains($task->getProject()); - } - - return $task->getPerformedBy() === $user; - } }
! . ?
, , "archived".
use App\Entity\User; use App\Security\CurrentUserProvider; use App\Specification\AlwaysSpecified; +use App\Specification\AndX; use App\Specification\CompositeSpecification; use App\Specification\Equals; use App\Specification\Join; use App\Specification\MemberOf; +use App\Specification\Not; +use App\Specification\Project\IsArchived; use App\Specification\Specification; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; @@ -26,14 +29,23 @@ final class IsViewable extends CompositeSpecification return new AlwaysSpecified(); } + $isNotArchived = new Not(new IsArchived()); $user = $this->currentUserProvider->getUser(); if ($this->authorizationChecker->isGranted(User::ROLE_MANAGER)) { $isProjectMember = new MemberOf('members', $user); - return new Join('task', 'project', $isProjectMember); + return $this->getProjectSpecification(new AndX($isNotArchived, $isProjectMember)); } - return new Equals('performedBy', $user); + return new AndX( + new Equals('performedBy', $user), + $this->getProjectSpecification($isNotArchived) + ); + } + + private function getProjectSpecification(Specification $specification): Join + { + return new Join('task', 'project', $specification); } }
. , . . . . . — - , . . , - .
, ? php? , ?
Um exemplo completo do artigo pode ser encontrado no github .