Разработка и тестирование
переносимых компонентов

Дмитрий Елисеев
elisdn.ru

Может это и поажется непатриотичным по отношению к Yii...

поговорим о написании фреймворконезависимых компонентов.

Yii2 Geo Laravel Geo Symfony Geo
Geo
Yii2 Laravel Symfony

Для чего?

Чтобы перестать заморачиваться только над одним фреймворком и начать жить не думая о них.

Чтобы просто писать, ..., код.

Изучим

Какие паттерны и принципы помогут нам сделать компонент гибким.

Ну и какие вещи вы теперь заметите в чужих компонентах.

Задача: сделать компонент для всех фреймворков

Только поработав со своим компонентом как с чужим возникает понимание.

С этого и начнём

Как делают магазины?

Как делают корзину

Просто сессия:


class CartController extends Controller
{
    public function actionIndex(): string
    {
        session_start();

        $items = $_SESSION['cart'] ?? [];

        return $this->render('index', [
            'items' => $items,
        ]);
    }
}
                    

Так мы делали раньше. Сейчас нужно поинтереснее.

Как-то так:


class CartController extends Controller
{
    public function actionIndex(): string
    {
        $items = Yii::$app->session->get('cart', []);

        return $this->render('index', ['items' => $items]);
    }
}
                    

Программисту понятно, что происходит.

Но как-то не очень.

А что если надо в actionIndex ещё и стоимость посчитать?

Будет неинтересно:


class CartController extends Controller
{
    public function actionIndex()
    {
        $items = Yii::$app->session->get('cart', []);

        $cost = array_sum(array_map(function ($item) {
            return $item['price'] * $item['quantity'];
        }, $items));

        if ($cost > 2000) {
            $cost = $cost * 0.95;
        }

        return $this->render('index', [
            'items' => $items,
            'cost' => $cost,
        ]);
    }
}
                    

А если фотографии товаров вывести?


class CartController extends Controller
{
    public function actionIndex()
    {
        $items = Yii::$app->session->get('cart', []);

        $cost = array_sum(array_map(function ($item) {
            return $item['price'] * $item['quantity'];
        }, $items));

        if ($cost > 2000) {
            $cost = $cost * 0.95;
        }

        foreach ($items as &$item) {
            $item['product'] = Product::findOne($item['product_id']);
        }

        return $this->render('index', [
            'items' => $items,
            'cost' => $cost,
        ]);
    }
}
                    

Ну и дополним остальными действиями:


class CartController extends Controller
{
    public function actionIndex(): string
    {
        $items = Yii::$app->session->get('cart', []);

        $cost = array_sum(array_map(function ($item) {
            return $item['price'] * $item['quantity'];
        }, $items));

        if ($cost > 2000) {
            $cost = $cost * 0.95;
        }

        foreach ($items as &$item) {
            $item['product'] = Product::findOne($item['product_id']);
        }

        return $this->render('index', [
            'items' => $items,
            'cost' => $cost,
        ]);
    }

    public function actionAdd($productId, $color, $quantity = 1): Response
    {
        if (!$product = Product::findOne($productId)) {
            throw new NotFoundHttpException('Товар не найден.');
        }

        $items = Yii::$app->session->get('cart', []);

        $found = false;
        foreach ($items as &$item) {
            if ($item['product_id'] == $productId && $item['color'] == $color) {
                $item['quantity'] += $quantity;
                $found = true;
                break;
            }
        }
        if (!$found) {
            $items[] = [
                'id' => sha1(serialize([$productId, $color])),
                'product_id' => $productId,
                'color' => $color,
                'price' => $product->price,
                'quantity' => $quantity,
            ];
        }

        Yii::$app->session->set('cart', $items);

        return $this->redirect(['index']);
    }

    public function actionRemove($id): Response
    {
        $items = Yii::$app->session->get('cart', []);

        foreach ($items as $i => $item) {
            if ($item['id'] == $id) {
                unset($items[$i]);
                Yii::$app->session->set('cart', $items);
                return $this->redirect(['index']);
            }
        }

        throw new NotFoundHttpException('Item not found.');
    }

    public function actionClear(): Response
    {
        Yii::$app->session->set('cart', []);

        return $this->redirect(['index']);
    }
}
                    

Код понятный для программиста, но неудобный для него же.

Плюсы

  • Всё на виду
  • Быстро правится
  • Понятно новичку

Минусы

  • Не тестируется
  • Не переиспользуется index, если товары нужно выводить и в виджете
  • Не переиспользуется add и remove, если делаем API

Для переиспользования выносим.

Куда?

Тру Yii-фреймворщик вынесет в статические методы хелпера или запихнёт в модель данных. Куда поместить код? В контроллер или модель? Кто-то скажет в «модель» и поместит в Product.

Но у познавшего дзен ООП-программиста есть один секрет мастерства - выносить каждую вещь в объект, который называется так, что из себя он представляет.

Когда программит слышит фразу «перенести товар со склада в корзину», то понимает её так, что у него должны быть объекты «товар», «склад» и «корзина».

Поэтому на вопрос «куда положить код»:

Контроллер Модель данных

он выберет:

Контроллер Модель данных Корзину

Лапшекод в контроллере можно побороть созданием специализированного компонента, в который инкапсулировать всю реализацию.

Придумаем абстрактный объект корзины.

Это должна быть штука, которая выглядит так:


namespace app\cart;

use yii\base\Component;

class Cart extends Component
{
    public function getItems(): array

    public function getCost(): float

    public function add($productId, $quantity): void

    public function remove($id): void

    public function clean(): void
}
                    

Перенесём код из контроллера:


class Cart extends Component
{
    public function getItems(): array
    {
        return Yii::$app->session->get('cart', []);
    }

    public function getCost(): float
    {
        $items = Yii::$app->session->get('cart', []);

        $cost = array_sum(array_map(function ($item) {
            return $item['price'] * $item['quantity'];
        }, $items));

        if ($cost > 2000) {
            $cost = $cost * 0.95;
        }

        return $cost;
    }

    public function add($productId, $color, $quantity, $price): void
    {
        $items = Yii::$app->session->get('cart', []);

        foreach ($items as &$item) {
            if ($item['product_id'] == $productId && $item['color'] == $color) {
                $item['quantity'] += $quantity;
                Yii::$app->session->set('cart', $items);
                return;
            }
        }

        $items[] = [
            'id' => sha1(serialize([$productId, $color])),
            'product_id' => $productId,
            'color' => $color,
            'price' => $price,
            'quantity' => $quantity,
        ];

        Yii::$app->session->set('cart', $items);
    }

    public function remove($id): void
    {
        $items = Yii::$app->session->get('cart', []);

        foreach ($items as $i => $item) {
            if ($item['id'] == $id) {
                unset($items[$i]);
                Yii::$app->session->set('cart', $items);
                return;
            }
        }

        throw new NotFoundHttpException('Item not found.');
    }

    public function clear(): void
    {
        Yii::$app->session->set('cart', []);
    }
}
                    

Зарегистрируем компонент приложения:


'components' => [

    ...

    'cart' => [
        'class' => 'app\cart\Cart',
    ],
],
                    

и упростим контроллер:


class CartController extends Controller
{
    public function actionIndex(): string
    {
        $items = Yii::$app->cart->getItems();
        $cost = Yii::$app->cart->getCost();

        foreach ($items as &$item) {
            $item['product'] = Product::findOne($item['product_id']);
        }

        return $this->render('index', [
            'items' => $items,
            'cost' => $cost,
        ]);
    }

    public function actionAdd($productId, $color, $quantity = 1): Response
    {
        if (!$product = Product::findOne($productId)) {
            throw new NotFoundHttpException('Товар не найден.');
        }

        Yii::$app->cart->add($product->id, $color, $quantity, $product->price);

        return $this->redirect(['index']);
    }

    public function actionRemove($id): Response
    {
        Yii::$app->cart->remove($id);

        return $this->redirect(['index']);
    }

    public function actionClear(): Response
    {
        Yii::$app->cart->clear();

        return $this->redirect(['index']);
    }
}
                    
  • Контроллер становится пустым. Использует модель в общем.
  • Принимает параметры из запроса и вызывает некоторые чьи то методы.
  • Мы больше понимаем и меньше раздумываем.
  • Отпадает проблема тестирования контроллера. И копипасты.

С массивом возиться неудобно:


class Cart extends Component
{
    ...

    public function getCost(): float
    {
        $items = Yii::$app->session->get('cart', []);

        $cost = array_sum(array_map(function ($item) {
            return $item['price'] * $item['quantity'];
        }, $items));

        if ($cost > 2000) {
            $cost = $cost * 0.95;
        }

        return $cost;
    }

    public function add($productId, $color, $quantity, $price): void
    {
        $items = Yii::$app->session->get('cart', []);

        foreach ($items as &$item) {
            if ($item['product_id'] == $productId && $item['color'] == $color) {
                $item['quantity'] += $quantity;
                Yii::$app->session->set('cart', $items);
                return;
            }
        }

        $items[] = [
            'id' => sha1(serialize([$productId, $color])),
            'product_id' => $productId,
            'color' => $color,
            'price' => $price,
            'quantity' => $quantity,
        ];

        Yii::$app->session->set('cart', $items);
    }

    ...
}
                    

Сделаем объект:


class CartItem
{
    private $productId;
    private $color;
    private $quantity;
    private $price;

    public function __construct($productId, $color, $quantity, $price)
    {
        $this->productId = $productId;
        $this->color = $color;
        $this->quantity = $quantity;
        $this->price = $price;
    }

    public function getId(): string
    {
        return sha1(serialize([$this->productId, $this->color]));
    }

    public function getProductId(): int
    {
        return $this->productId;
    }

    public function getQuantity(): int
    {
        return $this->quantity;
    }

    public function getPrice(): int
    {
        return $this->price;
    }

    public function getColor(): string
    {
        return $this->color;
    }
}
                    

И поменяем код:


class Cart extends Component
{
    public function getItems(): array
    {
        return Yii::$app->session->get('cart', []);
    }

    public function getCost(): float
    {
        $items = Yii::$app->session->get('cart', []);

        $cost = array_sum(array_map(function (CartItem $item) {
            return $item->getPrice() * $item->getQuantity();
        }, $items));

        if ($cost > 2000) {
            $cost = $cost * 0.95;
        }

        return $cost;
    }

    public function add(CartItem $new): void
    {
        $items = Yii::$app->session->get('cart', []);

        foreach ($items as $i => $item) {
            if ($item->getId() == $new->getId()) {
                $items[$i] = new CartItem(
                    $item->getProductId(),
                    $item->getColor(),
                    $item->getPrice(),
                    $item->getQuantity() + $new->getQuantity()
                );
                Yii::$app->session->set('cart', $items);
                return;
            }
        }

        $items[] = $new;
        Yii::$app->session->set('cart', $items);
    }

