Laravel: Внедрение зависимости и Сервис контейнер

Источник: «Dependency Injection and Service Container in Laravel»
В этой статье я расскажу о принципе внедрения зависимостей, так же известном как контейнер Inversion of Control (IoC). Расскажу как Laravel использует его внутри и предлагает эту концепцию разработчикам, чтобы их код был понятным и менее взаимозависимым.

Внедрение зависимости

Внедрение зависимостей (Dependency injection) — метод программирования, позволяющий отделить программные компоненты друг от друга.

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

Результат взаимозависимости приводит к следующим последствиям:

Вот пример, как класс создаёт свои собственные зависимости:

class InvoiceController extends Controller
{
protected PaymentService $paymentService;

public function __construct()
{
$this->paymentService = new PaymentService();
}
}

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

class InvoiceService
{
public function __construct(
protected PaymentService $paymentService) { }
}

У классов есть возможность либо принять конкретную реализацию, либо интерфейс, который заменяется конкретной реализацией во время выполнения.

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

Существуют разные типы внедрения зависимостей, такие как внедрение сеттера, внедрение конструктора, внедрение метода и другие типы. В этой статье я сосредоточусь на внедрении конструктора.

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

Четыре основные роли внедрения зависимости

Для реализации внедрения зависимости в код, нужно знать четыре основные роли:

Вышеупомянутые четыре роли являются обязательными для успешной реализации и использования внедрения зависимости в приложении. Четвёртая роль, инъектор — это то, о чём вам не нужно беспокоиться. Как правило, почти каждый фреймворк предоставляет инъектор или контейнер для внедрения зависимостей.

Инъектор — мозг, стоящий за концепцией внедрения зависимости. Например, фреймворк даёт возможность зарегистрировать зависимость. Когда фреймворк обнаруживает класс, которому требуется зарегистрированная зависимость, он использует свой инъектор для создания экземпляра зависимости и внедрения в требующий её класс.

Теперь, когда у вас есть представление о внедрении зависимости, давайте посмотрим, как её реализует Laravel.

Внедрение зависимости означает, что зависимость вставляется в класс извне. Это означает, что вы не должны создавать экземпляры зависимостей с помощью оператора new внутри класса, а использовать её как параметр конструктора или через сеттер.

Как Laravel реализует внедрение зависимости

Контейнер IoC лежит в основе фреймворка Laravel. Laravel поставляется с Сервис Контейнером, который отвечает за управление зависимостями в приложении и внедрении их там, где необходимо.

Контейнер даёт возможность определять привязки к классам и интерфейсам. В то же время у него есть уникальная функция, известная как Zero Configuration Resolution — Разрешение Нулевой Конфигурации, позволяющее контейнеру разрешать зависимости, даже не регистрируя их. Он может сделать это при условии, что зависимость не имеет других зависимостей или имеет некоторые зависимости, про которые контейнер уже знает как создать экземпляр.

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

Простое внедрение зависимости

Давайте рассмотрим пример, показывающий, как работает внедрение зависимостей в Laravel-приложении:

public class PaymentService
{
public function doPayment ()
{
// ...
}
}

class PaymentController extends Controller
{
public function __construct (protected PaymentService $paymentService)
{

}

public function payment ()
{
// $paymentService
}
}

Во-первых, определите PaymentService содержащий единственный метод doPayment(). Затем, поместите код, отвечающий за выполнение платежа после оформления заказа или покупки.

Далее внутри PaymentController определите конструктор, который принимает в качестве параметра объект PaymentService. Метод payment() использует объект $paymentService для выполнения платежа.

Когда вы отправляете запрос методу payment(), Laravel выполняет множество задач за кулисами. Оной из таких задач является создание экземпляра класса PaymentController. При его создании он замечает, что конструктору требуется зависимость.

