Command Bus (командная шина)

Введение

Командная шина в Laravel - это удобный способ инкапсуляции (изолирования) задач вашего приложения в простые и понятные «команды». Чтобы разобраться в назначении команд, давайте представим, что мы пишем приложение, в котором мы будем продавать подкасты нашим пользователям.

Когда пользователь приобретает подкаст, нам нужно произвести несколько действий: мы должны снять деньги с его карты, добавить запись в БД, что покупка успешно состоялась и отослать email с подтверждением покупки. Возможно, еще нам нужно провести несколько проверок - а разрешено ли этому пользователю вообще покупать этот конкретный подкаст.

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

Поэтому вместо помещения этой логики в контроллер мы оформим её в так называемую команду и назовём её PurchasePodcast.

Создание команды

Создаем команду при помощи artisan-команды make:command:

php artisan make:command PurchasePodcast

Созданный этой командой класс помещается в папку app/Commands. По умолчанию команда содержит два метода - конструктор и метод handle. При помощи конструктора вы можете добавить нужные зависимости в класс команды, а метод handle собственно исполняет команду. Например:

class PurchasePodcast extends Command implements SelfHandling {

    protected $user, $podcast;

    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct(User $user, Podcast $podcast)
    {
        $this->user = $user;
        $this->podcast = $podcast;
    }

    /**
     * Execute the command.
     *
     * @return void
     */
    public function handle()
    {
        // Пишем функцонал покупки подкаста здесь...

        event(new PodcastWasPurchased($this->user, $this->podcast));
    }

}

Метод handle тоже может принимать зависимости в аргументах (type hinting), как и конструктор. Как и конструктору, они будут поданы на вход автоматически при помощи IoC контейнера.

    /**
     * Execute the command.
     *
     * @return void
     */
    public function handle(BillingGateway $billing)
    {
        // Пишем функцонал покупки подкаста здесь...
    }

Выполнение команд

Мы создали команду, как теперь запустить её? Конечно, мы можем выполнить метод handle нашего класса, однако лучше запускать его через командную шину Laravel. О преимуществах такого подхода будет рассказано ниже.

Если вы взглянете на базовый контроллер вашего приложения, который расширяют ваши собственные контроллеры, вы увидите там трейт DispatchesCommands. Этот трейт позволяет запускать команды при помощи метода dispatch. Например:

public function purchasePodcast($podcastId)
{
    $this->dispatch(
        new PurchasePodcast(Auth::user(), Podcast::findOrFail($podcastId))
    );
}

Командная шина исполняет команду и берёт на себя всю рутину по обеспечению класса команды всеми необходимыми зависимостями, перечислеными в аргументах конструктора класса команды и аргументах метода handle.

Вы можете задействовать командную шину в любом вашем классе - для этого добавьте трейт Illuminate\Foundation\Bus\DispatchesCommands. Если вы хотите принимать инстанс командной шины в конструкторе, то укажите в аргументах (type hint) объект Illuminate\Contracts\Bus\Dispatcher. И наконец вы можете просто использовать фасад Bus для запуска команды:

    Bus::dispatch(
        new PurchasePodcast(Auth::user(), Podcast::findOrFail($podcastId))
    );

Передача аргументов из запроса

Практически всегда вам понадобится передать данные HTTP-запроса (например, выборочное из $_POST) в команду. Вместо того, чтобы заставлять вас вручную описывать каждый запрос, Laravel предлагает нечто более автоматизированное. Посмотрите на метод dispatchFrom трейта DispatchesCommands:

$this->dispatchFrom('Command\Class\Name', $request);

Этот метод смотрит конструктор класса, имя которого передано первым аргументом, и вынимает из переменной $request (это HTTP-запрос или просто любой объект типа ArrayObject) те ключи, которые совпадают с названием переменных в аргументе конструктора. Например, если в аргументах конструктора есть переменная $firstNаme, то ей присвоится значение firstName HTTP-запроса.

Вы можете передать третьим аргументом массив значений по умолчанию:

$this->dispatchFrom('Command\Class\Name', $request, [
    'firstName' => 'Taylor',
]);

Запуск команды в фоне

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

Для создания команды, которая будет запускаться в фоне, добавьте флаг --queued:

php artisan make:command PurchasePodcast --queued

Созданный класс будет наследовать интерфейс Illuminate\Contracts\Queue\ShouldBeQueued и иметь трейт SerializesModels. Этот функционал позволит команде добавляться в очередь для последующего запуска слушателем очереди, а также добавит возможность сериализовать и десериализовать Eloquent-модели.

Если у вас уже есть созданная команда и вы хотите сделать её работающей в фоне, просто вручную добавьте implements Illuminate\Contracts\Queue\ShouldBeQueued. Этот интерфейс не содержит обязательных методов и является просто индикатором для командной шины. После этого метод dispatch вместо того, чтобы запустить команду, поместит её в очередь для последующего запуска в фоне.

Чтобы узнать поподробнее о том, как в Laravel осуществляется запуск задач в фоне, обратитесь к документации по очередям.

Конвейеры команд

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

Для добавления конвейера к вашей командной шине, вызовите метод pipeThrough диспетчера в своём методе App\Providers\BusServiceProvider::boot:

$dispatcher->pipeThrough(['UseDatabaseTransactions', 'LogCommand']);

Конвейер команд описывается в методе handle:

class UseDatabaseTransactions {

    public function handle($command, $next)
    {
        return DB::transaction(function() use ($command, $next)
        {
            return $next($command);
        }
    }

}

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

В качестве элемента конвейера так же можно использовать замыкания:

$dispatcher->pipeThrough([function($command, $next)
{
    return DB::transaction(function() use ($command, $next)
    {
        return $next($command);
    }
}]);