Архитектура

11 мая 2018 г.

Жизненный цикл

Любой запрос начинает и заканчивает свою жизнь в файле public/index.php. Сперва вызывается автозагрузчик композера (загружающий все необходимые классы) и из файла bootstrap/app.php извлекается экземпляр приложения. Затем запрос передается в ядро (класс Kernel) - либо HTTP, либо консольное, в зависимости от истоника запроса. Сфокусируемся на Http-ядре (в консольном принципиальных отличий нет) - классе App\Http\Kernel из файла app/Http/Kernel.php. Ядро (точнее, его метод handle), в свою очередь, после обработки запроса возвращает ответ (респонс), который отдается клиенту.

HTTP-ядро наследует класс Illuminate\Foundation\Http\Kernel. который определяет массив загрузчиков (бутраперов) - классов, запускающихся до выполнения запроса. Эти бутстраперы определяют обработку ошибок, логирование, окружение и прочее. Также в ядре орпределяется список посредников (middleware), каждый из которых должен обработать запрос. В посредниках выполняется, например, чтение и запись сессии; определение, не в режиме ли обслуживания находится приложение; валидация CSRF-токена и др.

Одной из важнейших задач бутстраппинга в ядре является загрузка сервис-провайдеров, определенных в config/app.php в массиве providers. В каждом провайдере вызывается метод register (регистрирующий провайдера), а затем метод boot (собственно, загрузка провайдера). Сервис-провайдеры отвечают за загрузку всех компонентов фреймворка: базы данных, очередей задач, валидации, маршрутизации и т.д. Кастомные сервис-провайдеры, как уже было отмечено, располагаются в каталоге app/Providers. Главным из них является AppServiceProvider. По умолчанию он практически пустой, мы вольны добавит в него свой собственный бутстраппинг и привязать собственные сервис-провайдеры.

Как только приложение загружено и все сервис-провайдеры зарегистрированы, запрос передается маршрутизатору (роутеру). Роутер прогоняет запрос через все middleware, определенные для этого роута (если таковые имеются) и отдает на обработку соответствующему контроллеру.

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

Сейчас речь будет идти о внедрении зависимостей (dependency injection, DI). Dependency injection - устоявшийся термин, означающий вот что: зависимости класса (т.е. классы, от которых зависит данный класс) внедряются в класс в конструкторе (обычно) или через сеттер.

Пример. Есть у нас класс.

class Foo
{
    private $bar;

    public function __construct(Bar $bar)
    {
        $this->bar = $bar;
    }
}

Как видим, в этом классе используется свойство bar, являющееся экземпляром класса Bar. Т.е. чтобы получить экземпляр Foo, нужно вызвать

$foo = new Foo(new Bar());

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

А теперь представим, что у класса Bar тоже есть зависимость (класс Baz). Тогда нам нужно создавать экземпляр Foo так

$foo = new Foo(new Bar(new Baz()));

А теперь представим, что у класса Baz тоже есть зависимость...

Стоп. Полагаю, эта кроличья нора слишком глубока для нас.

Laravel дает нам для внедрения зависимостей сервис-контейнер (устоявшийся термин - Inversion of Control, IoC), позволяющий привязать зависимость к классу. Обычно это делается в сервис-провайдере (подробнее о нем - в следующей главе) - там наше приложение доступно как $this->app. А привязка осуществляется так (один раз!):

