Пайплайны в Laravel. Часть 1

Laravel Pipeline

В этой серии статей я расскажу вам о пакете Pipeline (Пайплайн, Конвейер), входящем в Laravel, который практически никто не использует и он не документирован, хотя и офигенен!

Иногда, приложения должны что-либо модифицировать. Зачастую это выполняется отдельными функциями, которые могут выполнятся независимо друг от друга, либо в определенном порядке. Каждая из них берет «что-то», изменяет и передаёт дальше, в конце концов выдавая обработанный результат.

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

Сокрытый в тенях фреймворка, в самом тёмном углу Illuminate-кода, находится пакет Pipeline. Этот каталог, спрятанный от посторонних глаз, содержит три класса: Сервис-провайдер, Пайплайн и Хаб. Брошенные разработчиками они глубоко спят, ожидая кто-же их пробудит.

(Всё, завязывайте пугать себя)

Давайте сначала начнём с Пайплана, а в следующих статьях перейдем к Хабу и сервис-провайдеру.

Класс Pipeline

Чтобы полностью понять его концепцию, давайте сначала опишем его:

Пайплайн принимает передаваемый (passable) разработчиком параметр, который должен быть модифицирован серией логический блоков, называемых пайпами (pipe) или каналами. Только после выполнения последнего пайпа вы сможете получите конечный результат.

Иллюстраиця работы пайпов в Laravel

Хорошо, основная идея ясна. Как насчет примеров? Хотите верьте, хотите нет, но у меня только один. Класс Pipeline является ключевым для управления маршрутными мидлварами. По сути, он берет Запрос и пропускает его через каждый мидлвар, зарегистрированный для текущего Маршрута, прежде чем тот достигнет Контроллера.

Вот код Pipeline:

namespace Illuminate\Pipeline;

use Closure;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Pipeline\Pipeline as PipelineContract;
use RuntimeException;
use Throwable;

class Pipeline implements PipelineContract
{
    /**
     * The container implementation.
     *
     * @var \Illuminate\Contracts\Container\Container
     */
    protected $container;

    /**
     * The object being passed through the pipeline.
     *
     * @var mixed
     */
    protected $passable;

    /**
     * The array of class pipes.
     *
     * @var array
     */
    protected $pipes = [];

    /**
     * The method to call on each pipe.
     *
     * @var string
     */
    protected $method = 'handle';

    /**
     * Create a new class instance.
     *
     * @param  \Illuminate\Contracts\Container\Container|null  $container
     * @return void
     */
    public function __construct(Container $container = null)
    {
        $this->container = $container;
    }

    /**
     * Set the object being sent through the pipeline.
     *
     * @param  mixed  $passable
     * @return $this
     */
    public function send($passable)
    {
        $this->passable = $passable;

        return $this;
    }

    /**
     * Set the array of pipes.
     *
     * @param  array|mixed  $pipes
     * @return $this
     */
    public function through($pipes)
    {
        $this->pipes = is_array($pipes) ? $pipes : func_get_args();

        return $this;
    }

    /**
     * Set the method to call on the pipes.
     *
     * @param  string  $method
     * @return $this
     */
    public function via($method)
    {
        $this->method = $method;

        return $this;
    }

    /**
     * Run the pipeline with a final destination callback.
     *
     * @param  \Closure  $destination
     * @return mixed
     */
    public function then(Closure $destination)
    {
        $pipeline = array_reduce(
            array_reverse($this->pipes()), $this->carry(), $this->prepareDestination($destination)
        );

        return $pipeline($this->passable);
    }

    /**
     * Run the pipeline and return the result.
     *
     * @return mixed
     */
    public function thenReturn()
    {
        return $this->then(function ($passable) {
            return $passable;
        });
    }

    /**
     * Get the final piece of the Closure onion.
     *
     * @param  \Closure  $destination
     * @return \Closure
     */
    protected function prepareDestination(Closure $destination)
    {
        return function ($passable) use ($destination) {
            try {
                return $destination($passable);
            } catch (Throwable $e) {
                return $this->handleException($passable, $e);
            }
        };
    }

    /**
     * Get a Closure that represents a slice of the application onion.
     *
     * @return \Closure
     */
    protected function carry()
    {
        return function ($stack, $pipe) {
            return function ($passable) use ($stack, $pipe) {
                try {
                    if (is_callable($pipe)) {
                        // If the pipe is a callable, then we will call it directly, but otherwise we
                        // will resolve the pipes out of the dependency container and call it with
                        // the appropriate method and arguments, returning the results back out.
                        return $pipe($passable, $stack);
                    } elseif (! is_object($pipe)) {
                        [$name, $parameters] = $this->parsePipeString($pipe);

                        // If the pipe is a string we will parse the string and resolve the class out
                        // of the dependency injection container. We can then build a callable and
                        // execute the pipe function giving in the parameters that are required.
                        $pipe = $this->getContainer()->make($name);

                        $parameters = array_merge([$passable, $stack], $parameters);
                    } else {
                        // If the pipe is already an object we'll just make a callable and pass it to
                        // the pipe as-is. There is no need to do any extra parsing and formatting
                        // since the object we're given was already a fully instantiated object.
                        $parameters = [$passable, $stack];
                    }

                    $carry = method_exists($pipe, $this->method)
                                    ? $pipe->{$this->method}(...$parameters)
                                    : $pipe(...$parameters);

                    return $this->handleCarry($carry);
                } catch (Throwable $e) {
                    return $this->handleException($passable, $e);
                }
            };
        };
    }

