意外と知られていない?Laravelの強力な機能Pipeline

Laravelで密かに利用されているPipelineをご存知でしょうか?内部的に様々なMiddlewareにも使われているもので、使い方によってはかなり強力なツールになるのですが、意外と知らない人もいるようなので、記事にしてみました。LaravelのPipelineについて、気になったかた、知らなかったかは是非最後まで読んでみてください!

LaravelのPipelineとは?

LaravelのPipelineは、一連の処理をステップごとに分けて実行するためのデザインパターンを実装した機能です。データやリクエストを複数のPipelineの中で順次処理し、最終的な結果を得ることができます。各Pipelineでは、データに対して異なる処理を施すことができ、シンプルかつ柔軟な処理フローを構築することが可能です。

Pipelineの主な特長

  1. 処理のカプセル化:各処理ステップ(Pipeline)は独立して定義され、後続の処理に影響を与えることなく追加や削除が可能。
  2. 再利用可能なPipeline:同じ目的のPipelineは再利用することができ、コードの重複を避けることができます。
  3. 動的な処理の組み立て:Pipelineは動的に処理の流れを組み立てることができ、条件に応じて処理の内容を変更することも可能です。

Laravelの日本語ドキュメントにもPipelineの記載が若干載っています。

LaravelにおけるPipelineの使用方法

Laravel標準でPipelineファサードが用意されています(Illuminate\Support\Facades\Pipeline)。

$result = Pipeline::send('A')
    ->through([
      AddBToTheEnd::class, // 文字列の末尾に「B」という文字を付与する
      AddCToTheEnd::class  // 文字列の末尾に「C」という文字を付与する
    ])
    ->then(function(string $data) {
      return $data . 'D';
    });

Pipelineで使用されるsend, through, thenはPipelineの流れを構築するための主要なメソッドです。それぞれがどのような役割を果たしているか、順番に説明していきます。

send(データを送る)

sendは、Pipelineの最初の部分で、Pipelineに送るデータやリクエストを指定するために使います。このメソッドに渡されたデータが、次に続く処理を通じて順次処理されていきます。

上記の例では、send()の中に「A」という文字列を入れているので、次に続く処理では「A」という文字列を受け取って処理をスタートすることになります。

through(Pipelineを通過させる)

throughは、どの「Pipeline」を通過するかを指定するためのメソッドです。throughに渡されるのは、処理の流れにおいて順番に実行されるクラスやクロージャです。

各Pipelineは、データやリクエストに対して個別の処理を行い、その結果を次のPipelineに渡します。Pipelineは配列で指定され、順番に処理が実行されます。

上記の例では、「AddBToTheEndクラス」と「AddCToTheEndクラス」が指定されていて、且つ、「AddBToTheEnd」の次に「AddCToTheEnd」が処理されます。

※AddBToTheEnd、AddCToTheEndのPipelineクラスはそれぞれ処理の中で文字列の末尾に「B」と「C」を追加するものとします。

then(処理の最終段階)

thenは、Pipelineの最後に呼び出されるメソッドで、全てのPipelineを通過した後に何をするかを定義します。このメソッドにクロージャを渡すと、最終的な処理がそのクロージャ内で行われます。

上記の例では、thenで受け取った「$data」に対して、「D」という文字を末尾に追加したものを結果として返却するようになっています。つまり、上記の例で最終的に変数「$result」に格納されるものは「ABCD」という文字列になります。

また、thenの代わりにthenReturnを使用することも可能で、thenReturnを使えば全てのPipelineを通過した後の最終結果を即座に返却させることが可能です。

$result = Pipeline::send('A')
    ->through([
      AddBToTheEnd::class,
      AddCToTheEnd::class
    ])
    ->thenReturn();

上記のthenReturnの場合、最終的に変数「$result」に格納されるものは「ABC」という文字列になります。

LaravelにおけるPipelineクラスの作り方

クラスの作成を行い、publicなhandle関数を作成すれば良いだけになります。

<?php
declare(strict_types=1);

use Closure;

class AddBToTheEnd
{
    /**
     * @param string $data
     * @param Closure $next
     * @return string
     */
    public function handle(string $data, Closure $next): string
    {
        $data .= 'B';
        return $next($data);
    }
}

上記は先ほど(↑)登場したAddBToTheEndのPipelineを実際に実装した場合になります。

ポイントはhandle関数に2つの引数がある点。それぞれの引数について説明します。