    public function remove($id): void
    {
        $items = Yii::$app->session->get('cart', []);

        foreach ($items as $i => $item) {
            if ($item->getId() == $id) {
                unset($items[$i]);
                Yii::$app->session->set('cart', $items);
                return;
            }
        }

        throw new NotFoundHttpException('Item not found.');
    }

    public function clear(): void
    {
        Yii::$app->session->set('cart', []);
    }
}
                    

И в контроллере:


class CartController extends Controller
{
    public function actionIndex(): string
    {
        $items = Yii::$app->cart->getItems();
        $cost = Yii::$app->cart->getCost();

        foreach ($items as &$item) {
            $item['product'] = Product::findOne($item['product_id']);
        }

        return $this->render('index', [
            'items' => $items,
            'cost' => $cost,
        ]);
    }

    public function actionAdd($productId, $color, $quantity = 1): Response
    {
        if (!$product = Product::findOne($productId)) {
            throw new NotFoundHttpException('Товар не найден.');
        }

        Yii::$app->cart->add(new CartItem($product->id, $color, $quantity, $product->price));

        return $this->redirect(['index']);
    }

    public function actionRemove($id): Response
    {
        Yii::$app->cart->remove($id);

        return $this->redirect(['index']);
    }

    public function actionClear(): Response
    {
        Yii::$app->cart->clear();

        return $this->redirect(['index']);
    }
}
                    

Инкапсулируем слияние:


class CartItem
{
    ...

    public function plus(CartItem $item): self
    {
        return new CartItem($this->productId, $this->color, $this->quantity + $item->quantity, $this->price);
    }
}
                    

И это:


class Cart extends Component
{
    ...

    public function add(CartItem $new): void
    {
        $items = Yii::$app->session->get('cart', []);

        foreach ($items as $i => $item) {
            if ($item->getId() == $new->getId()) {
                $items[$i] = new CartItem(
                    $item->getProductId(),
                    $item->getColor(),
                    $item->getPrice(),
                    $item->getQuantity() + $new->getQuantity()
                );
                Yii::$app->session->set('cart', $items);
                return;
            }
        }

        $items[] = $new;
        Yii::$app->session->set('cart', $items);
    }

    ...
}
                    

Превращается в:


class Cart extends Component
{
    ...

    public function add(CartItem $new): void
    {
        $items = Yii::$app->session->get('cart', []);

        foreach ($items as $i => $item) {
            if ($item->getId() == $new->getId()) {
                $items[$i] = $item->plus($new);
                Yii::$app->session->set('cart', $items);
                return;
            }
        }

        $items[] = $new;
        Yii::$app->session->set('cart', $items);
    }

    ...
}
                    

В корзине повторяется работа с сессией:


class Cart extends Component
{
    public function getItems(): array
    {
        return Yii::$app->session->get('cart', []);
    }

    public function getCost(): float
    {
        $items = Yii::$app->session->get('cart', []);
        ...
        return $cost;
    }

    public function add(CartItem $new): void
    {
        $items = Yii::$app->session->get('cart', []);
        ...
        Yii::$app->session->set('cart', $items);
    }

    public function remove($id): void
    {
        $items = Yii::$app->session->get('cart', []);

        foreach ($items as $i => $item) {
            ...
            Yii::$app->session->set('cart', $items);
            ...
        }
        ...
    }

    public function clear(): void
    {
        Yii::$app->session->set('cart', []);
    }
}
                    

Вынесем в методы:


class Cart extends Component
{
    public function getItems(): array
    {
        return $this->loadItems();
    }

    ...

    public function remove($id): void
    {
        $items = $this->loadItems();
        ...
        $this->saveItems($items);
    }

    public function clear(): void
    {
        $this->saveItems([]);
    }

    private function loadItems(): array
    {
        return Yii::$app->session->get('cart', []);
    }

    private function saveItems(array $items): void
    {
        Yii::$app->session->set('cart', $items);
    }
}
                    

Метод loadItems дёргается отовсюду.

Можно закешировать:


class Cart extends Component
{
    ...

    private $items;

    private function loadItems(): array
    {
        if ($this->items === null) {
            $this->items = Yii::$app->session->get('cart', []);
        }
        return $this->items;
    }

    private function saveItems(array $items): void
    {
        Yii::$app->session->set('cart', $items);
        $this->items = $items;
    }
}
                    

Но такая корзина одноразовая.

Что если хочется изменить ключ сессии или сделать две корзины?

Извлекаем ключ сессии в параметр:


class Cart extends Component
{
    public $sessionKey;

    ...

    private function loadItems(): array
    {
        if ($this->items === null) {
            $this->items = Yii::$app->session->get($this->sessionKey, []);
        }
        return $this->items;
    }

    private function saveItems(array $items): void
    {
        Yii::$app->session->set($this->sessionKey, $items);
        $this->items = $items;
    }
}
                    

и получаем две корзины:


'components' => [
    'cart' => [
        'class' => 'app\cart\Cart',
        'sessionKey' => 'cart',
    ],
    'favorites' => [
        'class' => 'app\cart\Cart',
        'sessionKey' => 'favorite',
    ],
],
                    

Получили компонент фреймворка.

Что делать дальше?

  • В любом сложном компоненте обычно меются два типа кода: неизменяемый и изменяемый.
  • Сессия здесь изменяема.
  • Если делаем универсальный компонент нужно предоставить возможность менять изменяемые части.

Что в нашем классе изменяемое?

Что мешает использовать его в других фреймворках?


use yii\web\NotFoundHttpException;

class Cart extends Component
{
    public function getItems(): array
    {
        return $this->loadItems();
    }

    public function getCost(): float
    {
        $items = $this->loadItems();

        $cost = array_sum(array_map(function (CartItem $item) {
            return $item->getPrice() * $item->getQuantity();
        }, $items));

        if ($cost > 2000) {
            $cost = $cost * 0.95;
        }

        return $cost;
    }

    public function add(CartItem $new): void
    {
        $items = $this->loadItems();

        foreach ($items as $i => $item) {
            if ($item->getId() == $new->getId()) {
                $items[$i] = $item->plus($new);
                $this->saveItems($items);
                return;
            }
        }

        $items[] = $new;
        $this->saveItems($items);
    }

    public function remove($id): void
    {
        $items = $this->loadItems();

        foreach ($items as $i => $item) {
            if ($item->getId() == $id) {
                unset($items[$i]);
                $this->saveItems($items);
                return;
            }
        }

        throw new NotFoundHttpException();
    }

    public function clear(): void
    {
        $this->saveItems([]);
    }

    private $items;

    private function loadItems(): array
    {
        if ($this->items === null) {
            $this->items = Yii::$app->session->get('cart', []);
        }
        return $this->items;
    }

    private function saveItems(array $items): void
    {
        Yii::$app->session->set('cart', $items);
        $this->items = $items;
    }
}
                    

NotFoundHttpException


use yii\web\NotFoundHttpException;

class Cart extends Component
{
    ...

    public function remove($id): void
    {
        $items = Yii::$app->session->get('cart', []);

        foreach ($items as $i => $item) {
            if ($item->getId() == $id) {
                unset($items[$i]);
                Yii::$app->session->set('cart', $items);
                return;
            }
        }

        throw new NotFoundHttpException('Item not found.');
    }

    ...
}
                    

HTTP-коды и редиректы – это дело контроллера

Разделяем на ошибку логики:


namespace app\cart\exception;

class MissingItemException extends \LogicException
{

}
                    

class Cart extends Component
{
    ...

    public function remove($id): void
    {
        $items = Yii::$app->session->get('cart', []);

        foreach ($items as $i => $item) {
            if ($item->getId() == $id) {
                unset($items[$i]);
                Yii::$app->session->set('cart', $items);
                return;
            }
        }

        throw new MissingItemException();
    }

    ...
}}
                    

и контроллера:


use yii\web\NotFoundHttpException;

class CartController extends Controller
{
    ...

    public function actionRemove($id): Response
    {
        try {
            Yii::$app->cart->remove($id);
        } catch (MissingItemException $e) {
            throw new NotFoundHttpException('Item not found.');
        }

        return $this->redirect(['index']);
    }

    ...
}
                    

Наследование от Component


class Cart extends \yii\base\Component
{
    ...
}
                    

По сути Component здесь нам и не нужен.

Уберём:


class Cart
{
    public $sessionKey;

    ...

    private function loadItems(): array
    {
        return Yii::$app->session->get($this->sessionKey, []);
    }

    private function saveItems(array $items): void
    {
        Yii::$app->session->set($this->sessionKey, $items);
    }
}
                    

Кто-то может забыть указать ключ.

Чтобы сделать его обязательным...

сделаем его приватным...

и организуем заполнение через конструктор:


class Cart
{
    private $sessionKey;

    public function __construct($sessionKey)
    {
        if (empty($sessionKey)) {
            throw new \InvalidArgumentException('Specify session key.');
        }
        $this->sessionKey = $sessionKey;
    }

    ...
}
                    

В конфигурации Yii не умеет работать с примитивными полями в конструкторе.

Но можем создавать вручную:


'components' => [
    'cart' => function () {
        return new Cart('cart');
    },
],
                    

Вынесли sessionKey

Вынесли Exception

Убрали Component

Что нам мешает ещё?


class Cart
{
    private $sessionKey;

    public function __construct($sessionKey)
    {
        if (empty($sessionKey)) {
            throw new \InvalidArgumentException('Specify session key.');
        }
        $this->sessionKey = $sessionKey;
    }

    ...

    private $items;

    private function loadItems(): array
    {
        if ($this->items === null) {
            $this->items = Yii::$app->session->get($this->sessionKey, []);
        }
        return $this->items;
    }

    private function saveItems(array $items): void
    {
        Yii::$app->session->set($this->sessionKey, $items);
        $this->items = $items;
    }
}
                    

Yii::$app->session

Мы можем использовать разные хранилища

В других фреймворках нет Yii::$app

Для смены хранилища можно воспользоваться наследованием
если методы и поле сделать protected:


class Cart
{
    ...

    protected $items;

    protected function loadItems(): array
    {
        if ($this->items === null) {
            $this->items = Yii::$app->session->get($this->sessionKey, []);
        }
        return $this->items;
    }

    protected function saveItems(array $items): void
    {
        Yii::$app->session->set($this->sessionKey, $items);
        $this->items = $items;
    }
}
                    

и переопределив в подклассе:


class CookiesCart extends Cart
{
    private $cookieName;
    private $timeout;

    public function __construct($cookieName, $timeout)
    {
        if (empty($cookieKey)) {
            throw new \InvalidArgumentException('Specify session key.');
        }
        $this->cookieName = $cookieName;
        $this->timeout = $timeout;
    }

    protected function loadItems(): array
    {
        if ($this->items === null) {
            $cookie = Yii::$app->request->cookies->get($this->cookieName);
            $this->items = $cookie ? Json::decode($cookie->value) : [];
        }
        return $this->items;
    }

