LoginSignup
34
24

More than 3 years have passed since last update.

個人的ベストプラクティスに基づいたWebフレームワークの構成を考えてみた

Last updated at Posted at 2018-12-18

この記事はNewsPicks Advent Calendar 2018の18日目の記事です。

NewsPicksでエンジニアをしている@pakkunです。
記事や広告の入稿システムの開発、メンテナンスを行なっています。

昨日は、@kz_moritaSwiftのEnumで見る代数的データ型ついてでした。いかがでしたでしょうか。

今日の記事については、Webフレームワークの構成について、書いていきたいと思います。

さっそくですが、システムというのは簡単に複雑化してしまいますよね。
私は、仕事をしているともっと簡単に実装できないものだろうかとよく悩みます。
そんな悩みの中で、やっぱりWebフレームワークの構成には特に悩みます。

というわけで、最近少し綺麗な構成を考えられるようになってきたので、今から実装するなら「こうしていきたい!」というのをつらつらと書いていきたいと思います。
まだ実装したコードではないので、ご注意ください。妄想です。年末年始にでも実装してみます!

本記事では、MVC、レイヤードアーキテクチャあたりを知っている前提で書いています。
※私も日々勉強中です!

インデックス

  • Webフレームワークでの悩みポイント
  • 構成の前提条件
  • 個人的に理想とする構成のディレクトリ
  • 個人的に理想とする構成のフロー
  • Webフレームワークの構成のまとめ
  • 実装するならこれだけは守って行きたいルール

上記内容で書いていきます。

Webフレームワークでの悩みポイント

では、さっそくですが、私がよく悩むポイントをまとめました。

  • DDDとIDDDがごっちゃになる・・・。
    • 自分の知識不足もありますが、何が正しいのかよく混乱します。
  • レイヤードアーキテクチャで実装していると、リポジトリがトランザクション境界なのに、なぜかアプリケーションサービスでトランザクションを行う。
    • IDDDがこういう実装していたはずだけど、違和感しかない・・・。
    • DDDでもリポジトリじゃない方が良いと書いてあったはず・・・。
  • アプリケーションサービスで複数のリポジトリを扱う
    • これもIDDDだったはずだが、リポジトリが集約を管理しているのであれば、複数のリポジトリを扱わなくていいのではないだろうか。
    • 現実世界ならハンバーガーセットという集約に、ハンバーガー、ジュース、ポテトという集約が入っていても違和感がないはず。
    • ビジネスロジックをアプリケーションサービスでごちゃごちゃしたくない
      • マイクロサービス化した際に地獄では・・・?
    • 腐敗防止層の仕事なんじゃないのだろうかと悩む・・・。

上記あたりで悩むので、フレームワーク構成や実装を模索してみました。

構成の前提条件

ただし、安易に模索してもしょうがないので、構成の前提条件を定義します。

  • ルーティング、ミドルウェア周りは、Webフレームワークの使用を想定。
    • いわゆるMVCあたりの構成だけを考えます。
  • データの永続化処理、モデルオブジェクトの作成するファクトリは、ORMに担当してもらうことを想定。
    • 特にファクトリを実装するのはかなり大変なので、集約としてルートモデルを簡単に作れるORMを使用します。
    • ただし、複数のデータリソースを考慮し、ORMはファクトリの一部として使用します。
  • レイヤードアーキテクチャベースで考えるといくつかの条件で制約が入りそうだったので、一度忘れて理想だけを追求。
    • ただし、参考にはしています。
  • シンプルに実装できることが目的。
  • あくまで構成の話。
    • 実装方法は多少違えど、Java、Golang、PHPなど言語に関係なく実装できるように考えます。
    • ディレクトリ構造は多少変わる可能性はあるのかもしれません。

個人的に理想とする構成のディレクトリ

構成の前提条件で考えた結果、私が理想とするディレクトリ構成は下記となりました。

+ app
    + requests        // リクエストクラスを管理
    + controllers     // コントローラークラスを管理
    + models          // モデルクラスを管理
        + mysql       // MySQLのモデルクラスを管理。モデルの抽象化は難しいのであえて定義。
        + api         // APIのモデルクラスを管理。
    + repositories    // データの取得、永続化を管理。
        + {name}      // テーブル名かApi名でディレクトリを作成。境界を作ります。
            + query   // データの取得、オブジェクトの生成を管理。
            + command // データの永続化を管理。
    + valueObjects    // 値オブジェクトを管理。
    + views           // Webならテンプレートファイルを管理
    + [json]          // APIの場合ならJson構造のクラスを管理

Repository周りがちょっと特殊になっていますが、よく見る構造になりました。

個人的に理想とするデータのフロー

スクリーンショット 2018-12-17 10.09.15.png

