Понимание IoC

01 июня greabock

Инверсия управления (англ. Inversion of Control, IoC) — важный принцип объектно-ориентированного программирования, используемый для уменьшения зацепления в компьютерных программах. Также архитектурное решение интеграции, упрощающее расширение возможностей системы, при котором контроль над потоком управления программы остаётся за каркасом - ru.wikipedia.org


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

Когда мы говорим об IoC в Laravel, то следует знать, что он стоит на трех китах:

  1. Внедрение зависимостей (Dependency Injection)
  2. Сервис-контейнер (ServiceContainer)
  3. Отражения (Reflection)

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

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

Предположим, что у нас есть контроллер. И для его успешной работы ему нужен объект некоего служебного класса (сервиса), который выполнит для него какую-то работу. Для примера, пусть этим служебным классом будет некий хитрый мэйлер, который посылает почту каким-то хитрым образом (сейчас это не важно). Мы без труда можем создать объект мэйлера прямо в методе контроллера.

namespace App\Http\Controllers

use Request;
use Mailers\Mailer;
use Models\User;
use Response
use App\Http\Controllers\Controller;

class MailController extends Controller
{
    #...
    public function sendMail()
    {
        //Создали объект мэйлера
        $mailer = new Mailer;

        $mailer
            ->from(Request::get('sender_id'))
            ->to(Request::get('receiver_id'))
            ->subject(Request::get('subject'));

        $result = $mailer->send();

        return Response::json($result);
    }
    #...
}

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

namespace App\Http\Controllers

use Request;
use App\Mailers\Mailer;
use App\Models\User;
use Response
use App\Http\Controllers\Controller;

class MailController extends Controller
{
    #...
    public function __construct()
    {
        //вынесли создание объекта мэйлера в метод-конструктор
        $this->mailer = new Mailer;
    }

    public function sendMail()
    {
        $this->mailer
            ->from(Request::get('sender_id'))
            ->to(Request::get('receiver_id'))
            ->subject(Request::get('subject'));

        $result = $this->mailer->send();

        return Response::json($result);
    }
    #...
}

Это круто и классно работает. Ровно до тех пор, пока вам нравится ваш мэйлер. Допустим, что этот мэйлер, организовывал переписку между пользователями посредством электронной почты. И вам внезапно захотелось вести переписку между пользователями локально - в вашем приложении. Тогда, вы заменяете класс App\Mailers\Mailer на App\Mailers\LocalMailer, но тут есть два "но". Во-первых, нет никаких гарантий, что новый LocalMailer имеет те же публичные методы для работы. Во-вторых, если этот старый мэйлер используется в куче различных контроллеров, то вам придется руками заменить его везде, где он создается. Вот здесь к нам на помощь и приходят интерфейсы и внедрение зависимостей;

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


namespace App\Mailers\Contracts;

interface MailerInterface {

    public function from();

    public function to();

    public function subject();

    public function send();

}

и сами мэйлеры должны реализовывать, этот интерфейс:


namespace App\Mailers;

use App\Mailers\Contracts\MailerInterface;

class Mailer implements MailerInterface {
    #реализация интерфейса
}

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

    #...
    public function __construct(MailerInterface $mailer)
    {
        $this->mailer = $mailer;
    }
    #...

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

Обычное внедрение зависимости снаружи класса выглядит так:

use App\Mailers\Mailer;
use App\Mailers\LocalMailer;

#...
$controller = new MailController(new LocalMailer);
//или
$controller = new MailController(new Mailer);

Это и называется Dependency Injection.


Выжимка: Внедрение зависимостей в рамках IoC в Laravel, это когда конкретный объект создается не в контексте исполнения (функции или метода класса), а приходит туда извне в виде аргумента. А гарантия доступности публичных методов обеспечивается типизацией интерфейса, абстрактного (или конкретного) класса.


Однако Laravel не стал бы столь популярным если бы в нем не было волшебства. Двинемся дальше в поисках магии...

Сервис-контейнер

Сервис-контейнер в Laravel решает три основных задачи:

  1. Он может хранить информацию о том, как получить данные
  2. Он может хранить данные
  3. Он может разрешать зависимости

Хотя я здесь говорю "данные" ( и это действительно так ), на практике, в контейнере чаще всего хранится объект или информация о том, как его получить (замыкание или имя класса).


Разберем подробнее(все примеры приведены так, как если бы они использовались в сервис-провайдере).

Связывание

$this->app->bind('some', 'App\SomeClass');

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

$some = $this->app->make('some');

Он будет возвращать вновь созданный объект класса App\SomeClass.

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

$this->app->bind('some', function($app){
    $some = new \App\SomeClass('argument_1', 'argument_2');
    $some->setSomething('example');
    return $some;
});

Обратите внимание, что замыкание единственным аргументом принимает объект класса Illuminate\Foundation\Application, который и является этим самым сервис-контейнером, и он (сервис-контенер) также доступен через алиас фасада App, хелпер app(), а также как $this->app во многих классах Laravel. Таким образом, внутри замыкания можно вызывать данные из контейнера, которые были определены ранее. Например так:

$this->app->bind('some', function($app){
    return new \App\SomeClass($app->make('some.else'));
});

Одиночки

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

$this->app->singleton('some', 'App\SomeClass');

// true - это один и тот же объект;
$check = $this->app->make('some') === $this->app->make('some') ; 

Инстансы