1. 第一引数について

第一引数で指定している「$data」はパイプラインで渡ってくるデータになります。つまり、Pipeline::send()で送られるデータです。

今回の場合はPipeline::send()で「A」という文字列が送られてくるため、引数「$data」が期待するデータ型は「string」になります。

プリミティブな型以外にも自分で定義したデータ型を引数のデータ型として指定することも可能です。

Pipeline全体の役割を考えて、データ型を決めることがポイントになります。

※もちろん、先ほどの例では、AddBToTheEndの後にAddCToTheEndが処理されるようになっていたため、AddCToTheEndの第一引数「$data」には、AddBToTheEndの処理結果、つまり「AB」という文字列が渡されることになります。

2. 第二引数について

第二引数で指定している「$next」は「Closure」になります。こちらは、どのようなPipelineでも変わることがないため、handle関数の第二引数は全て同じようになります。

※$nextの役割は、Pipelineから別のPipelineへの橋渡しであるため、全てのPipelineが終了した場合、returnで戻ってきます。そのため、handle関数の戻り値は第一引数で指定したデータ型と同じになります。

LaravelでのPipelineのパフォーマンス最適化

LaravelのPipelineは、処理の流れをステップごとに分割し、柔軟に処理を組み立てることができる強力な機能ですが、大量のデータや複雑な処理を扱う場合、パフォーマンスの最適化が重要になります。ここでは、Pipelineのパフォーマンスを向上させるための考慮事項とベストプラクティスについて解説します。

1. 必要な処理だけを実行する

Pipeline内の各処理では、条件に応じて処理をスキップしたり、必要のない処理を避けることができます。全てのリクエストやデータに対して無条件に処理を行うのではなく、各Pipelineで必要なチェックを行い、不要な処理をスキップ(早期リターン)することが重要です。不要な処理を避けることで、無駄なリソースの消費を抑えられます。

public function handle($request, Closure $next)
{
    if (条件) {
        // 処理をスキップして次のPipelineへ
        return $next($request);
    }
    
    // 必要な処理を実行
    $request->data = 'processed data';
    
    return $next($request);
}

2. 無駄なI/O操作を避ける

Pipelineを使うことでそれぞれの処理をステップ毎に分割できる反面、愚直に実装してしまうと意図せずデータベースクエリやAPI呼び出しが増加してしまい、パフォーマンスに大きな影響を与える可能性があります。Pipeline内でのI/O操作を最適化するために、必要最小限の操作に絞り、キャッシュを活用して重複した処理を避けることが重要です。

可能な限りデータベースクエリやAPI呼び出しをまとめ、繰り返し同じデータを取得するのを避けましょう。

EloquentModelを使ってデータ取得する場合はwithやload、loadMissingなどを活用してみると良いと思います。

3. Pipelineの終了条件を適切に設定する

Pipeline内の処理は、特定の条件を満たした場合に早期に終了できるように設計すべきです。これにより、必要以上に後続のPipelineが実行されることを防ぐことが可能になります。

Pipelineは複数組み合わせて使用することが可能ですが、中には、これ以上他のPipelineに繋げる必要がないと言うケースも出てくると思います。そのような場合は後続のPipelineに繋げるのではなく、Pipeline処理自体を終了させることを推奨します。

public function handle($request, Closure $next)
{
    if ($request->data === 'complete') {
        // Pipelineを早期終了
        return $request;
    }

    return $next($request);
}

LaravelにおけるPipeline活用時のログ出力について

Pipelineを活用する場合に限った話しではないのですが、スレッド単位のログの追跡は極めて重要になります。Laravelにおいて、APIのリクエスト単位で共通のIDをログに付与しログを追跡可能にするための方法を下記の記事にて紹介しています。とても簡単な方法で且つ、運用上メリットの高い手法であるためぜひ取り入れてみてください。

LaravelでwithContextを使用して同一スレッドのログを識別させる

LaravelでPipelineを活用した検索機能の実装例

Pipelineは処理を分割し、分割した処理を自由に適応させるあるいは適応させないことができる点が強みです。そのためPipelineクラスを自由に分割、適応させられるような設計を志し、ニーズがマッチするシーンで使用することで、効果抜群に威力を発揮すると思います。その代表的な例として「検索機能」がそれに該当すると考えるため、本記事では、Laravelで検索機能のPipelineを使用して実装する例を提示して、締めくくりたいと思います。

