Guten Tag allerseits! Überraschenderweise ist die Erwähnung des Musters "Spezifikation" im PHP-Kontext äußerst selten. Mit seiner Hilfe können Sie jedoch nicht nur die kombinatorische Explosion von Repository-Methoden vermeiden , sondern auch die Wiederverwendung von Code verbessern . Ich möchte wiederum auf eine weitere Gelegenheit eingehen, die dieses Muster bietet. Es kann helfen, ein Problem zu lösen, das in fast jeder Webanwendung auftritt. Und ich persönlich habe dieses Wissen vor ein paar Jahren wirklich vermisst.
Was werden wir machen
Nehmen wir an, wir entwickeln einen Task-Tracker. Auf der Hauptseite wird eine Liste der Aufgaben angezeigt. Wir müssen auch eine separate Aufgabe anzeigen.
<?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, ]); } }
Angenommen, wir haben drei Arten von Benutzern:
- Admin - kann mit allen Aufgaben arbeiten.
- Manager - kann nur mit den Aufgaben seines Projekts arbeiten.
- Entwickler - kann nur mit ihm zugewiesenen Aufgaben arbeiten.
Daher muss ein System von Rechten erstellt werden, damit jeder Benutzertyp nur auf die für ihn bestimmten Aufgaben zugreifen kann. Es wird ungefähr so aussehen:
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; + } }
Natürlich ist es nicht gut, viel Code in den Controller zu schreiben. Auf die eine oder andere Weise können Sie es auf mehrere Dienste verteilen und die Standard-Symfony-Wähler verwenden. Das Hauptproblem bei diesem Code ist jedoch, dass unsere Geschäftsregeln sowohl in der Filtermethode als auch in der isViewable-Methode vollständig wiederholt werden. Und die Korrektur dieser Tatsache sieht nicht mehr so offensichtlich aus. Was können Sie dagegen tun? Wir benötigen eine Geschäftsregelabstraktion, die sowohl für eine Liste von Elementen als auch für eine einzelne Entität funktioniert. Dies bietet die Spezifikationsvorlage.
Eine Spezifikation schreiben
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? , ?
Ein vollständiges Beispiel aus dem Artikel finden Sie auf github .