Реализация тонких контроллеров в Yii2

Приходится часто на разных форумах, посвященных Yii фреймворку сталкиваться с мнением, что нужно делать тонкие контроллеры и толстые модели, это безусловно так, но в подавляющем большинстве это определение понимается людьми буквально, то есть контроллеры делаем тонкими, а весь код выносим в модели (классы наследующиеся от ActiveRecord). Это ошибочное мнение и делать так неправильно.

ActiveRecord модель — это монолитная сущность, которая работает только с собственными данными и живет своей жизнью. Внутри себя AR модель не должна обращаться да и, вообще, знать о существовании контроллеров, сессии и http-запросов.

Реализация сервисного слоя и хелперов в Yii2

Когда речь идет о толстой модели, имеется в виду не 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 вам интересны, напишите в комментариях, и я буду уделять фреймворку и рефакторингу больше времени и писать об этом чаще.

Похожие записи: Yii2 shopping cart extension: корзина для интернет магазина Yii2 basic: структура приложения Маршрутизация в Yii2: настройка UrlManager и Yii2 routing Yii2: как убрать web из URL-адреса и настроить ЧПУ?

Добавить комментарийОтменить ответ

Нажимая на кнопку «Добавить», я даю согласие на обработку своих персональных данных в соответствии с политикой конфиденциальности