Рефакторинг: Сервисы, Статические Методы и Внедрение Зависимостей

Иногда нужно переместить бизнес-логику за пределы Контроллеров или Моделей, обычно в так называемые Сервисы. Но есть несколько способов их использования — как статический «хелпер», как Объект и с помощью Dependency Injection (DI — Внедрения зависимости). Давайте посмотрим, когда тот или иной способ нужно использовать.

Самая большая проблема этой тематики — существует множество статей о том, КАК использовать DI и Сервисы, но почти нет объяснений, ПОЧЕМУ вы должны их использовать и КОГДА это действительно необходимо. Итак, давайте рассмотрим всё на примерах.

В этой статье мы рассмотрим пример отчета с использованием различных методов перемещения кода из Контроллера в Сервис:

  1. Первый способ: из Контроллера в статический Сервис «Helper»
  2. Второй способ: создать Сервисный Объект с нестатическим методом
  3. Третий способ: Сервисный объект с Параметром
  4. Четвертый способ: Внедрение Зависимости — простой случай
  5. Пятый способ: Внедрение Зависимости с Интерфейсом — Расширенное использование

Начальный пример: Отчет в Контроллере

Допустим, мы создаем ежемесячный отчет, например такой:

Отчет в Контроллере

И если мы весь код поместим в Контроллер, то это будет выглядеть примерно так:

// ... тут всякие use

class ClientReportController extends Controller
{
    public function index(Request $request)
    {
        $q = Transaction::with('project')
            ->with('transaction_type')
            ->with('income_source')
            ->with('currency')
            ->orderBy('transaction_date', 'desc');
        if ($request->has('project')) {
            $q->where('project_id', $request->project);
        }

        $transactions = $q->get();

        $entries = [];

        foreach ($transactions as $row) {
            // ... Еще 50 строк кода чтобы заполнить $entries по месяцам
        }

        return view('report', compact('entries'));
    }
}

Видите этот запрос к базе данных, а также скрытые 50 строк кода — как-то многовато для Контроллера и поэтому где-то нужно все это хранить, верно?

Первый способ: из Контроллера в статический Сервис «Helper»

Самый популярный способ отделения логики от контроллера — создать отдельный класс, обычно называемый Service. Его еще называют «хелпером» или просто «функцией».

Обратите внимание: классы Service не являются частью Laravel и нет artisan-команды make:service. Это просто обычный PHP-класс для вычислений, а «service» — это обычное для него имя.

Итак, мы создаем файл app/Services/ReportService.php:

namespace App\Services;

use App\Transaction;
use Carbon\Carbon;

class ReportService {

    public static function getTransactionReport(int $projectId = NULL)
    {
        $q = Transaction::with('project')
            ->with('transaction_type')
            ->with('income_source')
            ->with('currency')
            ->orderBy('transaction_date', 'desc');
        if (!is_null($projectId)) {
            $q->where('project_id', $projectId);
        }
        $transactions = $q->get();
        $entries = [];

        foreach ($transactions as $row) {
            // ... Еще 50 строк кода чтобы заполнить $entries по месяцам
        }

        return $entries;
    }

}

И теперь мы можем просто вызвать эту функцию из Контроллера, например так:

// ... тут опять всякие use
use App\Services\ReportService;

class ClientReportController extends Controller
{
    public function index(Request $request)
    {
        $entries = ReportService::getTransactionReport($request->input('project'));

        return view('report', compact('entries'));
    }
}

Вот и все, контроллер стал намного чище.

Как видите, мы использовали статический метод и вызывали его с помощью синтаксиса ::, поэтому фактически не создавали объект для сервисного класса.

Когда это использовать?

Когда легко можно заменить код простой функцией без класса. Это как глобальный хелпер, но только находится внутри класса ReportService только для того, чтобы оставаться объектно-ориентированным и поддерживать порядок пространствами имен и папками.

Также имейте в виду, что статические методы и классы не имеют состояния. Это означает, что метод вызывается только один раз и не сохраняет никаких данных внутри самого класса.

Но если вы хотите сохранить какие-либо данные в этом сервисе…

Второй способ: создать Сервисный Объект с нестатическим методом

Другой способ инициации этого класса — сделать этот метод нестатичным и создать объект:

app/Services/ReportService.php:

class ReportService {

    // Просто "public", без "static"
    public function getTransactionReport(int $projectId = NULL)
    {
        // ... Тот же самый код, что и в статической версии

        return $entries;
    }

}

ClientReportController:

// ... всякие use
use App\Services\ReportService;