基本的には、レイヤードアーキテクチャライクになっています。
ただ、アプリケーションサービスは、下記の理由により、不要と判断しました。

  • トランザクションはトランザクション境界であるリポジトリ内で行いたい。
    • 複雑な画面構成にしない制約でもあります。
  • DBを更新したら、キャッシュの削除も行いたい。
    • キャッシュを管理するレイヤーとして残しても良かったんですが、キャッシュしているかを意識しない構造にできたので、不必要と考えました。

図の役割について簡単に説明していきます。

※サンプルのコードも書きますが、個人的に好きなPHP/Laravel風で書いていきます!
※NewsPicksのサーバサイドは、基本的にはJava、Kotlinです。PHP/Laravelは個人の趣味です。Javaだと想像で書くまでの技術力不足を感じて断念しました・・・。
※サンプルのコードは前段に記載した通り、実行まではしていないので、あくまで参考としてみてください。

Routing

Webフレームワークのルーティングなので、割愛します。
そういえば、図には書いていませんが、MiddlewareもRoutingに関連しますね。

Request

GETパラメータやPOSTパラメータをラップしたクラスです。配列やマップでは、振る舞いをメソッドとして定義できないので、オブジェクトにします。
Requestクラスでは、割とロジックが入ることが多いので、Requestクラスで下記のような処理を正規化をします。

  • カンマ区切りのデータを配列に変換する

本来であれば、クライアント側で、データを正規化しておけるのがベストです。
今回考えたフレームワークの構成としては、Requestクラスで細かい値のバリデーションは行いません。
ユニークチェックなど、DBに依存する処理をRequestクラス、Controllerクラスで扱いたくなかったので、Repository内の仕組みにバリデーションを持たせます。
フレームワークによっては、リクエストクラスを持つものもあるかと思いますが、もしサポートされていなければ、Controller内で、new StoreRequest($params)と定義しても良いです。
型のある言語によっては、数値、文字列などは、リクエストクラスで自動的に行われることもあるはずなので、そこは臨機応変に対応しましょう。

実装例としては下記のような感じになります。
※Laravelを使用すると、FormRequestに実装があるので、だいぶシンプルになります。


namespace App/Requests/Example;

class StoreRequest extends FormRequest {
    public getIdsFromCsv() {
        return split(',', $this->get('csv_ids'));
    }
}

Controller

Repositoryを使用してデータを取得し、ViewもしくはJsonで展開するだけです。
Repositoryについては、なかなか見ない構造になっていますので、後述で説明します。

簡単な例ではありますが、基本的には下記程度のコードに落ち着くと思います。


class UserController {
    // Dependency Injection
    public __construct(UserRepositoryInterface $userRepository) {
        $this->userRepository = $userRepository;
    }

    /**
     * 一覧画面
     */
    public index() {
        // UserRepositoryからPaginateQueryオブジェクトを使用し、データを取得
        // select()で、データの取得、モデルオブジェクトの生成まで行う
        $users = $this->userRepository->paginateQuery()->select();
        $data = [
            'users' => $users
        ];
        return view('user.index', $users);
    }

    /**
     * 新規作成画面
     */
    public create($id) {
        // UserRepositoryからFindQueryオブジェクトを使用し、データを取得
        // select()で、データの取得、モデルオブジェクトの生成まで行う
        $user = $this->userRepository->findQuery($id)->select();
        $data = [
            'user' => $user
        ];
        return view('user.edit', $users);
    }

    /**
     * 新規作成処理
     */
    public store(Request $request) {
        $params = $request->all();

        // UserRepositoryからCreateCommandオブジェクトを使用し、データの登録を行う
        // CreateCommandは、与えられたパラメータに対して、バリデーション機能を持つ。
        $createCommand = $this->userRepository->createCommand($params);
        $validator = $createCommand->getValidator();
        if ($validator->fails()) {
            return redirect()->action('UserController@create')
                        ->withErrors($validator)
                        ->withInput();
        }

        // バリデーションが通れば、データを登録
        $user_id = $createCommand($params)
            // トランザクション前後で処理が行いたい場合があるので、コールバックで定義できることを想定
            ->after(function ($user) {
                // 今回の例では、メール送信を想定
                $user->notify(new CreateUserMail());
            })
            // 永続化処理を行う
            ->dispatch()
            // CreateのみIDの取得を行う
            ->getId();
        // 保存が完了したらリダイレクト
        return redirect()->action('UserController@show', ['id' => $user_id]);
    }
}

12/19追記
PHPで考えていたので、リクエストクラス周りをかなり妥協していたが、オブジェクトで考えるのであれば、形はどうであれ、下記のような実装にした方が良い。

$prams = $storeRequest->getParams(); [PramsObject]
$this->userRepository()->setParams($params);

