Help us understand the problem. What is going on with this article?

Laravelで複雑性と戦うための4つのTips

11月からオープンロジで働いている@pakkunです!
入社したばかりのため、物流というドメイン知識を今必死に覚えています :rocket:

アドベントカレンダーを書くと年末を感じますね!
今年は、Laravelを使っていても気づいたら複雑な処理が増えている時があるなーと思い、複雑な実装になりうる課題をシンプルにする4つのTipsを書きました。

個人的なTipsになりますが、この記事を読んだ方が、より早く価値が提供できるようになれば幸いです!

この記事でシンプルにできるのは下記となります。

  1. 複数のモデルの値を結合したい、もしくはモデルの値を使用して計算したい
  2. Joinした結果、もしくはGroup Byをした結果を使いたい
  3. Apiの結果をモデルとして定義したい
  4. GETやPOSTでリクエストされた情報を変換して使いたい

1. 複数のモデルの値を結合したい、もしくはモデルの値を使用して計算したい

Viewでユーザーのフルネームを表示しようとして、下記のようにUserモデルのlast_namefirst_nameを使用したことはありませんか。

<div>{{ $user->last_name . ' ' . $user->first_name }}</div>
<div>{{ $user->last_name }} {{ $user->first_name }}</div>

フルネームという言葉に着目しましょう。このフルネームというは一つの単語で表せていますので、概念として定義することができます。
概念で定義できるのであれば、モデルにフルネームを取得するメソッドが定義できます。

class User extends Model {
    public function getFullName($Join = ' ') {
        return $this->getAttribute('last_name') . $Join . $this->getAttribute('first_name');
    }
}

下記のようにモデルにロジックを集約することにより、Viewからロジックを消すことができました。

<div>{{ $user->getFullName() }}</div>

少し複雑な例で考えてみましょう。
ハンバーガーセットモデルには、ハンバーガーモデル、ドリンクモデル、サイドメニューモデルがあり、その3つのモデルにはカロリー情報を持っているとします。
その時にハンバーガーセットモデルにカロリーを計算するメソッドを持たせてみましょう。

class HamburgerSet extends Model
{
    /**
     * Hamburger relation.
     *
     * @return \Illuminate\Database\Eloquent\Relations\HasOne
     */
    public function hamburger()
    {
        return $this->hasOne(Hamburger::class)->withDefault(function($model) {
            $model->calorie = 0;
        });
    }

    /**
     * Drink relation.
     *
     * @return \Illuminate\Database\Eloquent\Relations\HasOne
     */
    public function drink()
    {
        return $this->hasOne(Drink::class)->withDefault(function($model) {
        $model->calorie = 0;
    });
    }

    /**
     * Side menu relation.
     *
     * @return \Illuminate\Database\Eloquent\Relations\HasOne
     */
    public function sideMenu()
    {
        return $this->hasOne(SideMenu::class)->withDefault(function($model) {
        $model->calorie = 0;
    });
    }

    /**
     * カロリー合計値
     *
     * @return
     */
    public function totalCalorie()
    {
        $hamburgerCalorie = $this->getRelation('hamburger')->calorie;
        $drinkCalorie = $this->getRelation('drink')->calorie;
        $sideMenuCalorie = $this->getRelation('sideMenu')->calorie;

        return $hamburgerCalorie + $drinkCalorie + $sideMenuCalorie;
    }
}

View上では計算のロジックを一切書かずに、ハンバーガーセットモデル内でカロリーを計算して、合計のカロリーを表示することができるようになりました。

<div>{{ $hamburgerSet->totalCalorie() }}</div>

Viewだけではなく、Controller、ApplicatoinService(UseCase)などでも、文字列結合や計算をしている場合、一つの概念にできないか検討しましょう。
メリットとしては、モデルにメソッドとして定義できたことにより、加工や計算という処理をテストすることができるようになっています。
つまりは、テスタビリティが向上しています。

そして、このモデルへのロジックの定義は、ドメイン駆動設計に繋がります。
ドメイン駆動設計をすべて導入するのはすごく難しいですが、このような形で少しずつ適用していくことも可能です。

今回のカロリーの例では、とても単純例でしたが、食品表示としての情報にしたい場合、下記のようにValueObjectとして定義することも可能です。

class HamburgerSet() {

    // ...

    public function getFoodInformatoin() {
        return new FoodInformation($this->hamburger, $this->drink, $this->sideMenu);
    }

    // ...
}

ValueObjectを使用することにより、計算や加工という処理を一つのオブジェクトに集約することができました。

$foodInformatoin = $hamburger->getFoodInformatoin()
$foodInformatoin->getCalorie(); // カロリー
$foodInformatoin->getMaterials(); // 素材
$foodInformatoin->getConstituents(); // 成分

ドメイン駆動設計では、モデルを含むドメイン領域以外にビジネスロジックを定義していくことをドメインモデル貧血症と呼びます。
文字列結合や計算をモデルやドメインモデルの領域以外で実装し始めたら、一度立ち止まって、モデルに定義できないかをぜひ考えてみてください!
もしかしたら、ドメインモデル貧血症になっているかも!?

2. Joinした結果、もしくはGroup Byをした結果を使いたい

開発をしていると「Joinした結果を表示したい!」、「Group Byした結果を表示したい!」という要望に出会ったことはあるのではないでしょうか。
そんな時、SQLを使用せずに、ORMで頑張っていませんか?
しかし、ORMはテーブルのツリー構造に依存しているため、ORMだけでは簡単に計算や加工を行うことができないと思います。

その時は、その概念をモデルとして定義できないかを考えてみましょう!
MySQLなどのRDBには、Viewを作る機能が備わっています。その機能を使用し、Eloquentモデルとして定義すれば、新しい概念をモデルとして表現できます。
ただ、Viewを作るのはちょっとって思う人もいるかもしれません。
わかります。自分もそう思います。

Laravelのv5.6から、EloquentモデルにfromSub()という便利な機能を提供されています。
そのため、RDBでViewを作らずとも同じことが実現できます。
※それ以前のバージョンではfrom()という機能が提供されていますので、form()に定義したSQLにas table_nameを付与すれば同じように実装ができます。

今回は、fromSub()を使用してEloquentモデルを定義していきます。

例として、注文(order)した商品(item)の価格(price)の合計金額(subtotal)を取得するEloquentモデル(OrderSubtotal)を新しく定義してみます。
注文(order)には複数の商品(item)が1対多で紐付き、下記のようなテーブル構造を想定しています。

  • テーブル構造
    • orders
      • order_id
    • item_order
      • order_id
      • item_id
    • items
      • item_id
      • price
class OrderSubtotal extends Model
{
    protected $table = 'order_item_prices';

    protected static function boot()
    {
        parent::boot();
        // グローバルスコープを使用してサブクエリをテーブルとして定義
        static::addGlobalScope('fromSub', function (Builder $builder) {
            $sql = <<< EOF
SELECT
       orders.id as order_id,
       sum(items.price) as subtotal
FROM orders
       LEFT JOIN item_order
         ON orders.id = item_order.order_id
       LEFT JOIN items
         ON items.id = item_order.item_id
group by orders.id
EOF;
            $builder->fromSub($sql, 'order_item_prices');
        });
    }

    public function scopeOfOrderId($query, $orderId)
    {
        return $query->where('order_id', $orderId);
    }
}

作成したEloquentモデルは下記のように使用できます。

$orderSubtotal = OrderSubtotal::ofOrderId(1)->first();
echo $orderSubtotal->subtotal;

この方法を使用することにより、テーブルとしては存在しない概念をモデルとして定義することができました。
モデルとして定義できたことにより、hasOnehasManybelongsToManyのリレーションを利用できるようになっています。

class Order extends Model
{
    /**
     * Order subtotal relation.
     *
     * @return \Illuminate\Database\Eloquent\Relations\HasOne
     */
    public function orderSubtotal() {
        return $this->hasOne(OrderSubtotal::class);
    }
}
echo $order->orderSubtotal->subtotal;

リレーション、クエリスコープも使えるので、通常のEloquentモデルと同様にとても柔軟な実装ができるようになっています。
今回の例では簡単なクエリでしたが、もっと複雑な集計や計算にも耐えうる実装になっています。
無理にEloquentモデルだけで実装してしまい、可読性が低くなってしまっている場合には、ぜひ検討してみてください!

3. Apiの結果をモデルとして定義したい

Apiからデータを取得する機会も多いと思います。
ただ、取得した結果を配列やstdClassとして使用している人は多いのではないでしょうか。
配列やstdClassで扱っていると、メソッドが定義できないため、View、Controllerなどでデータの結合や計算が行われがちになります。
先程の問題でもあったモデルにメソッドが定義できないドメインモデル貧血症がまさにここでも起きています。

そんな時には、jessenger/modelを検討してみましょう。
Apiの結果を簡単にモデルとして定義することができます。
例としてGitHubのoctcatのリポジトリの結果をモデルにしてみます。

use Jenssegers\Model\Model;

class GitHubOctocatRepository extends Model
{
    const BASE_URL                     = 'https://api.github.com';
    const OCTOCAT_REPOSITORY_LIST_PATH = '/users/octocat/repos';
    const OCTOCAT_REPOSITORY_PATH      = '/repos/octocat';
    private $client;

    public function __construct(array $attributes = [])
    {
        parent::__construct($attributes);
        $this->client = new \GuzzleHttp\Client([
            'base_uri' => self::BASE_URL,
        ]);
    }

    /**
     * Octocatリポジトリ一覧の取得
     *
     * @return \Illuminate\Support\Collection
     * @throws \GuzzleHttp\Exception\GuzzleException
     */
    public function get()
    {
        $response = $this->client->request('GET', self::OCTOCAT_REPOSITORY_LIST_PATH);
        return collect($this->hydrate(json_decode($response->getBody()->getContents())));
    }

    /**
     * リポジトリ名からOctocatのリポジトリを取得
     *
     * @param $repositoryName
     * @return Model
     * @throws \GuzzleHttp\Exception\GuzzleException
     */
    public function findByRepositoryName($repositoryName)
    {
        $response = $this->client->request('GET', self::OCTOCAT_REPOSITORY_PATH . '/' . $repositoryName);
        return $this->newInstance(json_decode($response->getBody()->getContents()));
    }

    /**
     * 省略した概要の取得
     *
     * @return string
     */
    public function getAbbrDescription()
    {
        return mb_strimwidth($this->getAttribute('description'), 0, 10, "...", 'UTF-8');
    }
}

使い方は下記になります。Eloquentモデルとほぼ同じように扱うことができます。

$gitHubOctocatRepositories = GitHubOctocatRepository::get();
$gitHubOctocatRepositories->each(function ($gitHubOctocatRepository) {
    echo $gitHubOctocatRepository->getAbbrDescription() . '\n';
})

このようにモデルとして定義できると、ロジックをモデルに集約することが可能になります。
Apiを使用してもLaravelのEloquentモデルのようにしたい場合は、ぜひ検討してみましょう!
もちろん、ライブラリを使わずに自身で自作しても問題はありません!
大切なのは、モデルとして定義し、ロジックを集約できるようになることです!

4. GETやPOSTでリクエストされた情報を変換して使いたい

一覧で複数のIDを検索したいという要望があり、カンマ区切りで指定できるようにする場合もあるかと思います。
何も考えずに実装した場合、Controllerでデータを正規化する処理を書いてしまいがちですが、そのような処理は、Requestクラスで行うようにするとスマートです。
下記のようにカンマ区切りのデータをよしなに変換できるメソッドを定義してみましょう。

class IndexRequest extends FormRequest
{
    // ...

    public function getIds()
    {
        return explode(',', $this->input('csv_ids', ''));
    }
}

Requestクラスで正規化することにより、Controllerに処理を書かずともIDの一覧が取得できるようになりました。

class AbcController {
    public function index(IndexRequest $IndexRequest) {
        $ids = $request->getIds();
        // ...
    }
}

最近では、SPAで開発している場合もあり、ブラウザ側でリクエストする際の値を最適化できる機会も増えましたが、
JavaScriptを使用するほどではないアプリケーションを開発している時などであれば、このような解決方法も検討してみてはどうでしょうか。

まとめ

今回の4つのTipsはすべてテスタビリティの向上に役に立ちます。
考え方や実装方法を少し変えるだけで、可読性も向上します。

GitHubにリポジトリを作成してありますので、気になった方はぜひ実際に試してみてください!

oneut/laravel-advent-calendar-2019

※アカウントにこだわりがないので、アカウント名はぶれています :upside_down:

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした