Сервис классы в Laravel

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

Сценарий

Возьмем, к примеру, следующий фрагмент кода:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class CartItemController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function index()
    {
        $content = session()->has('cart') ? session()->get('cart') : collect([]);
        $total = $content->reduce(function ($total, $item) {
            return $total += $item->get('price') * $item->get('quantity');
        });

        return view('cart.index', [
            'content' => $content,
            'total' => $total,
        ]);
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function store(Request $request)
    {
        $request->validate([
            'name' => 'required|string',
            'price' => 'required|numeric',
            'quantity' => 'required|integer',
        ]);

        $cartItem = collect([
            'name' => $request->name,
            'price' => floatval($request->price),
            'quantity' => intval($request->quantity),
            'options' => $request->options,
        ]);

        $content = session()->has('cart') ? session()->get('cart') : collect([]);

        $id = request('id');

        if ($content->has($id)) {
            $cartItem->put('quantity', $content->get($id)->get('quantity') + $request->quantity);
        }

        $content->put($id, $cartItem);

        session()->put('content', $content);

        return back()->with('success', 'Item added to cart');
    }

    /**
     * Display the specified resource.
     *
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function show($id)
    {
        $content = session()->get('cart');

        if ($content->has($id)) {
            $item = $content->get($id);

            return view('cart', compact('item'));
        }

        return back()->with('fail', 'Item not found');
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function update(Request $request, $id)
    {
        $content = session()->get('cart');

        if ($content->has($id)) {
            $cartItem = $content->get($id);

            switch ($request->action) {
                case 'plus':
                    $cartItem->put('quantity', $content->get($id)->get('quantity') + 1);
                    break;
                case 'minus':
                    $updatedQuantity = $content->get($id)->get('quantity') - 1;

                    if ($updatedQuantity < 1) {
                        $updatedQuantity = 1;
                    }

                    $cartItem->put('quantity', $updatedQuantity);
                    break;
            }

            $content->put($id, $cartItem);

            session()->put('cart', $content);

            return back()->with('success', 'Item updated in cart');
        }

        return back()->with('fail', 'Item not found');
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function destroy($id)
    {
        $content = session()->get('cart');

        if ($content->has($id)) {
            session()->put('cart', $content->except($id));

            return back()->with('success', 'Item removed from cart');
        }

        return back()->with('fail', 'Item not found');
    }
}

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

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

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

Понимание бизнес-логики

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

В случае простой системы корзины покупок бизнес-логику добавления товара в корзину можно описать следующим образом:

  1. Возьмите необходимую информацию о продукте (идентификатор, название, цена, количество) в качестве входных данных.
  2. Проверьте входные данные.
  3. Сформируйте новый товар в корзине.
  4. Проверьте, существует ли товар уже в корзине.
  5. Если да, обновите его количество, а если нет, добавьте вновь сформированный товар в корзину.

Теперь давайте посмотрим на store метод:

    /**
     * Store a newly created resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function store(Request $request)
    {
        // validate the input data.
        $request->validate([
            'name' => 'required|string',
            'price' => 'required|numeric',
            'quantity' => 'required|integer',
        ]);

        // form a new cart item.
        $cartItem = collect([
            'name' => $request->name,
            'price' => floatval($request->price),
            'quantity' => intval($request->quantity),
            'options' => $request->options,
        ]);

        // check if the item already exists in the cart.
        $content = session()->has('cart') ? session()->get('cart') : collect([]);
        $id = request('id');
        if ($content->has($id)) {
            // if yes, update it's quantity
            $cartItem->put('quantity', $content->get($id)->get('quantity') + $request->quantity);
        }

        // if no, add the newly formed item to cart.
        $content->put($id, $cartItem);
        $this->session->put('content', $content);

        return back()->with('success', 'Item added to cart');
    }

Как видите, бизнес-логика довольно точно преобразуется в код. Проблема в том, что контроллеры не предназначены для хранения бизнес-логики.

Сервисные классы приходят на помощь

Согласно очень популярному репозиторию alexeymezenin/laravel-best-practices :

Бизнес-логика должна быть в классе обслуживания

Идея классов обслуживания не является чем-то встроенным в структуру или документацией в официальной документации. В результате разные люди относятся к ним по-разному. В конце концов, классы сервисов — это простые классы, отвечающие за хранение бизнес-логики.

Сервис класс для хранения бизнес-логики, связанной с корзиной покупок, может быть следующим:

<?php

namespace App\Services;

use Illuminate\Support\Collection;
use Illuminate\Session\SessionManager;

class CartService {
    const MINIMUM_QUANTITY = 1;
    const DEFAULT_INSTANCE = 'shopping-cart';

    protected $session;
    protected $instance;

    /**
     * Constructs a new cart object.
     *
     * @param Illuminate\Session\SessionManager $session
     */
    public function __construct(SessionManager $session)
    {
        $this->session = $session;
    }

    /**
     * Adds a new item to the cart.
     *
     * @param string $id
     * @param string $name
     * @param string $price
     * @param string $quantity
     * @param array $options
     * @return void
     */
    public function add($id, $name, $price, $quantity, $options = []): void
    {
        $cartItem = $this->createCartItem($name, $price, $quantity, $options);

        $content = $this->getContent();

        if ($content->has($id)) {
            $cartItem->put('quantity', $content->get($id)->get('quantity') + $quantity);
        }

        $content->put($id, $cartItem);

        $this->session->put(self::DEFAULT_INSTANCE, $content);
    }

    /**
     * Updates the quantity of a cart item.
     *
     * @param string $id
     * @param string $action
     * @return void
     */
    public function update(string $id, string $action): void
    {
        $content = $this->getContent();

        if ($content->has($id)) {
            $cartItem = $content->get($id);

            switch ($action) {
                case 'plus':
                    $cartItem->put('quantity', $content->get($id)->get('quantity') + 1);
                    break;
                case 'minus':
                    $updatedQuantity = $content->get($id)->get('quantity') - 1;

                    if ($updatedQuantity < self::MINIMUM_QUANTITY) {
                        $updatedQuantity = self::MINIMUM_QUANTITY;
                    }

                    $cartItem->put('quantity', $updatedQuantity);
                    break;
            }

            $content->put($id, $cartItem);

            $this->session->put(self::DEFAULT_INSTANCE, $content);
        }
    }

    /**
     * Removes an item from the cart.
     *
     * @param string $id
     * @return void
     */
    public function remove(string $id): void
    {
        $content = $this->getContent();

        if ($content->has($id)) {
            $this->session->put(self::DEFAULT_INSTANCE, $content->except($id));
        }
    }

    /**
     * Clears the cart.
     *
     * @return void
     */
    public function clear(): void
    {
        $this->session->forget(self::DEFAULT_INSTANCE);
    }

    /**
     * Returns the content of the cart.
     *
     * @return Illuminate\Support\Collection
     */
    public function content(): Collection
    {
        return is_null($this->session->get(self::DEFAULT_INSTANCE)) ? collect([]) : $this->session->get(self::DEFAULT_INSTANCE);
    }

    /**
     * Returns total price of the items in the cart.
     *
     * @return string
     */
    public function total(): string
    {
        $content = $this->getContent();

        $total = $content->reduce(function ($total, $item) {
            return $total += $item->get('price') * $item->get('quantity');
        });

        return number_format($total, 2);
    }

    /**
     * Returns the content of the cart.
     *
     * @return Illuminate\Support\Collection
     */
    protected function getContent(): Collection
    {
        return $this->session->has(self::DEFAULT_INSTANCE) ? $this->session->get(self::DEFAULT_INSTANCE) : collect([]);
    }

    /**
     * Creates a new cart item from given inputs.
     *
     * @param string $name
     * @param string $price
     * @param string $quantity
     * @param array $options
     * @return Illuminate\Support\Collection
     */
    protected function createCartItem(string $name, string $price, string $quantity, array $options): Collection
    {
        $price = floatval($price);
        $quantity = intval($quantity);

        if ($quantity < self::MINIMUM_QUANTITY) {
            $quantity = self::MINIMUM_QUANTITY;
        }

        return collect([
            'name' => $name,
            'price' => $price,
            'quantity' => $quantity,
            'options' => $options,
        ]);
    }
}

