Help us understand the problem. What is going on with this article?

Laravel Aspectで始めるアスペクト指向プログラミング

先日会社の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で行うといった使い分けが良いかと思います。

参考:
- 横断的関心事 - アスペクト指向なWiki

kubotak
フロントエンドが好物 これでもサーバーサイドエンジニア
https://kubotak.page
macloud
M&Aクラウドは「テクノロジーの力で、M&Aに流通革命を」をミッションにM&Aプラットフォーム「M&Aクラウド」を開発運営するスタートアップです。
https://macloud.jp
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした