Beberapa kata tentang spesifikasinya

Selamat siang untuk semuanya! Anehnya, penyebutan pola "Spesifikasi" dalam konteks php sangat jarang. Tetapi dengan bantuannya Anda tidak hanya dapat menghindari ledakan kombinatorial metode repositori , tetapi juga meningkatkan penggunaan ulang kode . Saya, sebaliknya, ingin memikirkan satu kesempatan lagi yang disediakan oleh pola ini. Ini dapat membantu memecahkan masalah yang terjadi di hampir setiap aplikasi web. Dan secara pribadi, saya sangat merindukan pengetahuan ini beberapa tahun yang lalu.







Apa yang kita lakukan



Mari kita asumsikan kita sedang mengembangkan pelacak tugas. Halaman utama akan menampilkan daftar tugas. Kami juga perlu melihat tugas terpisah.







TaskController.php
<?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,
        ]);
    }
}
      
      





Lebih lanjut, misalkan kita memiliki 3 jenis pengguna:







  • Admin - dapat bekerja dengan semua tugas.
  • Manajer - hanya dapat bekerja dengan tugas proyeknya.
  • Pengembang - hanya dapat bekerja dengan tugas yang diberikan kepadanya.


Oleh karena itu, perlu dibuat sistem hak sehingga setiap jenis pengguna hanya memiliki akses ke tugas-tugas yang diperuntukkan baginya. Ini akan terlihat seperti ini:







TaskController.php
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;
+    }
 }
      
      





Tentu saja, menulis banyak kode di pengontrol tidak baik. Dengan satu atau lain cara, Anda dapat menyebarkannya ke seluruh layanan, gunakan pemilih symfony standar. Tetapi masalah utama dengan kode ini adalah bahwa aturan bisnis kita benar-benar diulangi baik dalam metode filter maupun metode isViewable. Dan koreksi atas fakta ini tidak lagi terlihat begitu jelas. Apa yang dapat Anda lakukan? Kita membutuhkan abstraksi aturan bisnis yang berfungsi baik untuk daftar item dan untuk satu entitas. Inilah yang disediakan template Spesifikasi.







Menulis Spesifikasi



2 , php. Happyr/Doctrine-Specification K-Phoen/rulerz. , symfony 5 . , , .







, . . , , , .







Specification.php
<?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.







CompositeSpecification.php
<?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);
    }
}
      
      





, .







AlwaysSpecified.php
<?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 [];
    }
}
      
      





Equals.php
<?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,
        ];
    }
}
      
      





MemberOf.php
<?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,
        ];
    }
}
      
      





Not.php
<?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();
    }
}
      
      





. . .







Join.php
<?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);
    }
}
      
      





-



, , - . .







IsViewable.php
<?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);
    }
}
      
      





.







TaskController.php
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".







IsViewable.php
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? , ?







Contoh lengkap dari artikel tersebut dapat ditemukan di github .








All Articles