Как мы уже заметили, сервис классы не являются чем-то встроенным в инфраструктуру, поэтому нет artisan make команды для создания класса сервиса. Вы можете создать их где угодно. Мы храним свои классы внутри App/Services каталога.

Файл CartService.php содержит оба метода public и protected. Открытые методы с именами add(), remove(), отвечают за добавление товара в корзину, удаление товара из корзины, обновление количества товара в корзине и очистку корзины соответственно update() и clear().

Другие общедоступные методы content() и total() отвечают за возврат содержимого корзины и общей стоимости добавленных товаров соответственно.

Наконец, protected методы getContent() и createCartItem() отвечают за возврат содержимого корзины внутри методов класса и формирование нового элемента корзины на основе полученных параметров.

Теперь, когда класс сервис готов, вам нужно использовать его внутри контроллера. Чтобы использовать класс обслуживания внутри CartItemController.php файла, код необходимо обновить следующим образом:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Services\CartService;
use App\Http\Requests\CartItemRequest;

class CartItemController extends Controller
{
    protected $cartService;

    /**
     * Instantiate a new controller instance.
     *
     * @return void
     */
    public function __construct(CartService $cartService)
    {
        $this->cartService = $cartService;
    }

    /**
     * Display a listing of the resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function index()
    {
        $content = $this->cartService->content();
        $total = $this->cartService->total();

        return view('cart.index', [
            'content' => $content,
            'total' => $total,
        ]);
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param  \App\Http\Requests\CartItemRequest  $request
     * @return \Illuminate\Http\Response
     */
    public function store(CartItemRequest $request)
    {
        $this->cartService->add($request->id, $request->name, $request->price, $request->quantity, $request->options);

        return back()->with('success', 'Item added to cart');
    }