Laravel использует свой Сервис Контейнер для поиска зависимостей. На данный момент вы не рассказали Laravel, как создать экземпляр зависимости PaymentService. Однако Laravel достаточно умён, чтобы разрешить эту зависимость и внедрить её в конструктор PaymentController. У PaymentService нет других зависимостей, поэтому Laravel легко создаст его экземпляр и сделает из него объект.

Как только Laravel создаёт экземпляр класс PaymentService, создаёт новый объект из PaymentController, используя его конструктор и передавая требуемую зависимость.

Вы увидели, как Внедрение Зависимости Laravel работает в простых случаях, когда у класса есть зависимость от другого класса, у которого нет других зависимостей. Что произойдёт, если у PaymentService появится зависимость?

Добавление зависимостей к другим зависимостям

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

Давайте посмотрим на другой пример кода:

public class PaymentService
{
public function __construct (protected string $secretKey){ }

public function doPayment ()
{
// ...
}
}

class PaymentController extends Controller
{
public function __construct(protected PaymentService $paymentService)
{

}

public function payment ()
{
// $paymentService
}
}

Класс PaymentService определяет конструктор принимающий зависимость $secretKey типа string. В этом случае Laravel не сможет создать экземпляр PaymentService без вашей помощи. Причина? Laravel не может предсказать или предоставить новую зависимость.

Вы должны предоставить Laravel дополнительные инструкции о создании экземпляра PaymentService.

Внутри файла app\Providers\AppServiceProvider.php вы регистрируете привязку, сообщающую Laravel, как создать экземпляр нового объекта PaymentService.