Связывание инстансов (экземпляров) ведет себя точно так же как и связывание одиночек, с той лишь разницей, что объект в контейнере создается не в момент первого вызова, а еще до помещения в контейнер.

$some = new Some;
$this->app->instance('some', $some);

само собой, здесь нет необходимости в замыканиях, так как всё необходимое пресетирование можно сделать еще до помещения в контейнер.

Немного магии

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

$this->app->make('App\SomeClass');

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

С фасадами это работает несколько иначе. Если в методе фасада getFacadeAccessor() указан класс, который не был помещен в контейнер (методом bind() или любым другим)

    protected static function getFacadeAccessor()
    {
        return 'App\SomeClass';
    }

то контейнер так же попытается создать объект этого класса. Но в этом случае он поместит этот объект в себя, после чего он (объект) будет доступен также как singleton или instance, и все последующие вызовы будут возвращать тот же объект.

Отражения

(они же Рефлексии, они же Симметрии)

В PHP5 существует набор классов - так называемый Reflection API - который позволяет исследовать существующие классы и собирать их мета-данные. Что это означает? Это означает, что через рефлексии можно получить информацию о методах класса, типизации их аргументов, а также собирать информацию из док-блоков. Я думаю, что многие из вас видели или даже работали с генераторами документации по API приложения. Они работают как раз через исследование рефлексий. В контексте IoC, нас интересует в первую очередь именно типизация аргументов.

Я не буду вдаваться в подробности работы с рефлексиями, так как это материал не на одну статью. Если есть желание "расчехлиться", то добро пожаловать в соответствующий раздел php.net.

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

Собираем все в кучу, и готовим IoC

И так, мы имеем:

  1. Внедрение зависимостей. Это когда мы не думаем о том с чем мы работаем, а думаем лишь о том как с этим можно работать. А что именно это будет - определяется вне контекста исполнения.
  2. Сервис контейнер. Он обеспечивает доступ к объектам, или информации о том, как их правильно создавать.
  3. Рефлексии. Они позволяют нам извлечь информацию о том что хочет получить объект, класс, или замыкание.

Как это работает

Каждый раз, когда мы вызываем из контейнера что-то

$this->app->make('App\SomeClass');

для создания чего не было определено замыкания (то есть: он либо был привязан без замыкания, по имнени класса bind/singleton, либо вообще не был помещен в контейнер и вызывается просто по имени класса ), контейнер с помощью рефлексии исследует конструктор этого класса, и попытается разрешить эти зависимости снова вызывая

$this->make('<Класс зависимости>');

и так рекурсивно снова и снова, пока все зависимости не будут разрешены.

Как это применять

Помните, в самом начале статьи мы говорили про Mailer?

и у нас в конструкторе была определена зависимость:

    #...
    public function __construct(MailerInterface $mailer)
    {
        $this->mailer = $mailer;
    }
    #...

Так вот, контроллеры в Laravel создаются через этот самый App::make(), а это означает что все зависимости будут по возможности разрешены. Ну а так как, в нашем случае, мы используем для типизации интерфейс (и, как мы помним из мануала php, экземпляры интерфейсов и абстрактных классов созданы быть не могут), а не конкретный класс, то нам в сервис провайдере нужно определить разрешение для этой зависимости:

$this->bind('App\Mailers\Contracts\MailerInterface', 'App\Mailers\LocalMailer');

И теперь во всех контроллерах, и классах созданных через App::make(), где будет внедрен MailerInterface, для разрешения зависимости будет поставлен LocalMailer. И если нам когда-то взбредет в голову поменять его на что-то иное, то нам будет достаточно заменить его в нашем сервис-провайдере на то, что нам нужно. Само собой разумеется, что это "что нужно" должно реализовывать MailerInterface.

Но нам не всегда нужно или хочется городить интерфейсы. Если нам достаточно получить объект какого-то конкретного класса, то мы можем просто Типизировать аргумент этим классом,и контейнер попытается создать его через App::make():

    #...
    public function __construct(LocalMailer $mailer)
    {
        $this->mailer = $mailer;
    }
    #...

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

На последок следует сказать, что разрешение зависимостей возможно не только при создании объектов, но и при вызове функций/замыканий/методов.

$this->app->call('<зымыкание>', [#params...])

где замыкание является или callable/Closure - объектом, или названием статической функции/метода (если это метод, то само собой нужно указасть и класс, включая путь к пространству имен). Если же этот метод должен вызываться в контексте объекта, то первым аргументом передается массив, первым элементом которого является объект, а вторым имя метода

$this->app->call([$object, 'method'], [#params...]);

хотя это и выглядит излишне "замудрёным", на самом деле это не так. Дело в том что App::call() - это в действительности обертка над call_user_func_array. Эта обертка сначала разрешает зависимости, а потом дополняет их аргументами.

Кстати говоря, все методы контроллеров так же вызываются через App::call(). Поэтому все зависимости указанные в методе-экшене, так же как и в методе-конструкторе - будут, по возможности, разрешены.

Итог

Мы рассмотрели важные для понимания моменты касающиеся работы IoC.При работе с сервис-контейнером есть множество трюков, которые позволяют легко жонглировать зависимостями. Большинство из них описаны в документации [en][ru]. Ну и не стесняйтесь исследовать сорцы - там самое интересное.

Всем спасибо! Общайтесь в чатике, читайте ленту новостей в группе.