    /**
     * Display the specified resource.
     *
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function show($id)
    {
        $content = $this->cartService->content();

        $item = $content->get($id);

        return view('cart', compact('item'));
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function update(Request $request, $id)
    {
        $this->cartService->update($id, $request->id);

        return back()->with('success', 'Item updated in cart');
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function destroy($id)
    {
        $this->cartService->remove($id);

        return back()->with('success', 'Item removed from cart');
    }
}

Благодаря нулевому разрешению конфигурации сервисный контейнер автоматически разрешает любой класс, который не зависит ни от какого интерфейса. Следовательно, простое внедрение CartService в конструктор контроллера дает результат:

class CartItemController extends Controller
{
    protected $cartService;

    /**
     * Instantiate a new controller instance.
     *
     * @return void
     */
    public function __construct(CartService $cartService)
    {
        $this->cartService = $cartService;
    }
}

Теперь экземпляр класса CartService становится доступным внутри контроллера и доступен как $this->cartService свойство. Остальные действия контроллера были обновлены для использования службы, и, как вы можете видеть, контроллер теперь стал намного чище.

Повторное использование

Помимо очистки контроллера, вы также получаете возможность доступа к действиям, связанным с корзиной покупок, где угодно. Рассмотрим, например, следующий компонент Livewire:

<?php

namespace App\Http\Livewire;

use App\Facades\Cart;
use Livewire\Component;
use Illuminate\Contracts\View\View;

class ProductComponent extends Component
{
    public $product;
    public $quantity;

    /**
     * Mounts the component on the template.
     *
     * @return void
     */
    public function mount(): void
    {
        $this->quantity = 1;
    }

    /**
     * Renders the component on the browser.
     *
     * @return \Illuminate\Contracts\View\View
     */
    public function render(): View
    {
        return view('livewire.product');
    }

    /**
     * Adds an item to cart.
     *
     * @return void
     */
    public function addToCart(): void
    {
        Cart::add($this->product->id, $this->product->name, $this->product->unit_price, $this->quantity);
        $this->emit('productAddedToCart');
    }
}

Вы можете добавить, удалить, обновить или очистить корзину покупок где угодно. До реализации класса обслуживания единственным способом управления корзиной были HTTP-запросы. Теперь вы даже можете управлять тележкой с помощью artisan команд.

Заключительные мысли

В концепции сервисных классов, обсуждаемой в этой статье, нет ничего конкретного, и мы не утверждаем, что она является панацеей. Если вы обдумываете использование этих классов и не переусердствуете, все будет в порядке.