チュートリアル

このチュートリアルではBEAR.Sundayの基本機能のDIAOPREST APIを紹介します。1

プロジェクト作成

年月日を入力すると曜日を返すWebサービスを作成してみましょう。 まずプロジェクトを作成します。

composer create-project bear/skeleton MyVendor.Weekday

vendor名をMyVendorproject名をWeekdayとして入力します。2

リソース

最初にアプリケーションリソースファイルをsrc/Resource/App/Weekday.phpに作成します。

<?php

declare(strict_types=1);

namespace MyVendor\Weekday\Resource\App;

use BEAR\Resource\ResourceObject;
use DateTimeImmutable;

class Weekday extends ResourceObject
{
    public function onGet(int $year, int $month, int $day): static
    {
        $dateTime = DateTimeImmutable::createFromFormat('Y-m-d', "$year-$month-$day");
        $weekday = $dateTime->format('D');
        $this->body = ['weekday' => $weekday];

        return $this;
    }
}

このMyVendor\Weekday\Resource\App\Weekdayリソースクラスは/weekdayというパスでアクセスすることができます。 GETメソッドのクエリーがonGetメソッドの引数に渡されます。

コンソールでアクセスしてみましょう。まずはエラーを試してみます。

php bin/app.php get /weekday
400 Bad Request
content-type: application/vnd.error+json