Repository/{resource_name}

レイヤードアーキテクチャを参考にデータの取得、永続化を目的として定義します。
理想を追った結果、リポジトリは、オブジェクト・パラメータ・キャッシュのタグ情報だけを管理している点です。実際の処理は、QueryクラスとCommandクラスで処理を行います。

このような実装にした理由は下記となります。

  • バリデーション処理を持たせるためには、オブジェクトにしたかった。
  • トランザクションの前後で処理をフックできるようにしておきたかった。

QueryクラスとCommandクラスについては、Bertrand Meyer氏のコマンドクエリ分離原則を参考に処理を明確に分割することにします。

  • Query: データ取得を管理するオブジェクト
  • Command: データ更新を管理するオブジェクト

また、Redisなどのコンテンツキャッシュをするため、キーとなるタグ名だけを管理します。
高機能なキャッシュライブラリが必要になるので、言語選択次第では、どこまで同じように実装できるかが不安なところでもありますが、もし、DB更新時にタグベースでキャッシュをクリアすることができれば、効率よくキャッシュが利用できます。
キャッシュは、100年にしても良いですが、アクセスの少ないデータであれば、無駄にメモリを消費するだけなので、1週間〜1ヶ月程度が妥当だと思います。

この説明では、Repositoryの実装だけを記載します。QueryクラスとCommandクラスについては次項目以降で説明します。

まず、インターフェイスの例を記載します。


namespace App/Repositories/User;

interface UserRepositoryInterface {
    public paginateQuery($params);
    public findQuery($id);
    public createCommand($params);
    public updateCommand($id, $params);
}

次に実装の例を記載します。実際は、AbstractClassでもっと最適化出来そうな気がします。


namespace App/Repositories/User;

class UserRepository extends Repository {
    private $cacheTag = 'users';

     // Dependency Injection
    public __construct(
        PaginateQuery paginateQuery,
        FindQuery findQuery,
        CreateCommand createCommand,
        UpdateCommand UpdateCommand
    ) {
        // Query
        $this->paginateQuery = paginateQuery;
        $this->findQuery = findQuery;

        // Command
        $this->createCommand = createCommand;
        $this->updateCommand = updateCommand;
    }

    public paginateQuery($params) {
        // データ取得時にキャッシュのタグ名を付与して、PaginateQueryオブジェクトを返します
        // ※キャッシュ中の関連するアイテムへタグ付けの機能を持つライブラリを使うことを前提としています
        // `setParams()`内で`return $this;`をしています
        return $this->paginateQuery->newInstance()->setCacheTag($this->cacheTag)->setParams($params);
    }

    public findQuery($id) {
        // データ取得時にキャッシュのタグ名を付与して、FindQueryオブジェクトを返します
        // ※キャッシュ中の関連するアイテムへタグ付けの機能を持つライブラリを使うことを前提としています
        // `setParams()`内で`return $this;`をしています
        return $this->findQuery->newInstance()->setCacheTag($this->cacheTag)->setParams($id);
    }

    public createCommand($params) {
        // データ更新時にキャッシュを削除できるようにタグ名を付与して、CreateCommandオブジェクトを返します
        return $this->createCommand->setCacheTag($this->cacheTag)->setParams($params);
    }

    public updateCommand($id, $params) {
        // データ更新時にキャッシュを削除できるようにタグ名を付与して、UpdateCommandオブジェクトを返します
        return $this->updateCommand->setCacheTag($this->cacheTag)->setParams($id, $params);
    }
}

Repository/{resource_name}/Query

データの取得、モデルオブジェクトの作成はこのディレクトリ内でQueryクラスを作成して実装します。
集約を管理するのが目的です。

DBから取得した値とAPIから取得した値を一つのモデルオブジェクトにしても良いです。
複数のルートモデル(集約)同士を一つのモデルで管理しても良いです。
ただし、その場合、管理用の親モデルを用意し、それぞれのルートモデル(集約)を親モデルのメンバ変数で参照を持つ程度を想定しています。

もし、データの取得元がDB、Dynamo、Mongoなどさまざまなリソースをし、ネストした構造でモデルを再構築する場合があるかもしれません。
その時は、マイクロサービス化も検討してみても良いのかもしれません。
そうすることにより、HTTPリクエストで、綺麗なデータが取得できるようになるかもしれません。
ただし、マイクロサービスまでを考慮して設計をしたことがないので、ちゃんと設計したいところではあります。

ORMを使用しているので、EagerLoadingもQueryクラスで解決します。

Queryのインターフェイスの例を記載します。


interface RepositoryQueryInterface {
    // インスタンスを新規に生成するメソッド
    public newInstance();
    // キャッシュのタグ名を管理するメソッド
    public setCacheTag();
    // 引数を管理を管理するメソッド
    public setParams();
    // バリデーターを管理するメソッド
    public getValidator();
    // バリデーションルールを管理するメソッド
    public rules();
    // クエリの実行、ファクトリの生成を管理するメソッド
    public select();
}

実装の例を記載します。本当はAbstractClassを用意してsetCacheTag()あたりは、そちらに定義した方が良いです。


namespace App/Repositories/User/Query;

class PaginateQuery extends AbstractRepositoryQuery implements RepositoryQueryInterface {
    private $model = null;

    private $params = $params;

       // Dependency Injection
    public __construct(User $user) {
        $this->model = $user;
    }

    public newInstance() {
        // Dependency Injectionを活用しつつ、オブジェクトを新規に作成させるために実装
        // この場合のモデルは、SQLのクエリビルダー、ファクトリとしての機能しかしていないので、再利用している。
        return new PaginateQuery($this->model);
    }

    public setCacheTag(cacheTag) {
        $this->cacheTag = cacheTag;
        return $this;
    }

    public setParams($params) {
        $this->params = $params;
        return $this;
    }

    public rules() {
        return [
            'name' => 'string',
        ];
    }

    public getValidator() {
        // バリデーションオブジェクトの生成
        return Validator::make($this->params, $this->rules());
    }

    public select() {
        $title = $this->params['title'];

        // キャッシュ自体のキーも発行します。
        // AbstractRepositoryQueryで`createSerializedKey()`を実装してあることを想定しています。
        $key = $this->createSerializedKey(__CLASS__, __METHOD__, $params, $this->cacheTag);

        // ここでキャッシュのタグを使用してキャッシュを行います。
        return Cache::tags($this->cacheTag)->remember($key, $cacheMinutes, function() {
            return $this->model
                // EagerLoadingも考慮する必要がある場合は、ここで対応します。
                ->with('profile')
                // if文を書きたくないので、Laravelならスコープクエリ内で値がなかったら`whereを定義しない`にして、早期リターンで実装しておくのが個人的に好きです。
                ->likeName($name) 
                ->paginate();
        });
    }
}

Repository/{resource_name}/Command

新規作成や更新、削除などの永続化処理はこのディレクトリ内でCommandクラスを作成して実装します。
トランザクション前後で下記のようなコールバックを入れたい時がたまにあるので、その機能も提供します。

  • メール送信に失敗したらロールバックをしたい
    • 本当はメール送信フラグをテーブルで定義するか、SQSのようなキューサービスを使用して、メール専用のバッチで対応すべきですが、簡易的にでも実装できるように一応考慮しておきます。
    • 正直、メール送信のためだけに、バッチ実装をするのはめんどくさいんですよね・・・。

CreateCommand(新規作成)クラスのみ、唯一IDを管理します。update/deleteに関してはIDは管理しません。

Commandのインターフェイスの例を記載します。


interface RepositoryCommandInterface {
    // インスタンスを新規に生成するメソッド
    public newInstance();
    // キャッシュのタグ名を管理するメソッド
    public setCacheTag();
    // 引数を管理を管理するメソッド
    public setParams();
    // バリデーターを管理するメソッド
    public getValidator();
    // バリデーションルールを管理するメソッド
    public rules();
    // SQLを実行し、データの永続化を行うメソッド
    public dispatch();
    // 新規作成時のみIDを返します。AbstractCreateCommandを作った方が良い気がする・・・。
    public getId();
}

新規作成のCreateCommandクラスで実装の例を記載します。実際にはかなり最適化ができるクラスですが、想像なのでかなり雑です・・・。


namespace App/Repositories/User/Command;

class CreateCommand implements Command {
    private $model = null;
    private $params = $params;
    private $beforeCallback = null;
    private $afterCallback = null;
    private $id = 0;

    // Dependency Injection
    public __construct(User $user) {
        $this->model = $user;
    }

    // AbstractClassで定義しておきたい
    public setCacheTag($cacheTag) {
        $this->cacheTag = $cacheTag;
    }

    public setParams($id, $params) {
        $this->id = $id;
        $this->params = $params;
        return $this;
    }

    public getValidator() {
        return Validator::make($this->params, $this->rules());
    }

    public rules() {
        return [
            'name' => 'string|required',
        ];
    }

    // AbstractClassで定義しておきたい
    public before(callable $callback) {
        $this->beforeCallback = $callback;
    }

    // AbstractClassで定義しておきたい
    public after(callable $callback) {
        $this->beforeCallback = $callback;
    }

    public dispatch() {
        // タグに紐づくキャッシュの削除
        // AbstractClassで定義しておきたい
        Cache::tags($this->cacheTag)->flush();

        // 集約をトランザクション境界とし、保存処理を行う。
        DB::transaction(function () {
            // SQL実行前のイベントフック
            $this->before();

            // 保存処理
            $user = $this->user->newInstance();
            $user->name = $this->params['name'];
            $user->save();

            // DB内の値で再生成する。
            // トランザクションの分離レベルの仕様を忘れた・・・。あとで一応調べる。
            $savedUser = $this->model->find($user->id);

            // SQL実行後のイベントフック
            // このコールバック内で例外が起きれば、ロールバックできるようにしておけるとGood
            $this->after($savedUser);

            // 新規作成の場合だけ、IDを管理
            $this->id = $savedUser->id;
        });
    }

    public getId() {
        // 新規作成の時だけIDを返す
        return $this->id;
    }
}

Model/db

ORMを使用します。
集約を考えた際にファクトリー相当の処理を管理するのは大変なので、個人的にはORMを使用した方が楽だと思っています。

  • N+1問題が起きやすくなる。
  • クラスオブジェクトへのマッピングが大変。
  • テーブル構造とオブジェクト構造が一致しづらくなる

    • 個人的にテーブル構造とオブジェクト構造の透明性が欲しい
    • よくORMで複雑なSQLが書けないと言われることがありますが、どれだけみんなが複雑なSQLを発行しているのか気になる・・・。
  • ORMを使用しない場合は、Factory、DAO、SQLのクエリビルダーあたりが必要になると思います。

Model/DBの実装例を記載します。
※Laravelで実装するとほとんど定義がいらないので、振る舞いだけ実装しています。


class User extends Model {
    public getFullName() {
        return $this->getAttributes('last_name') . ' ' . $this->getAttributes($this->full_name);
    }
}

地図の位置をモデルとして定義した場合、拠点間の距離をオブジェクトで表現することも容易になります。
積極的にメソッドを使っていきましょう!


class MapPoint extends Model {
    public distance(mapPoint) {
        // 計算用のオブジェクトを生成
        return new Distance($this, mapPoint);
    }
}

$distance = $mapPoint->distance($otherPoint);
$distance->route(); // ルート
$distance->time(); // 時間

Model/api

リソースから取得した情報であれば、モデルにするように心がけたいので、HTTP Requestを使用したREST APIもモデルとして定義します。
REST APIの場合、ORM相当のライブラリがない場合があるので、自作する可能性がありますが、データは最適化されている場合が多いので、個人的にはあまり頑張らなくて良い気がしています。
レスポンス結果にネストされたリレーション情報が存在していれば、N+1問題も起きないので、可能な限り、集約で提供できるようにしておきたいと考えています。


namespace App/Model/GitHub;

class UserApi {
    private $attributes = [];

    // Dependency Injectionできるようにオブジェクト生成
    public __construct($attributes = []) {
        $this->initAttributes($attributes);
    }

    public initAttributes($attributes) {
        if (empty($attributes)) {
            return;
        }

        // オブジェクトにマッピング
        $this->attributes = [
            "id": $attributes['id'],
            "node_id": $attributes['node_id'],
            "name": $attributes['name'],
            "full_name": $attributes['full_name'],
            "private": $attributes['private'],
            // リレーションも生成
                        // コンストラクタで作るのを嫌がる人もいるかも。
            "owner": Owner::newInstance($attributes['owner'])
        ];
    }

    public static newInstance($attributes) {
        return new UserApi($attributes);
    }

    public get() {
        $client = new GuzzleHttp\Client();
        $response = $client->request('GET', 'https://api.github.com/repositories', [
            'auth' => ['user', 'pass']
        ]);

        return new Collection(array_map(function($attributes) {
            return UserApi::newInstance($attributes); 
        }, $response));
    }
}

Value Object

一意な値を持たないオブジェクトです。
一番例としてわかりやすいので、DateTimeオブジェクトになります。
日付というテキストデータに振る舞いを簡単に実装することができ、これがValue Objectです。

型のある言語であれば、モデルのメンバ変数はすべてValue Objectに定義できた方がベターです。
※12/19追記: Value ObjectにしようとするとORMとは別にモデル生成のレイヤーが必要になるので、もう少し複雑になる

基本的にはコンストラクタだけで値を受け取り、副作用がないようにします。

コンストラクタに複数の引数を持つ場合でも、Value Objectとして管理したいと思っていますが、別の名称があるなら、それでディレクトリを分割したいと思っています。

拠点間の距離をValue Objectで表現する場合で、実装の例を記載します。


class Distance {
    public __construct($mapPoint, $otherMapPoint) {
        $this->mapPoint = $mapPoint;
        $this->otherMapPoint = $otherMapPoint
    }

    public route() {
        // 2拠点間のルートの計算処理
        return $route;
    }

    public time() {
        // 2拠点間の時間の計算処理
        return $time;
    }
}

