はじめに
UI要件とリソース設計
Webサービスを運営していると、立ち上げ当初は想定していなかった要件により、アプリケーション設計をうまく組み直さないと歪んだ設計のまま大きくなってしまうことがよくあります。
立ち上げ当初はリソースベースの設計
Webアプリケーションフレームワークはリソースベースにアプリケーションを組むことを想定していることが多く、これに則って開発するのが効率的です。
フレームワークがリソースベースのControllerを生成するコマンドを用意していることもあります。
php artisan make:controller PhotoController --resource
参考: Resource Controllers - Laravel
特定の検索条件で絞り込んだリソース一覧ページをSearchEngineにインデックスさせたい
サービスを運営していくとオーガニックサーチユーザーにアプローチする施策を行いたくなったりします。サービスが持っているデータの中でニーズの多い条件で絞り込んだ一覧ページをランディングページとして作り、より多くのユーザーが求める情報にたどり着けるようにすることも大事なサービス価値です。
「検索結果」はリソースに対して一覧取得する際、クエリパラメータで絞り込む条件を指定することで表現します。上記PhotoController
であれば/photos?category=dog
のように。
しかしこのままインデックスさせようとすると、パラメータの組み合わせが無尽蔵に増えたり、パラメータの順番が違うだけの検索結果ページが生まれてしまいます。インデックスさせるページはできる限りシンプルかつ人間に分かりやすいURLにすることが重要です。
参考: シンプルな URL 構造を維持する
ここで1つのリソースに対して複数のURLが割り当てられる状態が発生します。
リソースだけをコントロールしなくなったController
PhotoController
はPhotoModel
を用いてデータ取得してView
を返すだけだったはずが、複数のURLでも同じ検索処理を行い、URLごとに検索パラメータを組み立て、絞り込みパラメータに応じたタイトルやディスクリプションなどのメタ情報を生成する処理が挟み込まれるようになりました。
さらにUI要件を動的に変更できるようにしたいとUI用のモデルが生まれ、リソースのモデルと混ざり合うとPhotoController
は一気に神々しさを帯びてきます。
このようになる前に
UI用のアプリケーションとリソース用のアプリケーションを分離する
マイクロサービス化してリソースベースのアプリケーションとUI要件をBFF(Backends For Frontends)に処理させるという分離案です。新規でサービスを作るならリソースとUI要件を分離できるこの形にしておくのが無難でしょう。
すでにこのようになってしまっていたら
すでに出来上がっているモノリシックなアプリケーションをいきなりマイクロサービス化するというのはコストがかかるので、現状のインフラ構成のまま分離する方法を考える必要があります。
案1. リソースをControllerから1つ奥のレイヤーに追い出す
PhotoResource
のようなリソースを提供するレイヤーを作成し、Controllerはページ(URL)に密な状態として必要なリソースを使うだけにします。
案2. UI要件をControllerの外側に分離する
Controllerが肥大化しすぎていたり、Controllerと複数のModelが複雑に依存関係を持っている状態では、案1のようなアプリケーションの全改修となるレイヤー分割も難しくなってきます。(もちろんいずれは全改修しないといけないのですが)
そこで、現状のリソースベースのアプリケーションを維持したまま、UI要件をControllerの外側に実装する方法です。LaravelであればMiddleware、CakePHPやYiiであればFilterという、Controllerの前後で処理を実行する機構が用意されています。
「UI要件をControllerの外側に分離する」を実装してみる
だいぶ前置きが長くなってしまいましたが、ここからが本題です。
URL付き検索条件ページを生成する際、Controllerからリソースに関係ない情報を取り除き、MiddlewareでUI要件を処理している状態が今回の完成形です。
Before
After
ページごとに異なるパラメータを定義ファイルで管理する
path: "/dog"
query:
category: "dog"
view:
title: "犬の写真特集"
description: "犬の写真を探すことができます。"
path: "/dog/top-10"
query:
category: "dog"
rank: "asc"
limit: 10
view:
title: "人気の犬の写真トップ10"
description: "人気投票でトップ10位以内に輝いた写真です。"
この定義ファイルを追加していくことでランディングページを作ることができるようにします。
定義フォーマットは何でも良いですが、個人的に見やすいのでyamlにしました。
1ページを1ファイルで表現し、パス階層構造にファイルを配置すると一覧性が良くなりそうです。
今回作成するパッケージの構成
今回はLaravelでとりあえず動くものを簡易的に作るので、Laravel内にUI要件を処理するパッケージを作成します。(本当はComposerで外部に切り出したい)
app/
┣━ Http/Middleware/
┃ ┗━ LandingPageMiddleware.php
┗━ Packages/LandingPageGenerator/
┣━ ConfigLoader.php
┣━ Generator.php
┣━ containers/
┃ ┣━ QueryParameterContainer.php
┃ ┗━ ViewParameterContainer.php
┗━ pages/
┣━ dog.yml
┗━ dog/
┗━ top-10.yml
1. 定義ファイルを読み込むクラスを作成
yamlをすべて読み込み、['/dog' => [...]]
のようにパスごとに定義を持つ配列を生成するConfigLoaderクラスを作成します。
<?php
namespace App\Packages\LandingPageGenerator;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
use Symfony\Component\Yaml\Yaml;
class ConfigLoader
{
public function load(): array
{
// 定義ファイルを配置するディレクトリ
$dir = dirname(__FILE__) . '/pages';
// 定義ファイルをサブディレクトリも含めてすべて参照するイテレータを作成
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(
$dir,
FilesystemIterator::SKIP_DOTS|
FilesystemIterator::KEY_AS_PATHNAME|
FilesystemIterator::CURRENT_AS_FILEINFO
), RecursiveIteratorIterator::SELF_FIRST
);
// 定義ファイルの内容を配列に格納して返す
$pages = [];
foreach($iterator as $pathname => $info){
if ($info->getExtension() === 'yml') {
$page = Yaml::parseFile($pathname);
$path = $page['path'];
$pages[$path] = $page;
}
}
return $pages;
}
}
2. 検索条件をControllerに渡すクラスを作成
yamlで定義された検索条件をLaravelのRequestオブジェクトにセットすることができるラッパークラスを作成します。
<?php
namespace App\Packages\LandingPageGenerator\containers;
use Illuminate\Http\Request;
class QueryParameterContainer
{
private $request;
public function __construct(Request $request)
{
$this->request = $request;
}
public function add(string $key, $value): void
{
$this->request->query->add([$key => $value]);
}
}
3. メタ情報をViewに渡すクラスを作成
yamlで定義されたメタ情報をLaravelのViewオブジェクトにセット(上書き)することができるラッパークラスを作成します。
<?php
namespace App\Packages\LandingPageGenerator\containers;
use Illuminate\Contracts\View\Factory;
use Illuminate\Contracts\View\View;
class ViewParameterContainer
{
private $viewFactory;
public function __construct(Factory $viewFactory)
{
$this->viewFactory = $viewFactory;
}
public function add(string $key, string $value): void
{
$this->viewFactory->composer('*', function (View $view) use ($key, $value) {
$view->with($key, $value);
});
}
}
4. メイン処理クラスを作成
1~3のクラスを組み合わせて、定義ファイルを読み取り、パスにマッチする定義が見つかった場合は検索条件をControllerに渡す処理とメタ情報をViewに渡す処理を実装します。
<?php
namespace App\Packages\LandingPageGenerator;
use App\Packages\LandingPageGenerator\containers\QueryParameterContainer;
use App\Packages\LandingPageGenerator\containers\ViewParameterContainer;
class Generator
{
private $pages;
private $queryParameterContainer;
private $viewParameterContainer;
public function __construct(
ConfigLoader $loader,
QueryParameterContainer $queryParameterContainer,
ViewParameterContainer $viewParameterContainer
) {
$this->pages = $loader->load();
$this->queryParameterContainer = $queryParameterContainer;
$this->viewParameterContainer = $viewParameterContainer;
}
/**
* 定義された検索条件をクエリパラメータに追加する
*/
public function runQueryProcess(string $path): void
{
if ($this->hasPage($path)) {
$page = $this->pages[$path];
foreach ($page['query'] as $key => $value) {
$this->queryParameterContainer->add($key, $value);
}
}
}
/**
* 定義されたメタ情報をViewパラメータに追加する
*/
public function runViewProcess(string $path): void
{
if ($this->hasPage($path)) {
$view = $this->pages[$path]['view'];
$this->viewParameterContainer->add('title', $view['title']);
$this->viewParameterContainer->add('description', $view['description']);
}
}
private function hasPage($path): bool
{
return array_key_exists($path, $this->pages);
}
}
5. 作成したパッケージを実行するMiddlewareを作成
Middlewareで4.のメイン処理クラスを実行させると、定義ファイルにあるパスにアクセスすると想定通り絞り込まれた検索結果ページが、定義したメタ情報と共に表示させることができます。
<?php
namespace App\Http\Middleware;
use App\Packages\LandingPageGenerator\Generator;
use Closure;
use Illuminate\Http\Request;
class LandingPageMiddleware
{
private $generator;
public function __construct(Generator $generator)
{
$this->generator = $generator;
}
public function handle(Request $request, Closure $next)
{
$path = $request->getPathInfo();
$this->generator->runQueryProcess($path);
$this->generator->runViewProcess($path);
return $next($request);
}
}
(カーネルやルーティング等への追記は割愛)
おわりに
今回はUI要件をControllerの外側に分離する実装をしてみました。
まだこれは構想段階なので実際のサービスで実践したことはありません。
1定義1ファイルのyaml読み込みを毎リクエスト行うわけには行かないので、事前にPHP配列の1ファイル化するような仕組みを作ったり、キャッシュを挟めるようにしたりする必要があります。ぱっと思いつくだけでも結構な課題があるので、実運用に耐えうる状態に持っていけるのか検証していきたいと思います。