    protected function saveItems(array $items): void
    {
        Yii::$app->response->cookies->add(new Cookie([
            'name' => $this->cookieName,
            'value' => Json::encode($items),
            'expire' => time() + $this->timeout,
        ]));
    }
}
                    

Фактически нам приходится переопределять половину кода класса для каждого изменения, наследуя класс целиком.

Чтобы не мешался старый конструктор можно вынести общий неизменяемый код в базовый класс

и сделать методы абстрактными:


abstract class Cart
{
    public function getItems(): array
    {
        ...
    }

    public function getCost(): float
    {
        ...
    }

    public function add($id, $quantity = 1): void
    {
        ...
    }

    public function remove($id): void
    {
        ...
    }

    public function clear(): void
    {
        ...
    }

    abstract protected function loadItems(): array;

    abstract protected function saveItems(array $items): void;
}
                    

и изменяемый код поместить в наследников:


class SessionCart
{
    private $sessionKey;

    public function __construct($sessionKey)
    {
        $this->sessionKey = $sessionKey;
    }

    private $items;

    protected function loadItems(): array
    {
        if ($this->items === null) {
            $this->items = Yii::$app->session->get($this->sessionKey, []);
        }
        return $this->items;
    }

    protected function saveItems(array $items): void
    {
        Yii::$app->session->set($this->sessionKey, $items);
        $this->items = $items;
    }
}
                    

и изменяемый код поместить в наследников:


class CookiesCart extends Cart
{
    private $cookieName;
    private $timeout;

    public function __construct($cookieName, $timeout)
    {
        $this->cookieName = $cookieName;
        $this->timeout = $timeout;
    }

    private $items;

    protected function loadItems(): array
    {
        if ($this->items === null) {
            $cookie = Yii::$app->request->cookies->get($this->cookieName);
            $this->items = $cookie ? Json::decode($cookie->value) : [];
        }
        return $this->items;
    }

    protected function saveItems(array $items): void
    {
        Yii::$app->response->cookies->add(new Cookie([
            'name' => $this->cookieName,
            'value' => Json::encode($items),
            'expire' => time() + $this->timeout,
        ]));
    }
}
                    

Получилось:


           Cart
          /    \
SessionCart    CookiesCart
                    

У теперь вы вдруг захотели добавить логирование загрузки.
Или что-то ещё в разных комбинациях.
Пока всё линейно это нормально:


                Cart
             /       \
        LoggedCart   DbCart
          /    \
SessionCart    CookiesCart
                    

А если для cart надо логировать, а для favorites не надо?

Не можем использовать один и тот же CookiesCart так, чтобы он в одном месте логировался, а в другом нет.

Придётся копипастить:


               Cart
             /      \
       LoggedCart    \
          /           \
CookiesCart1        CookiesCart2
                    

и костылить трейты:


               Cart
             /      \
       LoggedCart    \
           / _________\ ____________ CookiesCartTrait
          / /          \      /
CookiesCart1       CookiesCart2
                    

Но это у нас всего одно переопределение.

Что у нас в классе модет меняться ещё?

Подсчёт стоимости

Пока всё в методе:


class Cart
{
    ...

    public function getCost(): float
    {
        $items = $this->loadItems();

        $cost = array_sum(array_map(function ($item) {
            return $item['price'] * $item['quantity'];
        }, $items));

        if ($cost > 2000) {
            $cost = $cost * 0.95;
        }

        if (date('m') == 12) {
            $cost = $cost * 0.97;
        }

        return $cost;
    }

    ...
}
                    

Если напишем тест для getCost...
то будут вылетать в декабре и придётся каждый раз переписывать.
И скидки постоянно меняются.

Можно сделать сумму по умолчанию:


abstract class Cart
{
    ...

    public function getCost(): float
    {
        $items = $this->loadItems();

        $cost = array_sum(array_map(function ($item) {
            return $item->getPrice() * $item->getQuantity();
        }, $items));

        return $cost;
    }

    ...
}
                    

и потом пересчитывать в наследниках:


class DiscountCart extends Cart
{
    public function getCost(): float
    {
        $cost = parent::getCost();

        if ($cost > 2000) {
            $cost = $cost * 0.95;
        }

        if (date('m') == 12) {
            $cost = $cost * 0.97;
        }

        return $cost;
    }
}
                    

и потом пересчитывать в наследниках:


class BigDiscountCart extends Cart
{
    public function getCost(): float
    {
        $cost = parent::getCost();
        if ($cost > 2000) {
            $cost = $cost * 0.95;
        }
        return $cost;
    }
}
                    

class NewYearDiscountCart extends BigDiscountCart
{
    public function getCost(): float
    {
        $cost = parent::getCost();
        if (date('m') == 12) {
            $cost = $cost * 0.97;
        }
        return $cost;
    }
}
                    

В итоге надо наследовать и хранение, и скидки:


               Cart
                / \
  BigDiscountCart  \
               /    \
NewYearDiscountCart  \
              / ______\ ____________ LoggedCartTrait
             / /       \  /
            /      LoggedCart2
       LoggedCart1      \
           / ____________\ ____________ CookiesCartTrait
          / /             \     /
CookiesCart1          CookiesCart2
        /                  \
    ShopCart            Favorites
                    

чтобы корзина считала со скидками, а избранное - без скидок.

И это не предел.

Тесты - боль.

Наследование жёсткое. На лету его не поменяешь.

Нельзя написать вроде «если у пользователя день рождения, то extends Discount1 иначе extends Discount2».

И дальше жизнь превращается в запутанный многоэтажный ад из сотен комбинаций наследников и трейтов...

А всё началось с фразы «а отнаследуюсь сейчас разок побыстрому».

Наследование не спасает

Как без наследования и абстрактных методов поступить так, чтобы можно было менять хранение и расчёт без переписывания корзины?

Вернём наш код:


class Cart
{
    private $sessionKey;

    public function __construct($sessionKey)
    {
        $this->sessionKey = $sessionKey;
    }

    ...

    public function getCost(): float
    {
        ...
        if ($cost > 2000) {
            $cost = $cost * 0.95;
        }
        return $cost;
    }

    private function loadItems(): array
    {
        if ($this->items === null) {
            $this->items = Yii::$app->session->get($this->sessionKey, []);
        }
        return $this->items;
    }

    private function saveItems(array $items): void
    {
        Yii::$app->session->set($this->sessionKey, $items);
        $this->items = $items;
    }
}
                    

Проблема в том, что в одном классе сошлись 3 вещи:

  • сам алгоритм корзины
  • хранилище
  • подсчёт стоимости

Что-то остаётся. Что-то может меняться редко, что то часто.

Одна из хороших практик - инкапсулирование изменчивости.

Первое, что поменяется при подключении к своему проекту - хранилище.

Но пока перейдём на использование...

DIC

Подключем компонент:


'components' => [
    'cart' => [
        'class' => 'app\cart\Cart',
        'sessionKey' => 'cart',
    ],
],
                    

или в случае с конструктором:


'components' => [
    'cart' => function () {
        return new Cart('cart');
    },
],
                    

И используем:


public function actionIndex()
{
    $items = Yii::$app->cart->getItems();
}
                    

Но можно настроить в контейнере:


'container' => [
    'singletons' => [
        'app\cart\Cart' => function () {
            return new Cart('cart');
        },
    ],
],
                    

Чтобы использовать так:


class CartController extends Controller
{
    private $cart;

    public function __construct($id, $module, Cart $cart, $config = [])
    {
        parent::__construct($id, $module, $config);
        $this->cart = $cart;
    }

    public function actionIndex(): string
    {
        $items = $this->cart->getItems();

        return $this->render('index', [
            'items' => $items,
        ]);
    }
}
                    

Но лучше вынести в bootstrap-класс:


namespace app\bootstrap;

use app\cart\Cart;
use yii\base\BootstrapInterface;

class SetUp implements BootstrapInterface
{
    public function bootstrap($app): void
    {
        $container = \Yii::$container;

        $container->setSingleton(Cart::class, [], [
            'cart'
        ]);
    }
}
                    

что эквивалентно:


namespace app\bootstrap;

use app\cart\Cart;
use yii\base\BootstrapInterface;

class SetUp implements BootstrapInterface
{
    public function bootstrap($app): void
    {
        $container = \Yii::$container;

        $container->setSingleton(Cart::class, function () {
            return new Class('cart');
        });
    }
}
                    

Регистрируем:


$config = [
    'id' => 'basic',
    'basePath' => dirname(__DIR__),
    'bootstrap' => [
        'log',
        'app\bootstrap\SetUp',
    ],
    'components' => [
        ...
    ]
];
                    

И иньектим:


class CartController extends Controller
{
    private $cart;

    public function __construct($id, $module, Cart $cart, $config = [])
    {
        parent::__construct($id, $module, $config);
        $this->cart = $cart;
    }

    public function actionIndex(): string
    {
        $items = $this->cart->getItems();

        return $this->render('index', [
            'items' => $items,
        ]);
    }

    ...

    public function actionClear(): Response
    {
        $this->cart->clear();

        return $this->redirect(['index']);
    }
}
                    

Мы уже вынесли изменяемый код в отдельные методы loadItems и saveItems.

Теперь можно... вынести его в отдельный объект и делегировать ему.

Вот был бы такой объект storage:


class Cart
{
    ...

    private $items;

    private function loadItems(): array
    {
        if ($this->items === null) {
            $this->items = $this->storage->get();
        }
        return $this->items;
    }

    private function saveItems(array $items): void
    {
        $this->storage->put($items);
        $this->items = $items;
    }
}
                    

От него нам нужны только два метода: get и put.

Опишем его интерфейс:


namespace app\cart\storage;

use app\cart\CartItem;

interface Storage
{
    /**
     * @return CartItem[]
     */
    public function get(): array;

    /**
     * @param CartItem[] $items
     */
    public function put(array $items): void;
}
                    

И напишем наш экземпляр для сессии:


class SessionStorage implements Storage
{
    private $key;

    public function __construct($key)
    {
        if (empty($key)) {
            throw new \InvalidArgumentException('Specify session key.');
        }
        $this->key = $key;
    }

    public function get(): array
    {
        return Yii::$app->session->get($this->key, []);
    }

    public function put(array $items): void
    {
        Yii::$app->session->set($this->key, $items);
    }
}
                    

и рядом:


class CookieStorage implements Storage
{
    private $name;
    private $timeout;

    public function __construct($name, $timeout)
    {
        if (empty($name)) {
            throw new \InvalidArgumentException('Specify session key.');
        }
        $this->name = $name;
        $this->timeout = $timeout;
    }

    public function get(): array
    {
        $cookie = Yii::$app->request->cookies->get($this->name);
        return $cookie ? array_map(function ($value) {
            return new CartItem(
                $value['product_id'],
                $value['color'],
                $value['quantity'],
                $value['price']
            );
        }, Json::decode($cookie->value)) : [];
    }