    /**
     * Parse full pipe string to get name and parameters.
     *
     * @param  string  $pipe
     * @return array
     */
    protected function parsePipeString($pipe)
    {
        [$name, $parameters] = array_pad(explode(':', $pipe, 2), 2, []);

        if (is_string($parameters)) {
            $parameters = explode(',', $parameters);
        }

        return [$name, $parameters];
    }

    /**
     * Get the array of configured pipes.
     *
     * @return array
     */
    protected function pipes()
    {
        return $this->pipes;
    }

    /**
     * Get the container instance.
     *
     * @return \Illuminate\Contracts\Container\Container
     *
     * @throws \RuntimeException
     */
    protected function getContainer()
    {
        if (! $this->container) {
            throw new RuntimeException('A container instance has not been passed to the Pipeline.');
        }

        return $this->container;
    }

    /**
     * Handle the value returned from each pipe before passing it to the next.
     *
     * @param  mixed  $carry
     * @return mixed
     */
    protected function handleCarry($carry)
    {
        return $carry;
    }

    /**
     * Handle the given exception.
     *
     * @param  mixed  $passable
     * @param  \Throwable  $e
     * @return mixed
     *
     * @throws \Throwable
     */
    protected function handleException($passable, Throwable $e)
    {
        throw $e;
    }
}

Этот класс предоставляет пять открытых метода, которые можно использовать в работе: send(), through(), via() (опционально), и, наконец, then() или thenReturn().

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

Запуск

Чтобы использовать Пайплайн нужно создать его экземпляр. Это легко сделать с помощью хелпера app() и указания класса Pipeline:

$result = app(\Illuminate\Pipeline\Pipeline::class);

Если не планируете использование сервис-контейнера, то можете сделать что-то вроде new Pipeline(new Container). Результат будет тот же, если только вам потом действительно не понадобится настоящий сервис-контейнер.

Отправка

Первое, что нужно сделать, это передать параметр. Это может быть строка, массив, Коллекция, экземпляр объекта, да что угодно. Для простоты будем использовать строку.

$result = app(\Illuminate\Pipeline\Pipeline::class)
    ->send('this should be correctly formatted');

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

Сквозь пайпы

Параметр есть, пора настроить пайп. Мы можем использовать массив пайпов в качестве одного единственного аргумента или несколько пайпов — как множество аргументов. Это полезно, когда вам нужно распаковывать аргументы для передачи списка пайпов. Пайпами могут быть:

  • имя Класса, которое может быть определено и инстанцировано через сервис-контейнер
  • Замыкание или invokable Класс
  • экземпляр объекта

Здесь я всё смешаю, просто потому, что могу:

$result = app(\Illuminate\Pipeline\Pipeline::class)
    ->send(’this should be correctly formatted’)
    ->through(
        function ($passable, $next) {
          return $next(ucfirst($passable));
        },
        AddPunctuation::class,
        new RemoveDoubleSpacing(),
        InvokableClassToRemoveDuplicatedWords::class
     );

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

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

Via handle (опционально)

Когда вы используете классы в качестве пайпов, то они должны иметь общий публичный метод. Это не относится к invokable классам.

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

Например, если название «handle» уже занято другой логикой, то мы можем использовать например метод modifyString().

$result = app(\Illuminate\Pipeline\Pipeline::class)
    ->send('this should be correctly formatted')
    ->through([
        ...
    ])
    ->via('modifyString');

Такое совпадение редкость, но, если что, то вы знаете, что делать.

Получение результата

Финальный шаг — выполнить Пайплайна. Это делается с помощью метода then() или thenReturn().

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

$result = app(\Illuminate\Pipeline\Pipeline::class)
    ->send('this should be correctly formatted')
    ->through(...)
    ->via('modifyString')
    ->thenReturn();

Если этот кода запустить, то Пайплайн вернет результирующую строку, модифицированную всеми пайпами.

В итоге, мы всё это можем использовать это в гипотетическом контроллере, посвященном Статьям (Articles).

Возьмем текст статьи, полученный из браузера и отфильтруем его. Всё это будет сделано с помощью Пайплайна.

namespace App\Http\Controllers;

use App\Article;
use Illuminate\Pipeline\Pipeline;
use App\Pipes\StringFormatting\Paragraphize;
use App\Pipes\StringFormatting\EncodeToHtml;
use App\Pipes\StringFormatting\AddPunctutation;
use App\Pipes\StringFormatting\RemoveDuplicates;

class ArticleController extends Controller
{
    /**
     * Create a new Article Controller instance
     *
     * @return void
     */
    public function __construct()
    {
        $this->middlware('auth');
    }

    /**
     * Updates the Article
     *
     * @param \Illuminate\Http\Request $request
     * @param \Illuminate\Pipeline\Pipeline $pipeline
     * @param \App\Article $article
     * @return \Illuminate\Http\Response
     */
    public function update(Request $request, Pipeline $pipeline, Article $article)
    {
        $validated = $request->validate([
            'title' => 'required|string',
            'body' => 'required|string',
        ]);
        
        // Как только будет пройдена валидация, то мы берем тело статьи
        // и пропускаем его через пайпы, форматирующие текст.
        // Конечно, они не учитывают грамматические и орфографические ошибки,
        // но нам это и не нужно.        
        $article->body = $pipeline->send($request->body)
            ->through([
                Paragraphize::class,
                RemoveDuplicates::class,
                AddPunctutation::class,
                EncodeToHtml::class,
            ])->thenReturn();
                    
        $article->save();
        
        session()->flash('alert', 'The Article has been updated!');
        
        return view('article.edit')->with('article', $article);
    }
}

Вот и всё. В следующей статье мы рассмотрим сами пайпы.

Автор: Italo Baeza Cabrera
Перевод: Алексей Широков

Наш Телеграм-канал — следите за новостями о Laravel.