Laravelで使えるデコレータライブラリを作った
はじめに
Laravelで使用できるデコレーターライブラリを作り、packagistに公開しました。
このライブラリを作ったきっかけは、サービスクラスなどで共通の処理(ログ出力やトランザクション管理など)をスマートに書きたかったからです。Laravelでは、こうした処理をミドルウェアやイベントで書くこともできますが、メソッド単位で柔軟に適用したい場面も多く、うまくはまらないことがよくありました。
そこで注目したのがデコレーターパターンと、PHP8から導入されたアトリビュートの組み合わせです。
本記事では、このライブラリの概要・使用方法・仕組みについて解説します。以下のような方に特におすすめです。
- Laravelでアスペクト指向的な記述をしたい方
- サービスクラスに共通処理を分離して書きたい方
- アトリビュートを使ったモダンなコード設計に興味がある方
ライブラリ作成の背景・課題
Laravelでサービスクラスを活用して開発していると、次第に以下のような共通処理をあちこちで書く必要が出てくることがあります。
- メソッドの実行前後でログに記録
- DBトランザクションの開始・コミット・ロールバック処理
- キャッシュの保存
こうした処理を、「毎回手書きで書く」、「トレイトでまとめる」、「基底クラスを作成して継承させる」といった方法でまとめるのも1つの手ですが、「共通処理をまとめたい」というニーズと、「柔軟にクラス設計したい」というニーズがぶつかり、中々良い方法が思いつきませんでした。
そんな中、このジレンマを解消する手段として思いついたのが、デコレーター+アトリビュートによるアスペクト的な仕組みでした。
ライブラリの特徴
このライブラリの最大の特徴は、クラスのメソッドにアトリビュートを付与するだけで、共通処理を簡潔に追加できる点です。
しかも、既存の処理を変更せずに適用できるのがポイントで、対象のメソッドに設定するだけでOKです。
実際のコード例を見てみましょう。
まず、以下のようにDecorator
インターフェイスを実装したアトリビュートを作成します。本例では、メソッドの実行前後にログを記録する処理を実装したLogDecorator
を作成したとします。
<?php
namespace App\Attributes;
use Attribute;
use Dorayaki4369\LaravelDecorator\Contracts\Attributes\Decorator;
#[Attribute(Attribute::TARGET_METHOD)]
class LogDecorator implements Decorator
{
public function decorate(callable $next, array $args, object $instance, string $parentClass, string $method): mixed
{
// 対象メソッド前に実行
\Illuminate\Support\Facades\Log::debug('Before the method is called');
$result = $next($args, $instance, $parentClass, $method);
// 対象メソッド実行後に実行
\Illuminate\Support\Facades\Log::debug('After the method is called');
return $result;
}
}
そして、作成したデコレーターを使用したいクラスのメソッドに設定し、
<?php
namespace App\Services;
use App\Attributes\LogDecorator;
class MyService
{
#[LogDecorator]
public function exec(string $str, array $options = []): string
{
// 何らかの処理
}
}
Laravelのサービスコンテナにてインスタンス化されたクラスからメソッドを呼び出すことで、メソッドの前後でログを記録する処理が実行されます。
<?php
namespace App\Controllers;
use App\Services\MyService;
class MyController
{
public function index(MyService $service): string
{
// メソッドの実行前後でLogDecoratorのログ記録処理が実行される
return $service->exec('hello', ['option' => 'value']);
}
}
上記のように、アトリビュートを付けてメソッドを呼び出すだけで、デコレーターの処理が自動的に実行されます。
設定不要・コード変更最小で共通処理を注入できるのが、このライブラリの大きな魅力です!
デフォルトデコレーター
さらに、本ライブラリではよく使わそうな3つのデコレーターを予め用意しています。
DBTransactionDecorator
クラス
対象メソッドをDatabase Transactionでラップするデコレーターです。
SimpleCacheDecorator
クラス
対象メソッドの引数をキーに、戻り値を値としてキャッシュし、2回目以降の呼び出し時にキャッシュされた値を変わりに返すデコレーターです。
ValidationDecorator
クラス
対象メソッドの引数に対しバリデーションを実行します。
仕組み
このライブラリは、Laravelのサービスコンテナ経由でクラス解決したとき、自動的にデコレーターを適用できる仕組みになっています。
例えば、Laravel開発では、使いたいサービスを直接インスタンス化するのではなく、以下のようにコントローラや他のクラスにインジェクションして使うことがあります。
<?php
namespace App\Controllers;
use App\Services\MyService;
class MyController
{
public function index(MyService $service)
{
return $service->exec('hello', ['option' => 'value']); // LaravelがMyServiceを自動解決してくれる
}
}
このときLaravelは、サービスコンテナを通じてMyService
のインスタンスを作成します。
このライブラリでは、そのインスタンス生成のタイミングをフックして、対象クラスを継承する匿名クラスを作成することで、デコレーターの処理を差し込んでいます。
仕組みは以下の3フェーズで構成されています。
-
スキャン
アプリケーション内のクラスを解析し、デコレーターが設定されているメソッドを探します。 -
インスタンス生成
Laravelのサービスコンテナがクラスを解決しようとしたとき、元のクラスを継承(ラップ)した「デコレーター付きインスタンス」を返します。 -
メソッド実行
対象メソッドが呼び出された際に、設定されたデコレーターを順番に実行します。
1. スキャン
アプリケーションの起動時などに、デコレーターを適用するクラス・メソッドを探します。
2. インスタンス生成
Laravelのサービスコンテナからクラスが解決されるタイミングで、ライブラリ側がそれをラップし、デコレーター処理付きの匿名インスタンスを返します。
例えば、冒頭のMyService
は以下のような匿名クラスが生成・実行されます。
<?php
return new class extends \App\Services\MyService
{
public function exec(string $str, array $options = []): string
{
return \Dorayaki4369\LaravelDecorator\Facades\Decorator::handle($this, __FUNCTION__, [$str, $options]);
}
};
この仕組みによって、呼び出し側のコードには一切影響を与えずに、共通処理を差し込むことが可能になります。
また、ライブラリの特徴で書いた制約の存在も、この生成方法が要因になっています。
3. 実行
メソッドが呼び出された瞬間、ライブラリは事前に設定されたデコレーターを順番に実行します。
各デコレーターは、decorate()
メソッド内でcallable $next
を使って処理をラップしていきます。
このようにクロージャをチェーンすることで、ミドルウェアと似た動作をするようにしています。
今後の展望
機能は一通りできたと思うので、最適化が課題かなと思っています。
特に、匿名クラス作成時にPHPコードを生成していますが、その処理が重い気がするので、生成コードをキャッシュする機能をつけようと思っています。
キャッシュ機能はdecorator:cache
みたいなArtisanコマンドとして呼び出せるようにし、optimize
コマンドにフックできるようにしたいです。
まとめ
本記事では、自作のデコレーターライブラリについて解説しました。
- クラスの共通処理(ログ・トランザクション・キャッシュなど)をスマートに書きたい
- メソッド単位でアスペクト的に処理を挿入したい
- Laravelの書き方をほとんど変えずに柔軟な設計をしたい
といったニーズに応えられると思うので、ぜひ気になった方はインストールして試してみてください!
まだまだ改善の余地があるので、使ってみた感想や「こんな機能ほしい」などのフィードバックも大歓迎です 🚀
Discussion