    public function put(array $items): void
    {
        Yii::$app->response->cookies->add(new Cookie([
            'name' => $this->name,
            'value' => Json::encode(array_map(function (CartItem $item) {
                return [
                    'product_id' => $item->getId(),
                    'color' => $item->getColor(),
                    'quantity' => $item->getQuantity(),
                    'price' => $item->getPrice(),
                ];
            }, $items)),
            'expire' => time() + $this->timeout,
        ]));
    }
}
                    


Корзина  ------      Storage
                    /       \
          SessionStorage  CookieStorage
                    

Теперь в корзину принимаем этот объект:


class Cart
{
    private $storage;

    public function __construct(Storage $storage)
    {
        $this->storage = $storage;
    }

    public function getItems(): array
    {
        return $this->loadItems();
    }

    ...

    private $items;

    private function loadItems(): array
    {
        if ($this->items === null) {
            $this->items = $this->storage->get();
        }
        return $this->items;
    }

    private function saveItems(array $items): void
    {
        $this->storage->put($items);
        $this->items = $items;
    }
}
                    

Теперь можем легко использовать хоть SessionStorage:


class SetUp implements BootstrapInterface
{
    public function bootstrap($app): void
    {
        $container = \Yii::$container;

        $container->setSingleton(Cart::class, function () {
            return new Cart(new SessionStorage('cart'));
        });
    }
}
                    

хоть CookieStorage:


class SetUp implements BootstrapInterface
{
    public function bootstrap($app): void
    {
        $container = \Yii::$container;

        $container->setSingleton(Cart::class, function () {
            return new Cart(new CookieStorage('cart', 3600 * 24));
        });
    }
}
                    

Аналогично можно сделать в БД для залогиненных:


class DbStorage implements Storage
{
    private $userId;

    public function __construct($userId)
    {
        $this->userId = $userId;
    }

    public function get(): array
    {
        $rows = (new Query())
            ->select('*')
            ->from('{{%cart_items}} i')
            ->where(['user_id' => $this->userId])
            ->innerJoin('{{%products}} p', 'p.id = i.product_id')
            ->orderBy(['id' => SORT_ASC])
            ->all();

        return array_map(function (array $row) {
            return new CartItem($row['product_id'], $row['color'], $row['quantity'], $row['price']);
        }, $rows);
    }

    public function put(array $items): void
    {
        \Yii::$app->db->createCommand()->delete('{{%cart_items}}', [
            'user_id' => $this->userId,
        ])->execute();

        \Yii::$app->db->createCommand()->batchInsert(
            '{{%cart_items}}',
            [
                'user_id',
                'product_id',
                'color',
                'quantity'
            ],
            array_map(function (CartItem $item) {
                return [
                    'user_id' => $this->userId,
                    'product_id' => $item->getProductId(),
                    'color' => $item->getColor(),
                    'quantity' => $item->getQuantity(),
                ];
            }, $items)
        )->execute();
    }
}
                    

и автоматически выбирать хранилища в момент создания корзины:


class SetUp implements BootstrapInterface
{
    public function bootstrap($app): void
    {
        $container = \Yii::$container;

        $container->setSingleton(Cart::class, function () use ($app) {
            if (!$app->user->isGuest) {
                $storage = new DbStorage($app->user->id);
            } else {
                $storage = new CookieStorage('cart', 3600 * 24);
            }
            return new Cart($storage);
        });
    }
}
                    

Но что если захочется перекладывать из куков в БД?

Легко:


class HybridStorage implements Storage
{
    private $storage;
    private $cookieName;
    private $cookieTimeout;
    private $user;

    public function __construct(\yii\web\User $user, $cookieName, $cookieTimeout)
    {
        $this->cookieName = $cookieName;
        $this->cookieTimeout = $cookieTimeout;
        $this->user = $user;
    }

    public function get(): array
    {
        return $this->getStorage()->get();
    }

    public function put(array $items): void
    {
        $this->getStorage()->put($items);
    }

    private function getStorage()
    {
        if ($this->storage === null) {
            $cookieStorage = new CookieStorage($this->cookieName, $this->cookieTimeout);
            if ($this->user->isGuest) {
                $this->storage = $cookieStorage;
            } else {
                $dbStorage = new DbStorage($this->user->id);
                if ($cookieItems = $cookieStorage->get()) {
                    $dbItems = $dbStorage->get();
                    $items = array_merge($dbItems, array_udiff($cookieItems, $dbItems, function (CartItem $first, CartItem $second) {
                        return $first->getId() === $second->getId();
                    }));
                    $dbStorage->put($items);
                    $cookieStorage->put([]);
                }
                $this->storage = $dbStorage;
            }
        }
        return $this->storage;
    }
}
                    

И подключаем:


class SetUp implements BootstrapInterface
{
    public function bootstrap($app): void
    {
        $container = \Yii::$container;

        $container->setSingleton(Cart::class, function () use ($app) {
            $storage = new HybridStorage($app->user, 'cart', 3600 * 24);
            return new Cart($storage);
        });
    }
}
                    

И уже можно делать...

Unit-тесты

Для него создадим хранилище-заглушку:


namespace app\tests\unit\cart\storage;

use app\cart\storage\Storage;

class MemoryStorage implements Storage
{
    private $items = [];

    public function get(): array {
        return $this->items;
    }

    public function put(array $items): void {
        $this->items = $items;
    }
}
                    

И напишем как-то так:


namespace tests\unit\cart;

use app\cart\Cart;
use app\cart\CartItem;
use app\cart\exception\MissingItemException;
use tests\unit\cart\storage\MemoryStorage;
use Codeception\Test\Unit;

class CartTest extends Unit
{
    private $cart;

    public function setUp(): void
    {
        parent::setUp();
        $this->cart = new Cart(new MemoryStorage());
    }

    public function testCreate(): void
    {
        $this->assertEquals([], $this->cart->getItems());
    }

    public function testAdd(): void
    {
        $this->cart->add(new CartItem(5, 'red', 3, 100));

        $this->assertCount(1, $items = $this->cart->getItems());

        $this->assertEquals(5, $items[0]->getProductId());
        $this->assertEquals(3, $items[0]->getQuantity());
        $this->assertEquals(100, $items[0]->getPrice());
    }

    public function testAddDifferent(): void
    {
        $this->cart->add(new CartItem(5, 'red', 3, 100));
        $this->cart->add(new CartItem(5, 'blue', 5, 200));

        $this->assertCount(2, $items = $this->cart->getItems());

        $this->assertEquals(5, $items[0]->getProductId());
        $this->assertEquals(3, $items[0]->getQuantity());
        $this->assertEquals(100, $items[0]->getPrice());

        $this->assertEquals(5, $items[1]->getProductId());
        $this->assertEquals(5, $items[1]->getQuantity());
        $this->assertEquals(200, $items[1]->getPrice());
    }

    public function testAddSome(): void
    {
        $this->cart->add(new CartItem(5, 'red', 3, 100));
        $this->cart->add(new CartItem(5, 'red', 5, 100));

        $this->assertCount(1, $items = $this->cart->getItems());

        $this->assertEquals(5, $items[0]->getProductId());
        $this->assertEquals(8, $items[0]->getQuantity());
        $this->assertEquals(100, $items[0]->getPrice());
    }

    public function testRemove(): void
    {
        $this->cart->add($item = new CartItem(5, 'red', 3, 100));
        $this->cart->remove($item->getId());
        $this->assertEquals([], $this->cart->getItems());
    }

    public function testRemoveMissing(): void
    {
        $this->expectException(MissingItemException::class);
        $this->cart->remove('42');
    }

    public function testClear(): void
    {
        $this->cart->add(new CartItem(5, 'red', 3, 100));
        $this->cart->clear();
        $this->assertEquals([], $this->cart->getItems());
    }

    public function testCostLessThan2000(): void
    {
        $this->cart->add(new CartItem(5, 'red', 3, 100));
        $this->cart->add(new CartItem(6, 'blue', 4, 100));
        $this->assertEquals(700, $this->cart->getCost());
    }

    public function testCostMoreThan2000(): void
    {
        $this->cart->add(new CartItem(5, 'red', 20, 100));
        $this->cart->add(new CartItem(6, 'blue', 10, 100));
        $this->assertEquals(2850, $this->cart->getCost());
    }
}
                    

Используем заглушку вместо хранилища

И не пользуемся фреймворковскими вещами

С хранилищем всё хорошо

Но есть нюанс.

С изменением формул стоимости:


class Cart
{
    ...

    public function getCost(): float
    {
        $items = $this->loadItems();

        $cost = array_sum(array_map(function (CartItem $item) {
            return $item->getPrice() * $item->getQuantity();
        }, $items));

        if ($cost > 2000) {
            $cost = $cost * 0.95;
        }

        return $cost;
    }
}
                    

приходится менять и тесты:


class CartTest extends Unit
{
    ...

    public function testCostLessThan2000(): void
    {
        $this->cart->add(new CartItem(5, 'red', 3, 100));
        $this->cart->add(new CartItem(6, 'blue', 4, 100));
        $this->assertEquals(700, $this->cart->getCost());
    }

    public function testCostMoreThan2000(): void
    {
        $this->cart->add(new CartItem(5, 'red', 20, 100));
        $this->cart->add(new CartItem(6, 'blue', 10, 100));
        $this->assertEquals(2850, $this->cart->getCost());
    }
}
                    

Ад комбинаторики:


class CartTest extends Unit
{
    ...

    public function testCostLessThan2000(): void {}
    public function testCostMoreThan2000(): void {}
    public function testCostBirthday(): void {}
    public function testCostNewYear(): void {}
    public function testCostBirthdayAndNewYear(): void {}

    ...
}
                    

А что если взять корзину:


class Cart
{
    ...

    public function getCost(): float
    {
        $items = $this->loadItems();

        $cost = array_sum(array_map(function (CartItem $item) {
            return $item->getPrice() * $item->getQuantity();
        }, $items));

        if ($cost > 2000) {
            $cost = $cost * 0.95;
        }

        return $cost;
    }
}
                    

и вынести подсчёт


class Cart
{
    ...

    public function getCost(): float
    {
        return $this->calculator->getCost($this->loadItems());
    }
}
                    

в какой-нибудь калькулятор:


class Cart
{
    private $storage;
    private $calculator;

    public function __construct(Storage $storage, Calculator $calculator)
    {
        $this->storage = $storage;
        $this->calculator = $calculator;
    }

    ...
}
                    

с одним методом:


namespace app\cart\cost;

use app\cart\CartItem;

interface Calculator
{
    /**
     * @param CartItem[] $items
     * @return float
     */
    public function getCost(array $items): float;
}
                    

В простейшем случае


namespace app\cart\cost;

