Laravel の FormRequest を Data Transfer Object 的に使ってみた


はじめに

Data Transfer Object

Data Transfer Object(DTO)はデザインパターンの一種で、アプリケーションソフトウェアのサブシステム間でデータを転送するのに使う。
Data Transfer Object - Wikipedia

元々はサブシステム間のデータの受け渡しにおいて、受け手側の処理に最適化されたオブジェクトを作成 (送り手で保持しているオブジェクトを変換) する役割を持ったデザインパターンです。

そこから派生して、同一システム内の異なるレイヤー間でデータを受け渡す際にも、DTO 的なクラスが使われることがあります。

FormRequest

一方、Laravel の FormRequest と言えば、GET や POST でブラウザ (HTTPクライアント) から送られてきたデータを受け取って、バリデーションした後に、アクションメソッドにインジェクトされるクラスです。

以下のような使い方が一般的と思います。

使用例) 著者から本を検索する

SearchBookController.php
public function index(SearchBookRequest $request)
{
    $author = $request->get('author');
    $books = Book::where('author', $author)->get();
    // ...
}

検索条件が少なければ、Controller 側で上のようにクエリーを組み立てて、Eloquent な Model に直接渡してもいいんでしょうが、数が増えてくると大変なことになるので、できればこんな感じにシュッとできればいいなぁ、というのが、本記事を書いた動機です。

SearchBookController.php
public function index(SearchBookRequest $request)
{
    $params = $request->all();
    $books = Book::where($params)->get();
    // ...
}

フォーム要素とテーブルスキーマが完全に一致している上に、検索演算子がすべて = であれば、上のやり方でできるわけですが、そうしたケースはめったになく、著者名は部分一致検索だったり、出版年月日は期間だったりして、色々ごにょごにょとやらないとクエリーが組み立てられません。

Controller を上のようなシンプルな構成に保ちつつ、Model 側でクエリーをつくるのもあまり複雑にしたくないので、キー名、比較演算子、値、の3つを FormRequest でつくって渡してみようという試みです。

フォームを扱うクラスに、SQL の情報が含まれてしまうのが、オブジェクト指向的にどうなの、というかんじもしますが、今回はお作法よりも利便性を取りたいと思います。

サンプルコード

SearchBookRequest.php
<?php

namespace App\Requests;

use Illuminate\Support\Str;

class SearchBookRequest extends Request
{
    private $comparators = [
        'author' => 'like',
        'published_at_from' => '>=',
        'published_at_to' => '<=',
    ];

    // authorize, rules は省略

    public function params()
    {
        // 空の検索条件は取ってこない
        $inputs = $this->intersect(['author', 'published_at_from', 'published_at_to']);
        return $this->transform($inputs, $this->comparators);
    }

    private function transform(array $inputs, array $comparators)
    {
        $queryParams = [];
        foreach ($params as $key => $value) {
            $queryParams[] = $this->buildQueryParam($key, data_get($comparators, $key, '='), $value);
        }
        return $queryParams;
    }

    private function buildQueryParam($k, $comp, $v)
    {
        if ($comp === 'like') {
            $v = "%{$v}%";
        }
        // 変換用メソッドがあればここで変換する
        $transformer = 'transform' . Str::camel($k);
        if (method_exists($this, $transformer)) {
            return $this->$transformer($k, $comp, $v);
        }
        return [$y, $comp, $v];
    }

    // 例として単純なキー名(フィールド名)の変換をしたが、複雑な変換も可能   
    private function transformPublishedAtFrom($k, $comp, $v)
    {
        return ['published_at', $comp, $v];
    }
}

これらをそのまま Eloquent な Model に渡すことができます。

SearchBookController.php
public function index(SearchBookRequest $request)
{
    $params = $request->params();
    $books = Book::where($params)->get();
    // ...
}

複雑な検索条件であっても、結果的に [$key, $comparator, $value] の配列で返すことができれば、Controller を変更する必要はありません。

今回は SearchBookRequest という具象クラスに実装しましたが、もちろん、抽象クラスで実装して汎用的に使うこともできますし、もっと言えば、FormRequest で実装する必要もなく、DI を使って別のクラスに委譲することもできます。

また、サンプルコードでは、フォーム要素とDBのフィールドが一対一で対応していますが、ひとつのフォーム要素の値によって、複数の検索条件が導出されるようなパターンもあると思うので、そうしたケースのときどのように実装すればいいか、興味のある方は実装してみると面白いかもしれません。

まとめ

  • FormRequest を DTO 的に使ってみたけど、使えそうだった

似たようなことをやられている方がいたら、ウチではこんな風にしてるよ、とかコメントいただけると助かります