Паттерн “спецификация” предоставляет возможность описывать требования к бизнес-объектам, и затем использовать их (и их композиции) для фильтрации не дублируя запросы.
Что такое спецификация?
В Википедии этот паттерн описан так:
«Спецификация» в программировании — это шаблон проектирования, посредством которого представление правил бизнес логики может быть преобразовано в виде цепочки объектов, связанных операциями булевой логики.
Изучив несколько статей про спецификацию с других источников, я решил объединить информацию с нескольких источников и описать полноценно этот паттерн в этой статье.
UML схема классического шаблона спецификация:
Немного теории
Для абстрактного понимания шаблона спецификации можно глянуть здесь. А мы перейдем к более-менее реальному примеру.
Допустим, у нас есть интернет магазин. У нас есть товары и товарные предложения. Каждый товар имеет название, цену и кол-во на складе.
Само приложение может отдавать нам список товаров на странице категории, на главной странице, в различных блоках на сайте, например, последние просмотренные товары, рекомендуемые товары, с этим товаром покупают и т.д.
При обычном подходе многие пишут код так: в каждом случае делается отдельный метод/экшен/вью композер/компонент, который отвечает за свою логику. Делает шаблон для вывода этого товара. И в этом шаблоне описывает логику кнопки “Заказать”. А именно, проверяет, что товара достаточно на складе.
А что если завтра бизнес нам скажет, что хочет добавить выключатель товара, который когда выключаешь, то товар становился не доступен для заказа? Тогда разработчик пойдет во все шаблоны добавлять новый функционал, новое условие. По пути где-то ошибется с условием, где-то забудет поправить или пропустит по иной причине. Так и случается лапшекод.
И мы пока рассмотрели с вами лишь вопрос с кнопкой в шаблонах. Но точно такие же условия у нас будут в сервисе корзины, чтобы проверить, можно ли данный товар добавить в корзину. Такое же условие будет в сервисе резервирования товара, чтобы понимать, можем ли мы этот товар зарезервировать.
Так как решить этот вопрос? Все очень просто. Необходимо все эти условия вынести в отдельный класс Specification.
Реализация
Имеем класс товара
<?php declare(strict_types=1);
namespace App\Model;
class Product
{
private float $price;
private int $quantityInStock;
private bool $visible;
public function getPrice(): float
{
return $this->price;
}
public function getQuantityInStock(): float
{
return $this->quantityInStock;
}
public function isVisible(): bool
{
return $this->visible;
}
}
Сделаем интерфейс
<?php declare(strict_types=1);
namespace App\Contracts\Specification;
interface Specification
{
public function isSatisfiedBy(Product $product): bool;
}
Напишем спецификацию на проверку доступного кол-ва товаров
<?php declare(strict_types=1);
namespace App\Specification;
class QntInStockSpecification implements Specification
{
private int $minQuantity;
public function __construct(int $minQuantity = 0)
{
$this->minQuantity = $minQuantity;
}
public function isSatisfiedBy(Product $product): bool
{
return $product->getQuantityInStock() > $this->minQuantity;
}
}
Теперь напишем общую спецификацию на то, что товар доступен для заказа
<?php declare(strict_types=1);
namespace App\Specification;
class AvailableToOrderSpecification implements Specification
{
/**
* @var Specification[]
*/
private array $specifications;
/**
* @param Specification[] $specifications
*/
public function __construct()
{
$this->specifications[] = new QntInStockSpecification();
}
/**
* Если хотя бы одна спецификация не пройдена, то возвращаем false
*/
public function isSatisfiedBy(Product $product): bool
{
foreach ($this->specifications as $specification) {
if (!$specification->isSatisfiedBy($product)) {
return false;
}
}
return true;
}
}
Теперь везде, где потребуется проверка на доступность к заказу, мы используем следующий подход
if((new AvailableToOrderSpecification())->isSatisfiedBy($product)){
echo 'button'; // smthn else
}
Т.к. мы вынесли в отдельный класс нашу проверку, то когда к нам придет бизнес и попросит добавить еще одну проверку для доступности покупки товара, нам достаточно будет создать еще одну спецификацию, например, VisibleSpecification
<?php declare(strict_types=1);
namespace App\Specification;
class VisibleSpecification implements Specification
{
public function isSatisfiedBy(Product $product): bool
{
return $product->isVisible();
}
}
И добавить его в нашу единую спецификацию
<?php declare(strict_types=1);
namespace App\Specification;
class AvailableToOrderSpecification implements Specification
{
/**
* @var Specification[]
*/
private array $specifications;
/**
* @param Specification[] $specifications
*/
public function __construct()
{
$this->specifications[] = new QntInStockSpecification();
$this->specifications[] = new VisibleSpecification();
}
/**
* Если хотя бы одна спецификация не пройдена, то возвращаем false
*/
public function isSatisfiedBy(Product $product): bool
{
foreach ($this->specifications as $specification) {
if (!$specification->isSatisfiedBy($product)) {
return false;
}
}
return true;
}
}
Т.к. мы выносим все проверки в отдельные спецификации мы можем использовать их как по отдельности, так и составлять из них более сложные спецификации как на примере кода выше. Так же эти спецификации хорошо тестируются.
Не забывайте использовать в своих проектах паттерны проектирования, SOLID, KISS и DRY подходы. Да прибудет с вами чистый код.
1) А не лучше эти проверки делать на уровне бд? Так же быстрее работать будет.
2) А если в спецификациях хранить части запроса то это получаются какие-то скопы в абстракциях. И тогда они вообще не к месту.
п.с. крутой блог.
данный паттерн не относится к бд
очень грубо говоря, этот паттерн нужен, чтобы вынести набор сложных условий, которые к тому же могут дублироваться в разных местах, в отдельный класс )
Спасибо за ответ. Я понял, что этот паттерн не относится к бд и где его уместно применять. Я лишь хотел сказать, что на больших данных такой подход имеет плохую производительность, и если хранилище позволяет фильтровать данные то было бы глупо от этого оказываться. Но в таком случае фильтрация перестает хранится в одном месте, что плохо. С другой стороны если источник данных не имеет возможности фильтрации то это замечательный и гибкий паттерн.
Хотелось бы услышать ваше мнение по поводу того, где находится грань компромисса между производительностью и гибкостью.
Вторым пунктом я, видимо, не удачно пытался натянуть этот паттерн на посторитель запросов. Упустим эту часть комментария.
Возможно этот пример не совсем подходящий, но я считаю что, мы бы не потеряли в гибкости если бы сделали так:
в моделе или построителе запросов описали отдельным скопом каждую спецификацию:
scopeQntInStock() { … }
scopeVisible() { … }
а потом в каком нибудь сервисе или репозитории имели метод
AvailableToOrder() {
return Product::qntInStock()
->visible()
->get();
}
и мы так же можем без больших трудозатрат добавлять новые спецификации в виде скопов в модель и этот метод.
А вот случае, когда источник данных, например, какая нибудь выгрузка в csv, то паттерн выглядит весьма уместно.
Спасибо за внимание.