$this->app->bind('Foo', function($app) {
    return new Foo($app->make('Bar'));
}

$this->app->bind('Bar', function($app) {
    return new Bar($app->make('Baz'));
}

И так далее (если есть зависимости для Baz - с ними поступаем точно так же).

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

$foo = $this->app->make('Foo');

(то же самое, что мы сделали при привязке в коллбэках с Bar и Baz, разрешив таким образом и их зависимости); либо, если нет доступа к $app - использовать хелпер

$foo = resolve('Foo');

Есть еще три способа привязки зависимостей.

Синглтон. Фактически то же самое, но экземпляр Foo будет создан лишь однажды; при последующих попытках создания будет возвращен тот же экземпляр.

$this->app->singltone('Foo', function($app) {
    return new Foo($app->make('Bar'));
}

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

$bar = $this->app->make('Bar'); // где-то, неважно где
$this->app->instance('Foo', $bar);

Примитив. Помимо классов, внедрять можно и примитивы (числа, строки и т.п.).

$this->app->when('App\Http\Controllers\UserController')
          ->needs('$variableName')
          ->give($value);

Связывание интерфейса с реализацией

Очень мощная фича сервис-контейнера. Допустим, у нас есть интерфейс App\Contracts\EventPusher. И несколько его реализаций. Мы же хотим во всем приложении использовать одну из них - App\Services\RedisEventPusher. Тогда делаем так

$this->app->bind(
    'App\Contracts\EventPusher',
    'App\Services\RedisEventPusher'
);

Вуаля! Теперь везде (где сервис-контейнер разруливает зависимости) вместо интерфейса App\Contracts\EventPusher используется реализация App\Services\RedisEventPusher. И если мы захотим поменять реализацию, то достаточно сделать это в одном месте - в привязке.

Контекстное связывание

А что, если мы хотим использовать разные реализации интерфейса для разных классов? Да пожалуйста. Предположим, что у нас есть два контроллера, каждый из которых использует свою реализацию контракта (о контрактах в Laravel - далее; фактически это просто интерфейс) Illuminate\Contracts\Filesystem\Filesystem. Тогда делаем так.

use Illuminate\Support\Facades\Storage;
use App\Http\Controllers\PhotoController;
use App\Http\Controllers\VideoController;
use Illuminate\Contracts\Filesystem\Filesystem;

$this->app->when(PhotoController::class)
    ->needs(Filesystem::class)
    ->give(function () {
        return Storage::disk('local');
    });

$this->app->when(VideoController::class)
    ->needs(Filesystem::class)
    ->give(function () {
        return Storage::disk('s3');
    });

Тэгирование

Предположим, что мы пишем агрегатор отчетов, принимающий массив различных реализаций интерфейса Report. После регистрации реализаций можно применить им всем один тэг reports.

$this->app->bind('SpeedReport', function () {
    //
});

$this->app->bind('MemoryReport', function () {
    //
});

$this->app->tag(['SpeedReport', 'MemoryReport'], 'reports');

Теперь привязываем их одним махом с помощью метода tagged.

$this->app->bind('ReportAggregator', function ($app) {
    return new ReportAggregator($app->tagged('reports'));
});

Расширение привязки

Замечу, что я пока не понял, зачем это может понадобиться. Вернемся к нашему классу Foo c внедренным в него Bar. Предположим, что функциональность класса Bar требует расширения. Но при этом ни сам класс, ни привязку его мы почему-то изменить не можем. Но можем, например, сделать декоратор DecoratedBar с конструктором, принимающим в качестве аргумента экземпляр Bar. И там, где нужно, вызвать метод extend.

$this->app->extend(Bar::class, function($bar) {
    return new DecoratedBar($bar);
});

Резолвинг

Метод make и хелпер resolve уже были описаны выше. Кроме make есть еще makeWith, позволяющий передать в конструктор параметры, которые нельзя передать через сервис-контейнер.

$baz = $this->app->makeWith('Baz', ['id' => 1]);

Ну а основным способом внедрения зависимостей является автоматическое внедрение. Его мы можем использовать практически везде в приложении: в контроллерах, обработчиках событий, очередях заданий, middleware и т.д., и т.п. Здесь просто достаточно прописать зависимость аргументом в конструктор (как мы делали с нашим классом Foo, внедряя в него зависимость Bar).

События контейнера

Сервис-контейнер генерирует событие каждый раз при резолвинге. Обработать это событие можно методом resolving.

$this->app->resolving(function ($object, $app) {
    // Вызывается при резолвинге объекта любого типа...
});

$this->app->resolving(Foo::class, function ($api, $app) {
    // Вызывается при резолвинге объекта типа "Foo"...
});

PSR-11

Напоследок замечу, что сервис-контейнер Ларавела реализует интерфейс PSR-11. Поэтому для получения экземпляра контейнера (если возникнет такая необходимость) нужно прописать Psr\Container\ContainerInterface. Например

Route::get('/', function (ContainerInterface $container) {
    $service = $container->get('Service');

    //
});

Сервис-провайдеры

Сервис-провайдеры - основа всего бутсраппинга Laravel. Наше приложение, так же как и ядро Laravel, загружается через сервис-провайдеры.

Под бутстраппингом мы понимаем регистрацию. Регистрацию зарегистрированных привязок сервис-контенйера; обработчиков событий; посредников; маршрутов.

Как мы помним, в config/app.php есть массив providers. Это и есть все сервис-провайдеры (классы) нашего приложения. Сюда же мы будем добавлять свои собственные сервис-провайдеры. Большинство из них загружается лишь по требованию, т.е. если мы реально не используем некий сервис-провайдер в приложении, то он и не загружается.

Далее попробуем создать свой сервис-провайдер.

Все серсис-провайдеры наследют класс Illuminate\Support\ServiceProvider. Большинство из них содержат методы register и boot.

В консоли новый сервис-провайдер создается командой make:provider:

php artisan make:provider RiakServiceProvider

После выполнения этой команды наш новосозданный провайдер находится в app/Providers/RiakServiceProvider. Как и большинство сервис-провайдеров, он содержит методы register и boot.

Метод register

Метод register должен лишь привязать провайдера к сервис-контейнеру. Нельзя регистрировать здесь обработчики событий, маршруты и прочее, иначе мы можем случайно попытаться использовать сервис, который еще не был загружен.

Из любого метода сервис-провайдера мы имеем доступ к свойству $app, предоставляющему, в свою очередь, доступ к сервис-контейнеру.

Изменим наш сервис-провайдер, чтоб он выглядел так.

<?php

namespace App\Providers;

use Riak\Connection;
use Illuminate\Support\ServiceProvider;

class RiakServiceProvider extends ServiceProvider
{
    /**
     * Register services.
     *
     * @return void
     */
    public function register()
    {
        $this->app->singleton(Connection::class, function ($app) {
            return new Connection(config('riak'));
        })
    }
}

Этот сервис-провайдер лишь определяет метод register и использует его для реализации класса Riak\Connection в сервис-контейнере.

Для таких сервис-провайдеров, которые мы лишь регистрируем в сервис-контейнере, желательно сделать загрузку по требованию (отложенную загрузку), чтобы провайдер загружался лишь тогда, когда он нужен (когда мы к нему обращаемся), и лишь если он нужен, а не при каждом запросе. Laravel сохраняет список всех сервисов, предоставляемых отложенными сервис-провайдерами, вместе с названием класса сервис-провайдера. Чтобы отложить загрузку сервис-провайдера, достаточно объявить свойство defer как true и определить метод provides, возвращающий все зарегистрированные в провайдере привязки.

<?php

namespace App\Providers;

use Riak\Connection;
use Illuminate\Support\ServiceProvider;

class RiakServiceProvider extends ServiceProvider
{
    /**
     * Indicates if loading of the provider is deferred.
     *
     * @var bool
     */
    protected $defer = true;

    /**
     * Register the service provider.
     *
     * @return void
     */
    public function register()
    {
        $this->app->singleton(Connection::class, function ($app) {
            return new Connection($app['config']['riak']);
        });
    }

    /**
     * Get the services provided by the provider.
     *
     * @return array
     */
    public function provides()
    {
        return [Connection::class];
    }

}

Если же нам требуется зарегистрировать много простых привязок, то лучше объявить свойства bindings и singletons вместо того чтобы прописывать их все вручную.

<?php

namespace App\Providers;

use App\Contracts\ServerProvider;
use App\Contracts\DowntimeNotifier;
use Illuminate\Support\ServiceProvider;
use App\Services\PingdomDowntimeNotifier;
use App\Services\DigitalOceanServerProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * All of the container bindings that should be registered.
     *
     * @var array
     */
    public $bindings = [
        ServerProvider::class => DigitalOceanServerProvider::class,
    ];

    /**
     * All of the container singletons that should be registered.
     *
     * @var array
     */
    public $singletons = [
        DowntimeNotifier::class => PingdomDowntimeNotifier::class,
    ];
}

Метод boot

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

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;

class ComposerServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
        view()->composer('view', function () {
            //
        });
    }
}