オブジェクトってとても便利に見えますね!!

Webフレームワークの構成のまとめ

想像上の構成ですが、割と綺麗な構成になっていると思います。
自分で言うのもなんですが、効率よくキャッシュも行えるので、これが実装できれば、かなり良い設計ではないでしょうか!!

この構成案だと、Repositoryとして定義したクラスに、ApplicationService感が出てしまったのが微妙ですが、この構成の方が理解がしやすいと思っています。

構成だけだと、どうしても秩序が保ちにくくなるので、実装するならこれだけは守って行きたいルールとして、普段から実装している上で、個人的に気をつけたい点も記載します。

実装するならこれだけは守って行きたいルール

URLはリソースフルであってほしい

これによって集約が守りやすいです。
Laravelに強く影響を受けたのでLaravelライクにしてありますが、秩序が保てるなら、臨機応変で良いです。

UserControllerの例

フルスタックWebフレームワークなら、Post/Redirect/Getパターンにしておくと良いです。
API開発だとどっちでも良い気がするけど、ここについてはまだ経験が不足しているので、気になるところ。

個人的に言えば、Command(更新)で値が帰ってくるのは正直気持ち悪いです。
例えるなら、SQLでInsert(Command)したらSelect(Query)されるのと同じ違和感があります。
今回の構成の中で、Repositoryの新規作成処理だけは、idをreturnする処理を入れているのは、特例です・・・。

役割 Method Path Controller View表示かリダイレクトか
一覧画面 GET /user UserController::index() Query(取得)なのでViewを表示
詳細画面 GET /user/1 UserController::show() Query(取得)なのでViewを表示
新規作成画面 GET /user/create UserController::create() Query(取得)なのでViewを表示
更新画面 GET /user/1/edit UserController::edit() Query(取得)なのでViewを表示
新規作成処理 POST /user UserController::store() Command(更新)なのでリダイレクト
更新処理 PATCH /user/1 UserController::update() Command(更新)なのでリダイレクト
削除処理 DELETE /user/1 UserController::delete() Command(更新)なのでリダイレクト
UserProfleControllerの例(従属情報の例)

ユーザーのプロフィールは、最初から存在している必要がないデータとした場合は下記の構成にできると良いです。
気をつけるのは、PUTかPATCHの使い分けです。PUTはリソースの置き換え、PATCHはリソースの更新と覚えておきましょう。
もう少しわかりやすくいうなら、PUT=Update Or CreatePATCH=Updateになります。

役割 Method Path Controller View表示かリダイレクトか
編集画面 GET /user/1/profile/edit UserProfileController::edit() Query(取得)なのでView表示
更新処理 PUT /user/1/profile UserProfileController::update() Command(更新)なのでリダイレクト

従属テーブルの場合、詳細画面は、UserController::show()側で情報が表示されているはずなので、特に必要がないはずです。
更新後のリダイレクト先は、UserController::show()になるはずです。

UserControllerに対しての詳細画面を作るか作らないかの判断

基本的には作った方がメリットが多いので、作る方が良いです。下記の要件があるなら作りましょう。

  • 編集画面でinput:text、textareaで値が全て見えない
    • 詳細ページを用意することにより、値がすべて見れるようになるので便利です。
  • 状態を管理する必要がある。
    • 承認申請のフローがある場合などは、値が更新されないようにロックしなければいけない場合など、考えることが多くなるので作った方が管理がしやすくなります。
    • 権限によって「編集はさせたくないけど詳細は見せたい」なども、詳細ページがあると管理がしやすくなります。
  • 従属テーブルが存在する
    • 編集画面から別の画面の編集にリンクしているUIの場合、編集中のデータが消えるか、中途半端に保存するなど、よくわからない設計が必要になります。

詳細画面がいらない場合は下記になります。

  • input:text、textareaで値が全て見える。
  • 状態を管理する必要がない場合
  • 権限による閲覧条件がない場合
  • 従属テーブルが存在しない場合
  • 従属テーブルが存在するが、複数画面に分割されない場合
    • UserController@createなどで全ての情報が作成できる
    • JavaScriptで、ネストした情報、複数の項目を管理できる

詳細画面がいらない条件を満たすサンプルとしては、JavaScript界隈のTodoMVCです。
ここまでシンプルなものは、なかなかないので、画面構成にも悩むことは多い気がします。

View表示時は、1Controller、NRepositoryでも良い

ダッシュボードのような場合もあるので、複数のリポジトリを参照しても良いです。

ただし、1Repositoryに対しては、1Modelにしておきたいです。

集約を守る限り、更新時は、1Controller、1Repository、1Model(ルート)にする

ユーザーのプロフィールを更新したいなら、集約を意識して下記のように実装しましょう。


$this->userRepository
    ->updateProfileCommand() // 集約を意識している
    ->setParams($user_id, $profileParams);
    ->dispatch();

Repositoryの戻り値の定義の明確化

Repositoryの戻り値に関しては、プロジェクトのはじめに定義しておけると良いです。

今回のQueryクラス、Commandクラスに対して定義してみます。
基本的にLaravelのクエリ実行メソッドがベースです。

Model = 単一オブジェクト
Collection = 複数モデルを扱うコレクションクラス

Class Name Parameters Return Value 備考
PaginateQuery 検索条件 Collection Collectionベースの
Paginatorクラスを想定
AllQuery - Collection
GetQuery 検索条件 Collection
FindQuery ID Model
FindManyQuery IDs Collection
First ID以外の
ユニークになる検索条件
Model   
CreateCommand 作成情報 ID 新規作成時のみIDを返します
UpdateCommand 更新情報 -
DeleteCommand 削除情報 -
AddHogeCommand Hogeの追加情報 - 従属テーブルの更新情報です。
集約を守ります。
AddManyHogeCommand Hogeの
複数の追加情報
- 従属テーブルの複数の更新情報です。
集約を守ります。
UpdateHogeCommand (場合によってはID),
Hogeの更新情報
- 従属テーブルの削除情報です。
集約を守ります。
複数データのうち、特定のデータを更新したい場合はIDが指定されています。
RemoveHogeCommand 単数の場合であれば無し、
複数のうちから削除の場合であればID
- 従属テーブルの削除情報です。
集約を守ります。
複数データのうち、特定のデータを削除したい場合はIDを指定します。

個人的に意識しているのは、特定の値だけを返す戻り値は用意しません。モデルをしっかりと渡しましょう。

自分たちで明確に定義できれば、実際の命名はなんでも良いです。ただ、このようにプロジェクトはじめに定義しておくことにより、戻り値が明確になります。
RepositoryやApplication Serviceのメソッドでも同様のことが言えますので、ぜひ定義してみてください。

// Application Serviceだと下記のようなイメージです。
public get(): Collection;
public find(): Model;

ちなみにですが、下記のように特定の取得条件を命名に入れるのもありです。


// 今回の例
class GetByTypeQuery { 
    // 省略
}

// メソッドで表現するなら
public findByType($type);

モデルに振る舞いを持たせる

振る舞い(メソッド)のないオブジェクトを用意すると、ドメインモデル貧血症に陥ります!
オブジェクトというとても便利なものがあるので有効活用しましょう!


User::getFullName(): string
// return $last_name + ' ' + $first_name

MapPoint::distance($mapPoint): Distance
// return new Distance($this, $mapPoint);

privateメソッドを可能な限り使わない

個人的に一番気をつけている点です。
おそらく、集約が守られていないか、オブジェクトの構成に失敗していることが多いはずです。
privateメソッドで定義したメソッドは、そのクラスにあるべきメソッドではないです。
そのprivateメソッドは、きっと他のクラスでも必要になることでしょう。

ControllerやApplicationServiceで、Modelを別のオブジェクトに変換しない

せっかく集約という概念を使っているなら、ControllerやApplicationServiceで、ルートモデルの変更はやめましょう。
個人経験には、それはRepositoryの仕事であって欲しいです。

メソッドは、オブジェクトの伝言ゲームではないはずです。

永続化した後のモデルは、再利用せずにDBから再取得する

副作用の起きたオブジェクトは破棄しましょう。
過剰ではありますが、正しいフローが生まれます。


echo $user->name;
// タロウ

$user->name = "イチロウ";
$user->save();

echo $user->name;
// イチロウ

コストは高いですが、個人的には下記がベストです。


echo $user->name;
// タロウ

$user->name = "イチロウ";
$user->save();

$updatedUser = $user->find($user->id);
echo $updatedUser->name;
// イチロウ

どうしてここまでこだわるのかというと、副作用の起きたモデルには何が起きているのかわかりません。
データを参照するために取得するモデルはファクトリからの生成されたモデルだけにしておくべきだと思っています。
というわけで、DBが更新されたら、すぐにリダイレクトをしましょう。

Stateに副作用が発生したら、Viewが破棄されて再レンダリングされるVirtual DOMと同じ状態を目指しています。
Post/Redirect/Getパターンもオブジェクトを再利用せずに再レンダリングをすることにより、DBに正しく保存されたことが確認できます。
Wikipediaを見ると、POSTの再送信が防げるのがメリットって書いてありますが、自分としてはこのフローの構築ができることにかなりの魅力を感じています。

ただ、速度を重視する場合もあるはずなので、臨機応変で対応しましょう。
オブジェクト指向を選ぶのであれば、意識しておいても良いと思います。

Query(取得)処理でモデルに副作用を発生させない

前項目の逆なので、本来は説明が不要ですが、大事なことなので、説明しておきます。

Repositoryから提供されたモデルは完成されたモデルです。副作用を起こさないようにしましょう。
SQLでSelectした結果が途中で別の結果に変わっていたら驚きませんか?

もしあなたがウェイターで、ハンバーグを運んだとします。お客さんに渡した時には、ミートボールになっていたら、お客さんはなんて言うでしょうか。
シェフ(Facotory)もまさかミートボールになっているとは思わないでしょう。
もし、そのミートボールに問題があり、食中毒が起きたら、誰の責任になるのでしょうか。
シェフの責任?ウェイターの責任?
また、ミートボールが評判になり、メニューに並ぶかもしれません。
ただ、シェフはそれを知らずにハンバーグを作っていることでしょう。
なぜなら、ウェイターが運んでいる途中で、ミートボールになっているのですから。
というわけで、個人的には責任の所在が不明になるので、ファクトリ以外でモデルを構築するのは、やめましょう。
責務が分離します。

変数名の省略

省略するとおそらく似た変数名が出来たときに混乱の元になります。
言語ごとにベースはあるはずなので、郷に入れば郷に従えでいいと思いますが、正しく変数名をつけましょう。

日付のカラム名

私はモダンなWebフレームワークで採用されているcreated_at、updated_atなどのような、カラム名のつけ方がとても好きなので、下記のように日付のカラム名には、Suffixをつけることをお薦めします。
受動態には別にしなくても良いと思います。

  • datetime: hoge_on
  • timestamp: hoge_at

※最近知りましたが、この書き方はRails公式の情報ではなく、リンク先の情報が広がったためのようです。

セキュアなカラム名

パスワードや個人情報とすぐにわかるデータ以外には、secure_というプリフィックスがあると管理が楽なので、お薦めです。
prefixベースで簡単にマスキングできるようになります。

secure_hoge

昔某社のスライドで見たんですが、スライドを見つけられず、リンクを記載したかった・・・。

軽量フレームワークとフルスタックフレームワークの選択

個人の経験ですが、下記で選択すると良いです。

  • APIに特化しているなら軽量フレームワーク
  • HTMLレンダリングするならフルスタックフレームワーク
    • バリデーションエラーで失敗した際に入力画面に入力済みの表示ができる

バリデーションエラーでの入力欄へのデータ復帰を実装しようとすると、結構めんどくさいですし、実装したら、最終的には同じようなフルスタックフレームワークができていると思います。
逆にフルスタックフレームワークを謳っていて、セッションを使用したデータ復帰がない場合、個人的に微妙な気がします。
フレームワーク構築が目的でないなら、楽をしていきましょう!

Constansだけを管理するファイルは不要

必要ないです。

たとえば、一覧ページで10件表示であれば、そのControllerの定数で表現した方が良いです。
無理にConstantsに定義するよりもずっと理解がしやすくなります。


class ExampleController {
    const LIMIT = 10;
}

もし共通化したいなら、AbstractClassに定義しても良いでしょう。
※定数のオーバーライドがちょっと怪しい気もするがたぶんできるはず・・・。

今回の例では出しませんでしたが、モデルに定数を書くことが一番多いと思います。
モデルで定数を定義しておくことにより、情報の所属が明確になるのでオススメです。
Constantsファイルを作ってもおそらく管理しきれないですし、情報の所属も明確になりません。
もしConstantsファイルがあるなら、すでに同じ意味の定数ができていたりしませんか?

オブジェクト指向の弱点を知っておく

オブジェクトは、データ構造を表すので、データをならす処理には向いていません。つまり、集計や計算には弱いです。
カオス化するのは大抵集計、計算周りの処理になるので、そういう処理は、下記の二つでなんとかしましょう。

  • バッチで計算を行い、集計した結果をDBに保存する
  • DBのViewを作る
    • お手軽にテーブル構造ができるので楽
    • ORMでモデルにしても良い
      • 多少複雑にはなるが、ORMならViewテーブルをリレーションすることも簡単です。

以上が、個人的に最初から実装するなら守りたいルールになります。

最後に

構成案を考えるにあたり、いろいろと思いが巡り、かなりの長文になりました・・・。
ここに記載した内容を念頭に置いて実装できれば、それなりに秩序を持ったソースコードにできるのではないかと思います。
ただ、妄想です。実際に実装したら何か不満点は出るかもしれません。もし出なかったら最高のWebフレームワークの構成と言えるのかもしれません。

マサカリや疑問点等があればコメントをお願いします。

明日は、@hoshi_koukiの「元SI案件従事者が NewsPicks でハッとさせられた5つのこと」です。
お楽しみに!

34
24
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
34
24