akippaで採用しているLaravelのデザインパターンのお話:後編
akippaのリードエンジニア村上です。
akippaで採用しているLaravelのデザインパターンのお話:前編の続きを書かせていただきたいと思います。
前編では、akippaで採用しているLaravelのデザインパターンの概要をお話させていただきましたが、今回の後編では、サンプルコードを用いて説明させていただきたいと思います。
※先日、同じ内容の記事を公開したのですが、公開の仕方を誤ってしまったので、記事を作り直したものになります。
サンプルコードの概要
実際に最近開発したもので、サンプルに適切と思える機能があったので、それを採用したいと思います。
ざっくりとした要件は以下です。
akippaの検索ページの検索テキストフィールドに、サジェスト(Google検索のように、入力した内容に応じて検索候補がずらずら出てくるやつ)を取り入れたい。
検索候補は、akippaの検索ページにて実際に過去に検索されたキーワードから抽出したい。
こんな感じです。
上記を実現するにあたり、検索画面でサジェスト候補を読み込ませる事とし、画面側から読み込んでもらうサジェスト候補を出力するバッチの開発を決定しました。
このバッチは、以下のようなユースケースを実行します。
検索キーワードが格納されたログにアクセスする。
そのログを元に、サジェスト候補となるキーワードを保存する。
ここでは敢えて、検索キーワードが格納されたログとはDBなのかそれともアクセスログなのか、とか、キーワードを保存するとは、DBなのかファイルなのかなど、具体的な技術詳細の表現は省いています。
じゃあ、コード書いてみますか。
インタフェース
先程、敢えて具体的な技術詳細の表現は省いたと言いましたが、まさにそのとおり、まずはインタフェースの定義だけしてみます。
まず、「検索キーワードが格納されたログにアクセスする」ためのインタフェースは以下とします。
<?php
namespace UseCase\Ports;
use Carbon\Carbon;
interface GetSearchKeywordQueryPort
{
public function find(Carbon $today): array;
}
引数に日付を与えると、その日に検索で使われたキーワードが取れるというメソッドの定義ですね。
次に「サジェスト候補となるキーワードを保存する」ためのインタフェースは以下とします。
<?php
namespace UseCase\Ports;
interface UploadKeywordSuggestPort
{
public function store(array $keywords): void;
}
引数として受けたキーワード配列を、何かに保存するというメソッドの定義です。
では、これらのインタフェースを使って、ユースケースを書いてみます。
ユースケース
<?php
use UseCase\Ports\GetSearchKeywordQueryPort;
use UseCase\Ports\UploadKeywordSuggestPort;
use Carbon\Carbon;
final class OutPutSuggestKeyWord
{
const NG_KEYWORD_LIST = ["NGワード1", "NGワード2"];
/**
* @var GetSearchKeywordQueryPort
*/
private $queryPort;
/**
* @var UploadKeywordSuggestPort
*/
private $uploadPort;
public function __construct(
GetSearchKeywordQueryPort $queryPort,
UploadKeywordSuggestPort $uploadPort
) {
$this->queryPort = $queryPort;
$this->uploadPort = $uploadPort;
}
/**
* @param Carbon $today
* @return void
*/
public function execute(Carbon $today): void
{
$searchKeyWords = $this->queryPort->find($today);
$suggestKeyWords = [];
foreach ($searchKeyWords as $keyWord) {
if (array_search($keyWord, self::NG_KEYWORD_LIST) === false) {
$suggestKeyWords[] = $keyWord;
}
}
$this->uploadPort->store($suggestKeyWords);
}
}
さきほど定義したインタフェースを使って、ユースケースを書きました。
「検索キーワードが格納されたログにアクセスする」メソッドで検索キーワードを取り、サジェスト候補として不適切なキーワードを除外した結果を「サジェスト候補となるキーワードを保存する」メソッドに渡しているだけです。
このユースケースが、純粋なビジネスロジックとなります。
どこ(DB?アクセスログ?)から対象を取得し、どこ(DB?ファイル?)に保存するのか!?という技術詳細に一切触れていないという事がわかるかと思います。
アダプター
ここまでで、ユースケースはできあがったものの、現時点では動きません。インタフェース(抽象)を実装した技術詳細への接続(具象)が必要です。
「検索キーワードが格納されたログにアクセスする」ですが、いくつかの手段があります。アクセスログから取得するか?アクセスログであっても、S3に置かれているテキストファイルを読むか?Athena経由で取るか?などです。弊社では元々、別用途で検索キーワードをDBに保存してたので、そこから取ることとします。
では実装!
<?php
namespace App\Adapters;
use UseCase\Ports\GetSearchKeywordQueryPort;
use App\Eloquent\EloquentSearchKeywordsLogs;
use Carbon\Carbon;
class GetSuggestKeywordQueryAdapter implements GetSearchKeywordQueryPort
{
public function find(Carbon $today): array
{
$query = EloquentSearchKeywordsLogs::query()
->select(['keyword'])
->where('searched_at', '=', $today->toDateString())
->groupBy(['keyword']);
return $query->get()->toArray();
}
}
LaravelフレームワークのEloquentモデルというDBからデータ取るときに用いる機能を使っています。
DBに格納されているキーワードから、引数で指定された日付のデータを取得しています。
次に「サジェスト候補となるキーワードを保存する」をどうするか考えます。DBに保存するのか、ファイルなのか、ファイルとしたら、どこにどんな形式で保存するのか…ですね。
今回は、AWSのS3に、CSV形式にして保存する、としましょう。
では実装!
<?php
namespace App\Adapters;
use UseCase\Ports\UploadKeywordSuggestPort;
use App\Services\Common\S3ClientService;
class UploadS3KeywordSuggestAdapter implements UploadKeywordSuggestPort
{
private const FILENAME = 'suggest_keyword.txt';
public function store(array $keywords): void
{
$contents = implode(",", $keywords);
$realFilePath = storage_path('app/tmp/suggest/suggest_keyword.txt');
file_put_contents($realFilePath, $contents);
$s3ClientService = new S3ClientService();
$response = $s3ClientService->putFileAs($realFilePath);
}
}
配列で受け取ったキーワードを、CSV形式にしてS3へアップロードする、という事をしています。(S3へのアップロード処理の詳細は割愛&あくまで実装のイメージを伝えたいだけなので、コードは適当!)
これで、本来必要な処理である、インタフェースを実装したアダプターが出来上がりました。
DI(依存性注入)
それでは、作ったアダプターを、ぜひユースケースに使ってもらいましょう。
<?php
namespace App\Providers\UseCases;
use UseCase\OutPutSuggestKeyWord;
use App\Adapters\GetSuggestKeywordQueryAdapter;
use App\Adapters\UploadS3KeywordSuggestAdapter;
use Illuminate\Contracts\Container\Container;
use Illuminate\Support\ServiceProvider;
class SuggestKeywordPackageServiceProvider extends ServiceProvider
{
public function boot()
{
//
}
public function register()
{
$this->app->bind(OutPutSuggestKeyWord::class, function (Container $app) {
return new OutPutSuggestKeyWord(
$app->make(GetSuggestKeywordQueryAdapter::class),
$app->make(UploadS3KeywordSuggestAdapter::class)
);
});
}
}
LaravelにはDI(依存性注入)の仕組みがあって、それを活用します。
上記のような感じで、作成したユースケース(OutPutSuggestKeyWord)に対して、インタフェースを実装したアダプターを注入しています。
こうする事で、ユースケースを呼び出したときに、今回実装したアダプターが紐付いてくれるんですね。(これ読み込ませるためにその他細かい設定など必要ですが、ここでは割愛します。)
このデザインパターンの何がうれしいの?
ここまで、akippaが採用しているデザインパターンのサンプルコードをご覧いただきましたが、面倒くさいですよね。
何がうれしいんでしょう?
プログラムは、変更のしやすさが大事だ!と、僕は前編で記載しました。
今回は、サジェストの候補となるキーワードをDBから取得して、それをS3へ保存する仕様としましたが、今後、状況に応じて、採用する技術詳細が変わるかも知れません。
例えば、キーワードの取得は、ALBのアクセスログをAthena経由で取得する事にして、サジェストの一覧はDBに保存する事にしよう!って変わるとします。
もし、ビジネスロジック(ユースケース)と、技術詳細が同じプログラム内で書かれていたら、ビジネスロジックの修正が必要になってしまいます。
手段が変わっただけで「検索キーワードが格納されたログにアクセスし、そのログを元に、サジェスト候補となるキーワードを保存する」という要件には変わりないのに。
このデザインパターンを採用していると、採用する技術詳細が変わった場合は、今回用意したインタフェースを実装したアダプターを新しく作って、DI(依存性注入)でその新しいアダプターを挿し直すだけになります。
つまり、ユースケースのプログラムは一切の変更をせずに済みます。
また、今後Laravelフレームワークを使い続けるかどうかもわかりません。
インタフェースやユースケースのコードを見ていただくと、実は一切Laravelフレームワークの機能を使っていない事にお気づきでしょうか。
新しいフレームワークを採用する事となった場合、そのフレームワークで提供されているDB接続機能を使って同じようにアダプターを実装するだけとなり、この場合でもユースケースは変更しなくてよいのです。
という事で、2回に渡り、akippaで採用しているLaravelのデザインパターンのお話をさせていただきました。
前編でも紹介させていただきましたが、このデザインパターンは以下記事で掲載されているものを採用させて頂いております。
以下のドメイン駆動設計入門の書籍で取り上げられているサンプルコードも近しい感じで、すごくわかりやすくてオススメです!
今回は以上です。