検索機能の前提条件

検索対象のテーブル定義

検索条件のテーブル名をbooksとし、対応するEloquentモデルをBookとする。

テーブル定義は下記の通り。

idnamecategorypriceauthor
intvarcharintintvarchar
booksテーブルのカラム例

それぞれカラムの説明になります。

  • name:本の名前を文字列として管理します。
  • category: 本のカテゴリーを数値で管理します。対応する数値は下記の通り。
    • 1: 文学・評論
    • 2: ビジネス・経済
    • 3: 歴史・地理
    • 4: 専門書
  • price: 本の価格を数値で管理します。
  • author: 本の著者を文字列で管理します。

検索条件の定義とAPIの定義

APIエンドポイント: https://localhost/api/v1/user

検索条件(クエリパラメータ)を下記の表に記します。

パラメータ名複数指定可否
name
categories
price
price_operator
author

「複数指定可否」について

  • 可の場合: 該当のパラメータは複数指定することが可能です。例えばcategoriesの場合categories[0]=1&categories[1]=2というように複数指定することができます。
  • 否の場合: 該当のパラメータは単一のみになります。他のパラメータと組み合わせることは可能です。

APIレスポンスは下記のようにします。

{
  'books': [
    {
      'id': int,
      'name': string,
      'category': int,
      'price': int,
      'author': string,
    }
    // 複数ある場合は続く
  ]
}

実装例

booksテーブルの作成

php artisan make:migration create_books_table

mgrationの内容

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('books', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->integer('category');
            $table->integer('price');
            $table->string('author');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('books');
    }
};

Bookモデルの作成

php artisan make:model Book

app/Models/Book.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Book extends Model
{
    use HasFactory;

    protected $guarded = [
        'id',
    ];
}

Enumの作成

今回Enumで管理をする値は「category」と「price_operator」の2つとします。

また、Enumの実装にあたり、laravel-enumというライブラリを使用します。LaravelでEnumを扱う上で、色々と便利なライブラリなのでおすすめです。(気が向いたらいつか記事にします)

  • laravel-enumのインストール
composer require bensampo/laravel-enum
  • ctegoryのenumを作成
php artisan make:enum Category

app/Enums/Category.php

<?php

declare(strict_types=1);

namespace App\Enums;

use BenSampo\Enum\Attributes\Description;
use BenSampo\Enum\Enum;

/**
 * @method static static LiteratureOrCriticism()
 * @method static static BusinessOrEconomics()
 * @method static static HistoryOrGeography()
 * @method static static Specialized()
 */
final class Category extends Enum
{
    #[Description('文学・評論')]
    public const LiteratureOrCriticism = 1;

    #[Description('ビジネス・経済')]
    public const BusinessOrEconomics = 2;

    #[Description('歴史・地理')]
    public const HistoryOrGeography = 3;

    #[Description('専門書')]
    public const Specialized = 4;
}
  • price_operatorのenumを作成
php artisan make:enum PriceOperator

app/Enums/PriceOperator.php

<?php

declare(strict_types=1);

namespace App\Enums;

use BenSampo\Enum\Attributes\Description;
use BenSampo\Enum\Enum;

/**
 * @method static static equal()
 * @method static static lessThan()
 * @method static static greaterThan()
 * @method static static lessThanOrEqual()
 * @method static static greaterThanOrEqual()
 */
final class PriceOperator extends Enum
{
    #[Description('=')]
    public const equal = 1;

    #[Description('<')]
    public const lessThan = 2;

    #[Description('>')]
    public const greaterThan = 3;

    #[Description('<=')]
    public const lessThanOrEqual = 4;

    #[Description('>=')]
    public const greaterThanOrEqual = 5;
}

Requestクラスの作成

php artisan make:request BookIndexRequest

app/Http/Requests/BookIndexRequest.php

<?php

namespace App\Http\Requests;

use App\Enums\Category;
use App\Enums\PriceOperator;
use BenSampo\Enum\Rules\EnumValue;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;

class BookIndexRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, ValidationRule|array|string>
     */
    public function rules(): array
    {
        return [
            'name' => ['string', 'max:255'],
            'categories.*' => [new EnumValue(Category::class, false)],
            'price' => ['integer'],
            'price_operator' => ['string', new EnumValue(PriceOperator::class, false)],
            'author' => ['string', 'max:255'],
        ];
    }
}

