[PHP, Symfony, Doctrine ORM, Usability] Пара слов о спецификациях
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Всем доброго времени суток! Удивительно, но упоминание о шаблоне "Спецификация" в контексте php встречается крайне редко. А ведь с его помощью можно не только избежать комбинаторного взрыва методов репозитория, но и улучшить переиспользование кода. Я же в свою очередь хотел бы остановиться на еще одной возможности, предоставляемой данным паттерном. С ее помощью можно решить проблему, которая возникает почти в каждом веб-приложении. И лично мне очень не хватало этого знания еще пару лет назад.
Что будем делать
Предположим, что мы разрабатываем task tracker. На главной странице будет выводиться список задач. Также нам понадобится просмотр отдельной задачи.
TaskController.php
SPL
<?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,
]);
}
}
Далее предположим, что у нас есть 3 типа пользователей:
- Admin — может работать со всеми задачами.
- Manager — может работать только с задачами своего проекта.
- Developer — может работать только с назначенными ему задачами.
Следовательно необходимо создать систему прав, чтобы каждый тип пользователей имел доступ лишь к предназначенным ему задачам. Выглядеть это будет примерно так:
TaskController.php
SPL
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;
+ }
}
Конечно, писать много кода в контроллере — это не очень хорошо. Можно так или иначе раскидать его по сервисам, задействовать стандартные symfony voters. Но основная проблема этого кода в том, что наши бизнес-правила полностью повторяются и в методе filter, и в методе isViewable. И исправление этого факта уже не выглядит столь очевидно. Что можно с этим сделать? Нам нужна абстракция бизнес-правила, работающая как для списка элементов, так и для отдельной сущности. Именно это и предоставляет шаблон "Спецификация".
Пишем Спецификацию
В настоящий момент я нашел 2 проекта, реализующих данный паттерн для php. Happyr/Doctrine-Specification и K-Phoen/rulerz. При этом первый не поддерживает работу с отдельными объектами, а второй фактически заброшен и на symfony 5 уже не устанавливается. Да и формирование правил в строке, признаться, мне не слишком нравится.
Не беда, для нашей задачи реализовать этот шаблон мы можем и самостоятельно. Я пошел по пути наименьшего сопротивления и поместил логику в саму спецификацию. Это, безусловно, не так гибко и сильно завязывает нас на используемую инфраструктуру доктрины, но для данного примера я счел это не принципиальным.
Specification.php
SPL
<?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
SPL
<?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
SPL
<?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
SPL
<?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
SPL
<?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
SPL
<?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
SPL
<?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
SPL
<?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
SPL
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
SPL
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? И можно ли ожидать, что он станет стандартом на уровне фреймворков?
С полным примером из статьи можно ознакомиться на github.
===========
Источник:
habr.com
===========
Похожие новости:
- [PHP, Программирование, *nix, Отладка, Laravel] Настройка Xdebug3 для Laravel-приложения в Docker
- [WordPress, Разработка веб-сайтов, PHP] Актуален ли PHP в 2021 году? (перевод)
- [PHP, PostgreSQL, SQL] Установка Redmine за 15 минут (RVM + RoR + Unicorn + Nginx)
- [Usability, Интернет-маркетинг, Контекстная реклама, Повышение конверсии] Как Google разбазаривает наши деньги и время пользователей
- [Open source, PHP, Symfony, Yii, Laravel] Environment Synchronization
- [PHP, Анализ и проектирование систем, Микросервисы] Из монолита на микросервисы — меняем архитектуру правильно и безболезненно
- [Open source, PHP, PostgreSQL, GitHub, Laravel] Как подружить ltree и Laravel
- [Программирование, Анализ и проектирование систем, Проектирование и рефакторинг, ООП] Symfony и Гексагональная архитектура (перевод)
- [Usability, Accessibility, WebAssembly] Большой отчёт об изменениях в веб-доступности (перевод)
- [CMS, Разработка веб-сайтов, Usability, Веб-аналитика, Интернет-маркетинг] 26 шагов SEO-настройки для нового сайта: что учесть, чтобы не переделывать
Теги для поиска: #_php, #_symfony, #_doctrine_orm, #_usability, #_php, #_symfony, #_doctrine, #_pattern, #_specification, #_architecture, #_php, #_symfony, #_doctrine_orm, #_usability
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 07:06
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Всем доброго времени суток! Удивительно, но упоминание о шаблоне "Спецификация" в контексте php встречается крайне редко. А ведь с его помощью можно не только избежать комбинаторного взрыва методов репозитория, но и улучшить переиспользование кода. Я же в свою очередь хотел бы остановиться на еще одной возможности, предоставляемой данным паттерном. С ее помощью можно решить проблему, которая возникает почти в каждом веб-приложении. И лично мне очень не хватало этого знания еще пару лет назад. Что будем делать Предположим, что мы разрабатываем task tracker. На главной странице будет выводиться список задач. Также нам понадобится просмотр отдельной задачи. TaskController.phpSPL<?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, ]); } } Далее предположим, что у нас есть 3 типа пользователей:
Следовательно необходимо создать систему прав, чтобы каждый тип пользователей имел доступ лишь к предназначенным ему задачам. Выглядеть это будет примерно так: TaskController.phpSPLnamespace 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; + } } Конечно, писать много кода в контроллере — это не очень хорошо. Можно так или иначе раскидать его по сервисам, задействовать стандартные symfony voters. Но основная проблема этого кода в том, что наши бизнес-правила полностью повторяются и в методе filter, и в методе isViewable. И исправление этого факта уже не выглядит столь очевидно. Что можно с этим сделать? Нам нужна абстракция бизнес-правила, работающая как для списка элементов, так и для отдельной сущности. Именно это и предоставляет шаблон "Спецификация". Пишем Спецификацию В настоящий момент я нашел 2 проекта, реализующих данный паттерн для php. Happyr/Doctrine-Specification и K-Phoen/rulerz. При этом первый не поддерживает работу с отдельными объектами, а второй фактически заброшен и на symfony 5 уже не устанавливается. Да и формирование правил в строке, признаться, мне не слишком нравится. Не беда, для нашей задачи реализовать этот шаблон мы можем и самостоятельно. Я пошел по пути наименьшего сопротивления и поместил логику в саму спецификацию. Это, безусловно, не так гибко и сильно завязывает нас на используемую инфраструктуру доктрины, но для данного примера я счел это не принципиальным. Specification.phpSPL<?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.phpSPL<?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.phpSPL<?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.phpSPL<?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.phpSPL<?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.phpSPL<?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.phpSPL<?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.phpSPL<?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.phpSPLnamespace 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.phpSPLuse 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? И можно ли ожидать, что он станет стандартом на уровне фреймворков? С полным примером из статьи можно ознакомиться на github. =========== Источник: habr.com =========== Похожие новости:
|
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 07:06
Часовой пояс: UTC + 5