class SimpleCost implements Calculator
{
    public function getCost(array $items): float
    {
        $cost = 0;
        foreach ($items as $item) {
            $cost += $item->getPrice() * $item->getQuantity();
        }
        return $cost;
    }
}
                    

и не забудем тест:


namespace tests\unit\cart\cost;

use app\cart\CartItem;
use app\cart\cost\SimpleCost;
use Codeception\Test\Unit;

class SimpleCostTest extends Unit
{
    public function testCalc(): void
    {
        $calculator = new SimpleCost();
        $this->assertEquals(1000, $calculator->getCost([
            new CartItem(5, 'red', 2, 200),
            new CartItem(7, 'black', 4, 150),
        ]));
    }
}
                    

И подставим:


class SetUp implements BootstrapInterface
{
    public function bootstrap($app): void
    {
        $container = \Yii::$container;

        $container->setSingleton(Cart::class, function () {
            return new Cart(
                new CookieStorage('cart', 3600 * 24),
                new SimpleCost()
            );
        });
    }
}
                    

Пригодится в тестах:


class CartTest extends Unit
{
    private $cart;

    public function setUp(): void
    {
        $this->cart = new Cart(new MemoryStorage(), new SimpleCost());
        parent::setUp();
    }

    ...
}
                    

Ещё есть формула:


if ($cost > 2000) {
    return 0.95 * $cost;
}
return $cost;
                    

Оформим в BigCost:


class BigCost implements Calculator
{
    public function getCost(array $items): float
    {
        $cost = 0;
        foreach ($items as $item) {
            $cost += $item->getPrice() * $item->getQuantity();
        }

        if ($cost > 2000) {
            return 0.95 * $cost;
        }

        return $cost;
    }
}
                    

Повторяется код из SimpleCost

Делегируем:


class BigCost implements Calculator
{
    private $simpleCost;

    public function __construct(SimpleCost $simpleCost)
    {
        $this->simpleCost = $simpleCost;
    }

    public function getCost(array $items): float
    {
        $cost = $this->simpleCost->getCost($items)

        if ($cost > 2000) {
            return 0.95 * $cost;
        }

        return $cost;
    }
}

$calculator = new BigCost(new SimpleCost());
echo $calculator->getCost($items);
                    

Или любой другой:


class BigCost implements Calculator
{
    private $next;

    public function __construct(Calculator $next)
    {
        $this->next = $next;
    }

    public function getCost(array $items): float
    {
        $cost = $this->next->getCost($items);

        if ($cost > 2000) {
            return 0.95 * $cost;
        }

        return $cost;
    }
}

$calculator = new BigCost(new NewYearCost(new SimpleCost()));
echo $calculator->getCost($items);
                    

Протестируем:


class BigCostTest extends Unit
{
    public function testActive(): void
    {
        $calculator = new BigCost(new SimpleCost());
        $this->assertEquals(2850, $calculator->getCost([
            new CartItem(5, 'red', 2, 200),
            new CartItem(7, 'black', 4, 150),
        ]));
    }

    public function testNone(): void
    {
        $calculator = new BigCost(new SimpleCost());
        $this->assertEquals(300, $calculator->getCost([
            new CartItem(5, 'red', 2, 100),
            new CartItem(7, 'black', 1, 100),
        ]));
    }
}
                    

Неудобно каждый раз массив товаров придумывать.

Сделаем для тестов заглушку DummyCost:


namespace tests\unit\cart\cost;

use app\cart\cost\Calculator;

class DummyCost implements Calculator
{
    private $value;

    public function __construct($value)
    {
        $this->value = $value;
    }

    public function getCost(array $items): float
    {
        return $this->value;
    }
}
                    

И наш код:


class BigCostTest extends Unit
{
    public function testActive(): void
    {
        $calculator = new BigCost(new SimpleCost());
        $this->assertEquals(2850, $calculator->getCost([
            new CartItem(5, 'red', 2, 200),
            new CartItem(7, 'black', 4, 150),
        ]));
    }

    public function testNone(): void
    {
        $calculator = new BigCost(new SimpleCost(300));
        $this->assertEquals(300, $calculator->getCost([
            new CartItem(5, 'red', 2, 100),
            new CartItem(7, 'black', 1, 100),
        ]));
    }
}
                    

Немного упростим:


class BigCostTest extends Unit
{
    public function testActive(): void
    {
        $calculator = new BigCost(new DummyCost(3000));
        $this->assertEquals(2850, $calculator->getCost([]));
    }

    public function testNone(): void
    {
        $calculator = new BigCost(new DummyCost(300));
        $this->assertEquals(300, $calculator->getCost([]));
    }
}
                    

Но с таким работать неудобно:


class BigCost implements Calculator
{
    private $next;

    public function __construct(Calculator $next)
    {
        $this->next = $next;
    }

    public function getCost(array $items): float
    {
        $cost = $this->next->getCost($items);
        if ($cost > 2000) {
            return 0.95 * $cost;
        }
        return $cost;
    }
}
                    

Лимит и процент захардкожен

Можно настроить:


class BigCost implements Calculator
{
    private $next;
    private $limit;
    private $percent;

    public function __construct(Calculator $next, $limit, $percent)
    {
        $this->next = $next;
        $this->limit = $limit;
        $this->percent = $percent;
    }

    public function getCost(array $items): float
    {
        $cost = $this->next->getCost($items);
        if ($cost > $this->limit) {
            return (1 - $this->percent / 100) * $cost;
        }
        return $cost;
    }
}
                    

И конфигурируем:


class SetUp implements BootstrapInterface
{
    public function bootstrap($app): void
    {
        $container = \Yii::$container;

        $container->setSingleton(Cart::class, function () {
            return new Cart(
                new CookieStorage('cart', 3600 * 24),
                new BigCost(new SimpleCost(), 2000, 5)
            );
        });
    }
}
                    

В тестах тоже:


class BigCostTest extends Unit
{
    public function testActive(): void
    {
        $calculator = new BigCost(new DummyCost(1000), 500, 5);
        $this->assertEquals(950, $calculator->getCost([]));
    }

    public function testNone(): void
    {
        $calculator = new BigCost(new DummyCost(1000), 2000, 5);
        $this->assertEquals(1000, $calculator->getCost([]));
    }
}
                    

Другие стоимости

Да будет NewYearCost в декабре


class NewYearCostTest extends Unit
{
    public function testActive(): void
    {
        $calculator = new NewYearCost(new DummyCost(1000), 5, 12);
        $this->assertEquals(950, $calculator->getCost([]));
    }

    public function testNone(): void
    {
        $calculator = new NewYearCost(new DummyCost(1000), 5, 6);
        $this->assertEquals(1000, $calculator->getCost([]));
    }
}
                    

И FridayCost по пятницам:


class FridayCostTest extends Unit
{
    /**
     * @dataProvider getDays
     */
    public function testCost($date, $cost): void
    {
        $calculator = new FridayCost(new DummyCost(100), 5, $date);
        $this->assertEquals($cost, $calculator->getCost([]));
    }

    public function getDays(): array
    {
        return [
            'Monday' => ['2016-04-18', 100],
            'Tuesday' => ['2016-04-19', 100],
            'Wednesday' => ['2016-04-20', 100],
            'Thursday' => ['2016-04-21', 100],
            'Friday' => ['2016-04-22', 95],
            'Saturday' => ['2016-04-23', 100],
            'Sunday' => ['2016-04-24', 100],
        ];
    }
}
                    

И дерзкий MinCost:


class MinCostTest extends Unit
{
    public function testMin(): void
    {
        $calc = new MinCost([
            new DummyCost(100),
            new DummyCost(80),
            new DummyCost(90),
        ]);
        $this->assertEquals(80, $calc->getCost([]));
    }
}
                    

Сказано – сделано:


class NewYearCost implements Calculator
{
    private $next;
    private $month;
    private $percent;

    public function __construct(Calculator $next, $percent, $month)
    {
        $this->next = $next;
        $this->month = $month;
        $this->percent = $percent;
    }

    public function getCost(array $items): float
    {
        $cost = $this->next->getCost($items);
        if ($this->month === 12) {
            return (1 - $this->percent / 100) * $cost;
        }
        return $cost;
    }
}
                    

И по пятницам


class FridayCost implements Calculator
{
    private $next;
    private $percent;
    private $date;

    public function __construct(Calculator $next, $percent, $date)
    {
        $this->next = $next;
        $this->date = $date;
        $this->percent = $percent;
    }

    public function getCost(array $items): float
    {
        $now = \DateTime::createFromFormat('Y-m-d', $this->date);
        if ($now->format('l') == 'Friday') {
            return (1 - $this->percent / 100) * $this->next->getCost($items);
        }
        return $this->next->getCost($items);
    }
}
                    

И великий маркетинговый ход:


class SetUp implements BootstrapInterface
{
    public function bootstrap($app): void
    {
        $container = \Yii::$container;

        $container->setSingleton(Cart::class, function () {
            return new Cart(
                new CookieStorage('cart', 3600 * 24),
                new BigCost(new MinCost([
                    new FridayCost(new SimpleCost(), 7, date('Y-m-d')),
                    new NewYearCost(new SimpleCost(), 3, date('m')),
                ]), 2000, 5)
            );
        });
    }
}
                    
  • Мы не наследуем, а вкладываем их друг в друга.
  • Можем строить любые деревья.
  • Можем переставлять элементы местами.
  • С наследованием так не сможем.

Фреймворконезависимость сделали.

Что ещё нужно крутым библиотекам?

Логирование

Напичкаем всюду Yii::info():


class Cart
{
    private $storage;
    private $calculator;

    public function __construct(Storage $storage, Calculator $calculator)
    {
        $this->storage = $storage;
        $this->calculator = $calculator;
    }

    ...

    public function add(CartItem $new): void
    {
        $items = $this->loadItems();

        foreach ($items as $i => $item) {
            if ($item->getId() == $new->getId()) {
                $items[$i] = $item->plus($new);
                $this->saveItems($items);
                \Yii::info('Increased product ' . $item->getProductId() . ' quantity.', 'cart');
                return;
            }
        }

        $items[] = $new;
        $this->saveItems($items);
        \Yii::info('Added product ' . $new->getProductId(), 'cart');
    }

    public function remove($id): void
    {
        $items = $this->loadItems();

        foreach ($items as $i => $item) {
            if ($item->getId() == $id) {
                unset($items[$i]);
                $this->saveItems($items);
                \Yii::info('Removed product ' . $item->getProductId(), 'cart');
                return;
            }
        }

        throw new MissingItemException();
    }

    public function clear(): void
    {
        $this->saveItems([]);
        \Yii::info('Cleared cart.', 'cart');
    }

    ...
}
                    

Как убрать грязный код? Вынести в объект.

Как отвязаться от фреймворка? Между корзиной и классом...

поставить интерфейс


namespace app\cart\logger;

interface Logger
{
    public function info($message): void;
}
                    

И в корзину принимаем любой логгер:


class Cart
{
    private $storage;
    private $calculator;
    private $logger;

    public function __construct(Storage $storage, Calculator $calculator, Logger $logger)
    {
        $this->storage = $storage;
        $this->calculator = $calculator;
        $this->logger = $logger;
    }

    ...

    public function add(CartItem $new): void
    {
        $items = $this->loadItems();

        foreach ($items as $i => $item) {
            if ($item->getId() == $new->getId()) {
                $items[$i] = $item->plus($new);
                $this->saveItems($items);
                $this->logger->info('Increased product ' . $new->getProductId() . ' quantity.');
                return;
            }
        }

        $items[] = $new;
        $this->saveItems($items);
        $this->logger->info('Added product ' . $new->getProductId());
    }

    public function remove($id): void
    {
        $items = $this->loadItems();

        foreach ($items as $i => $item) {
            if ($item->getId() == $id) {
                unset($items[$i]);
                $this->saveItems($items);
                $this->logger->info('Removed product ' . $item->getProductId());
                return;
            }
        }

        throw new MissingItemException();
    }

    public function clear(): void
    {
        $this->saveItems([]);
        $this->logger->info('Cleared cart.');
    }

    ...
}
                    

Теперь пишем класс для фреймворка:


class YiiLogger implements Logger
{
    public function info($message): void
    {
        \Yii::info($message, 'cart');
    }
}
                    

и подсовываем его корзине:


class SetUp implements BootstrapInterface
{
    public function bootstrap($app): void
    {
        $container = \Yii::$container;

        $container->setSingleton(Cart::class, function () {
            return new Cart(
                new CookieStorage('cart', 3600 * 24),
                new BigCost(new MinCost([
                    new FridayCost(new SimpleCost(), 7, date('Y-m-d')),
                    new NewYearCost(new SimpleCost(), 3, date('m')),
                ]), 2000, 5),
                new YiiLogger()
            );
        });
    }
}
                    

У многих серийных опенсорсников есть практика делать общие базовые классы или интерфейсы для своих проектов или библиотек.

Сделать некий пакет common и туда запихать LoggerInterface и т.п., а потом к другим проектам подтягивать его.

Но сколько в мире библиотек с логированием, столько и логгеров. Подключил 100 библиотек - реализуй 100 логгеров. Неудобно.

И тут в PHPFig предложили, а что если мы придумаем общий LoggerInterface?
Один на весь мир.

И придумали...

PSR-3 LoggerInterface

Ставим:


composer require psr/log
                    

Выглядит так:


namespace Psr\Log;

interface LoggerInterface
{
    public function emergency($message, array $context = []);
    public function alert($message, array $context = []);
    public function critical($message, array $context = []);
    public function error($message, array $context = []);
    public function warning($message, array $context = []);
    public function notice($message, array $context = []);
    public function info($message, array $context = []);
    public function debug($message, array $context = []);
    public function log($level, $message, array $context = []);
}
                    

Если работаете на Symfony или Laravel, то там monolog есть из коробки.

Свой системный логгер в корзину и передаёте.

У Yii же особый путь.

Он пишет всё своё, с такими вещами не совместимое.

Yii::getLogger() в корзину не передашь.

Так что надо сделать свою реализацию:


use Psr\Log\LoggerInterface;

class YiiLogger implements LoggerInterface
{
    public function emergency($message, array $context = []): void {}

    public function alert($message, array $context = []): void {}

    public function critical($message, array $context = []): void {}

    public function error($message, array $context = []): void {}

    public function warning($message, array $context = []): void {}

    public function notice($message, array $context = []): void {}

    public function info($message, array $context = []): void {
        \Yii::info($message, 'cart');
    }

    public function debug($message, array $context = []): void {}

    public function log($level, $message, array $context = []): void {}
}
                    

Нас интересует только метод info, но можно дописать и остальные.

Теперь принимаем в корзину логгер от PSR:


namespace app\cart;

use app\cart\cost\Calculator;
use app\cart\storage\Storage;
use Psr\Log\LoggerInterface;

class Cart
{
    private $storage;
    private $calculator;
    private $logger;

    public function __construct(Storage $storage, Calculator $calculator, LoggerInterface $logger)
    {
        $this->storage = $storage;
        $this->calculator = $calculator;
        $this->logger = $logger;
    }
}
                    

Но логирование – вещь необязательная.

Его можно сделать необязательным, в конструкторе:


class Cart
{
    private $storage;
    private $calculator;
    private $logger;

    public function __construct(Storage $storage, Calculator $calculator, LoggerInterface $logger = null)
    {
        $this->storage = $storage;
        $this->calculator = $calculator;
        $this->logger = $logger;
    }

    ...
}
                    

Или необязательные вещи можно и не передавать,
сделав для заполнения логгера отдельный сеттер:


class Cart
{
    private $storage;
    private $calculator;
    private $logger;

    public function __construct(Storage $storage, Calculator $calculator)
    {
        $this->storage = $storage;
        $this->calculator = $calculator;
    }

    public function setLogger(LoggerInterface $logger): void
    {
        $this->logger = $logger;
    }

    ...
}
                    

В любом случае в коде теперь придётся расставить if-ы:


if ($this->logger) {
    $this->logger->info('Increased product ' . $new->getProductId() . ' quantity.');
}
                    

Их у нас будет четыре:


class Cart
{
    ...

    public function add(CartItem $new): void
    {
        $items = $this->loadItems();
        foreach ($items as $i => $item) {
            if ($item->getId() == $new->getId()) {
                $items[$i] = $item->plus($new);
                $this->saveItems($items);
                if ($this->logger) {
                    $this->logger->info('Increased product ' . $new->getProductId() . ' quantity.');
                }
                return;
            }
        }
        $items[] = $new;
        $this->saveItems($items);
        if ($this->logger) {
            $this->logger->info('Added product ' . $new->getProductId());
        }
    }

    public function remove($id): void
    {
        $items = $this->loadItems();
        foreach ($items as $i => $item) {
            if ($item->getId() == $id) {
                unset($items[$i]);
                $this->saveItems($items);
                if ($this->logger) {
                    $this->logger->info('Removed product ' . $item->getProductId());
                }
                return;
            }
        }
        throw new MissingItemException();
    }

    public function clear(): void
    {
        $this->saveItems([]);
        if ($this->logger) {
            $this->logger->info('Cleared cart.');
        }
    }

    ...
}
                    

Можно вынести в приватный метод:


class Cart
{
    ...

    public function clear(): void
    {
        $this->saveItems([]);
        $this->log('Cleared cart.');
    }

    ...

    private function log($message)
    {
        if ($this->logger) {
            $this->logger->info('Cleared cart.');
        }
    }
}
                    

Но всё равно костыльно.

Можно сделать NullObject:


namespace app\cart\logger;

use Psr\Log\LoggerInterface;

class NullLogger implements LoggerInterface
{
    public function emergency($message, array $context = []): void {}
    public function alert($message, array $context = []): void {}
    public function critical($message, array $context = []): void {}
    public function error($message, array $context = []): void {}
    public function warning($message, array $context = []): void {}
    public function notice($message, array $context = []): void {}
    public function info($message, array $context = []): void {}
    public function debug($message, array $context = []): void {}
    public function log($level, $message, array $context = []): void {}
}
                    

и в конструкторе присваивать его:


class Cart
{
    private $storage;
    private $calculator;
    private $logger;

    public function __construct(Storage $storage, Calculator $calculator)
    {
        $this->storage = $storage;
        $this->calculator = $calculator;
        $this->logger = new NullLogger();
    }

    public function setLogger(LoggerInterface $logger): void
    {
        $this->logger = $logger;
    }

    ...

    public function clear(): void
    {
        $this->saveItems([]);
        $this->logger->info('Cleared cart.');
    }

    ...
}
                    

Теперь можно полностью избавиться от всех if-ов.

Что ещё может быть полезно?

События

Когда неследуемся от yii\base\Component у нас есть методы on и off для навешивания обработчиков событий.

А иначе придётся идти пешком...

и реализовать их самостоятельно:


class Cart
{
    const EVENT_ITEM_ADDED = 'cart.item_added';
    const EVENT_ITEM_INCREASED = 'cart.item_increases';
    const EVENT_ITEM_REMOVED = 'cart.item_removed';
    const EVENT_CLEARED = 'cart.cart_cleared';

    private $listeners = [];

    ...

    public function addEventListener($name, callable $listener): void
    {
        $this->listeners[$name][] = $listener;
    }

    public function removeEventListener($name, callable $listener): void
    {
        if (array_key_exists($name, $this->listeners)) {
            foreach ($this->listeners[$name] as $i => $current) {
                if ($current === $listener) {
                    unset($this->listeners[$i]);
                }
            }
        }
    }

    ...
}
                    

Чтобы потом кто угодно закинул в корзину свой коллбэк:


$cart = new Cart(...);

$cart->addEventListener(Cart::EVENT_ITEM_ADDED, function (CartEvent $event) {
    echo 'Added product ' . $event->item->getProductId() . PHP_EOL;
});

...
                    

И потом в него прилетит объект со всеми данными о событии вроде такого:


namespace app\cart\event;

use app\cart\Cart;
use app\cart\CartItem;

class CartEvent
{
    public $cart;
    public $item;

    public function __construct(Cart $cart, CartItem $item = null)
    {
        $this->cart = $cart;
        $this->item = $item;
    }
}
                    

Помимо внешних методов внутри будет приватный метод trigger

который будет обходить массив коллбэков и их вызывать:


class Cart
{
    const EVENT_ITEM_ADDED = 'cart.item_added';
    const EVENT_ITEM_INCREASED = 'cart.item_increases';
    const EVENT_ITEM_REMOVED = 'cart.item_removed';
    const EVENT_CLEARED = 'cart.cart_cleared';

    private $listeners = [];

    ...

    private function trigger($name, $event): void
    {
        if (array_key_exists($name, $this->listeners)) {
            foreach ($this->listeners[$name] as $i => $listener) {
                $listener($event);
            }
        }
    }

    ...
}
                    

Теперь достаточно корзине изнутри себя вызвать:


$this->trigger(self::EVENT_ITEM_INCREASED, new CartEvent($this, $item));
                    

и во все коллбеки, навешанные на EVENT_ITEM_INCREASED,
в цикле прилетит этот CartEvent.

Так что вызываем изнутри:


class Cart
{
    ..

    public function add(CartItem $new): void
    {
        $items = $this->loadItems();

        foreach ($items as $i => $item) {
            if ($item->getId() == $new->getId()) {
                $items[$i] = $item->plus($new);
                $this->saveItems($items);
                $this->trigger(self::EVENT_ITEM_INCREASED, new CartEvent($this, $item));
                $this->logger->info('Increased product ' . $new->getProductId() . ' quantity.');
                return;
            }
        }

        $items[] = $new;
        $this->saveItems($items);
        $this->trigger(self::EVENT_ITEM_ADDED, new CartEvent($this, $new));
        $this->logger->info('Added product ' . $new->getProductId());
    }

    public function remove($id): void
    {
        $items = $this->loadItems();

        foreach ($items as $i => $item) {
            if ($item->getId() == $id) {
                unset($items[$i]);
                $this->saveItems($items);
                $this->trigger(self::EVENT_ITEM_REMOVED, new CartEvent($this, $item));
                $this->logger->info('Removed product ' . $item->getProductId());
                return;
            }
        }

        throw new MissingItemException();
    }

    public function clear(): void
    {
        $this->saveItems([]);
        $this->trigger(self::EVENT_CLEARED, new CartEvent($this));
        $this->logger->info('Cleared cart.');
    }

    ...
}
                    

Но с одним нашим CartEvent мы далеко не уедем:


class CartEvent
{
    public $cart;
    public $item;

    public function __construct(Cart $cart, CartItem $item = null)
    {
        $this->cart = $cart;
        $this->item = $item;
    }
}
                    

Какому-то событию нужно одно поле, другому - два или больше.

От необязательного параметра можно избавиться, разложив класс на два:


class CartEvent
{
    private $cart;

    public function __construct(Cart $cart) {
        $this->cart = $cart;
    }

    public function getCart(): Cart {
        return $this->cart;
    }
}
                    

class CartItemEvent
{
    private $cart;
    private $item;

    public function __construct(Cart $cart, CartItem $item) {
        $this->cart = $cart;
        $this->item = $item;
    }

    public function getCart(): Cart {
        return $this->cart;
    }

    public function getItem(): CartItem {
        return $this->item;
    }
}
                    

и в коде уже создавать нужный экземпляр:


$this->trigger(self::EVENT_ITEM_INCREASED, new CartItemEvent($this, $item));
$this->trigger(self::EVENT_ITEM_ADDED, new CartItemEvent($this, $new));
$this->trigger(self::EVENT_ITEM_REMOVED, new CartItemEvent($this, $item));
$this->trigger(self::EVENT_CLEARED, new CartEvent($this));
                    

И код станет таким:


class Cart
{
    const EVENT_ITEM_ADDED = 'cart.item_added';
    const EVENT_ITEM_INCREASED = 'cart.item_increases';
    const EVENT_ITEM_REMOVED = 'cart.item_removed';
    const EVENT_CLEARED = 'cart.cart_cleared';

    ...

    public function getItems(): array
    {
        return $this->loadItems();
    }

    public function getCost(): float
    {
        return $this->calculator->getCost($this->loadItems());
    }

    public function add(CartItem $new): void
    {
        $items = $this->loadItems();

        foreach ($items as $i => $item) {
            if ($item->getId() == $new->getId()) {
                $items[$i] = $item->plus($new);
                $this->saveItems($items);
                $this->trigger(self::EVENT_ITEM_INCREASED, new CartItemEvent($this, $item));
                $this->logger->info('Increased product ' . $new->getProductId() . ' quantity.');
                return;
            }
        }

        $items[] = $new;
        $this->saveItems($items);
        $this->trigger(self::EVENT_ITEM_ADDED, new CartItemEvent($this, $new));
        $this->logger->info('Added product ' . $new->getProductId());
    }

    public function remove($id): void
    {
        $items = $this->loadItems();

        foreach ($items as $i => $item) {
            if ($item->getId() == $id) {
                unset($items[$i]);
                $this->saveItems($items);
                $this->trigger(self::EVENT_ITEM_REMOVED, new CartItemEvent($this, $item));
                $this->logger->info('Removed product ' . $item->getProductId());
                return;
            }
        }

        throw new MissingItemException();
    }

    public function clear(): void
    {
        $this->saveItems([]);
        $this->trigger(self::EVENT_CLEARED, new CartEvent($this));
        $this->logger->info('Cleared cart.');
    }

    ...
}
                    

У нас сейчас есть некая избыточность.

Логирование происходит рядом с каждым событием:


$this->trigger(self::EVENT_ITEM_INCREASED, new CartItemEvent($this, $item));
$this->logger->info('Increased product ' . $new->getProductId() . ' quantity.');
                    

Поэтому такое логирование можно легко вынести из корзины в обработчик:


$cart->addEventListener(Cart::EVENT_ITEM_INCREASED, function (CartItemEvent $event) {
    Yii::info('Increased product ' . $event->item->getProductId() . ' quantity.', 'cart');
});
...
$this->trigger(self::EVENT_ITEM_INCREASED, new CartItemEvent($this, $item));
                    

И всевозможные логгеры нам окажутся не нужны.

Сделаем слушатель:


namespace app\cart\listener;

use app\cart\event\CartEvent;
use app\cart\event\CartItemEvent;

class LogEventListener
{
    public function onItemAdded(CartItemEvent $event): void
    {
        \Yii::info('Added product ' . $event->getItem()->getProductId(), 'cart');
    }

    public function onItemIncreased(CartItemEvent $event): void
    {
        \Yii::info('Increased product ' . $event->getItem()->getProductId() . ' quantity.', 'cart');
    }

    public function onItemRemoved(CartItemEvent $event): void
    {
        \Yii::info('Removed product ' . $event->getItem()->getProductId(), 'cart');
    }

    public function onCleared(CartEvent $event): void
    {
        \Yii::info('Cleared cart.', 'cart');
    }
}
                    

и навесим его методы на соответсвующие события:


class SetUp implements BootstrapInterface
{
    public function bootstrap($app): void
    {
        $container = \Yii::$container;

        $container->setSingleton(Cart::class, function () {
            $cart = new Cart(
                new CookieStorage('cart', 3600 * 24),
                new BigCost(new MinCost([
                    new FridayCost(new SimpleCost(), 7, date('Y-m-d')),
                    new NewYearCost(new SimpleCost(), 3, date('m')),
                ]), 2000, 5)
            );

            $listener = new LogEventListener();
            $cart->addEventListener(Cart::EVENT_ITEM_ADDED, [$listener, 'onItemAdded']);
            $cart->addEventListener(Cart::EVENT_ITEM_INCREASED, [$listener, 'onItemIncreased']);
            $cart->addEventListener(Cart::EVENT_ITEM_REMOVED, [$listener, 'onItemRemoved']);
            $cart->addEventListener(Cart::EVENT_CLEARED, [$listener, 'onCleared']);

            return $cart;
        });
    }
}
                    

В итоге мы убрали LoggerInterface и оставили корзину без него только с событиями:


class Cart
{
    const EVENT_ITEM_ADDED = 'cart.item_added';
    const EVENT_ITEM_INCREASED = 'cart.item_increases';
    const EVENT_ITEM_REMOVED = 'cart.item_removed';
    const EVENT_CLEARED = 'cart.cart_cleared';

    private $listeners = [];

    private $storage;
    private $calculator;

    public function __construct(Storage $storage, Calculator $calculator)
    {
        $this->storage = $storage;
        $this->calculator = $calculator;
    }

    public function addEventListener($name, callable $listener): void
    {
        $this->listeners[$name][] = $listener;
    }

    public function removeEventListener($name, callable $listener): void
    {
        if (array_key_exists($name, $this->listeners)) {
            foreach ($this->listeners[$name] as $i => $current) {
                if ($current === $listener) {
                    unset($this->listeners[$i]);
                }
            }
        }
    }

    private function trigger($name, $event): void
    {
        if (array_key_exists($name, $this->listeners)) {
            foreach ($this->listeners[$name] as $i => $listener) {
                $listener($event);
            }
        }
    }

    ...

    public function add(CartItem $new): void
    {
        $items = $this->loadItems();

        foreach ($items as $i => $item) {
            if ($item->getId() == $new->getId()) {
                $items[$i] = $item->plus($new);
                $this->saveItems($items);
                $this->trigger(self::EVENT_ITEM_INCREASED, new CartItemEvent($this, $item));
                return;
            }
        }

        $items[] = $new;
        $this->saveItems($items);
        $this->trigger(self::EVENT_ITEM_ADDED, new CartItemEvent($this, $new));
    }

    public function remove($id): void
    {
        $items = $this->loadItems();

        foreach ($items as $i => $item) {
            if ($item->getId() == $id) {
                unset($items[$i]);
                $this->saveItems($items);
                $this->trigger(self::EVENT_ITEM_REMOVED, new CartItemEvent($this, $item));
                return;
            }
        }

        throw new MissingItemException();
    }

    public function clear(): void
    {
        $this->saveItems([]);
        $this->trigger(self::EVENT_CLEARED, new CartEvent($this));
    }

    ...
}
                    

Справились

Методы addEventListener, removeEventListener и trigger могут пригодиться нам для разных объектов.

Чтобы не копипастить, их можно вынести либо в трейт, либо в отдельный объект.

Менеджер событий

Менеджер событий:


namespace app\cart\event;

class EventManager
{
    private $listeners = [];

    public function addListener($name, callable $listener): void
    {
        $this->listeners[$name][] = $listener;
    }

    public function removeListener($name, callable $listener): void
    {
        if (array_key_exists($name, $this->listeners)) {
            foreach ($this->listeners[$name] as $i => $current) {
                if ($current === $listener) {
                    unset($this->listeners[$i]);
                }
            }
        }
    }

    public function dispatch($name, $event): void
    {
        if (array_key_exists($name, $this->listeners)) {
            foreach ($this->listeners[$name] as $i => $listener) {
                $listener($event);
            }
        }
    }
}
                    

А константы можно вынести в класс Events:


namespace app\cart\event;

class Events
{
    const ITEM_ADDED = 'cart.item_added';
    const ITEM_INCREASED = 'cart.item_increases';
    const ITEM_REMOVED = 'cart.item_removed';
    const CLEARED = 'cart.cart_cleared';
}
                    

И в контейнере вместо привязки обработчиков к самой корзине:


class SetUp implements BootstrapInterface
{
    public function bootstrap($app): void
    {
        $container = \Yii::$container;

        $container->setSingleton(Cart::class, function () {
            $cart = new Cart(
                new CookieStorage('cart', 3600 * 24),
                new BigCost(new MinCost([
                    new FridayCost(new SimpleCost(), 7, date('Y-m-d')),
                    new NewYearCost(new SimpleCost(), 3, date('m')),
                ]), 2000, 5)
            );

            $listener = new LogEventListener();
            $cart->addEventListener(Cart::EVENT_ITEM_ADDED, [$listener, 'onItemAdded']);
            $cart->addEventListener(Cart::EVENT_ITEM_INCREASED, [$listener, 'onItemIncreased']);
            $cart->addEventListener(Cart::EVENT_ITEM_REMOVED, [$listener, 'onItemRemoved']);
            $cart->addEventListener(Cart::EVENT_CLEARED, [$listener, 'onCleared']);

            return $cart;
        });
    }
}
                    