※EnumValueはlaravel-enumで用意されているバリエーションルールです。値がenumで定義されている値かどうかを検証します。第二引数のbool値は型を厳密に比較するかどうかです。

Pipelineのデータ設計

Pipeline全体を設計するための要素として、Pipelineのデータ設計は極めて重要な要素になります。Pipelineのデータ設計、すなわち、Pipeline::sendで送るデータの型であり、各種Pipelineの第一引数で受け取るデータの型になります。

このセクションでは検索機能におけるPipelineの役目を考えるのと同時に、Pipelineのデータについて、設計し、実際に組み立ててみようと思います。

Pipelineの目的

Pipelineの目的はデータ処理の一連のステップを柔軟に管理し、必要な変換やフィルタリングを段階的に行うことです。そのため、各ステップが独立して動作し、必要に応じて拡張や変更が可能な状態にすることが今回の目的と言えます。すなわち、検索機能での「検索クエリの組み立て」がそれに該当すると考えます。

Pipelineのデータ型

検索クエリの組み立て」 がPipelineの目的であるため、検索クエリを組み立てるために必要な、検索条件の値、すなわち、クエリパラメータで指定可能な「name」「category」「price」「price_operator」「author」は必要な値になるかと思います。

クラス名をBookSearchPipelineDataとして、クエリパラメータに対応するプロパティを追加します。

app/Pipelines/BookSearchPipelineData.php

<?php

namespace App\Pipelines;

class BookSearchPipelineData
{
    /**
     * @param string|null $name
     * @param int[] $categories
     * @param PriceQuery|null $priceQuery
     * @param string|null $author
     */
    public function __construct(
        public readonly ?string $name,
        public readonly array $categories,
        public readonly ?PriceQuery $priceQuery,
        public readonly ?string $author,
    ) {
    }
}

app/Pipelines/PriceQuery.php

<?php

namespace App\Pipelines;

use App\Enums\PriceOperator;

class PriceQuery
{
    /**
     * @param int $price
     * @param PriceOperator $operator
     */
    public function __construct(
        public readonly int $price,
        public readonly PriceOperator $operator,
    ) {
    }
}

さらに、Pipeline中で組み立てた検索クエリを格納する必要があるため、上記に「Illuminate\Database\Eloquent\Builder」を格納する$queryプロパティを追加します。

<?php

namespace App\Pipelines;

use Illuminate\Database\Eloquent\Builder;

class BookSearchPipelineData
{
    /**
     * @param string|null $name
     * @param int[] $categories
     * @param PriceQuery|null $priceQuery
     * @param string|null $author
     * @param Builder $query
     */
    public function __construct(
        public readonly ?string $name,
        public readonly array $categories,
        public readonly ?PriceQuery $priceQuery,
        public readonly ?string $author,
        public Builder $query, // これを追加
    ) {
    }
}

以上で、Pipelineに渡すデータの定義が完了です。

name条件適応Pipelineの作成

app/Pipelines/NameConditionPipeline.php

<?php

namespace App\Pipelines;

use Closure;
use Illuminate\Support\Facades\Log;

class NameConditionPipeline
{
    /**
     * @param BookSearchPipelineData $data
     * @param Closure $next
     * @return BookSearchPipelineData
     */
    public function handle(BookSearchPipelineData $data, Closure $next): BookSearchPipelineData
    {
        if (is_null($data->name)) {
            Log::info('Name is null');

            return $next($data);
        }

        $data->query->where('name', 'like', "%{$data->name}%");

        return $next($data);
    }
}

category条件適応Pipelineの作成

app/Pipelines/CategoryConditionPipeline.php

<?php

namespace App\Pipelines;

use Closure;
use Illuminate\Support\Facades\Log;

class CategoryConditionPipeline
{
    /**
     * @param BookSearchPipelineData $data
     * @param Closure $next
     * @return BookSearchPipelineData
     */
    public function handle(BookSearchPipelineData $data, Closure $next): BookSearchPipelineData
    {
        if (empty($data->categories)) {
            Log::info('Category is empty');

            return $next($data);
        }

        $data->query->whereIn('category', $data->categories);

        return $next($data);
    }
}

price条件適応Pipelineの作成

app/Pipelines/PriceConditionPipeline.php

<?php

namespace App\Pipelines;

use Closure;
use Illuminate\Support\Facades\Log;

