この記事は株式会社アイスタイルアドベントカレンダーの18日目の記事です。
弊社ではPHPをメインで利用しています。
一部のプロジェクトでは表題のアスペクト指向を用いて開発しています。
アスペクトのおさらい
横断的関心の分離をする技術で、アスペクト(AOP)はオブジェクト指向プログラミング (OOP)を補完する技術の一つです。
AOPはポイントカットとアドバイスを利用して定義を行います(AspectJ)。
ポイントカット
後述するアドバイスがどのような条件で実行するかを定義するものです。
例えば、
- Hogeクラスのfugaメソッドが実行される時
- Hogeクラスの全てのメソッドが実行される時
- Hogeクラスのメソッドのうち、setというプレフィックスが付いてるものが実行される時
などがあります(ジョイントポイント)。
言語やライブラリによって定義されているものなどがありますので、
それぞれのライブラリに従って定義します。
アドバイス
アドバイスはポイントカットで定義された条件のものが実行される場合に、
どのように処理を適用されるかのタイミングを指します。
これには下記の3つのものがあります。
- before ジョインポイント発生前に適用
- after ジョインポイント発生後に適用
- around 元の処理を置き換えてしまう場合や、特定の処理で挟み込む など
代表的なもの
AOPの代表的なものとしては、ログやトランザクション、キャッシュなどが挙げられます。
JavaであればSpringなどのフレームワーク、
Aspect Oriented Programming with Spring
PHPであればGo! AOP PHP、ray-di/Ray.Aop あたりが参考になると思います。
多くの言語にアスペクト指向のライブラリがありますので、利用する機会も多いかもしれません。
ユースケースによる分離
上記の挙げた処理のログなどは、多くのクラスの本質ではない部分としても挙げられるのではないでしょうか。
例えば、書籍予約のシステムがあるとします。
このシステムでは本質の機能として、書籍予約、書籍購入、書籍情報などがあります。
これらの機能に対して、ログは書籍予約のログであったり、購入時のログであったりと、
上記の機能に対して横断的に関わるものになります。
このログを上記の機能を実装するクラスの基底クラスにログの処理がまとめて、
継承するクラスがそれらを利用するものとする場合は、機能が増えるたびに複雑になっていき、
それぞれのクラスはログに依存しなければなりません。
このログと同様にユースケースによる分離を考えてみましょう。
PHPコード(フレームワークはLaravel)を使って紹介します。
書籍予約
書籍予約は主に、書籍予約、書籍情報、予約というコンポーネントから成り立ちます。
簡単なクラスを利用します。
書籍情報
実装例として配列を利用する簡単なクラスです
<?php
namespace App\Repositories;
/**
* Interface BookRepositoryInterface
*/
interface BookRepositoryInterface
{
/**
* @param $index
*
* @return mixed
*/
public function findBook($index);
}
<?php
namespace App\Repositories;
/**
* Class BookRepository
*/
class BookRepository implements BookRepositoryInterface
{
/** @var array */
protected $data = [
1 => [
'id' => 1,
'title' => 'Laravelリファレンス',
'price' => 4200
],
];
/**
* @param $index
*
* @return mixed
*/
public function findBook($index)
{
return $this->data[$index];
}
}
予約操作
書籍の予約受け付け可能数と予約追加を実装例として記述しています。
namespace App\Repositories;
interface ReserveRepositoryInterface
{
/**
* @param $id
*
* @return mixed
*/
public function createReservation($id);
/**
* @param $id
*
* @return mixed
*/
public function getQuantity($id);
}
サンプルのため、予約受け付け可能数を0としています。
<?php
namespace App\Repositories;
/**
* Class ReserveRepository
*/
class ReserveRepository implements ReserveRepositoryInterface
{
/** @var array */
protected $stocks = [
1 => [
'stock' => 0
]
];
/**
* @param $id
*/
public function createReservation($id)
{
// データベースに予約レコード追加など
}
/**
* @param $id
*
* @return int
*/
public function getQuantity($id)
{
return $this->stocks[$id]['stock'];
}
}
サービスコンテナへの登録
サービスプロバイダを利用して、抽象に依存するようにします。
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class RepositoryServiceProvider extends ServiceProvider
{
/**
* {@inheritdoc}
*/
public function register()
{
$this->app->bind(
\App\Repositories\ReserveRepositoryInterface::class,
\App\Repositories\ReserveRepository::class
);
$this->app->bind(
\App\Repositories\BookRepositoryInterface::class,
\App\Repositories\BookRepository::class
);
}
}
それぞれは実際のデータベースなどを使うものに置き換えるなどしてみてください。
書籍予約
それぞれの機能(コンポーネント)を用意しました。
実際の「本を予約する」機能を実装します。
<?php
namespace App\Services;
use App\Repositories\BookRepositoryInterface;
use App\Repositories\ReserveRepositoryInterface;
class BookReservation
{
/** @var ReserveRepositoryInterface */
protected $reserve;
/** @var BookRepositoryInterface */
protected $book;
/**
* BookReservation constructor.
*
* @param ReserveRepositoryInterface $reserve
* @param BookRepositoryInterface $book
*/
public function __construct(
ReserveRepositoryInterface $reserve,
BookRepositoryInterface $book
) {
$this->reserve = $reserve;
$this->book = $book;
}
/**
* @return mixed
*/
public function getBook()
{
return $this->book->findBook(1);
}
/**
* @param $id
*
* @return mixed
* @throws \Exception
*/
public function makeReservation($id)
{
if ($this->reserve->getQuantity($id)) {
return $this->reserve->createReservation($id);
}
throw new \Exception();
}
}
コントローラなどのクラスから実際に利用する場合は次のようになります。
<?php
namespace App\Http\Controllers;
use App\Services\BookReservation;
class IndexController extends Controller
{
/**
* @param BookReservation $reservation
*/
public function append(BookReservation $reservation)
{
$book = $reservation->getBook();
$reservation->makeReservation($book['id']);
}
}
多少簡略化していますが、この例では実際に動かすと、受付可能数が0のためエクセプションが投げられます。
このエクセプションをキャッチした後に、例えばキャンセル待ちリストに入れる処理や、
管理者にメールを送信するなどが続くと思いますが、
それらの機能を拡張として扱うために、アスペクトを利用して分離してみましょう。
Laravelではアスペクトなどはもちろん利用できないため、パッケージやライブラリを利用することになります。
ここからの例は拙作のLaravel-Aspectライブラリ利用になりますが、考え方の参考になると思います。
(導入方法などはGitHubにありますので割愛します)
ここではキャンセル待ちリストに追加する、を拡張で追加していきましょう。
キャンセル待ちリストへの追加
実際にアスペクトととして動作させる処理を実装します。
ジョイントポイントの一つとして、アノテーションを利用することができます。
WaitingListアノテーション
<?php
namespace App\Annotation;
/**
* @Annotation
* @Target("METHOD")
*/
final class WaitingList
{
}
アスペクト/アドバイス
実際にエクスプションをキャッチして予約待ちリストへ追加させます(アラウンドアドバイス)。
$invocation->proceed()
がアスペクトが作用するメソッドの処理です。
この返却値を例としてfalseに変更しています。
任意で自由に変更できるのがわかると思います。
<?php
namespace App\Interceptor;
use Ray\Aop\MethodInvocation;
use Ray\Aop\MethodInterceptor;
use Ytake\LaravelAspect\Annotation\AnnotationReaderTrait;
/**
* Class WaitingListInterceptor
*/
class WaitingListInterceptor implements MethodInterceptor
{
use AnnotationReaderTrait;
/**
* @param MethodInvocation $invocation
*
* @return object
* @throws \Exception
*/
public function invoke(MethodInvocation $invocation)
{
try {
$result = $invocation->proceed();
}catch(\Exception $e) {
// 予約リストへの追加処理
$result = false;
}
return $result;
}
}
ポイントカット
Laravel-Aspectライブラリで、
指定したクラスを対象に、特定のアノテーションが記述されたメソッドが実行する場合に適用するポイントカットが用意してありますので、
それを継承して利用します。
これで実行の定義として、先に作成したApp\Annotation\WaitingList
アノテーションが記述されているメソッドが実行される時に、
App\Interceptor\WaitingListInterceptor
クラスが作用する、という定義になります。
<?php
namespace App\PointCut;
use App\Interceptor\WaitingListInterceptor;
use Illuminate\Contracts\Container\Container;
use Ytake\LaravelAspect\PointCut\PointCutable;
use Ytake\LaravelAspect\PointCut\CommonPointCut;
/**
* Class WaitingListPointCut
*/
class WaitingListPointCut extends CommonPointCut implements PointCutable
{
/** @var string */
protected $annotation = \App\Annotation\WaitingList::class;
/**
* @param Container $app
*
* @return \Ray\Aop\Pointcut
*/
public function configure(Container $app)
{
$this->setInterceptor(new WaitingListInterceptor);
return $this->withAnnotatedAnyInterceptor($app);
}
}
適用クラスの決定
作成したアスペクトをどのクラスを対象にするかを記述します。
先に作成した\App\Services\BookReservation
クラスを対象とします。
<?php
namespace App\Modules;
use App\PointCut\WaitingListPointCut;
use Ytake\LaravelAspect\Modules\AspectModule;
/**
* Class WaitingListModule
*/
class WaitingListModule extends AspectModule
{
/** @var array */
protected $classes = [
\App\Services\BookReservation::class,
];
public function attach()
{
self::$pointcuts[] = (new WaitingListPointCut)->configure($this->app);
foreach ($this->classes as $class) {
$this->instanceResolver($class);
}
}
}
フレームワークに教える
サービスプロバイダで利用するアスペクトを登録すると利用準備が整います。
public function boot()
{
$aspect = $this->app['aspect.manager'];
$aspect->register(\App\Modules\WaitingListModule::class);
$aspect->dispatch();
}
動作を拡張
\App\Services\BookReservation
クラスの書籍予約メソッドにアノテーションを記述し、
処理を拡張させます。
/**
* @\App\Annotation\WaitingList
* @param $id
*
* @return mixed
* @throws \Exception
*/
public function makeReservation($id)
{
if ($this->reserve->getQuantity($id)) {
return $this->reserve->createReservation($id);
}
throw new \Exception();
}
ブラウザで実行すると、エクセプションではなくfalseが返却されるのを確認できると思います。
おまけ
アスペクトはいくつも利用することができます。
/**
* @Loggable
* @Cacheable(key={"#id"})
* @WaitingList
*
* @param $id
*
* @return mixed
* @throws \Exception
*/
public function makeReservation($id)
{
if ($this->reserve->getQuantity($id)) {
return $this->reserve->createReservation($id);
}
throw new \Exception();
}
実装例を交えて、ユースケースによるアスペクトを使った拡張方法を紹介しました。