Может это и поажется непатриотичным по отношению к Yii...
поговорим о написании фреймворконезависимых компонентов.
Чтобы перестать заморачиваться только над одним фреймворком и начать жить не думая о них.
Чтобы просто писать, ..., код.
Какие паттерны и принципы помогут нам сделать компонент гибким.
Ну и какие вещи вы теперь заметите в чужих компонентах.
Только поработав со своим компонентом как с чужим возникает понимание.
С этого и начнём
Как делают магазины?
Как делают корзину
Просто сессия:
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']);
}
}
Код понятный для программиста, но неудобный для него же.
Для переиспользования выносим.
Куда?
Тру 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 вещи:
Что-то остаётся. Что-то может меняться редко, что то часто.
Одна из хороших практик - инкапсулирование изменчивости.
Первое, что поменяется при подключении к своему проекту - хранилище.
Но пока перейдём на использование...
Подключем компонент:
'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);
});
}
}
И уже можно делать...
Для него создадим хранилище-заглушку:
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?
Один на весь мир.
И придумали...
Ставим:
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.
Посмотрим на наш:
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
Что у нас есть?
Что нам помогло?
SOLID
SOLID
- http://elisdn.ru
- http://elisdn.ru/oop-week
- http://elisdn.ru/yii2-shop
- http://slides.elisdn.ru/2017/yiiconf-independent