class PriceConditionPipeline
{
    /**
     * @param BookSearchPipelineData $data
     * @param Closure $next
     * @return BookSearchPipelineData
     */
    public function handle(BookSearchPipelineData $data, Closure $next): BookSearchPipelineData
    {
        if (is_null($data->priceQuery)) {
            Log::info('Price query is null');
            return $next($data);
        }

        $data->query->where('price', $data->priceQuery->operator->description, $data->priceQuery->price);

        return $next($data);
    }
}

author条件適応Pipelineの作成

app/Pipelines/AuthorConditionPipeline.php

<?php

namespace App\Pipelines;

use Closure;
use Illuminate\Support\Facades\Log;

class AuthorConditionPipeline
{
    /**
     * @param BookSearchPipelineData $data
     * @param Closure $next
     * @return BookSearchPipelineData
     */
    public function handle(BookSearchPipelineData $data, Closure $next): BookSearchPipelineData
    {
        if (is_null($data->author)) {
            Log::info('Author is null');
            return $next($data);
        }

        $data->query->where('author', '=', "%{$data->author}%");

        return $next($data);
    }
}

Resourceクラスの実装

php artisan make:resource BookIndexResource

app/Http/Resources/BookIndexResource.php

<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Pagination\LengthAwarePaginator;

class BookIndexResource extends JsonResource
{
    public static $wrap = 'books';

    public function __construct(
        private readonly LengthAwarePaginator $paginator
    ) {
        parent::__construct($paginator);
    }

    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return $this->paginator->getCollection()->map(fn ($book) => [
            'id' => $book->id,
            'name' => $book->name,
            'category' => $book->category,
            'price' => $book->price,
            'author' => $book->author,
        ])->toArray();
    }
}

Controllerの作成

php artisan make:controller BookController

app/Http/Controllers/BookController.php

<?php

namespace App\Http\Controllers;

use App\Enums\PriceOperator;
use App\Http\Requests\BookIndexRequest;
use App\Http\Resources\BookIndexResource;
use App\Models\Book;
use App\Pipelines\AuthorConditionPipeline;
use App\Pipelines\BookSearchPipelineData;
use App\Pipelines\CategoryConditionPipeline;
use App\Pipelines\NameConditionPipeline;
use App\Pipelines\PriceConditionPipeline;
use App\Pipelines\PriceQuery;
use Illuminate\Support\Facades\Pipeline;

class BookController extends Controller
{
    /**
     * @param BookIndexRequest $request
     * @return BookIndexResource
     */
    public function index(BookIndexRequest $request): BookIndexResource
    {
        $priceQuery = $request->exists('price') && $request->exists('price_operator')
            ? new PriceQuery($request->price, PriceOperator::fromValue((int) $request->price_operator))
            : null;

        $pipelineData = new BookSearchPipelineData(
            name: $request->name,
            categories: $request->categories ?? [],
            priceQuery: $priceQuery,
            author: $request->author,
            query: Book::query(),
        );
        $result = Pipeline::send($pipelineData)
            ->through([
                NameConditionPipeline::class,
                CategoryConditionPipeline::class,
                PriceConditionPipeline::class,
                AuthorConditionPipeline::class,
            ])
            ->thenReturn();

        return new BookIndexResource($result->query->paginate());
    }
}

ルーティング定義

routes/api.php

Route::get('/books', [BookController::class, 'index']);

APIエンドポイント

https://localhost/api/v1/books
  • 値段指定
https://localhost/api/v1/books?price=3000&price_operator=1
  • カテゴリ指定
https://localhost/api/v1/books?categories[0]=1&categories[2]=4

まとめ

LaravelのPipelineいかがだったでしょうか?

意外と知っている人を見かけなかったので記事にしてみました。Pipelineは、複雑な処理を分割してシンプルに実行するための強力なツールです。上手く使えば複雑なロジックでも簡単に見せることができます。その反面少し油断すれば、I/O処理の増加等によりパフォーマンスの劣化を招くことになりますので、使用する際は、無駄な処理を避け、効率的なデータフローを実現することが鍵だと考えます。

※実装例を作ってみましたが、例のようなシンプルな条件のみのケースであれば正直EloquentのWhenを使用すれば早いですが、条件が複雑になってくるにつれて、Pipelineの真価を感じると思います。

意外と知られていない?Laravelの強力な機能Pipelineのご紹介
最新情報をチェックしよう!