class ClientReportController extends Controller
{
    public function index(Request $request)
    {
        $entries = (new ReportService())->getTransactionReport($request->input('project'));

        return view('report', compact('entries');
    }
}

Или, если вам не нравятся длинные строки:

$reportService = new ReportService();
$entries = $reportService->getTransactionReport($request->input('project'));

Не сильно отличается от статического метода, не так ли? Потому, что для такого простого случая это фактически не имеет значения.

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

class ReportService {

    private $year;

    public function setYear($year)
    {
        $this->year = $year;

        return $this;
    }

    public function getTransactionReport(int $projectId = NULL)
    {
        $q = Transaction::with('project')
            ->with('transaction_type')
            ->with('income_source')
            ->with('currency')
            ->whereYear('transaction_date', $this->year)
            ->orderBy('transaction_date', 'desc');
        // ... остальной код

А затем в Контроллере делаете так:

public function index(Request $request)
{
    $entries = (new ReportService())
        ->setYear(2020)
        ->getTransactionReport($request->input('project'));

    // ... остальной код

Когда это использовать?

Честно говоря, в редких случаях, в основном, для цепочки методов, как в примере выше.

Если ваш Сервис не принимает какие-либо параметры при создании нового объекта ReportService(), просто используйте статические методы. Вам вообще не нужно создавать объект.

Третий способ: Сервисный объект с Параметром

А если вам нужен Сервис с параметрами? Например, вы хотите получить годовой отчет и инициировать этот класс, передавая $year, который будет применяться ко всем методам внутри этого сервиса.

app/Services/YearlyReportService.php:

class YearlyReportService {

    private $year;

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

    public function getTransactionReport(int $projectId = NULL)
    {
        // Обратите внимание ->whereYear('transaction_date', $this->year)
        $q = Transaction::with('project')
            ->with('transaction_type')
            ->with('income_source')
            ->with('currency')
            ->whereYear('transaction_date', $this->year)
            ->orderBy('transaction_date', 'desc');

        $entries = [];

        foreach ($transactions as $row) {
            // ... Еще 50 строк кода
        }

        return $entries;
    }

    // Еще один отчет, использующий тот же год $this->year
    public function getIncomeReport(int $projectId = NULL)
    {
        // Обратите внимание ->whereYear('transaction_date', $this->year)
        $q = Transaction::with('project')
            ->with('transaction_type')
            ->with('income_source')
            ->with('currency')
            ->whereYear('transaction_date', $this->year)
            ->where('transaction_type', 'income')
            ->orderBy('transaction_date', 'desc');

        $entries = [];

        // ... Еще немного логики

        return $entries;
    }
}

Выглядит сложнее, да?

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

// ... всякие use
use App\Services\YearlyReportService;

class ClientReportController extends Controller
{
    public function index(Request $request)
    {
        $year = $request->input('year', date('Y')); // текущий год по умолчанию
        $reportService = new YearlyReportService($year);

        $fullReport = $reportService->getTransactionReport($request->input('project'));
        $incomeReport = $reportService->getIncomeReport($request->input('project'));
    }
}

В этом примере оба метода Сервиса будут использовать один и тот же параметр Year, который мы передали при создании объекта.

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

Когда это использовать?

Когда у вашего Сервиса есть параметр, и вы хотите создать его объект, передавая некий параметр, который будет использоваться повторно при вызове всех методов Сервиса для этого объекта Сервиса.

Четвертый способ: Внедрение Зависимости — простой случай

Если у вас в Контроллере есть несколько методов и вы хотите повторно использовать один и тот же Сервис во всех них, то вы можете инжектить Сервис в конструктор Контроллера:

class ClientReportController extends Controller
{
    private $reportService;

    public function __construct(ReportService $service)
    {
        $this->reportService = $service;
    }

    public function index(Request $request)
    {
        $entries = $this->reportService->getTransactionReport($request->input('project'));
        // ...
    }

    public function income(Request $request)
    {
        $entries = $this->reportService->getIncomeReport($request->input('project'));
        // ...
    }
}

Что здесь фактически происходит?

  1. Мы создаем private-свойство $reportService;
  2. Мы передаем параметр ReportService в метод __construct();
  3. Внутри Конструктора мы присваиваем этот параметр private-свойству;
  4. Затем во всех наших методах этого Контроллера мы можем использовать $this->reportService и все его методы.

Это работает благодаря Laravel, поэтому вам не нужно беспокоиться о создании этого объекта класса, вам просто нужно передать правильный тип параметра в конструктор.

Когда это использовать?

Когда в вашем контроллере есть несколько методов, которым нужно использовать один и тот же Сервис, и когда Сервису не нужны никакие параметры (как например, $year в выше приведенном коде). Этот способ экономит время, поскольку не нужно создавать новый ReportService() в каждом методе Контроллера.

Но погодите, есть кое-что еще — вы можете сделать это в любом методе, а не только в конструкторе. Это называется method injection.

Например:

class ClientReportController extends Controller
{
    public function index(Request $request, ReportService $reportService)
    {
        $entries = $reportService->getTransactionReport($request->input('project'));
        // ...
    }

Как видите, не нужны, ни конструктор, ни private-свойство — вы просто инжектите тайпхинт-переменную и используете ее внутри метода. Laravel создает этот объект с помощью «магии».

Но, если честно, для нашего примера это не так уж и полезно, ведь кода становится больше, чем в случае с созданием Сервиса. Так в чем же реальное преимущество использования Внедрения зависимостей?

Пятый способ: Внедрение Зависимости с Интерфейсом — Расширенное использование

В предыдущем примере мы передавали параметр в Контроллер, и Laravel «магически» превращал его в объект Сервиса.

А как бы мы могли контролировать значение этой переменной? Например, передать один Сервис для фазы тестирования и другой для реального использования.

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

Помните эти два Сервиса из примера выше: ReportService и YearlyReportService? Пусть они реализуют один и тот же интерфейс.

Создаем новый файл app/Interfaces/ReportServiceInterface.php:

namespace App\Interfaces;

interface ReportServiceInterface {

    public function getTransactionReport(int $projectId = NULL);

}

И все, здесь больше ничего делать не нужно. Интерфейс — это просто набор правил, здесь методы не имеют «тел». Итак, мы определяем, что каждый класс, реализующий этот интерфейс, должен иметь этот метод getTransactionReport().

Файл app/Services/ReportService.php:

use App\Interfaces\ReportServiceInterface;

class ReportService implements ReportServiceInterface {

    public function getTransactionReport(int $projectId = NULL)
    {
        // ... все тот же старый код

и файл app/Services/YearlyReportService.php:

use App\Interfaces\ReportServiceInterface;

class YearlyReportService implements ReportServiceInterface {

    private $year;

    public function __construct(int $year = NULL)
    {
        $this->year = $year;
    }

    public function getTransactionReport(int $projectId = NULL)
    {
        // Снова тот же старый код с $year в качестве параметра

Теперь основная часть — какой класс мы тайпхинтим в Контроллер? ReportService или YearlyReportService?

На самом деле, нам больше не нужно тайпхинтить классы — мы тайпхинтим интерфейс.

use App\Interfaces\ReportServiceInterface;

class ClientReportController extends Controller
{
    private $reportService;

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

    public function index(Request $request)
    {
        $entries = $this->reportService->getTransactionReport($request->input('project'));
        // ... старый код

Основной частью является __construct(ReportServiceInterface $reportService). Теперь мы можем подключить и переключить любой класс, который реализует этот интерфейс.

Но по умолчанию мы теряем «магию» Laravel, потому что фреймворк не знает, какой класс использовать. Поэтому, если вы оставите всё как есть, то получите ошибку:

Illuminate\Contracts\Container\BindingResolutionException
Target [App\Interfaces\ReportServiceInterface] is not instantiable while building [App\Http\Controllers\Admin\ClientReportController].

Всё так и есть, ведь мы не сказали, какой класс нам нужен.

Нужно это сделать в файле app/Providers/AppServiceProvider.php в методе register().

Чтобы сделать этот пример абсолютно понятным, давайте добавим условие if: если локальная среда, то берем ReportService, в противном случае используем YearlyReportService.

use App\Interfaces\ReportServiceInterface;
use App\Services\ReportService;
use App\Services\YearlyReportService;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        if (app()->environment('local')) {
            $this->app->bind(ReportServiceInterface::class, function () {
                return new ReportService();
            });
        } else {
            $this->app->bind(ReportServiceInterface::class, function () {
                return new YearlyReportService();
            });
        }
    }
}

Видите, как это работает?

Мы выбираем какой сервис использовать, в зависимости от того, где мы сейчас работаем — на локальном компьютере или на боевом сервере.

Когда это использовать?

Пример, приведенный выше, это наиболее распространенный вариант использования Внедрения Зависимости с Интерфейсом — когда вам нужно менять свои Сервисы в зависимости от какого-либо условия и вы легко можете сделать это в сервис-провайдере.

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

Очень длиииииная статья, да? Дело в том, что эта тема довольно сложная и я хотел объяснить ее на примерах из реальной жизни, чтобы вы понимали не только, как использовать Внедрение зависимостей и Сервисы, но и ПОЧЕМУ их нужно использовать и КОГДА их нужно использовать, в каждом конкретном случае.

Автор: Povilas Korop
Перевод: Demiurge Ash

Следите за выходом новых статей через наши каналы в Телеграм и Вконтакте