Приходится часто на разных форумах, посвященных Yii фреймворку сталкиваться с мнением, что нужно делать тонкие контроллеры и толстые модели, это безусловно так, но в подавляющем большинстве это определение понимается людьми буквально, то есть контроллеры делаем тонкими, а весь код выносим в модели (классы наследующиеся от ActiveRecord). Это ошибочное мнение и делать так неправильно.
ActiveRecord модель — это монолитная сущность, которая работает только с собственными данными и живет своей жизнью. Внутри себя AR модель не должна обращаться да и, вообще, знать о существовании контроллеров, сессии и http-запросов.
Когда речь идет о толстой модели, имеется в виду не AR моделька, а доменная модель. Если коротко и простыми словами, то доменная модель — это вся бизнес-логика приложения, т.е. это может быть большое количество различных классов, каждый из которых отвечает за конкретную задачу.
Возникает резонный вопрос: если нельзя в ActiveRecord модель, то куда же выносить этот громоздкий код? Вот для таких целей существует так называемый сервисный слой.
Сервисный слой и хелперы в Yii2
Сервисный слой — это отдельный класс или несколько классов, которые помогают разгрузить контроллер, и являются связующим звеном между контроллером и доменной моделью. В сервисном слое можно писать в логи, кэшировать данные, управлять транзакциями, взаимодействовать с сущностями, производить различные логические и вспомогательные операции.
Где размещать классы сервисного слоя? Да, в принципе, где угодно. Если вы используете стандартную структура приложения Yii2 basic, т.е. контроллеры у вас находятся в корневой папке controllers
, то логично там же в корне создать папку services
и складывать их там. В таком случае namespace у сервисов будет app\services
.
При доработке очередного интернет-магазина на Yii2 фреймворке я частенько сталкиваюсь примерно вот с такими экшенами в контроллерах.
class OrderController extends Controller
{
/**
* @inheritdoc
*/
public function actionIndex()
{
$cart = Yii::$app->cart;
if (empty($cart)) {
return $this->render('empty-cart');
}
$order = new Order();
if ($order->load(Yii::$app->request->post()) && $order->validate()) {
$order->amount = $cart->getAmount();
$order->count = $cart->getTotalCount();
if ($order->save()) {
foreach ($cart->getItems() as $cartItem) {
$orderItem = new OrderItem([
'order_id' => $order->id,
'product_id' => $cartItem->getProduct()->id,
'name' => $cartItem->getProduct()->name,
'price' => $cartItem->getPrice(),
'quantity' => $cartItem->getQuantity(),
]);
$orderItem->save();
}
Yii::$app->mailer->compose(
['html' => 'order/customer/notify-html', 'text' => 'order/customer/notify-text'],
['order' => $order, 'items' => $cart->getItems()]
)
->setFrom([Yii::$app->params['supportEmail'] => 'Интернет-магазин ' . Yii::$app->name])
->setTo($order->email)
->setSubject('Информация о Вашем заказе в интернет-магазине ' . Yii::$app->name)
->send();
Yii::$app->mailer->compose(
['html' => 'order/admin/notify-html', 'text' => 'order/admin/notify-text'],
['order' => $order, 'items' => $cart->getItems()]
)
->setFrom([Yii::$app->params['supportEmail'] => 'Заказ в интернет-магазине ' . Yii::$app->name])
->setTo(Yii::$app->params['adminEmail'])
->setSubject('Поступил новый заказ в интернет-магазин ' . Yii::$app->name)
->send();
Yii::$app->session->setFlash('success', 'Ваш заказ принят. Мы скоро свяжемся с Вами.');
$cart->clear();
return $this->render('order-success');
}
Yii::$app->session->setFlash('error', 'Произошла ошибка при оформлении заказа');
}
return $this->render('index', [
'order' => $order,
]);
}
}
Этот пример контроллера оформления заказа далеко не самый большой. Чтобы хоть как-то код читался, я указал минимум полей в AR модельках, убрал доставку с оплатой и использовал готовый компонент корзины. Итак, создаем класс нашего сервиса.
namespace app\services;
class OrderService
{}
Давайте определимся что нужно вынести, а что оставить в контроллере, и еще необходимо бы обернуть все сохранения в базу данных в транзакцию. Первое, что бросается в глаза, это отправка почты, уж слишком много строк кода она занимает. Как видно в коде отправляются два письма: администратору и клиенту. Для большей гибкости создаем два приватных метода, независимых друг от друга.
private function sendMailCustomer($order, $cart)
{
$sent = Yii::$app->mailer->compose(
['html' => 'order/customer/notify-html', 'text' => 'order/customer/notify-text'],
['order' => $order, 'items' => $cart->getItems()]
)
->setFrom([Yii::$app->params['supportEmail'] => 'Интернет-магазин ' . Yii::$app->name])
->setTo($order->email)
->setSubject('Информация о Вашем заказе в интернет-магазине ' . Yii::$app->name)
->send();
if (!$sent) {
throw new \RuntimeException('Произошла ошибка при отправке электронной почты.');
}
}
private function sendMailAdmin($order, $cart)
{
$sent = Yii::$app->mailer->compose(
['html' => 'order/admin/notify-html', 'text' => 'order/admin/notify-text'],
['order' => $order, 'items' => $cart->getItems()]
)
->setFrom([Yii::$app->params['supportEmail'] => 'Заказ в интернет-магазине ' . Yii::$app->name])
->setTo(Yii::$app->params['adminEmail'])
->setSubject('Поступил новый заказ в интернет-магазин ' . Yii::$app->name)
->send();
if (!$sent) {
throw new \RuntimeException('Произошла ошибка при отправке электронной почты.');
}
}
Теперь создаем публичный метод checkout()
, в который переносим основной код оборачивая его в транзакцию, тем самым закрываем проблему целостности данных при сохранении в БД и избавляемся от избыточных условий.
public function checkout($order, $cart)
{
$transaction = Yii::$app->db->beginTransaction();
try {
$order->amount = $cart->getAmount();
$order->count = $cart->getTotalCount();
$order->save();
foreach ($cart->getItems() as $cartItem) {
$orderItem = new OrderItem([
'order_id' => $order->id,
'product_id' => $cartItem->getProduct()->id,
'name' => $cartItem->getProduct()->name,
'price' => $cartItem->getPrice(),
'quantity' => $cartItem->getQuantity(),
]);
$orderItem->save();
}
$this->sendMailCustomer($order, $cart);
$this->sendMailAdmin($order, $cart);
$cart->clear();
$transaction->commit();
} catch (\Exception $e) {
$transaction->rollBack();
throw $e;
}
}
Стоит обратить внимание, что транзакция в таком виде может понадобиться для операций с другими сущностями, чтобы не «копипастить», ее можно вынести в отдельный класс. В данном случае есть несколько реализаций такого класса. Самая очевидная — это создать сервис TransactionService
и передавать его в наш OrderService
через конструктор или просто создавать через new
, но есть еще более простые варианты, а именно, поместить код транзакции в трейт или в хелпер.
Реализуем, используя хелпер. Для этого создаем папку helpers
и в ней класс TransactionHelper
со статическим методом wrap()
, в который и поместим код транзакции. Аргументом передавая статическому методу анонимную функцию.
class TransactionHelper
{
/**
* @param callable $function
* @throws \yii\db\Exception
* @throws \Exception
*/
public static function wrap(callable $function)
{
$transaction = \Yii::$app->db->beginTransaction();
try {
$function();
$transaction->commit();
} catch (\Exception $e) {
$transaction->rollBack();
throw $e;
}
}
}
Хелперы очень удобны для выноса в них примитивного повторяющего кода. Они помогают существенно сократить и сделать более читабельным основной код, а самое главное в их использовании — это уход от «copy-paste».
Теперь поправим checkout()
. Используя конструкцию use
, затянем в анонимную функцию все необходимые внешние переменные для использования их внутри функции.
public function checkout($order, $cart)
{
TransactionHelper::wrap(function () use ($order, $cart) {
$order->amount = $cart->getAmount();
$order->count = $cart->getTotalCount();
$order->save();
foreach ($cart->getItems() as $cartItem) {
$orderItem = new OrderItem([
'order_id' => $order->id,
'product_id' => $cartItem->getProduct()->id,
'name' => $cartItem->getProduct()->name,
'price' => $cartItem->getPrice(),
'quantity' => $cartItem->getQuantity(),
]);
$orderItem->save();
}
$this->sendMailCustomer($order, $cart);
$this->sendMailAdmin($order, $cart);
$cart->clear();
});
}
Все, наш сервис готов. Благодаря тому, что мы разделили код по ответственности, можно быстро понять, что за что отвечает. Весь код OrderService
выглядит так.
class OrderService
{
/**
* @param $order
* @param $cart
* @throws \yii\db\Exception
*/
public function checkout($order, $cart)
{
TransactionHelper::wrap(function () use ($order, $cart) {
$order->amount = $cart->getAmount();
$order->count = $cart->getTotalCount();
$order->save();
foreach ($cart->getItems() as $cartItem) {
$orderItem = new OrderItem([
'order_id' => $order->id,
'product_id' => $cartItem->getProduct()->id,
'name' => $cartItem->getProduct()->name,
'price' => $cartItem->getPrice(),
'quantity' => $cartItem->getQuantity(),
]);
$orderItem->save();
}
$this->sendMailCustomer($order, $cart);
$this->sendMailAdmin($order, $cart);
$cart->clear();
});
}
/**
* @param $order
* @param $cart
*/
private function sendMailCustomer($order, $cart)
{
$sent = Yii::$app->mailer->compose(
['html' => 'order/customer/notify-html', 'text' => 'order/customer/notify-text'],
['order' => $order, 'items' => $cart->getItems()]
)
->setFrom([Yii::$app->params['supportEmail'] => 'Интернет-магазин ' . Yii::$app->name])
->setTo($order->email)
->setSubject('Информация о Вашем заказе в интернет-магазине ' . Yii::$app->name)
->send();
if (!$sent) {
throw new \RuntimeException('Произошла ошибка при отправке электронной почты.');
}
}
/**
* @param $order
* @param $cart
*/
private function sendMailAdmin($order, $cart)
{
$sent = Yii::$app->mailer->compose(
['html' => 'order/admin/notify-html', 'text' => 'order/admin/notify-text'],
['order' => $order, 'items' => $cart->getItems()]
)
->setFrom([Yii::$app->params['supportEmail'] => 'Заказ в интернет-магазине ' . Yii::$app->name])
->setTo(Yii::$app->params['adminEmail'])
->setSubject('Поступил новый заказ в интернет-магазин ' . Yii::$app->name)
->send();
if (!$sent) {
throw new \RuntimeException('Произошла ошибка при отправке электронной почты.');
}
}
}
Обратите внимание, как мы реализовали наш сервис. Если что-то пойдет не так, то в каждом методе будет брошено исключение. При отправке почты — \RuntimeException
, а при различных ошибках внутри транзакции — \Exception
и \yii\db\Exception
. Это дает возможность в контроллере просто ловить все исключения и, исходя от результата, полученного из сервиса, отдавать необходимый ответ пользователю. Код контроллера упростился до примитивного.
class OrderController extends Controller
{
private $service;
private $cart;
public function __construct($id, $module, OrderService $service, $config = [])
{
parent::__construct($id, $module, $config);
$this->service = $service;
$this->cart = Yii::$app->cart;
}
/**
* @inheritdoc
*/
public function actionIndex()
{
if (empty($this->cart)) {
return $this->render('empty-cart');
}
$order = new Order();
if ($order->load(Yii::$app->request->post()) && $order->validate()) {
try {
$this->service->checkout($order, $this->cart);
Yii::$app->session->setFlash('success', 'Ваш заказ принят. Мы скоро свяжемся с Вами.');
return $this->render('order-success');
} catch (\Exception $e) {
Yii::$app->errorHandler->logException($e);
Yii::$app->session->setFlash('error', $e->getMessage());
}
}
return $this->render('index', [
'order' => $order,
]);
}
}
Стоит отметить, что сервис и компонент корзины мы принимаем в конструкторе контроллера. Это очень удобно, если в контроллере несколько экшенов, а также, если у сервиса есть собственный конструктор, то он будет автоматически подхвачен и распарсен контейнером внедрения зависимостей Yii2 фреймворка.
Рефакторингом этого кода можно заниматься и дальше, но это уже темы для других статей, а в этом посте я попытался донести сам смысл организации кода. Если данная тема и вообще Yii2 вам интересны, напишите в комментариях, и я буду уделять фреймворку и рефакторингу больше времени и писать об этом чаще.