Можно указать зависимости для нашего сервис-провайдера: сервис-контейнер позаботится об их внедрении.

use Illuminate\Contracts\Routing\ResponseFactory;

public function boot(ResponseFactory $response)
{
    $response->macro('caps', function ($value) {
        //
    });
}

Фасады

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

Вск фасады определены в пространстве Illuminate\Support\Facades. Соответственно, мы легко получаем доступ к ним вот так

use Illuminate\Support\Facades\Cache;

Route::get('/cache', function () {
    return Cache::get('key');
});

Зачем нужны фасады? Во-первых, они позволяют не запоминать длинные названия классов, которые нужно внедрить или настраить вручную. Во-вторых, их легко тестировать.

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

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

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

return View::make('profile');

и этот

return view('profile');

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

Все фасады (и те, что предоставляет Laravel из коробки, и те, что мы будем делать сами) наследуют класс Illuminate\Support\Facades\Facade. Этот класс используеи "магический" метод __callStatic() для переназначения вызовов из фасада к объекту, извлеченному из сервис-контроллера. В следующем примере вызов делается к системе кэширования Laravel.

<?php

namespace App\Http\Controllers;

use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Cache;

class UserController extends Controller
{
    /**
     * Show the profile for the given user.
     *
     * @param  int  $id
     * @return Response
     */
    public function showProfile($id)
    {
        $user = Cache::get('user:'.$id);

        return view('profile', ['user' => $user]);
    }
}

