- PVSM.RU - https://www.pvsm.ru -
Всем доброго времени суток! Удивительно, но упоминание о шаблоне "Спецификация" [1] в контексте php встречается крайне редко. А ведь с его помощью можно не только избежать комбинаторного взрыва методов репозитория [2], но и улучшить переиспользование кода [3]. Я же в свою очередь хотел бы остановиться на еще одной возможности, предоставляемой данным паттерном. С ее помощью можно решить проблему, которая возникает почти в каждом веб-приложении. И лично мне очень не хватало этого знания еще пару лет назад.
Предположим, что мы разрабатываем task tracker. На главной странице будет выводиться список задач. Также нам понадобится просмотр отдельной задачи.
<?php
declare(strict_types=1);
namespace AppController;
use AppEntityTask;
use AppRepositoryTaskRepository;
use SymfonyBundleFrameworkBundleControllerAbstractController;
use SymfonyComponentHttpFoundationResponse;
use SymfonyComponentRoutingAnnotationRoute;
#[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,
]);
}
}
Далее предположим, что у нас есть 3 типа пользователей:
Следовательно необходимо создать систему прав, чтобы каждый тип пользователей имел доступ лишь к предназначенным ему задачам. Выглядеть это будет примерно так:
namespace AppController;
use AppEntityTask;
+use AppEntityUser;
use AppRepositoryTaskRepository;
+use AppSecurityCurrentUserProvider;
+use DoctrineORMQueryBuilder;
use SymfonyBundleFrameworkBundleControllerAbstractController;
use SymfonyComponentHttpFoundationResponse;
+use SymfonyComponentHttpKernelExceptionAccessDeniedHttpException;
use SymfonyComponentRoutingAnnotationRoute;
+use SymfonyComponentSecurityCoreAuthorizationAuthorizationCheckerInterface;
#[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;
+ }
}
Конечно, писать много кода в контроллере — это не очень хорошо. Можно так или иначе раскидать его по сервисам, задействовать стандартные symfony voters. Но основная проблема этого кода в том, что наши бизнес-правила полностью повторяются и в методе filter, и в методе isViewable. И исправление этого факта уже не выглядит столь очевидно. Что можно с этим сделать? Нам нужна абстракция бизнес-правила, работающая как для списка элементов, так и для отдельной сущности. Именно это и предоставляет шаблон "Спецификация".
В настоящий момент я нашел 2 проекта, реализующих данный паттерн для php. Happyr/Doctrine-Specification [4] и K-Phoen/rulerz [5]. При этом первый не поддерживает работу с отдельными объектами, а второй фактически заброшен и на symfony 5 уже не устанавливается. Да и формирование правил в строке, признаться, мне не слишком нравится.
Не беда, для нашей задачи реализовать этот шаблон мы можем и самостоятельно. Я пошел по пути наименьшего сопротивления и поместил логику в саму спецификацию. Это, безусловно, не так гибко и сильно завязывает нас на используемую инфраструктуру доктрины, но для данного примера я счел это не принципиальным.
<?php
declare(strict_types=1);
namespace AppSpecification;
use DoctrineORMQueryBuilder;
use SymfonyComponentPropertyAccessPropertyAccess;
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 AppSpecification;
use DoctrineORMQueryBuilder;
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 AppSpecification;
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 AppSpecification;
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 AppSpecification;
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 AppSpecification;
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 AppSpecification;
use DoctrineORMQueryBuilder;
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 AppSpecificationTask;
use AppEntityUser;
use AppSecurityCurrentUserProvider;
use AppSpecificationAlwaysSpecified;
use AppSpecificationCompositeSpecification;
use AppSpecificationEquals;
use AppSpecificationJoin;
use AppSpecificationMemberOf;
use AppSpecificationSpecification;
use SymfonyComponentSecurityCoreAuthorizationAuthorizationCheckerInterface;
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 AppController;
use AppEntityTask;
-use AppEntityUser;
use AppRepositoryTaskRepository;
-use AppSecurityCurrentUserProvider;
-use DoctrineORMQueryBuilder;
+use AppSpecificationTaskIsViewable;
use SymfonyBundleFrameworkBundleControllerAbstractController;
use SymfonyComponentHttpFoundationResponse;
use SymfonyComponentHttpKernelExceptionAccessDeniedHttpException;
use SymfonyComponentRoutingAnnotationRoute;
-use SymfonyComponentSecurityCoreAuthorizationAuthorizationCheckerInterface;
#[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 AppEntityUser;
use AppSecurityCurrentUserProvider;
use AppSpecificationAlwaysSpecified;
+use AppSpecificationAndX;
use AppSpecificationCompositeSpecification;
use AppSpecificationEquals;
use AppSpecificationJoin;
use AppSpecificationMemberOf;
+use AppSpecificationNot;
+use AppSpecificationProjectIsArchived;
use AppSpecificationSpecification;
use SymfonyComponentSecurityCoreAuthorizationAuthorizationCheckerInterface;
@@ -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? И можно ли ожидать, что он станет стандартом на уровне фреймворков?
С полным примером из статьи можно ознакомиться на github [6].
Автор: vsh797
Источник [7]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/php-2/361149
Ссылки в тексте:
[1] "Спецификация": https://en.wikipedia.org/wiki/Specification_pattern
[2] избежать комбинаторного взрыва методов репозитория: https://beberlei.de/2013/03/04/doctrine_repositories.html
[3] переиспользование кода: https://habr.com/ru/post/334404/
[4] Happyr/Doctrine-Specification: https://github.com/Happyr/Doctrine-Specification
[5] K-Phoen/rulerz: https://github.com/K-Phoen/rulerz
[6] github: https://github.com/vshmakov/specifications
[7] Источник: https://habr.com/ru/post/540082/?utm_source=habrahabr&utm_medium=rss&utm_campaign=540082
Нажмите здесь для печати.