{
    "message": "Bad Request",
    "logref": "e29567cd",

エラーはapplication/vnd.error+jsonメディアタイプで返されます。 400はリクエストに問題があるエラーコードです。エラーにはlogrefIDがつけられvar/log/でエラーの詳しい内容を参照することができます。

次は引数をつけて正しいリクエストを試します。

php bin/app.php get '/weekday?year=2001&month=1&day=1'
200 OK
Content-Type: application/hal+json

{
    "weekday": "Mon",
    "_links": {
        "self": {
            "href": "/weekday?year=2001&month=1&day=1"
        }
    }
}

application/hal+jsonというメディアタイプで結果が正しく返って来ました。

これをWeb APIサービスにしてみましょう。 Built-inサーバーを立ち上げます。

php -S 127.0.0.1:8080 bin/app.php

curlでHTTPのGETリクエストを行って確かめてみましょう。

curl -i 'http://127.0.0.1:8080/weekday?year=2001&month=1&day=1'
HTTP/1.1 200 OK
Host: 127.0.0.1:8080
Date: Fri, 01 Sep 2017 09:31:13 +0200
Connection: close
X-Powered-By: PHP/7.1.8
content-type: application/hal+json

{
    "weekday": "Mon",
    "_links": {
        "self": {
            "href": "/weekday/2001/1/1"
        }
    }
}

このリソースクラスにはGET以外のメソッドは用意されていないので、他のメソッドを試すと405 Method Not Allowedが返されます。これも試してみましょう。

curl -i -X POST 'http://127.0.0.1:8080/weekday?year=2001&month=1&day=1'
HTTP/1.1 405 Method Not Allowed
...

HTTP OPTIONS メソッドリクエストで利用可能なHTTPメソッドと必要なパラメーターを調べることができます。(RFC7231)

curl -i -X OPTIONS http://127.0.0.1:8080/weekday
HTTP/1.1 200 OK
...
Content-Type: application/json
Allow: GET

{
    "GET": {
        "parameters": {
            "year": {
                "type": "integer"
            },
            "month": {
                "type": "integer"
            },
            "day": {
                "type": "integer"
            }
        },
        "required": [
            "year",
            "month",
            "day"
        ]
    }
}

テスト

PHPUnitを使ったリソースのテストを作成しましょう。

tests/Resource/App/WeekdayTest.phpに以下のテストコードを記述します。

<?php

declare(strict_types=1);

namespace MyVendor\Weekday\Resource\App;

use BEAR\Resource\ResourceInterface;
use MyVendor\Weekday\Injector;
use PHPUnit\Framework\TestCase;

class WeekdayTest extends TestCase
{
    private ResourceInterface $resource;

    protected function setUp(): void
    {
        $injector = Injector::getInstance('app');
        $this->resource = $injector->getInstance(ResourceInterface::class);
    }

    public function testOnGet(): void
    {
        $ro = $this->resource->get('app://self/weekday', ['year' => '2001', 'month' => '1', 'day' => '1']);
        $this->assertSame(200, $ro->code);
        $this->assertSame('Mon', $ro->body['weekday']);
    }
}

setUp()ではコンテキスト(app)を指定するとアプリケーションのどのオブジェクトでも生成できるアプリケーションのインジェクター Injectorを使ってリソースクライアント(ResourceInterface)を取得していて、テストメソッドtestOnGetでリソースをリクエストしてテストします。

実行してみましょう。

./vendor/bin/phpunit
PHPUnit 9.5.4 by Sebastian Bergmann and contributors.

....                                                                4 / 4 (100%)

Time: 00:00.281, Memory: 14.00 MB

インストールされたプロジェクトには他にはテストやコード検査を実行するコマンドが用意されています。 テストカバレッジを取得するにはcomposer coverageを実行します。

composer coverage

pcovはより高速にカバレッジ計測を行います。

composer pcov

カバレッジの詳細をbuild/coverage/index.htmlをWebブラウザで開くことで見ることができます。

コーディングスタンダードにしたがっているかのチェックはcomposer csコマンドで確認できます。 その自動修正はcomposer cs-fixコマンドでできます。

composer cs
composer cs-fix

静的解析はcomposer saコマンドでおこないます。

これまでのコードで実行してみましょう。以下のエラーがphpstanで検出されます。

 ------ --------------------------------------------------------- 
  Line   src/Resource/App/Weekday.php                             
 ------ --------------------------------------------------------- 
  15     Cannot call method format() on DateTimeImmutable|false.  
 ------ --------------------------------------------------------- 

先程のコードはDateTimeImmutable::createFromFormatで不正な値(年が-1など)を渡した時にfalseが帰ってくる事を考慮していませんでした。

試してみましょう。

php bin/app.php get '/weekday?year=-1&month=1&day=1'

PHPエラーが発生した場合でもエラーハンドラーがキャッチして、正しいapplication/vnd.error+jsonメディアタイプでエラーメッセージが表示されていますが、 静的解析の検査をパスするにはDateTimeImmutableの結果をassertするか型を検査して例外を投げるコードを追加します。

assertの場合

$dateTime =DateTimeImmutable::createFromFormat('Y-m-d', "$year-$month-$day");
assert($dateTime instanceof DateTimeImmutable);

例外を投げる場合

まず専用の例外src/Exception/InvalidDateTime.phpを作成します。

<?php

declare(strict_types=1);

namespace MyVendor\Weekday\Exception;

use RuntimeException;

class InvalidDateTime extends RuntimeException
{
}

値の検査をしたコードに修正します。

<?php

declare(strict_types=1);

namespace MyVendor\Weekday\Resource\App;

use BEAR\Resource\ResourceObject;
+use DateTimeImmutable;
use MyVendor\Weekday\Exception\InvalidDateTime;

class Weekday extends ResourceObject
{
    public function onGet(int $year, int $month, int $day): static
    {
        $dateTime = DateTimeImmutable::createFromFormat('Y-m-d', "$year-$month-$day");
+        if (! $dateTime instanceof DateTimeImmutable) {
+            throw new InvalidDateTime("$year-$month-$day");
+        }

        $weekday = $dateTime->format('D');
        $this->body = ['weekday' => $weekday];

        return $this;
    }
}

テストも追加します。

+    public function tesInvalidDateTime(): void
+    {
+        $this->expectException(InvalidDateTime::class);
+        $this->resource->get('app://self/weekday', ['year' => '-1', 'month' => '1', 'day' => '1']);
+    }

例外作成のベストプラクティス:

入力のミスのために起こった例外なので、コード自身には問題がありません。このような実行時になって判明する例外はRuntimeExceptionです。それを拡張して専用の例外を作成しました。 反対に例外の発生がバグによるものでコードの修正が必要ならLogicExceptionを拡張して例外を作成します。

これで $dateTime->format('D');の実行時に$dateTimeにfalseが入る可能性がなくなりました。このように値を逐一検査するプログラミングを防御的プログラミング(defensive programming)と呼びます。 phpstanやpsalmの静的解析ツールが役立ちます。しかし、この検査がプロジェクトが求めるものに対してあまりにも厳しすぎると感じるなら設定ファイルの値を変えて検査レベルを変えてもいいでしょう。

composer testsは composer testに加えて、コーディング規約(cs)、静的解析の検査(sa)も行います。コミット前に行うのが良いでしょう。

composer tests

ルーティング

デフォルトのルーターはURLをディレクトリにマップするWebRouterです。 ここでは動的なパラメーターをパスで受け取るためにAuraルーターを使用します。

最初にcomposerでインストールします。

composer require bear/aura-router-module ^2.0

次にsrc/Module/AppModule.phpAuraRouterModulePackageModuleの前でインストールします。

<?php

declare(strict_types=1);

namespace MyVendor\Weekday\Module;

use BEAR\Dotenv\Dotenv;
use BEAR\Package\AbstractAppModule;
use BEAR\Package\PackageModule;
+use BEAR\Package\Provide\Router\AuraRouterModule;
use function dirname;

class AppModule extends AbstractAppModule
{
    protected function configure(): void
    {
        (new Dotenv())->load(dirname(__DIR__, 2));
+        $appDir = $this->appMeta->appDir;
+        $this->install(new AuraRouterModule($appDir . '/var/conf/aura.route.php'));
        $this->install(new PackageModule());
    }
}

ルータースクリプトファイルをvar/conf/aura.route.phpに設置します。

<?php
/** 
 * @see http://bearsunday.github.io/manuals/1.0/ja/router.html
 * @var \Aura\Router\Map $map 
 */

$map->route('/weekday', '/weekday/{year}/{month}/{day}');

試してみましょう。

php bin/app.php get /weekday/1981/09/08
200 OK
Content-Type: application/hal+json

{
    "weekday": "Tue",
    "_links": {
        "self": {
            "href": "/weekday/1981/09/08"
        }
    }
}

DI

求めた曜日をログする機能を追加してみましょう。

まず曜日をログするsrc/MyLoggerInterface.phpを作成します。

<?php

declare(strict_types=1);

namespace MyVendor\Weekday;

interface MyLoggerInterface
{
    public function log(string $message): void;
}

リソースはこのログ機能を使うように変更します。

<?php
namespace MyVendor\Weekday\Resource\App;

use BEAR\Resource\ResourceObject;
use MyVendor\Weekday\MyLoggerInterface;

class Weekday extends ResourceObject
{
+    public function __construct(public MyLoggerInterface $logger)
+    {
+    }

    public function onGet(int $year, int $month, int $day) : ResourceObject
    {
        $weekday = \DateTime::createFromFormat('Y-m-d', "$year-$month-$day")->format('D');
        $this->body = [
            'weekday' => $weekday
        ];
+        $this->logger->log("$year-$month-$day {$weekday}");

        return $this;
    }
}

Weekdayクラスはロガーサービスをコンストラクタで受け取って利用しています。 このように必要なもの(依存)をnewで生成したりコンテナから取得しないで、外部から代入してもらう仕組みを DI といいます。

次にMyLoggerInterfaceMyLoggerに実装します。

<?php

declare(strict_types=1);

namespace MyVendor\Weekday;

use BEAR\AppMeta\AbstractAppMeta;

use function error_log;

use const PHP_EOL;

class MyLogger implements MyLoggerInterface
{
    private string $logFile;

    public function __construct(AbstractAppMeta $meta)
    {
        $this->logFile = $meta->logDir . '/weekday.log';
    }

    public function log(string $message): void
    {
        error_log($message . PHP_EOL, 3, $this->logFile);
    }
}

MyLoggerを実装するためにはアプリケーションのログディレクトリの情報(AbstractAppMeta)が必要ですが、これも依存としてコンストラクタで受け取ります。 つまりWeekdayリソースはMyLoggerに依存していますが、MyLoggerもログディレクトリ情報を依存にしています。このようにDIで構築されたオブジェクトは、依存が依存を..と繰り返し依存の代入が行われます。

この依存解決を行うのがDIツール(dependency injector)です。

DIツールでMyLoggerInterfaceMyLoggerを束縛(bind)するためにsrc/Module/AppModule.phpconfigureメソッドを編集します。

class AppModule extends AbstractAppModule
{
    protected function configure(): void
    {
        (new Dotenv())->load(dirname(__DIR__, 2));
        $appDir = $this->appMeta->appDir;
        $this->install(new AuraRouterModule($appDir . '/var/conf/aura.route.php'));
+        $this->bind(MyLoggerInterface::class)->to(MyLogger::class);
        $this->install(new PackageModule());
    }
}

これでどのクラスでもコンストラクタでMyLoggerInterfaceでロガーを受け取ることができるようになりました。

実行してvar/log/cli-hal-api-app/weekday.logに結果が出力されていることを確認しましょう。

php bin/app.php get /weekday/2011/05/23
cat var/log/cli-hal-api-app/weekday.log

AOP

メソッドの実行時間を計測するためのベンチマーク処理を考えてみます。

$start = microtime(true);
// メソッド実行
$time = microtime(true) - $start;

ベンチマークを行う度にこのコードを付加して、不要になれば取り除くのは大変です。 アスペクト指向プログラミング(AOP)はこのようなメソッドの前後の特定処理をうまく合成することが出来ます。

まずAOPを実現するためにメソッドの実行を横取り(インターセプト)してベンチマークを行うインターセプターsrc/Interceptor/BenchMarker.phpに作成します。

<?php

declare(strict_types=1);

namespace MyVendor\Weekday\Interceptor;

use MyVendor\Weekday\MyLoggerInterface;
use Ray\Aop\MethodInterceptor;
use Ray\Aop\MethodInvocation;

use function microtime;
use function sprintf;

class BenchMarker implements MethodInterceptor
{
    public function __construct(private MyLoggerInterface $logger)
    {
    }

    public function invoke(MethodInvocation $invocation)
    {
        $start = microtime(true);
        $result = $invocation->proceed(); // 元のメソッドの実行
        $time = microtime(true) - $start;
        $message = sprintf('%s: %0.5f(µs)', $invocation->getMethod()->getName(), $time);
        $this->logger->log($message);

        return $result;
    }
}

元のメソッドを横取りしたインターセプターのinvokeメソッドでは、元メソッドの実行を$invocation->proceed();で行うことができます。 その前後にタイマーのリセット、計測記録の処理を行います。(メソッド実行オブジェクトMethodInvocation $invocationから元メソッドのオブジェクトとメソッドの名前を取得しています。)

次にベンチマークをしたいメソッドに目印をつけるためのアトリビュートsrc/Annotation/BenchMark.php に作成します。

<?php

declare(strict_types=1);

namespace MyVendor\Weekday\Annotation;

use Attribute;

#[Attribute(Attribute::TARGET_METHOD)]
final class BenchMark
{
}

AppModuleMatcherを使ってインターセプターを適用するメソッドを束縛(バインド)します。

+use MyVendor\Weekday\Annotation\BenchMark;
+use MyVendor\Weekday\Interceptor\BenchMarker;

class AppModule extends AbstractAppModule
{
    protected function configure(): void
    {
        (new Dotenv())->load(dirname(__DIR__, 2));
        $appDir = $this->appMeta->appDir;
        $this->install(new AuraRouterModule($appDir . '/var/conf/aura.route.php'));
        $this->bind(MyLoggerInterface::class)->to(MyLogger::class);
+        $this->bindInterceptor(
+            $this->matcher->any(),                           // どのクラスでも
+            $this->matcher->annotatedWith(BenchMark::class), // #[Attribute]と属性の付けられたメソッドに
+            [BenchMarker::class]                             // BenchMarkerインターセプターを適用
+        );
        $this->install(new PackageModule());
    }
}

ベンチマークを行いたいメソッドに#[BenchMark]とアトリビュートを加えます。

+use MyVendor\Weekday\Annotation\BenchMark;

class Weekday extends ResourceObject
{

+   #[BenchMark]
    public function onGet(int $year, int $month, int $day): static
    {

これで計測したいメソッドに#[BenchMark]とアトリビュートを加えればいつでもベンチマークできるようになりました。

アトリビュートとインターセプターによる機能追加は柔軟です。対象メソッドやメソッドを呼ぶ側に変更はありません。 アトリビュートはそのままでも束縛を外せばベンチマークを行いません。例えば、開発時にのみ束縛を行い特定の秒数を越すと警告を行うこともできます。

実行してvar/log/weekday.logに実行時間のログが出力されることを確認しましょう。

php bin/app.php get '/weekday/2015/05/28'
cat var/log/cli-hal-api-app/weekday.log

HTML

次に今のAPIアプリケーションをHTMLアプリケーションにしてみましょう。 今のappリソースに加えて、src/Resource/Page/Index.phppageリソースを追加します。

pageリソースクラスは場所と役割が違うだけでappリソースと基本的に同じクラスです。

<?php
namespace MyVendor\Weekday\Resource\Page;

use BEAR\Resource\Annotation\Embed;
use BEAR\Resource\ResourceObject;

class Index extends ResourceObject
{
    /**
     * @Embed(rel="weekday", src="app://self/weekday{?year,month,day}")
     */
    public function onGet(int $year, int $month, int $day) : ResourceObject
    {
        $this->body += [
            'year' => $year,
            'month' => $month,
            'day' => $day
        ];

        return $this;
    }
}

@Embedアノテーションでapp://self/weekdayリソースをbodyのweekdayキーに埋め込んでいます。+=で配列をmergeしているのはonGet実行前に@Embedでbodyに埋め込まれたweekdayと合成するためです

その際にクエリーをURI template (RFC6570)を使って{?year,month,day}として同じものを渡しています。 下記の2つのURI templateは同じURIを示しています。

  • app://self/weekday{?year,month,day}
  • app://self/weekday?year={year}&month={month}&day={day}

<iframe><img>タグで他のリソースを含むページをイメージしてください。これらもHTMLページが画像や他のHTMLなどのリソースを自身に埋め込んでいます。

@Embedでリソースを埋め込むかわりにuse ResourceInject;resourceリソースクライアントをインジェクトしてそのクライアントでappリソースをセットすることもできます。3

<?php
namespace MyVendor\Weekday\Resource\Page;

use BEAR\Resource\ResourceObject;
use BEAR\Sunday\Inject\ResourceInject;

class Index extends ResourceObject
{
    use ResourceInject;

    public function onGet(int $year, int $month, int $day) : ResourceObject
    {
      $params = get_defined_vars(); // ['year' => $year, 'month' => $month, 'day' => $day]
      $this->body = $params + [
          'weekday' => $this->resource->get('app://self/weekday', $params)
      ];

      return $this;
    }
}

このリソースがどのような表現になるのか試してみましょう。

php bin/page.php get '/?year=2000&month=1&day=1'
200 OK
content-type: application/hal+json

{
    "year": 2000,
    "month": 1,
    "day": 1,
    "_embedded": {
        "weekday": {
            "weekday": "Sat"
        }
    },
    "_links": {
        "self": {
            "href": "/index?year=2000&month=1&day=1"
        }
    }
}

他のリソースが_embeddedされているのが確認できます。 リソースはapplication/hal+jsonメディアタイプで出力されていますが、これをHTML(text/html)で出力するためにHTMLモジュールをインストールします。HTMLのマニュアル参照。

composerインストール

composer require madapaja/twig-module ^2.0

src/Module/HtmlModule.phpを作成します。

<?php
namespace MyVendor\Weekday\Module;

use Madapaja\TwigModule\TwigErrorPageModule;
use Madapaja\TwigModule\TwigModule;
use Ray\Di\AbstractModule;

class HtmlModule extends AbstractModule
{
    protected function configure()
    {
        $this->install(new TwigModule);
        $this->install(new TwigErrorPageModule);
    }
}

templatesフォルダをコピーします

cp -r vendor/madapaja/twig-module/var/templates var

bin/page.phpを変更してコンテキストをhtml-appにします。

<?php

use MyVendor\Weekday\Bootstrap;

require dirname(__DIR__) . '/autoload.php';
exit((new Bootstrap())(PHP_SAPI === 'cli' ? 'cli-html-app' : 'html-app', $GLOBALS, $_SERVER));

これでtext/html出力の準備はできました。 最後にvar/templates/Page/Index.html.twigファイルを編集します。

{% extends 'layout/base.html.twig' %}
{% block title %}Weekday{% endblock %}
{% block content %}
The weekday of {{ year }}/{{ month }}/{{ day }} is {{ weekday.weekday }}.
{% endblock %}

準備完了です。まずはコンソールでこのようなHTMLが出力されるか確認してみましょう。

php bin/page.php get '/?year=1991&month=8&day=1'
200 OK
content-type: text/html; charset=utf-8

<!doctype html>
...

もしこの時htmlが表示されなければ、テンプレートエンジンのエラーが発生しています。 その時はログファイル(var/log/app.cli-html-app.log)でエラーを確認しましょう。

次にWebサービスを行うためにpublic/index.phpも変更します。

<?php

use MyVendor\Weekday\Bootstrap;

require dirname(__DIR__) . '/autoload.php';
exit((new Bootstrap())(PHP_SAPI === 'cli-server' ? 'html-app' : 'prod-html-app', $GLOBALS, $_SERVER));

PHPサーバーを立ち上げてwebブラウザでhttp://127.0.0.1:8080/?year=2001&month=1&day=1をアクセスして確認してみましょう。

php -S 127.0.0.1:8080 public/index.php

コンテキストはアプリケーションの実行モードのようなもので、複数指定できます。試してみましょう。

<?php

use MyVendor\Weekday\Bootstrap;

// JSONアプリケーション (最小)
require dirname(__DIR__) . '/autoload.php';
exit((new Bootstrap())('prod-app', $GLOBALS, $_SERVER));
<?php

use MyVendor\Weekday\Bootstrap;

// プロダクション用HALアプリケーション
require dirname(__DIR__) . '/autoload.php';
exit((new Bootstrap())('prod-hal-app', $GLOBALS, $_SERVER));

prodコンテクストを付加した時はコンテキストに応じたインスタンスを生成するPHPコードが生成されます。アプリケーションのvar/tmp/{context}/diフォルダを確認してみましょう。 これらのファイルは普段見る必要はありませんが、インスタンスがどのように作られているかを確認することができます。

コンテキストを変える事でどの依存がどう変わったかをdiffコマンドで調べることもできます。

diff -q var/tmp/prod-app/di/ var/tmp/prod-hal-app/di/

REST API

sqlite3を使ったアプリケーションリソースを作成してみましょう。 まずはコンソールでvar/db/todo.sqlite3にDBを作成します。

mkdir var/db
sqlite3 var/db/todo.sqlite3

sqlite> create table todo(id integer primary key, todo, created_at);
sqlite> .exit

データベースはAuraSqlや, Doctrine DbalCakeDBなどから選べますが ここではCakePHP3でも使われているCakeDBをインストールしてみましょう。

composer require ray/cake-database-module ^1.0

src/Module/AppModule::configure()でモジュールのインストールをします。

<?php
// ...
use Ray\CakeDbModule\CakeDbModule; // add this line

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ...
        $dbConfig = [
            'driver' => 'Cake\Database\Driver\Sqlite',
            'database' => $appDir . '/var/db/todo.sqlite3'
        ];
        $this->install(new CakeDbModule($dbConfig));
        $this->install(new PackageModule());
    }
}

セッターメソッドのtrait DatabaseInjectを使うと$this->dbでCakeDBオブジェクトを使えます。

Todoリソースをsrc/Resource/App/Todos.phpに設置します。

<?php
namespace MyVendor\Weekday\Resource\App;

use BEAR\Package\Annotation\ReturnCreatedResource;
use BEAR\RepositoryModule\Annotation\Cacheable;
use BEAR\Resource\ResourceObject;
use Ray\CakeDbModule\Annotation\Transactional;
use Ray\CakeDbModule\DatabaseInject;

/**
 * @Cacheable
 */
class Todos extends ResourceObject
{
    use DatabaseInject;

    public function onGet(int $id) : ResourceObject
    {
        $this->body = $this
            ->db
            ->newQuery()
            ->select('*')
            ->from('todo')
            ->where(['id' => $id])
            ->execute()
            ->fetch('assoc');

        return $this;
    }

    /**
     * @Transactional
     * @ReturnCreatedResource
     */
    public function onPost(string $todo) : ResourceObject
    {
        $statement = $this->db->insert(
            'todo',
            ['todo' => $todo, 'created_at' => new \DateTime('now')],
            ['created_at' => 'datetime']
        );
        // created
        $this->code = 201;
        // hyperlink
        $id = $statement->lastInsertId();
        $this->headers['Location'] = '/todos?id=' . $id;

        return $this;
    }

    /**
     * @Transactional
     */
    public function onPut(int $id, string $todo) : ResourceObject
    {
        $this->db->update(
            'todo',
            ['todo' => $todo],
            ['id' => $id]
        );
        // no content
        $this->code = 204;

        return $this;
    }
}

アノテーションに注目してください。クラスに付いている@CacheableはこのリソースのGETメソッドがキャッシュ可能なことを示しています。 onPostonPut@Transactionalはデータベースアクセスのトランザクションを示しています。

onPost@ReturnCreatedResourceは作成したリソースをbodyに含みます。 この時LocationヘッダーのURIで実際にonGetがコールされるのでLocationヘッダーの内容が正しいことが保証されると同時にonGetをコールすることでキャッシュも作られます。

POSTしてみましょう。

まずキャッシュを有効にしたテストをするためにbin/test.phpコンテキストのブートファイルbin/test.phpを作成します。

<?php

use MyVendor\Weekday\Bootstrap;

require dirname(__DIR__) . '/autoload.php';
exit((new Bootstrap())('cli-prod-hal-api-app', $GLOBALS, $_SERVER));

コンソールコマンドでリクエストします。POSTですが便宜上クエリーの形でパラメーターを渡します。

php bin/test.php post '/todos?todo=shopping'
201 Created
Location: /todos?id=1

{
    "id": "1",
    "todo": "shopping",
    "created": "2017-06-04 15:58:03",
    "_links": {
        "self": {
            "href": "/todos?id=1"
        }
    }
}

ステータスコードは201 CreatedLocationヘッダーで新しいリソースが/todos/?id=1に作成された事がわかります。 RFC7231 Section-6.3.2 日本語訳

@ReturnCreatedResourceとアノテートされているのでボディに作成されたリソースを返します。

次にこのリソースをGETします。

php bin/test.php get '/todos?id=1'
200 OK
ETag: 2527085682
Last-Modified: Sun, 04 Jun 2017 15:23:39 GMT
content-type: application/hal+json

{
    "id": "1",
    "todo": "shopping",
    "created": "2017-06-04 15:58:03",
    "_links": {
        "self": {
            "href": "/todos?id=1"
        }
    }
}

ハイパーメディアAPIの完成です!APIサーバーを立ち上げましょう。

php -S 127.0.0.1:8081 bin/app.php

curlコマンドでGETします。

curl -i 'http://127.0.0.1:8081/todos?id=1'
HTTP/1.1 200 OK
Host: 127.0.0.1:8081
Date: Sun, 04 Jun 2017 18:02:55 +0200
Connection: close
X-Powered-By: PHP/7.1.4
ETag: 2527085682
Last-Modified: Sun, 04 Jun 2017 16:02:55 GMT
content-type: application/hal+json

{
    "id": "1",
    "todo": "shopping",
    "created": "2017-06-04 15:58:03",
    "_links": {
        "self": {
            "href": "/todos?id=1"
        }
    }
}

何回かリクエストしてLast-Modifiedの日付が変わらないことを確認しましょう。この時onGetメソッド内は実行されていません。(試しにメソッド内でechoなどを追加して確認してみましょう)

expiryを設定してないCacheableアノテーションのキャッシュは時間でキャッシュが無効になる事はありません。 onPut($id, $todo)onDelete($id) でリソースの変更が行われるとキャッシュが再生成されます。

次にPUTメソッドでこのリソースを変更します。

curl -i http://127.0.0.1:8081/todos -X PUT -d "id=1&todo=think"

ボディがない事を示す204 No Contentのレスポンスが返ってきます。

HTTP/1.1 204 No Content
...

Content-Type ヘッダーでメディアタイプを指定する事ができます。application/jsonでも試してみましょう。

curl -i http://127.0.0.1:8081/todos -X PUT -H 'Content-Type: application/json' -d '{"id": 1, "todo":"think" }'

再度GETを行うとEtagLast-Modifiedが変わっているのが確認できます。

curl -i 'http://127.0.0.1:8081/todos?id=1'

このLast-Modifiedの日付は@Cacheableで提供されるものです。 アプリケーションが管理したり、データベースのカラムを用意したりする必要はありません。

@Cacheableを使うと、リソースコンテンツは書き込み用のデータベースとは違うリソースの保存専用の「クエリーリポジトリ」で管理されEtagLast-Modifiedのヘッダーの付加が自動で行われます。

BEAR

Because Everything is A Resource. BEARでは全てがリソースです。

リソースの識別子URI、統一されたインターフェイス、ステートレスなアクセス、強力なキャッシュシステム、ハイパーリンク、レイヤードシステム、自己記述性。 BEAR.SundayアプリケーションのリソースはこれらのRESTの特徴を備えたもので、再利用性に優れています。

BEAR.SundayはDIで構造を結び、AOPで横断的関心事を結び、RESTの力でアプリケーションのコンポーネントをリソースとして結ぶコネクティングレイヤーのフレームワークです。


  1. このプロジェクトのソースコードは各セクション毎にbearsunday/Tutorialにコミットしています。適宜参照してください。 

  2. 通常はvendor名は個人またはチーム(組織)の名前を入力します。githubのアカウント名やチーム名が適当でしょう。projectにはアプリケーション名を入力します。 

  3. 最初の@Embedを使った方法は宣言型プログラミング(Declative Programming) 、後者は命令型プログラミング(Imperative Programming)です。@Embedを使った前者は簡潔で可読性が高くリソースの関係を良く表しています。