public function register()
{
$this->app()->bind(PaymentService::class, function() {
return new PaymentService('123456');
}
);

Вызов метода app() возвращает экземпляра класса Illuminate\Foundation\Application. Этот класс расширяет класс Illuminate\Container\Container. Следовательно, метод app() позволяет напрямую работать с Сервис Контейнером Laravel.

Container определяет метод bind(), позволяющий задать новую привязку внутри Сервис Контейнера.

Метод bind() принимает в качестве первого параметра имя или тип зависимости, для которой хотите определить привязку. Второй аргумент — PHP класс Closure. Код создаёт и возвращает новый экземпляр PaymentService, предоставляя правильный секретный ключ, требуемый сервисом.

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

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

Всё становится немного сложней, когда у вас есть несколько конкретных реализаций одного и того же сервиса. Давайте посмотрим, как вы проинструктируете Сервис Контейнер Laravel справляться с этой сложностью.

Зависимости с несколькими конкретными реализациями

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

Пример кода нескольких конкретных реализаций:

interface PaymentGateway
{
public function doPayment ();
}

classPaypalGateway implements PaymentGateway
{
public function __construct (protected string $secretKey) { }

public function doPayment ()
{

}
}

classStripeGateway implements PaymentGateway
{
public function __construct (protected string $secretKey) { }

public function doPayment ()
{

}
}

class PaymentController
{
public function __construct (
protected PaymentGateway $paymentGateway) { }

public function __invoke (Request $request)
{
// ...
}
}

$this->app()->bind(PaymentServiceContract::class, function () {
if (request()->gateway() === 'stripe') {
return new StripeGateway('123');
}

return new PaypalGateway('123');
});

Вы начинаете с определения интерфейса PaymentGateway. Этот интерфейс определяет, какие методы должны существовать и реализовываться на различных платёжных шлюзах, доступных в приложении.

Далее определим два новых платёжных сервиса: PaypalGateway и StripeGateway. Каждый сервис реализует интерфейс PaymentGateway и предоставляет различную конкретную реализацию для соответствующего платёжного шлюза. PaypalGateway подключается к сервису Paypal, а последний подключается к сервису Stripe.

PaymentControl определяет интерфейс PaymentGateway как зависимость. В этом случае контроллер запрашивает интерфейс вместо фактической реализации. Что происходит во время выполнения, так это то, что в зависимости от того, как вы настраиваете Сервис Контейнер, Laravel внедряет конкретную реализацию в этот контроллер, чтобы заменить экземпляр интерфейса.

Наконец, вы указываете Сервис Контейнеру Laravel возвращать конкретный экземпляр PaymentGateway на основе параметра запроса gateway. Если он имеет значение stripe, вы возвращаете StripeGateway, в противном случае вы возвращаете PaypalGateway. Это простая реализация для иллюстрации DI. Вы можете расширить его в соответствии с потребностями вашего приложения.

Использование интерфейсов как зависимостей позволяет без усилий менять реализации во время выполнения. Кроме того, таким образом вам не нужно менять весь источник в случае изменения сервиса PayPal или Stripe. В будущем вам может понадобиться добавить дополнительный платёжный сервис, и это можно легко сделать, добавив новую реализацию интерфейса PaymentGateway и зарегистрировать её с помощью метода bind() Сервис Контейнера Laravel.

Теперь, когда рассмотрели несколько вариантов использования Сервис Контейнера Laravel и различные способы внедрения зависимостей в приложение Laravel, давайте посмотрим, насколько просто тестировать фиктивные зависимости, особенно при использовании их в качестве интерфейсов.

Тестирование кода с зависимостями

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

Laravel предлагает тестирование PHPUnit из коробки. Недавно появилась новая библиотека тестирования Pest. Эта библиотека внутренне основана на PHPUnit. Тем не менее она предлагает более выразительный и простой способ тестирования и ожидания/утверждения результатов тестирования.

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

Таким образом мок-объекты имеют следующие преимущества:

Независимо от того, какую библиотеку тестирования вы используете; конечный результат тот же. Добавляя мок-объект, вы изолируете тестируемый класс от его зависимостей и предполагаете, что все вызовы методов зависимостей работают должным образом. Это, конечно, предполагает, что вы написали достаточно тестовых случаев, чтобы проверить правильность этих объектов зависимостей.

Чтобы создать мок-объект в Pest, необходимо установить плагин pest-plugin-mock через composer.

composer require pestphp/pest-plugin-mock --dev

Как создать и настроить мок-объекты в PHPUnit подробно рассказано в документации.

Давайте посмотрим на пример того, как имитировать зависимость с помощью библиотеки PHPUnit.

Для создания функционального теста в Laravel, выполните следующую команду:

php artisan make:test PaymentTest

Команда создаст новый файл PaymentTest.php в каталоге tests\Feature.

<?php

namespace Tests\Feature ;

// use Illuminate\Foundation\Testing\RefreshDatabase;

use App\Payments\PaymentGateway ;
use App\Payments\PaypalGateway ;
use Mockery ;
use Tests\TestCase ;

class PaymentTest extends TestCase
{
public function test_payment_returns_a_successful_response ()
{
// Создаём мок
$mock = Mockery::mock(PaypalGateway::class)->makePartial();

// Устанавливаем ожидания
$mock->shouldReceive('doPayment')
->once()->andReturnNull();

// Добавляем этот мок в сервис контейнер,
// вместо сервисного класса
app()->instance(PaymentGateway::class, $mock);

// Запускаем конечную точку
$this->get('/payment')->assertStatus(200);
}
}

В этом тестовом случае, я буду использовать пример реализации внедрения зависимости с использованием интерфейсов.

Использование внедрения зависимости упрощает создание тестовых двойников (часто называемых моками). Если вы передаёте зависимости в классы, легко передать тестовую реализацию двойников.

Невозможно сгенерировать тестовые двойники для зависимостей, которые жёстко закодированы.

Начнём с:

Задайте новую конечную точку в файле routes\web.php:

Route::get('/payment', PaymentController::class);

PaymentController должен быть определён как вызываемый контроллер:

class PaymentController extends Controller
{
public function __construct (
protected PaymentGateway $paymentGateway)
{
}

public function __invoke (Request $request)
{
$this->paymentGateway->doPayment();
}
}

PaymentController зависит от интерфейса PaymentGateway. При запуске теста параметр $paymentGateway заменится мок-объектом PaypalGateway. При использовании мока ничего не изменилось в том, как PaymentController вызывает методы в интерфейсе PaymentGateway. Мок-объект гарантирует, что код продолжает работать с одним и тем же проектом независимо от фактической реализации.

Теперь, кода вы знаете о преимуществах внедрения зависимостей и Сервис Контейнера Laravel, давайте рассмотрим какие варианты привязки предлагает Сервис Контейнер.

Привязки Сервис Контейнера

Сервис Контейнер Laravel предлагает несколько способов привязки и регистрации зависимостей. До сих пор вы видели, как использовать метод app-> bind() для привязки зависимости в приложении. Сервис Контроллер предлагает другие способы связывания зависимостей. Документация Laravel прекрасно описывает все методы привязки. Тем не менее я хотел бы пролить свет на три основных метода связывания, которые вы возможно будете использовать большую часть времени.

Ручное связывание

Вы уже видели, как происходит связывание с помощью метода bind(). Каждый раз, когда Laravel запрашивает зависимость, зарегистрированную с помощью метода bind(), он просит Сервис Контейнер создать и вернуть новый экземпляр зарегистрированной зависимости. В некоторых случаях это может быть неэффективно, особенно при создании дорогостоящих классов ресурсов.

Пример использования метода bind():

$this->app()->bind(PaymentService::class, function() {
return new PaymentService('123456');
});

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

Давайте посмотрим, чем одиночная привязка отличается от ручной привязки.

Одиночная привязка

Одиночная привязка гарантирует, что когда зависимость зарегистрирована как одиночная, будет только один и только один экземпляр класса на жизненный цикл запрос/ответ. В отличие от ручной привязки каждый запрос на получение зависимости из Сервис Контейнера приводит к созданию нового экземпляра этой зависимости. При одиночной привязке существует единственный экземпляр. Это полезно, когда есть сервисы или классы, которые слишком дороги, чтобы продолжать создавать их по запросу.

Пример использования singleton():

$this->app()->singleton(PaymentService::class, function() {
return new PaymentService('123456');
});

Каждый раз, когда возникает потребность в экземпляре PaymentService, в течении жизненного цикла запрос/ответ будет возвращён один и тот же объект экземпляра.

Привязка экземпляра

Привязка экземпляра похожа на одиночную привязку, за исключением того, что вы создаёте новый экземпляр зависимости и указываете Сервис Контейнеру Laravel всегда возвращать этот экземпляр.

Пример использования метода instance():

$paymentService = new PaymentService('123456');
$this->app()->instance(PaymentService::class, $paymentService);

Основное различие между привязкой экземпляра и двумя другими формами привязки заключается в том, что вы всегда создаёте новый экземпляр и добавляете его в Сервис Контейнер. В случае ручной или одиночной привязки только тогда, когда приложение запрашивает экземпляр зависимости, функция Closure выполняется и возвращает новый экземпляр. Думайте об этом как о раннем связывании по сравнению с поздним связыванием.

Как я уже упоминал, существуют и другие способы расширения Сервис Контейнера и регистрации зависимостей. Вы можете почитать все подробности на сайте документации Laravel.

Разрешение зависимостей Сервис Контейнера

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

app(PaymentService::class);

Вы можете просто использовать метод app() для разрешения и создания экземпляра зависимости.

app()->make(PaymentService::class);

Другой метод создания экземпляров объектов — использовать метод make(), определённый для объекта app().

resolve(PaymentService::class);

Функция resolve() — хелпер, создающий экземпляр класса объекта на основании переданного ему имени сопоставления. В этом случае мы запрашиваем у Сервис Контейнера создание экземпляра класса PaymentService

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

Заключение

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

Похожие статьи

Дополнительные материалы

Предыдущая Статья

Laravel: Что такое Фасады и как они работают

Следующая Статья

Laravel: 4 инструмента для статического анализа