привызывать их к этому менеджеру:


class SetUp implements BootstrapInterface
{
    public function bootstrap($app): void
    {
        $container = \Yii::$container;

        $container->setSingleton(Cart::class, function () {
            $listener = new LogEventListener();

            $eventManager = new EventManager();
            $eventManager->addListener(Events::ITEM_ADDED, [$listener, 'onItemAdded']);
            $eventManager->addListener(Events::ITEM_INCREASED, [$listener, 'onItemIncreased']);
            $eventManager->addListener(Events::ITEM_REMOVED, [$listener, 'onItemRemoved']);
            $eventManager->addListener(Events::CLEARED, [$listener, 'onCleared']);

            return new Cart(
                new CookieStorage('cart', 3600 * 24),
                new BigCost(new MinCost([
                    new FridayCost(new SimpleCost(), 7, date('Y-m-d')),
                    new NewYearCost(new SimpleCost(), 3, date('m')),
                ]), 2000, 5),
                $eventManager
            );
        });
    }
}
                    

И его мы уже будем передавать корзине:


class Cart
{
    private $storage;
    private $calculator;
    private $eventManager;

    public function __construct(Storage $storage, Calculator $calculator, EventManager $eventManager)
    {
        $this->storage = $storage;
        $this->calculator = $calculator;
        $this->eventManager = $eventManager;
    }

    ...

    public function add(CartItem $new): void
    {
        $items = $this->loadItems();

        foreach ($items as $i => $item) {
            if ($item->getId() == $new->getId()) {
                $items[$i] = $item->plus($new);
                $this->saveItems($items);
                $this->eventManager->dispatch(Events::ITEM_INCREASED, new CartItemEvent($this, $item));
                return;
            }
        }

        $items[] = $new;
        $this->saveItems($items);
        $this->eventManager->dispatch(Events::ITEM_ADDED, new CartItemEvent($this, $new));
    }

    public function remove($id): void
    {
        $items = $this->loadItems();

        foreach ($items as $i => $item) {
            if ($item->getId() == $id) {
                unset($items[$i]);
                $this->saveItems($items);
                $this->eventManager->dispatch(Events::ITEM_REMOVED, new CartItemEvent($this, $item));
                return;
            }
        }

        throw new MissingItemException();
    }

    public function clear(): void
    {
        $this->saveItems([]);
        $this->eventManager->dispatch(Events::CLEARED, new CartEvent($this));
    }

    ...
}
                    

С помощью интерфейсов и событий мы отвязались от фреймворка. Кроме...

CartItem

В каждом проекте товары разные.

У кого-то есть цвета (как у нас), у кого-то нет.

У всех магазинов разные CartItem.

Посмотрим на наш:


class CartItem
{
    private $productId;
    private $color;
    private $quantity;
    private $price;

    public function __construct($productId, $color, $quantity, $price)
    {
        $this->productId = $productId;
        $this->color = $color;
        $this->quantity = $quantity;
        $this->price = $price;
    }

    public function getId(): string
    {
        return sha1(serialize([$this->productId, $this->color]));
    }

    public function getPrice(): int
    {
        return $this->price;
    }

    public function getQuantity(): int
    {
        return $this->quantity;
    }

    public function plus(CartItem $item): self
    {
        return new CartItem($this->productId, $this->color, $this->quantity + $item->quantity, $this->price);
    }

    public function getProductId(): int
    {
        return $this->productId;
    }

    public function getColor(): string
    {
        return $this->color;
    }
}
                    

Методы getQuantity, getPrice, getId и plus используются именно корзиной и SimpleCost.

Остальные методы вроде getProductId и getColor требуются только фреймворку, чтобы вывести список на странице корзины или сохранить в базу в DbStorage.

Можно превратить класс CartItem в интерфейс, который будет содержать неизменяемые методы:


interface CartItem
{
    public function getId(): string;

    public function getPrice(): int;

    public function getQuantity(): int;

    public function plus(CartItem $item): CartItem;
}
                    

И теперь можно вынести ProductCartItem для нашего конкретного проекта:


class ProductCartItem implements CartItem
{
    private $productId;
    private $color;
    private $quantity;
    private $price;

    public function __construct($productId, $color, $quantity, $price)
    {
        $this->productId = $productId;
        $this->color = $color;
        $this->quantity = $quantity;
        $this->price = $price;
    }

    public function getId(): string
    {
        return sha1(serialize([$this->productId, $this->color]));
    }

    public function getPrice(): int
    {
        return $this->price;
    }

    public function getQuantity(): int
    {
        return $this->quantity;
    }

    public function plus(CartItem $item): CartItem
    {
        return new self($this->productId, $this->color, $this->quantity + $item->getQuantity(), $this->price);
    }

    public function getProductId(): int
    {
        return $this->productId;
    }

    public function getColor(): string
    {
        return $this->color;
    }
}
                    

И поменять контроллер:


class CartController extends Controller
{
    ...

    public function actionAdd($productId, $color, $quantity = 1): Response
    {
        if (!$product = Product::findOne($productId)) {
            throw new NotFoundHttpException('Товар не найден.');
        }

        $this->cart->add(new ProductCartItem($product->id, $color, $quantity, $product->price));

        return $this->redirect(['index']);
    }

    ...
}
                    

Или:


class CartController extends Controller
{
    ...

    public function actionAdd($productId, $color, $quantity = 1): Response
    {
        if (!$product = Product::findOne($productId)) {
            throw new NotFoundHttpException('Товар не найден.');
        }

        $this->cart->add(new ProductCartItem($product, $color, $quantity));

        return $this->redirect(['index']);
    }

    ...
}
                    

если немного переделаем методы сохранения.

И для тестов можно реализовать похожий TestCartItem:


class TestCartItem implements CartItem
{
    private $productId;
    private $color;
    private $quantity;
    private $price;

    public function __construct($productId, $color, $quantity, $price)
    {
        $this->productId = $productId;
        $this->color = $color;
        $this->quantity = $quantity;
        $this->price = $price;
    }

    public function getId(): string
    {
        return sha1(serialize([$this->productId, $this->color]));
    }

    public function getPrice(): int
    {
        return $this->price;
    }

    public function getQuantity(): int
    {
        return $this->quantity;
    }

    public function plus(CartItem $item): CartItem
    {
        return new self($this->productId, $this->color, $this->quantity + $item->getQuantity(), $this->price);
    }

    public function getProductId(): int
    {
        return $this->productId;
    }
}
                    

Итог


class Cart
{
    private $storage;
    private $calculator;
    private $eventManager;

    public function __construct(Storage $storage, Calculator $calculator, EventManager $eventManager)
    {
        $this->storage = $storage;
        $this->calculator = $calculator;
        $this->eventManager = $eventManager;
    }

    public function getItems(): array
    {
        return $this->loadItems();
    }

    public function getCost(): float
    {
        return $this->calculator->getCost($this->loadItems());
    }

    public function add(CartItem $new): void
    {
        $items = $this->loadItems();

        foreach ($items as $i => $item) {
            if ($item->getId() == $new->getId()) {
                $items[$i] = $item->plus($new);
                $this->saveItems($items);
                $this->eventManager->dispatch(Events::ITEM_INCREASED, new CartItemEvent($this, $item));
                return;
            }
        }

        $items[] = $new;
        $this->saveItems($items);
        $this->eventManager->dispatch(Events::ITEM_ADDED, new CartItemEvent($this, $new));
    }

    public function remove($id): void
    {
        $items = $this->loadItems();

        foreach ($items as $i => $item) {
            if ($item->getId() == $id) {
                unset($items[$i]);
                $this->saveItems($items);
                $this->eventManager->dispatch(Events::ITEM_REMOVED, new CartItemEvent($this, $item));
                return;
            }
        }

        throw new MissingItemException();
    }

    public function clear(): void
    {
        $this->saveItems([]);
        $this->eventManager->dispatch(Events::CLEARED, new CartEvent($this));
    }

    private $items;

    private function loadItems(): array
    {
        if ($this->items === null) {
            $this->items = $this->storage->get();
        }
        return $this->items;
    }

    private function saveItems(array $items): void
    {
        $this->storage->put($items);
        $this->items = $items;
    }
}
                    

Вместо одного большого класса получилось от шести классов и интерфейсов

Фреймворконезависимые вещи:


cart
├── src
│   ├── cost
│   │   ├── BigCost.php
│   │   ├── Calculator.php
│   │   ├── FridayCost.php
│   │   ├── MinCost.php
│   │   ├── NewYearCost.php
│   │   └── SimpleCost.php
│   ├── event
│   │   ├── CartEvent.php
│   │   ├── CartItemEvent.php
│   │   ├── EventManager.php
│   │   └── Events.php
│   ├── exception
│   │   └── MissingItemException.php
│   ├── storage
│   │   └── Storage.php
│   ├── CartItem.php
│   └── Cart.php
│
└── tests
    ├── cost
    │   ├── DummyCost.php
    │   ├── BigCostTest.php
    │   ├── FridayCostTest.php
    │   ├── MinCostTest.php
    │   ├── NewYearCostTest.php
    │   ├── SimpleCostTest.php
    ├── storage
    │   └── MemoryStorage.php
    └── CartTest.php
                    

И адаптеры для проекта


app
├── controllers
│   └── CartController.php
├── listener
│   └── LogEventListener.php
├── storage
│   ├── CookieStorage.php
│   ├── DbStorage.php
│   ├── HybridStorage.php
│   └── SessionStorage.php
├── models
│   └─── ProductCartItem.php
│
└── tests
    └── storage
        ├── CookieStorageTest.php
        ├── DbStorageTest.php
        ├── HybridStorageTest.php
        └── SessionStorageTest.php
                    

Что у нас есть?

  • Чистая корзина
  • Свой Exception
  • События
  • Интерфейс хранилища
  • Интерфейс калькулятора
  • Тесты

Что нам помогло?

  • Разделение на ответсвенности (SRP)
  • Точные абстракции (ISP)
  • Полиморфная совместимость классов (LSP)
  • Инверсия зависимостей (DIP)
  • Паттерны для низкой связанности («Наблюдатель»)
  • Композиция вместо наследования
  • Модификация функциональности без переписывания кода (OCP)

SOLID

SOLID

  • Принцип единственной ответственности (The Single Responsibility Principle)
  • Принцип открытости/закрытости (The Open Closed Principle)
  • Принцип подстановки Барбары Лисков (The Liskov Substitution Principle)
  • Принцип разделения интерфейса (The Interface Segregation Principle)
  • Принцип инверсии зависимостей (The Dependency Inversion Principle)

Время вопросов

Всё

- http://elisdn.ru
- http://elisdn.ru/oop-week
- http://elisdn.ru/yii2-shop
- http://slides.elisdn.ru/2017/yiiconf-independent