Здесь мы используем фасад Cache, который предоставляет доступ к нижележащей реализации интерфейса Illuminate\Contracts\Cache\Factory. Если мы посмотрим в класс Illuminate\Support\Facades\Cache, то не найдем там статического метода get. Фасад, как уже было сказано, наследует базовый класс Facade, и определяет метод getFacadeAccessor(), который возвращает название соответствующей привязки к сервис-контейнеру. В данном случае при вызове любого статического метода фасада Cache, Laravel извлекает из сервис-контейнера привязку cache, и вызывает запрошенный метод ( в данном случае get) из этого объекта.

Фасады реального времени

Используя фасады реального времени, мы можем обратиться к любому классу нашего приложения как к фасаду.

Предположим, у нас есть модель Podcast, содержащая метод publish. Но для публикации подкаста, мы должны внедрить реализацию класса Publisher. Это позволит нам легко тестировать этот метод, используя в качестве реализации Publisher заглушку.

<?php

namespace App;

use App\Contracts\Publisher;
use Illuminate\Database\Eloquent\Model;

class Podcast extends Model
{
    /**
     * Publish the podcast.
     *
     * @param  Publisher  $publisher
     * @return void
     */
    public function publish(Publisher $publisher)
    {
        $this->update(['publishing' => now()]);

        $publisher->publish($this);
    }
}

Но это обязывает нас каждый раз при вызове метода publish, внедрять реализацию Publisher. Так вот, фасады реального времени позволяют нам обойтись без этого. Все что нужно - в use для класса Publisher поставить префикс Facades. Остальное Laravel сделает за нас.

<?php

namespace App;

use Facades\App\Contracts\Publisher;
use Illuminate\Database\Eloquent\Model;

class Podcast extends Model
{
    /**
     * Publish the podcast.
     *
     * @return void
     */
    public function publish()
    {
        $this->update(['publishing' => now()]);

        Publisher::publish($this);
    }
}

Контракты

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

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

<?php

namespace App\Listeners;

use App\User;
use App\Events\OrderWasPlaced;
use Illuminate\Contracts\Redis\Database;

class CacheOrderInformation
{
    /**
     * The Redis database implementation.
     */
    protected $redis;

    /**
     * Create a new event handler instance.
     *
     * @param  Database  $redis
     * @return void
     */
    public function __construct(Database $redis)
    {
        $this->redis = $redis;
    }

    /**
     * Handle the event.
     *
     * @param  OrderWasPlaced  $event
     * @return void
     */
    public function handle(OrderWasPlaced $event)
    {
        //
    }
}

В этом обработчике используется реализация базы данных Redis.