先日会社のLaravelプロジェクトにLaravel Aspectを導入したのでQiitaでも紹介します。
2年前くらいにLTで紹介して、今ググると多分上位に私のスライドがでるんですが、肌感でユーザーがまだまだ少ないなーと感じているので良さを伝えたいと思います。
アスペクト指向プログラミング
Laravel Aspectの前にアスペクト指向プログラミング(AOP)について簡単に解説します。
AOPとは特定の振る舞いをアスペクトという機能単位で分離して記述し、プログラムのあらゆる箇所で適応する手法です。
オブジェクト指向プログラミングだけではうまく分離できない横断的関心事を分離できるようにする補助的な役割として利用されることが多いです。
横断的関心事
トランザクションやロギングなどのプログラムのあらゆる箇所に利用されるが、単一オブジェクトやメソッドに切り出して定義するのが困難な機能を横断的関心事と呼びます。
public function save(Article $article): bool
{
$this->db->beginTransaction();
try {
// なんやかんやの処理があり
$result = $this->db->...
} catch (\PDOException $e) {
// 例外時はログに残したい
$this->logger->error($e);
$this->db->rollBack();
throw $e
}
$this->db->commit();
return $result;
}
ここではデータベーストランザクションとロギングがありますが、やりたいことは記事の保存ですね。
この機能を実現しつつ以下のように書けたら素敵な気がしませんか?
public function save(Article $article): bool
{
// なんやかんやの処理があり
return $this->db->...
}
これを実現できるのがAOPであり、Laravelで簡単に導入できるのがLaravel Aspectです。
Laravel Aspect
https://github.com/ytake/Laravel-Aspect
前職の上司(@ex_takezawa)が公開しているOSSです。PHPフレームワークのBEAR.Sundayのパッケージの一つであるRay.AOPをLaravel向けにラッパーしたライブラリです。
Ray.AOPはLaravel以外でも利用可能です。(前職ではPhalconに導入しました)
導入方法はリポジトリのREADMEを御覧ください。
Laravel Aspectを導入すると先程の実装であれば以下のように書くことができます。
use Ytake\LaravelAspect\Annotation\Transactional;
use Ytake\LaravelAspect\Annotation\LogExceptions;
// 中略
/**
* @Transactional()
* @LogExceptions()
*/
public function save(Article $article): bool
{
// なんやかんやの処理があり
return $this->db->...
}
このように、横断的関心事をアノテーションで記述することができます。
スッキリ書けて、本質的なロジックを理解しやすいですね。
READMEを見てもらえればわかりますが、予め汎用的なアノテーションが用意されているのでとりあえず使ってみることもできます。
新たにアノテーションを追加する
汎用的なアノテーションが用意されている訳ですが、独自にアノテーションを作成することも可能です。
私の場合はappディレクトリに以下のようにAspect用のディレクトリを掘ります。
├── Aspect
│ ├── Annotation
│ ├── Interceptor
│ ├── Modules
│ └── PointCut
それでは新しいアノテーション追加の実装をそれぞれ見ていきます。
Annotation
アノテーションとして利用する文字列をクラスとして作成します。
プロパティをつけることでアノテーションに引数を作成することができます。
今回は特定の値の場合だけログを残すMatchValueLoggable
というアノテーションを作ってみます。
namespace App\Aspect\Annotation;
use Monolog\Logger;
use Ytake\LaravelAspect\Annotation\LoggableAnnotate;
/**
* @Annotation
* @Target("METHOD")
*/
final class MatchValueLoggable extends LoggableAnnotate
{
/** @var int Log level */
public $value = Logger::INFO;
/** @var bool */
public $skipResult = false;
/** @var string */
public $name = 'Loggable';
/** @var null|string */
public $key = null;
/** @var null|string */
public $expect = null;
/** @var "string|integer|float|bool" */
public $primitiveType = "string";
}
クラス名が@MatchValueLoggable
のように利用され、プロパティをつけることでアノテーションの引数を作ることができます。
use App\Aspect\Annotation\MatchValueLoggable;
// 中略
/**
* @MatchValueLoggable(key="$str",expect="test",primitiveType="string")
*/
public function targetMethod(string $str): void
{
// something logic
}
これでtargetMethod
の引数でtest
という文字列が渡された場合のみログを出力数するというアノテーションになります。
Interceptor
実際にアノテーションから起動される処理を記述していきます。
namespace App\Aspect\Interceptor;
use Illuminate\Log\LogManager;
use Ray\Aop\MethodInterceptor;
use Ray\Aop\MethodInvocation;
use Ytake\LaravelAspect\Interceptor\AbstractLogger;
use Ytake\LaravelAspect\Annotation\AnnotationReaderTrait;
use App\Aspect\Annotation\MatchValueLoggable;
class MatchValueLoggableInterceptor extends AbstractLogger implements MethodInterceptor
{
use AnnotationReaderTrait;
public function invoke(MethodInvocation $invocation)
{
$annotation = $invocation->getMethod()->getAnnotation($this->annotation);
$logger = static::$logger;
$result = $invocation->proceed();
if (!$this->isMatchValue($annotation, $invocation)) {
return $result;
}
$logFormat = $this->logFormatter($annotation, $invocation);
/** Monolog\Logger */
if ($logger instanceof LogManager) {
if (!is_null($annotation->driver)) {
$logger = $logger->driver($annotation->driver);
}
$logger->addRecord($logFormat['level'], $logFormat['message'], $logFormat['context']);
}
return $result;
}
private function isMatchValue(MatchValueLoggable $annotation, MethodInvocation $invocation): bool
{
$key = $annotation->key;
try {
$expectValue = $this->typeConversion($annotation->primitiveType, $annotation->expect);
} catch (\InvalidArgumentException $e) {
return false;
}
$arguments = $invocation->getArguments();
foreach ($invocation->getMethod()->getParameters() as $parameter) {
if ("$". $parameter->getName() !== $key) {
continue;
}
return $arguments[$parameter->getPosition()] === $expectValue;
}
return false;
}
private function typeConversion($target, $value)
{
switch ($target) {
case 'string':
return (string) $value;
case 'integer':
return (int) $value;
case 'float':
return (float) $value;
case 'bool':
if ($value === 'true' || $value === '1') {
return true;
}
if ($value === 'false' || $value === '0') {
return false;
}
}
throw new \InvalidArgumentException("Undefined type");
}
}
(随分長くなってしまった)
Interceptorが実行される際はinvoke()
関数が実行されます。
この関数の中で、
$invocation->proceed();
という処理が書かれているかと思います。
これがアノテーションが書かれたメソッドを実行するので、この処理以前に書けば対象メソッドが発火する前処理、後に書けば後処理を作成することができます。
またこのproceed()
はアノテーションが書かれたメソッドの返り値を返します。
返り値の結果を利用して判断が可能です。ただし、ここでproceed()
から受け取った結果を返却しないと実装が壊れてしまうので注意が必要です。
個人的にはAOPで値を加工することはおすすめしません。メソッドを挟んで、その引数や結果に応じて別の処理を行いたい場合に利用するのが良いと思います。
PointCut
アノテーションとインターセプターの紐付けを行います。
namespace App\Aspect\PointCut;
use App\Aspect\Interceptor\MatchValueLoggableInterceptor;
use Illuminate\Contracts\Container\Container;
use Psr\Log\LoggerInterface;
use Ray\Aop\Pointcut;
use Ytake\LaravelAspect\PointCut\CommonPointCut;
use Ytake\LaravelAspect\PointCut\PointCutable;
class MatchValueLoggablePointCut extends CommonPointCut implements PointCutable
{
protected $annotation = \App\Aspect\Annotation\MatchValueLoggable::class;
public function configure(Container $app): Pointcut
{
$interceptor = new MatchValueLoggableInterceptor();
$interceptor->setLogger($app[LoggerInterface::class]);
$this->setInterceptor($interceptor);
return $this->withAnnotatedAnyInterceptor();
}
}
protected $annotation
にアノテーションを指定し、configure()
関数でそのアノテーションが実行される処理を返します。
Modules
アノテーションとそのアノテーションが利用しているクラスを紐付けます。
namespace App\Aspect\Modules;
use App\Aspect\PointCut\MatchValueLoggablePointCut;
use Ytake\LaravelAspect\Modules\AspectModule;
use Ytake\LaravelAspect\PointCut\PointCutable;
final class MatchValueLoggableModule extends AspectModule
{
/**
* Usage Classes
*/
protected $classes = [
// アノテーションが使われているクラスを指定する
];
public function registerPointCut(): PointCutable
{
return new MatchValueLoggablePointCut();
}
}
protected $classes
にアノテーションを使っているクラスを定義します。
また、registerPointCut()
関数では先程作成したPointCutクラスを返します。
Modulesの登録
最後にconfig/ytake-laravel-aop.php
の以下の項目にModulesを追加しましょう。
'modules' => [
\App\Aspect\Modules\MatchValueLoggableModule::class,
],
これでアノテーションが利用できる準備が整いました。
最後に
サンプル用としてなんの汎用性もないアノテーションを作成してしまいましたが、手順としてはこんな感じなので皆さんの思うままのアノテーションを作って関心事を分離していきましょう。
最後に今回のアノテーションによるログ出力を置いておきます。
[2019-11-19 18:44:08] local.INFO: Loggable:App\Http\Controllers\IndexController.targetMethod {"args":{"str":"test"}}
おまけ
アノテーションのIDE補完
PhpStormを使っている場合はPHP Annotation
プラグインを導入することで補完されます。
LaravelのMiddlewareとの違い
基本的にメソッドを挟み込む処理を追加できるという点でこれらは類似しているかと思います。
ただし、MiddlewareはControllerに作用させるためHTTPリクエストに対して処理を追加する用途が主だと思います。
対してAOPは場所を問いません。Authの認可やレスポンスの加工などはMiddlewareで行い、実装上切り出したい関心事はAOPで行うといった使い分けが良いかと思います。
参考: