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);
}
}
В этом тестовом случае, я буду использовать пример реализации внедрения зависимости с использованием интерфейсов.
Использование внедрения зависимости упрощает создание тестовых двойников (часто называемых моками
). Если вы передаёте зависимости в классы, легко передать тестовую реализацию двойников.
Невозможно сгенерировать тестовые двойники для зависимостей, которые жёстко закодированы.
Начнём с:
- Создания нового мока для класса
PaypalGateway
. - Установим ожидания для мок-объекта. Например, вы сообщаете мок-объекту, что метод
doPayment()
будет выполнен один раз и вернёт значениеnull
. Здесь вы контролируете, что передавать методам, а что возвращать. У вас полный контроль над тем, что передаётся и возвращается из методов. - Заменим привязку интерфейса
PaymentGateway
экземпляром мок-объекта. Это заменит все предыдущие привязки, установленные внутриAppServiceProvider
(как было показано ранее в статье). Во время выполнения тестов вы захотите заменить сопоставления Сервис Контейнера для использования мок-объекта. - Наконец, отправите запрос
GET
на конечную точку/payment
.
Задайте новую конечную точку в файле 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: переносим Контроллер в Сервисный Класс с внедрением