これはBEAR.Sundayの全てのマニュアルページを一つにまとめたページです。

BEAR.Sundayとは

BEAR.Sundayは、クリーンなオブジェクト指向設計と、Webの基本原則に沿ったリソース指向アーキテクチャを組み合わせたPHPのアプリケーションフレームワークです。 このフレームワークは標準への準拠、長期的な視点、高効率、柔軟性、自己記述性に加え、シンプルさを重視します。

フレームワーク

BEAR.Sundayは3つのフレームワークで構成されています。

Ray.Di依存性逆転の原則に基づいてオブジェクトの依存をインターフェイスで結びます。

Ray.Aopアスペクト指向プログラミングで本質的関心と横断的関心を結びます。

BEAR.Resourceはアプリケーションのデータや機能をリソースにしてREST制約で結びます。

フレームワークは、アプリケーション全体に適用される制約と設計原則です。一貫性のある設計と実装を促進し、高品質でクリーンなアプリケーションの構築の力になります。

ライブラリ

BEAR.Sunday はフルスタック フレームワークとは異なり、認証やデータベースなどの特定のタスクのための独自のライブラリは提供しません。その代わりに、高品質なサードパーティ製のライブラリを使用することを好みます。

このアプローチは2つの設計思想に基づいています。1つ目は「フレームワークは変わらないがライブラリは変わる」という考え方です。フレームワークがアプリケーションの基盤として安定した構造を提供し続ける一方で、ライブラリは時間の経過とともに進化し、アプリケーションの特定のニーズを満たします。

2つ目は「ライブラリを選択する権利と責任はアプリケーションアーキテクトにある」というものです。アプリケーションアーキテクトは、アプリケーションの要件、制約、および目的に最も適したライブラリを選択する能力と責任を委ねられています。

BEAR.Sundayは、フレームワークとライブラリの違いを”不易流行”(変わらぬ基本原則と時代と共に進化する要素)として明確に区別し、アプリケーション制約としてのフレームワークの役割を重視します。

アーキテクチャ

BEAR.Sundayは、従来のMVC(Model-View-Controller)アーキテクチャとは異なり、リソース指向アーキテクチャ(ROA)を採用しています。このアーキテクチャでは、アプリケーションの設計において、データとビジネスロジックを統一してリソースとして扱い、それらに対するリンクと操作を中心に設計を行います。リソース指向アーキテクチャはREST APIの設計で広く使用されていますが、BEAR.SundayはそれをWebアプリケーション全体の設計にも適用しています。

長期的な視点

BEAR.Sunday は、アプリケーションの長期的な維持を念頭に置いて設計されています。

  • 制約: DI、AOP、RESTの制約に従った一貫したアプリケーション制約は、時間の経過とともに変わることがありません。

  • 永遠の1.x: 2015年の最初のリリース以来、BEAR.Sundayは後方互換性のない変更を導入することなく、継続的に進化してきました。開発者にはフレームワークの定期的な互換性破壊への対応とそのテストが必要という将来の技術負債がありません。

  • 標準準拠:HTTP標準、JsonSchema などの標準に従い、DIはGoogle Guice、AOPはJavaのAop Allianceに基づいています。

接続性

BEAR.Sundayは、Webアプリケーションを超えて、さまざまなクライアントとのシームレスな統合を可能にします。

  • HTTPクライアント: HTTPを使用して全てのリソースにアクセスすることが可能です。MVCのモデルやコントローラーと違い、BEAR.Sundayのリソースはクライアントから直接のアクセスが可能です。

  • composerパッケージ: composerでvendor下にインストールしたアプリケーションのリソースを直接呼び出す事ができます。マイクロサービスを使わずに複数のアプリケーションを協調する事ができます。

  • 多言語フレームワーク: BEAR.Thriftを使用して、PHP以外の言語や異なるバージョンのPHPとの連携を可能にします。

Webキャッシュ

リソース指向アーキテクチャとモダンなCDNの技術を組み合わせることにより、従来のサーバーサイドのTTLキャッシュを超えるWeb本来の分散キャッシングを実現します。BEAR.Sundayの設計思想は、Webの基本原則に沿っており、CDNを中心に配置した分散キャッシュシステムを活用することで、高いパフォーマンスと可用性を実現します。

  • 分散キャッシュ: キャッシュをクライアント、CDN、サーバーサイドに保存することで、CPU コストとネットワークコストの両方を削減します。

  • 同一性確認: ETagを使用してキャッシュされたコンテンツの同一性を確認し、コンテンツの変更があった場合にのみ再取得することで、ネットワーク効率を向上させます。

  • 耐障害性: イベントドリブンコンテンツの採用により、キャッシュに有効期限を設けないCDNキャッシュを基本にしたシステムは、PHPやDBがダウンした場合でもコンテンツを提供し続けます。

パフォーマンス

BEAR.Sundayは、最大限の柔軟性を保ちながら、パフォーマンスと効率性に重点を置いて設計されています。 極めて最適化されたブートストラップが実現され、ユーザー体験とシステムリソースの両方に好影響を与えています。 パフォーマンスはいつもBEAR.Sundayの最大関心事の一つであり、設計と開発の決定において常に中心的な役割を果たしています。

Because Everything is a Resource

「全てがリソース」のBEAR.Sundayは、Webの本質であるリソースを中心に設計されたPHPのWebアプリケーションフレームワークです。その真の価値は、オブジェクト指向原則とREST原則に基づいた優れた制約をアプリケーション全体の制約として提供することにあります。

この制約は、開発者に一貫性のある設計と実装を促し、長期的な視点に立ったアプリケーションの品質を高めます。同時に、この制約は開発者に自由をもたらし、アプリケーション構築の創造性を高めます。

AOP

アスペクト指向プログラミングは、横断的関心事の問題を解決します。対象メソッドの前後に任意の処理をインターセプターで織り込むことができます。 対象となるメソッドはビジネスロジックなど本質的関心事のみに関心を払い、インターセプターはログや検証などの横断的関心事に関心を払います。

BEAR.SundayはAOP Allianceに準拠したアスペクト指向プログラミングをサポートします。

インターセプター

インターセプターのinvokeメソッドで$invocationメソッド実行変数を受け取り、メソッドの前後に処理を加えます。 この変数は、インターセプター元メソッドを実行するためだけの変数です。前後にログやトランザクションなどの横断的処理を記述します。

use Ray\Aop\MethodInterceptor;
use Ray\Aop\MethodInvocation;

class MyInterceptor implements MethodInterceptor
{
    public function invoke(MethodInvocation $invocation)
    {
        // メソッド実行前の処理
        // ...

        // メソッド実行
        $result = $invocation->proceed();

        // メソッド実行後の処理
        // ...

        return $result;
    }
}

束縛

モジュールで対象となるクラスとメソッドをMatcherで”検索”して、マッチするメソッドにインターセプターを束縛します。

$this->bindInterceptor(
    $this->matcher->any(),                   // どのクラスでも
    $this->matcher->startsWith('delete'),    // "delete"で始まるメソッド名のメソッドには
    [Logger::class]                          // Loggerインターセプターを束縛
);

$this->bindInterceptor(
    $this->matcher->subclassesOf(AdminPage::class),  // AdminPageの継承または実装クラスの
    $this->matcher->annotatedWith(Auth::class),      // @Authアノテーションがアノテートされているメソッドには
    [AdminAuthentication::class]                     // AdminAuthenticationインターセプターを束縛
);

Matcherは他にこのような指定もできます。

インターセプターに渡されるMethodInvocationで対象のメソッド実行に関連するオブジェクトやメソッド、引数にアクセスすることができます。

リフレクションのメソッドでアノテーションを取得することができます。

$method = $invocation->getMethod();
$class = $invocation->getMethod()->getDeclaringClass();
  • $method->getAnnotations() - メソッドアノテーションの取得
  • $method->getAnnotation($name)
  • $class->getAnnotations() - クラスアノテーションの取得
  • $class->getAnnotation($name)

カスタムマッチャー

独自のカスタムマッチャーを作成するためにはAbstractMatchermatchesClassmatchesMethodを実装したクラスを作成します。

contains マッチャーを作成するためには、2つのメソッドを持つクラスを提供する必要があります。 1つはクラスのマッチを行うmatchesClassメソッド、もう1つはメソッドのマッチを行うmatchesMethodメソッドです。いずれもマッチしたかどうかをboolで返します。

use Ray\Aop\AbstractMatcher;

/**
 * 特定の文字列が含まれているか
 */
class ContainsMatcher extends AbstractMatcher
{
    /**
     * {@inheritdoc}
     */
    public function matchesClass(\ReflectionClass $class, array $arguments) : bool
    {
        list($contains) = $arguments;

        return (strpos($class->name, $contains) !== false);
    }

    /**
     * {@inheritdoc}
     */
    public function matchesMethod(\ReflectionMethod $method, array $arguments) : bool
    {
        list($contains) = $arguments;

        return (strpos($method->name, $contains) !== false);
    }
}

モジュール

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        $this->bindInterceptor(
            $this->matcher->any(),
            new ContainsMatcher('user'), // 'user'がメソッド名に含まれてるか
            [UserLogger::class]
        );
    }
};

リソース

BEAR.SundayアプリケーションはRESTfulなリソースの集合です。

サービスとしてのオブジェクト

ResourceObjectはHTTPのメソッドがPHPのメソッドにマップされたリソースのサービスのためのオブジェクト(Object-as-a-service)です。 ステートレスリクエストから、リソースの状態がリソース表現として生成され、クライアントに転送されます。(Representational State Transfer)

以下は、ResourceObjectの例です。

class Index extends ResourceObject
{
    public $code = 200;
    public $headers = [];

    public function onGet(int $a, int $b): static
    {
        $this->body = [
            'sum' => $a + $b  // $_GET['a'] + $_GET['b']
        ];

        return $this;
    }
}
class Todo extends ResourceObject
{
    public function onPost(string $id, string $todo): static
    {
        $this->code = 201; // ステータスコード
        $this->headers = [ // ヘッダー
            'Location' => '/todo/new_id'
        ];

        return $this;
    }
}

PHPのリソースクラスはWebのURIと同じようなpage://self/indexなどのURIを持ち、HTTPのメソッドに準じたonGet, onPostなどのonメソッドを持ちます。onメソッドで与えられたパラメーターから自身のリソース状態code,headers,bodyを決定し$thisを返します。

URI

URIはPHPのクラスにマップされています。アプリケーションではクラス名の代わりにURIを使ってリソースにアクセスします。

URI Class
   

リソースパラメーター

基本

ResourceObjectが必要なHTTPリクエストやCookieなどのWebのランタイムの値は、メソッドの引数に直接渡されます。

HTTPからリクエストではonGetonPostメソッドの引数にはそれぞれ$_GET$_POSTが変数名に応じて渡されます。例えば下記の$id$_GET['id']が渡されます。入力がHTTPの場合に文字列として渡された引数は指定した型にキャストされます。

class Index extends ResourceObject
{
    public function onGet(int $id): static
    {
        // ....

パラメーターの型

スカラーパラメーター

HTTPで渡されるパラメーターは全て文字列ですがintなど文字列以外の型を指定するとキャストされます。

配列パラメーター

パラメーターはネストされたデータ 1 でも構いません。JSONやネストされたクエリ文字列で送信されたデータは配列で受け取る事ができます。

class Index extends ResourceObject
{
    public function onPost(array $user):static
    {
        $name = $user['name']; // bear

クラスパラメーター

パラメータ専用のInputクラスで受け取ることもできます。

class Index extends ResourceObject
{
    public function onPost(User $user): static
    {
        $name = $user->name; // bear

Inputクラスは事前にパラメーターをpublicプロパティにしたものを定義しておきます。

<?php

namespace Vendor\App\Input;

final class User
{
    public int $id;
    public string $name;
}

この時、コンストラクタがあるとコールされます。2

<?php

namespace Vendor\App\Input;

final class User
{
    public function __constrcut(
        public readonly int $id,
        public readonly string $name
    } {}
}

ネームスペースは任意です。Inputクラスでは入力データをまとめたり検証したりするメソッドを実装する事ができます。

列挙型パラメーター

PHP8.1の列挙型を指定して取り得る値を制限することができます。

enum IceCreamId: int
{
    case VANILLA = 1;
    case PISTACHIO = 2;
}
class Index extends ResourceObject
{
    public function onGet(IceCreamId $iceCreamId): static
    {
        $id = $iceCreamId->value // 1 or 2

上記の場合1か2以外が渡されるとParameterInvalidEnumExceptionが発生します。

Webコンテキスト束縛

$_GET$_COOKIEなどのPHPのスーパーグローバルの値をメソッド内で取得するのではなく、メソッドの引数に束縛することができます。

use Ray\WebContextParam\Annotation\QueryParam;

class News extends ResourceObject
{
    public function foo(
    	  #[QueryParam('id')] string $id
    ): static {
       // $id = $_GET['id'];

その他$_ENV$_POST$_SERVERの値を束縛することでできます。

use Ray\WebContextParam\Annotation\QueryParam;
use Ray\WebContextParam\Annotation\CookieParam;
use Ray\WebContextParam\Annotation\EnvParam;
use Ray\WebContextParam\Annotation\FormParam;
use Ray\WebContextParam\Annotation\ServerParam;

class News extends ResourceObject
{
    public function onGet(
        #[QueryParam('id')] string $userId,            // $_GET['id'];
        #[CookieParam('id')] string $tokenId = "0000", // $_COOKIE['id'] or "0000" when unset;
        #[EnvParam('app_mode')] string $app_mode,      // $_ENV['app_mode'];
        #[FormParam('token')] string $token,           // $_POST['token'];
        #[ServerParam('SERVER_NAME') string $server    // $_SERVER['SERVER_NAME'];
    ): static {

クライアントが値を指定した時は指定した値が優先され、束縛した値は無効になります。テストの時に便利です。

リソース束縛

#[ResourceParam]アノテーションを使えば他のリソースリクエストの結果をメソッドの引数に束縛できます。

use BEAR\Resource\Annotation\ResourceParam;

class News extends ResourceObject
{
    public function onGet(
        #[ResourceParam('app://self//login#nickname') string $name
    ): static {

この例ではメソッドが呼ばれるとloginリソースにgetリクエストを行い$body['nickname']$nameで受け取ります。

コンテントネゴシエーション

HTTPリクエストのcontent-typeヘッダーがサポートされていてます。 application/jsonx-www-form-urlencodedメディアタイプを判別してパラメーターに値が渡されます。3


ベストプラクティス

RESTではリソースは他のリソースと接続されています。リンクをうまく使うとコードは簡潔になり、読みやすくテストや変更が容易なコードになります。

#[Embed]

他のリソースの状態をgetする代わりに#[Embed]でリソースを埋め込みます。

// OK but not the best
class Index extends ResourceObject
{
    public function __construct(
        private readonly ResourceInterface $resource
    )

    public function onGet(string $status): static
    {
        $this->body = [
            'todos' => $this->resource->uri('app://self/todos')(['status' => $status]) // lazy request
        ];

        return $this;
    }
}

// Better
class Index extends ResourceObject
{
    #[@Embed(rel: 'todos', src: 'app://self/todos{?status}')]
    public function onGet(string $status): static
    {
        return $this;
    }
}

他のリソースの状態を変えるときに#[Link]で示された次のアクションをhref()(ハイパーリファレンス)を使って辿ります。

// OK but not the best
class Todo extends ResourceObject
{
    public function __construct(
        private readonly ResourceInterface $resource
    )

    public function onPost(string $title): static
    {
        $this->resource->post('app://self/todo', ['title' => $title]);
        $this->code = 301;
        $this->headers[ResponseHeader::LOCATION] = '/';

        return $this;
    }
}

// Better
class Todo extends ResourceObject
{
    public function __construct(
        private readonly ResourceInterface $resource
    )

    #[Link(rel: 'create', href: 'app://self/todo', method: 'post')]
    public function onPost(string $title): static
    {
        $this->resource->href('create', ['title' => $title]);
        $this->code = 301;
        $this->headers[ResponseHeader::LOCATION] = '/';

        return $this;
    }
}

#[ResourceParam]

他のリソースをリクエストするために他のリソース結果が必要な場合は#[ResourceParam]を使います。

// OK but not the best
class User extends ResourceObject
{
    public function __construct(
        private readonly ResourceInterface $resource
    )

    public function onGet(string $id): static
    {
        $nickname = $this->resource->get('app://self/login-user', ['id' => $id])->body['nickname'];
        $this->body = [
            'profile'=> $this->resource->get('app://self/profile', ['name' => $nickname])->body
        ];

        return $this;
    }
}

// Better
class User extends ResourceObject
{
    public function __construct(
        private readonly ResourceInterface $resource
    )

    #[ResourceParam(param: 'name', uri: 'app://self//login-user#nickname')]
    public function onGet(string $id, string $name): static
    {
        $this->body = [
            'profile' => $this->resource->get('app://self/profile', ['name' => $name])->body
        ];

        return $this;
    }
}

// Best
class User extends ResourceObject
{
    #[ResourceParam(param: 'name', uri: 'app://self//login-user#nickname')]
    #[Embed(rel: 'profile', src: 'app://self/profile')]
    public function onGet(string $id, string $name): static
    {
        $this->body['profile']->addQuery(['name' => $name]);

        return $this;
    }
}

リソースリンク

リソースは他のリソースをリンクすることができます。リンクは外部のリソースをリンクする外部リンク4と、リソース自身に他のリソースを埋め込む内部リンク5の2種類あります。

外部リンク

リンクをリンクの名前のrel(リレーション)とhrefで指定します。hrefには正規のURIの他にRFC6570 URIテンプレートを指定することができます。

    #[Link rel: 'profile', href: '/profile{?id}']
    public function onGet($id): static
    {
        $this->body = [
            'id' => 10
        ];

        return $this;
    }

上記の例ではhrefはで表されていて、$body['id']{?id}にアサインされます。HALフォーマットでの出力は以下のようになります。

{
    "id": 10,
    "_links": {
        "self": {
            "href": "/test"
        },
        "profile": {
            "href": "/profile?id=10"
        }
    }
}

内部リンク

リソースは別のリソースを埋め込むことができます。#[Embed]srcでリソースを指定します。

内部リンクされたリソースも他のリソースを内部リンクしているかもしれません。その場合また内部リンクのリソースが必要で、それが再起的に繰り返されリソースグラフが得られます。クライアントはリソースを何度もフェッチすることなく目的とするリソース群を一度に取得できます。6 例えば顧客リソースと商品リソースをそれぞれ呼び出す代わりに、注文リソースで両者を埋め込みます。

use BEAR\Resource\Annotation\Embed;

class News extends ResourceObject
{
    #[Embed(rel: 'sports', src: '/news/sports')]
    #[Embed(rel: 'weather', src: '/news/weather')]
    public function onGet(): static

埋め込まれるのはリソースリクエストです。レンダリングの時に実行されますが、その前にaddQuery()メソッドで引数を加えたりwithQuery()で引数を置き換えることができます。

srcにはURI templateが利用でき、リクエストメソッドの引数がバインドされます。(外部リンクと違って$bodyではありません)

use BEAR\Resource\Annotation\Embed;

class News extends ResourceObject
{
    #[Embed(rel: 'website', src: '/website{?id}']
    public function onGet(string $id): static
    {
        // ...
        $this->body['website']->addQuery(['title' => $title]); // 引数追加

セルフリンク

#[Embed]でリレーションを_selfとしてリンクすると、リンク先のリソース状態を自身のリソース状態にコピーします。

namespace MyVendor\Weekday\Resource\Page;

class Weekday extends ResourceObject
{
    #[Embed(rel: '_self', src: 'app://self/weekday{?year,month,day}']
    public function onGet(string $id): static
    {

この例ではPageリソースのがAppリソースのweekdayリソースの状態を自身にコピーしています。

HALでの内部リンク

HALレンダラーでは_embedded として扱われます。

リンクリクエスト

クライアントはハイパーリンクで接続されているリソースをリンクすることができます。

$blog = $this
    ->resource
    ->get
    ->uri('app://self/user')
    ->withQuery(['id' => 1])
    ->linkSelf("blog")
    ->eager
    ->request()
    ->body;

リンクは3種類あります。$relをキーにして元のリソースのbodyリンク先のリソースが埋め込まれます。

  • linkSelf($rel) リンク先と入れ替わります。
  • linkNew($rel) リンク先のリソースがリンク元のリソースに追加されます
  • linkCrawl($rel) リンクをクロールしてリソースグラフを作成します。

クロール

クロールはリスト(配列)になっているリソースを順番にリンクを辿り、複雑なリソースグラフを構成することができます。 クローラーがwebページをクロールするように、リソースクライアントはハイパーリンクをクロールしてリソースグラフを生成します。

クロール例

author, post, meta, tag, tag/name がそれぞれ関連づけられてあるリソースグラフを考えてみます。 このリソースグラフに post-tree という名前を付け、それぞれのリソースの#[Link]アトリビュートでハイパーリファレンス href を指定します。

最初に起点となるauthorリソースにはpostリソースへのハイパーリンクがあります。1:nの関係です。

#[Link(crawl: "post-tree", rel: "post", href: "app://self/post?author_id={id}")]
public function onGet($id = null)

postリソースにはmetaリソースとtagリソースのハイパーリンクがあります。1:nの関係です。

#[Link(crawl: "post-tree", rel: "meta", href: "app://self/meta?post_id={id}")]
#[Link(crawl: "post-tree", rel: "tag", href: "app://self/tag?post_id={id}")]
public function onGet($author_id)
{

tagリソースはIDだけでそのIDに対応するtag/nameリソースへのハイパーリンクがあります。1:1の関係です。

#[Link(crawl:"post-tree", rel:"tag_name", href:"app://self/tag/name?tag_id={tag_id}")]
public function onGet($post_id)

それぞれが接続されました。クロール名を指定してリクエストします。

$graph = $resource
  ->get
  ->uri('app://self/marshal/author')
  ->linkCrawl('post-tree')
  ->eager
  ->request();

リソースクライアントは#[Link]アトリビュートに指定されたクロール名を発見するとそのrel 名でリソースを接続してリソースグラフを作成します。

var_export($graph->body);

array (
    0 =>
    array (
        'name' => 'Athos',
        'post' =>
        array (
            0 =>
            array (
                'author_id' => '1',
                'body' => 'Anna post #1',
                'meta' =>
                array (
                    0 =>
                    array (
                        'data' => 'meta 1',
                    ),
                ),
                'tag' =>
                array (
                    0 =>
                    array (
                        'tag_name' =>
                        array (
                            0 =>
                            array (
                                'name' => 'zim',
                            ),
                        ),
                    ),
 ...

レンダリングと転送

Resource object internal stucture

ResourceObjectのリクエストメソッドではリソースの表現について関心を持ちません。コンテキストに応じて注入されたレンダラーがリソースの表現を生成します。同じアプリケーションがコンテキストを変えるだけでHTMLで出力されたり、JSONで出力されたりします。

遅延評価

レンダリングはリソースが文字列評価された時に行われます。


$weekday = $api->resource->get('app://self/weekday', ['year' => 2000, 'month'=> 1, 'day'=> 1]);
var_dump($weekday->body);
//array(1) {
//    ["weekday"]=>
//  string(3) "Sat"
//}

echo $weekday;
//{
//    "weekday": "Sat",
//    "_links": {
//    "self": {
//        "href": "/weekday/2000/1/1"
//        }
//    }
//}

レンダラー

それぞれのResourceObjectはコンテキストによって指定されたその表現のためのレンダラーが注入されています。リソース特有のレンダリング行う時はrendererプロパティを注入またはセットします。

例)デフォルトで用意されているJSON表現のレンダラーをスクラッチで書くと

class Index extends ResourceObject
{
    #[Inject]
    public function setRenderer(RenderInterface $renderer)
    {
        $this->renderer = new class implements RenderInterface {
            public function render(ResourceObject $ro)
            {
                $ro->headers['content-type'] = 'application/json;';
                $ro->view = json_encode($ro->body);

                return $ro->view;
            }
        };
    }
}

転送

ルートオブジェクト$appにインジェクトされたリソース表現をクライアント(コンソールやWebクライアント)に転送します。通常、出力はheader関数やechoで行われるますが、巨大なデータなどにはストリーム転送が有効です。

リソース特有の転送を行う時はtransferメソッドをオーバーライドします。

public function transfer(TransferInterface $responder, array $server)
{
    $responder($this, $server);
}

リソースの自律性

リソースはリクエストによって自身のリソース状態を変更、それを表現にして転送する機能を各クラスが持っています。

技術

BEAR.Sundayの特徴的な技術と機能を以下の章に分けて解説します。

アーキテクチャと設計原則

リソース指向アーキテクチャ (ROA)

BEAR.Sunday のROAは、Web アプリケーション内でRESTful APIを実現するアーキテクチャです。これはBEAR.Sundayの設計原則の核となるものであり、ハイパーメディアフレームワークであると同時にサービスとしてのオブジェクト(Object as a service)として扱います。Webと同様に、全てのデータや機能をリソースとみなし、GET、POST、PUT、DELETEなどの標準化されたインターフェースを通じて操作します。

URI

URI(Uniform Resource Identifier)はWebの成功の鍵となる要素であり、BEAR.SundayのROAの中核でもあります。アプリケーションが扱うすべてのリソースにURIを割り当てることで、リソースを識別し、アクセスしやすくなります。 URIは、リソースの識別子として機能するだけでなく、リソース間のリンクを表現するためにも使用されます。

ユニフォームインターフェース

リソースへのアクセスはHTTPのメソッド(GET, POST, PUT, DELETE)を用いて行われます。これらのメソッドはリソースに対して実行できる操作を規定しており、リソースの種類に関わらず共通のインターフェースを提供します。

ハイパーメディア

BEAR.Sunday のリソース指向アーキテクチャ (ROA) では、各リソースがハイパーリンクを通じてアフォーダンス(クライアントが利用可能な操作や機能)を提供します。これらのリンクは、クライアントが利用できる操作を表し、アプリケーション内をナビゲートする方法を指示します。

状態と表現の分離

BEAR.SundayのROAでは、リソースの状態とそのリソース表現が明確に分離されています。リソースの状態はリソースクラスで管理され、リソースにインジェクトされたレンダラーが様々な形式(JSON, HTMLなど)でリソースの状態をリソース状態表現で変換します。ドメインロジックとプレゼンテーションロジックは疎結合で、同じコードでもコンテキストによって状態表現の束縛を変更すると表現も変わります。

MVCとの相違点

BEAR.SundayのROA(リソース指向アーキテクチャ)は、従来のMVCアーキテクチャとは異なるアプローチを採用しています。 MVCはモデル、ビュー、コントローラーの3つのコンポーネントでアプリケーションを構成し、コントローラーはリクエストオブジェクトを受け取り、一連の処理を制御してレスポンスを返します。一方、リソースはリクエストメソッドにおいて、単一責任原則(SRP)に従い、リソースの状態の指定のみを行い、表現には関与しません。

MVCではコントローラーとモデルの関係に制約はありませんが、リソースはハイパーリンクとURIを使用した他のリソースを含める明示的な制約があります。これにより、呼び出されるリソースの情報隠蔽を維持しながら、宣言的な方法でコンテンツの内包関係とツリー構造を定義できます。

MVCのコントローラーはリクエストオブジェクトから手動で値を取得しますが、リソースは必要な変数をリクエストメソッドの引数として宣言的に定義します。そのため、入力バリデーションもJsonSchemaを使用して宣言的に実行され、引数とその制約がドキュメント化されます。

依存性の注入 (DI)

依存性の注入 (Dependency Injection, DI) は、オブジェクト指向プログラミングにおけるアプリケーションの設計と構造を強化するための重要な手法です。DIの中心的な目的は、アプリケーションの機能を複数の独立したドメインまたは役割を持つコンポーネントに分割し、それらの間の依存関係を管理することです。

DIは、1つの機能(関心事、責務)を複数の機能に水平分割するのに役立ちます。分割された機能は「依存」として各部分を独立して開発、テストができるようになります。単一責任原則に基づき明確な責任と役割を持つそれらの依存を外部から注入することで、オブジェクトの再利用性とテスト性を向上させます。また依存は他の依存へと垂直でも分割され、依存関係のツリーを形成します。

BEAR.SundayのDIはRay.Diという独立したパッケージを使用しており、Google社製のDIフレームワークであるGuiceの設計思想を取り入れ、ほぼすべての機能をカバーしています。

その他に以下の特徴があります。

  • コンテキストにより束縛を変え、テスト時に異なる実装を注入することができます。
  • アトリビュートによる設定でコードの自己記述性が高まります。
  • Ray.Diはコンパイル時に依存性の解決を行うため、ランタイム時のパフォーマンスが向上します。これは、ランタイム時に依存性を解決する他のDIコンテナとは異なる点です。
  • オブジェクトの依存関係をグラフで可視化できます。例)ルートオブジェクト

Ray.Di logo

アスペクト指向プログラミング (AOP)

アスペクト指向プログラミング(AOP)は、ビジネスロジックなどの本質的な関心と、ログやキャッシュなどの横断的関心を分離することで、柔軟なアプリケーションを実現するパターンです。横断的関心とは、複数のモジュールやレイヤーにまたがって存在する機能や処理のことを指します。探索条件に基づいた横断的処理の束縛が可能で、コンテキストに基づいた柔軟な構成が可能です。

BEAR.SundayのAOPはRay.Aopという独立したパッケージを使用しており、PHPのアトリビュートをクラスやメソッドに付与して、横断的処理を宣言的に束縛します。Ray.Aopは、JavaのAOP Allianceに準拠しています。

AOPは「既存の秩序を壊す強い力」と誤解されがちな技術です。その存在意義は制約を超えた力の行使などではなく、マッチャーを使った探索的な機能の割り当てや横断的処理の分離などオブジェクト指向が不得意とする分野の補完にあります。AOPはアプリケーションの横断的な制約を作ることのできる、つまりアプリケーションフレームワークとして機能するパラダイムです。

パフォーマンスとスケーラビリティ

モダンCDNとの統合によるROAベースのイベントドリブンコンテンツ戦略

BEAR.Sundayは、リソース指向アーキテクチャ(ROA)を中核として、Fastlyなどのインスタントパージ可能なCDNと統合することで、高度なイベントドリブンキャッシュ戦略を実現しています。この戦略では、従来のTTL(Time to Live)によるキャッシュの無効化ではなく、リソースの状態変更イベントに応じてCDNとサーバーサイドのキャッシュ、およびETag(エンティティタグ)を即座に無効化します。

このようにCDNに揮発性のない永続的なコンテンツを作るというアプローチにより、SPOF(Single Point of Failure)を回避し、高い可用性と耐障害性を実現するだけでなく、ユーザー体験とコスト効率を最大化させ、ダイナミックコンテンツでもスタティックコンテンツと同じWeb本来の分散キャッシングを実現します。Webが1990年代から持っていたスケーラブルでネットワークコストも削減する分散キャッシュという原則を、現代的な技術で再実現しているのです。

セマンティックメソッドと依存によるキャッシュ無効化

BEAR.SundayのROAでは、各リソース操作にセマンティック(意味的な役割)が与えられています。例えば、GET メソッドはリソースを取得し、PUT メソッドはリソースを更新します。これらのメソッドがイベントドリブン方式で連携し、関連するキャッシュを効率的に無効化します。たとえば、特定のリソースが更新された際には、そのリソースを必要とするリソースのキャッシュが無効化されます。これにより、データの一貫性と新鮮さが保たれ、ユーザーに最新の情報が提供されます。

ETagによる同一性確認と高速な応答

システムがブートする前にETagを設定することで、コンテンツの同一性を迅速に確認し、変更がなければ304 Not Modified応答を返しネットワークの負荷を最小化します。

ドーナッツキャッシュとESIによる部分的な更新

BEAR.Sundayでは、ドーナッツキャッシュ戦略を採用しており、ESI(Edge Side Includes)を使用してCDNエッジで部分的なコンテンツ更新を可能にしています。この技術により、ページ全体を再キャッシュすることなく、必要な部分だけを動的に更新しキャッシュ効率を向上させます。

このように、BEAR.SundayとFastlyの統合によるROAベースのキャッシュ戦略は、高度な分散キャッシングの実現とともに、アプリケーションのパフォーマンス向上と耐障害性の強化を実現しています。

起動の高速化

DIの本来の世界では、ユーザーは可能な限りインジェクター(DIコンテナ)を直接扱いません。その代わり、アプリケーションのエントリーポイントで1つのルートオブジェクトを生成してアプリケーションを起動します。BEAR.SundayのDIでは、設定時でもDIコンテナの操作が実質存在しません。ルートオブジェクトは巨大ですが1つの変数なのでリクエストを超えて再利用され極限まで最適化したブートストラップを実現します。

開発者エクスペリエンス

テストの容易性

BEAR.Sundayは、以下の設計上の特徴により、テストが容易で効果的に行えます。

  • 各リソースは独立していて、RESTのステートレスリクエストの性質によりテストは容易です。 リソースの状態と表現が明確に分離されているため、HTML表現の時でもリソースの状態をテストすることが可能です。
  • ハイパーメディアのリンクをたどりながらAPIのテストを行え、PHPとHTTPの同一コードでテストできます。
  • コンテキストによる束縛により、テスト時に異なる実装を束縛します。

APIドキュメント生成

コードからAPIドキュメントを自動生成します。コードとドキュメントの整合性を保ち、保守性を高めます。

視覚化とデバッグ

リソースが自身でレンダリングする技術的特徴を生かし、開発時にHTML上でリソースの範囲を示し、リソース状態をモニターする事や、PHPコードやHTMLテンプレートをオンラインエディターで編集し、リアルタイムに反映することもできます。

拡張性と統合

PHPインターフェイスとSQL実行の統合

BEAR.SundayではPHPのインターフェイスを通じて、データベースとのやり取りを行うSQL文の実行を簡単に管理できます。クラスを実装することなく、PHPインターフェイスに直接SQLの実行オブジェクトを束縛することが可能です。ドメインとインフラストラクチャーの境界をPHPインターフェイスで繋ぎます。

その際引数には型も指定でき、不足している分はDIが依存解決を行い文字列として利用されます。SQL実行に現在時刻が必要な時でも渡す必要はなく、自動束縛されます。クライアントが全ての引数を渡す責任がなく、コードの簡潔さを保つのに役立ちます。

またSQLの直接管理は、エラー発生時のデバッグを容易にします。SQLクエリの動作を直接観察し、問題の特定と修正を迅速に行うことができます。

他システムとの統合

コンソールアプリケーションと統合し、ソースコードを変えずにWebとコマンドライン双方からアクセス可能にします。また 同一PHPランタイム内で異なるBEAR.Sundayアプリケーションを並行実行できることでマイクロサービスを構築する事なく独立した複数のアプリケーションを連携させることができます。

ストリーム出力

リソースのボディにファイルのポインタなどのストリームをアサインすることで、メモリ上では扱えない大規模なコンテンツを出力できます。その際、ストリームは通常の実変数と混在させることも可能で大規模なレスポンスを柔軟に出力できます。

他のシステムからの段階的移行

BEAR.Sundayは段階的な移行パスを提供し、LaravelやSymfonyなどの他のフレームワークやシステムとのシームレスな統合を可能にします。このフレームワークは、Composerパッケージとして実装できるため、開発者は既存のコードベースにBEAR.Sundayの機能を段階的に導入できます。

技術移行の柔軟性

BEAR.Sunday は、将来の技術的変化や要件の進化に備えて投資を保護します。このフレームワークから別のフレームワークや言語に移行する必要がある場合でも、構築したリソースは無駄になりません。 PHP環境では、BEAR.SundayアプリケーションをComposerパッケージとして統合して継続的に利用できますし、BEAR.Thriftを使用すると、他の言語からBEAR.Sundayリソースに効率的にアクセスできます。Thriftを使用しない時でもHTTPでアクセスが可能です。またSQLコードの再利用も容易です。

たとえ使用しているライブラリが特定のPHPバージョンに強く依存している場合でもBEAR.Thriftを使って異なるバージョンのPHPを共存することができます。

設計思想と品質

標準技術の採用と独自規格の排除

BEAR.Sunday は、可能な限り標準技術を採用し、フレームワーク独自の規格やルールを排除するという設計思想を持っています。 例えば、デフォルトでJSON形式とwwwフォーム形式のHTTPリクエストのコンテントネゴシエーションではサポートし、エラーレスポンスにはvnd.error+jsonメディアタイプ形式を使用します。リソース間のリンクにはHAL(Hypertext Application Language)を採用し、バリデーションにはJsonSchemaを用いるなど、標準的な技術や仕様を積極的に取り入れています。

一方で、独自のバリデーションルールや、フレームワーク特有の規格・ルールは可能な限り排除しています。

オブジェクト指向原則

BEAR.Sundayはアプリケーションを長期的にメンテナンス可能すとするためのオブジェクト指向原則を重視しています。

継承より合成

継承クラスよりコンポジションを推奨します。一般に子クラスから親クラスのメソッドを直接呼び出すことは、クラス間の結合度を高くする可能性があります。設計上ランタイムで継承が必要な抽象クラスはリソースクラスのBEAR\Resource\ResourceObjectのみですが、これもResourceObjectのメソッドは他のクラスが利用するためだけに存在します。ユーザーが継承したフレームワークの親クラスのメソッドをランタイムに呼び出すことはBEAR.Sundayではどのクラスにもありません。

全てがインジェクション

フレームワークのクラスが「設定ファイル」や「デバッグ定数」を実行中に参照して振る舞いを決定する事はありません。振る舞いに応じた依存が注入されます。これにより、アプリケーションの振る舞いを変更するためには、コードを変更する必要がなく、インターフェイスに対する依存性の実装の束縛を変更するだけで済みます。APP_DEBUGやAPP_MODE定数は存在しません。ソフトウエアが起動した後に現在どのモードで動いているか知る方法はありませんし、知る必要もありません。

後方互換性の永続的確保

BEAR.Sundayは、ソフトウェアの進化において後方互換性の維持を重視して設計されており、リリース以来、後方互換性を破壊することなく進化を続けています。現代のソフトウェア開発では、頻繁な後方互換性の破壊と、それに伴う改修やテストの負担が課題となっていますが、BEAR.Sundayはこの問題を回避してきました。

BEAR.Sunday では、セマンティックバージョニングを採用するだけでなく破壊を伴うメジャーバージョンアップを行いません。新しい機能の追加や既存機能の変更が既存のコードに影響を与えることを防いでいます。古くなって使われなくなったコードは「deprecated」の属性が与えられますが、削除されることはなく、既存のコードの動作にも影響を与えません。代わりに、新しい機能が追加され、進化が続けられます。

非環式依存原則

非環式依存原則(ADP)とは、依存関係が一方向であり、循環していないことを意味します。BEAR.Sundayフレームワークはこの原則に基づき、一連のパッケージで構成されており、大きなフレームワークパッケージが小さなフレームワークパッケージに依存する階層構造をしています。各レベルはそれを包含する他のレベルの存在自体を知る必要はなく、依存関係は一方向のみで循環しません。例えば、Ray.AopはRay.Diの存在すら知りませんし、Ray.DiはBEAR.Sundayの存在を知りません。

非環式依存原則に従ったフレームワーク構造

後方互換性が保持されているため、各パッケージは独立して更新が可能です。また、他のフレームワークで見られるような全体をロックするバージョン番号は存在せず、オブジェクト間を横断する依存関係を持つオブジェクトプロキシーの機構もありません。

この非環式依存原則はDI(依存性注入)の原則と調和していて、BEAR.Sundayが起動する際に生成されるルートオブジェクトも、この非環式依存原則の構造に従って構築されています。

ランタイムも同様です。リソースにアクセスが行われる際、まずメソッドに結びつけられたAOPアスペクトの横断的な処理が行われ、その後でメソッドがリソースの状態を決定しますが、この時点でメソッドは結びつけられたアスペクトの存在を認識していません。リソースの状態に埋め込まれたリソースも同じです。それらは外側の層や要素の知識を持っていません。 関心の分離が明確にされています。

コード品質

高いコード品質のアプリケーションを提供するためにBEAR.Sundayフレームワークも高い水準でコード品質を維持するよう努めています。

  • フレームワークのコードは静的解析ツールのPsalmとPHPStan双方で最も厳しいレベルで適用しています。
  • テストカバレッジ100%を保っていて、タイプカバレッジもほぼ100%です。
  • 原則的にイミュータブルなシステムであり、テストでも毎回初期化が不要なほどクリーンです。SwooleのようなPHPの非同期通信エンジンの力を引き出します。

BEAR.Sundayのもたらす価値

開発者にとっての価値

  • 生産性の向上:堅牢な設計パターンと原則に基づき時間が経っても変わらない制約で、開発者はコアなビジネスロジックに集中できます。
  • チームでの協業:開発チームに一貫性のあるガイドラインと構造を提供することで、異なるエンジニアたちのコードを疎結合のまま統一的に保ち、コードの可読性とメンテナンス性を向上します。
  • 柔軟性と拡張性:BEAR.Sundayのライブラリを含まない方針は、開発者にコンポーネントの選択における柔軟性と自由をもたらします。
  • テスト容易性:BEAR.Sunday の DI(依存性の注入)と ROA(リソース指向アーキテクチャ)がテスト容易性を高めます。

ユーザーにとっての価値

  • 高いパフォーマンス:BEAR.Sundayの最適化された高速起動とCDNを中心としたキャッシュ戦略はユーザーに高速で応答性の高いエクスペリエンスをもたらします。
  • 信頼性と可用性:BEAR.SundayのCDNを中心としたキャッシュ戦略は、単一障害点(SPOF)を最小化し、ユーザーは安定したサービスを享受できます。
  • 使いやすさ:BEAR.Sundayの優れた接続性は他の言語やシステムと協調することを容易にします。

ビジネスにとっての価値

  • 開発コストの削減:BEAR.Sundayが提供する一貫性のあるガイドラインと構造は、持続的で効率的な開発プロセスを促進し開発コストを削減します。
  • 維持コストの削減:BEAR.Sundayの後方互換性を維持するアプローチは、技術的継続性を高め、変更対応の時間とコストを最小限に抑えます。
  • 高い拡張性:BEAR.Sunday のコードの変更を最小限に抑えつつ振る舞いを変えるDI(依存性の注入)やAOP(アスペクト指向プログラミング)といった技術で、ビジネスの成長や変化に合わせながらアプリケーションを容易に拡張できます。
  • 優れたユーザーエクスペリエンス(UX):BEAR.Sunday は高いパフォーマンスと高い可用性を提供することで、ユーザーの満足度を高め、顧客ロイヤリティの向上、顧客基盤の拡大、ビジネスの成功に貢献します。

優れた制約は変わりません。BEAR.Sundayがもたらす制約は、開発者、ユーザー、ビジネスのそれぞれに具体的な価値を提供します。

BEAR.Sundayは、Webの原則と精神に基づいて設計されたフレームワークであり、開発者に明確な制約を提供することで、柔軟で堅牢なアプリケーションを構築する力を与えます。

ルーター

ルーターはWebやコンソールなどの外部コンテキストのリソースリクエストを、BEAR.Sunday内部のリソースリクエストに変換します。

$request = $app->router->match($GLOBALS, $_SERVER);
echo (string) $request;
// get page://self/user?name=bear

Webルーター

デフォルトのWebルーターではHTTPリクエストのパス($_SERVER['REQUEST_URI'])に対応したリソースクラスにアクセスされます。 例えば/indexのリクエストは{Vendor名}\{Project名}\Resource\Page\IndexクラスのHTTPメソッドに応じたPHPメソッドにアクセスされます。

Webルーターは規約ベースのルーターです。設定やスクリプトは必要ありません。

namespace MyVendor\MyProject\Resource\Page;

// page://self/index
class Index extends ResourceObject
{
    public function onGet(): static // GETリクエスト
    {
    }
}

CLIルーター

cliコンテキストではコンソールからの引数が外部入力になります。

php bin/page.php get /

BEAR.SundayアプリケーションはWebとCLIの双方で動作します。

複数の単語を使ったURI

ハイフンを使い複数の単語を使ったURIのパスはキャメルケースのクラス名を使います。 例えば/wild-animalのリクエストはWildAnimalクラスにアクセスされます。

パラメーター

HTTPメソッドに対応して実行されるPHPメソッドの名前と渡される値は以下の通りです。

HTTPメソッド PHPメソッド  渡される値
GET onGet $_GET
POST onPost $_POST または 標準入力
PUT onPut ※標準入力
PATCH onPatch ※標準入力
DELETE onDelete ※標準入力 

リクエストのメディアタイプは以下の2つが利用できます。

  • application/x-www-form-urlencoded // param1=one&param2=two
  • application/json // {“param1”: “one”, “param2”: “one”} (POSTの時は標準入力の値が使われます)

PHPマニュアルのPUT メソッドのサポートもご覧ください。

メソッドオーバーライド

HTTP PUT トラフィックや HTTP DELETE トラフィックを許可しないファイアウォールがあります。 この制約に対応するため、次の2つの方法でこれらの要求を送ることができます。

  • X-HTTP-Method-Override POSTリクエストのヘッダーフィールドを使用してPUTリクエストやDELETEリクエストを送る。
  • _method URI パラメーターを使用する。例)POST /users?…&_method=PUT

Auraルーター

リクエストのパスをパラメーターとして受け取る場合はAura Routerを使用します。

composer require bear/aura-router-module ^2.0

ルータースクリプトのパスを指定してAuraRouterModuleをインストールします。

use BEAR\Package\AbstractAppModule;
use BEAR\Package\Provide\Router\AuraRouterModule;

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ...
        $this->install(new AuraRouterModule($appDir . '/var/conf/aura.route.php'));
    }
}

キャッシュされているDIファイルを消去します。

rm -rf var/tmp/*

ルータースクリプト

ルータースクリプトではグローバルで渡されたMapオブジェクトに対してルートを設定します。 ルーティングにメソッドを指定する必要はありません。1つ目の引数はルート名としてパス、2つ目の引数に名前付きトークンのプレイスフォルダーを含んだパスを指定します。

var/conf/aura.route.php

<?php
/* @var \Aura\Router\Map $map */
$map->route('/blog', '/blog/{id}');
$map->route('/blog/comment', '/blog/{id}/comment');
$map->route('/user', '/user/{name}')->tokens(['name' => '[a-z]+']);
  • 最初の行では/blog/bearとアクセスがあるとpage://self/blog?id=bearとしてアクセスされます。 (=BlogクラスのonGet($id)メソッドに$id=bearの値でコールされます。)

  • /blog/{id}/commentBlog\Commentクラスにルートされます。

  • token()はパラメーターを正規表現で制限するときに使用します。

優先ルーター

Auraルーターでルートされない場合は、Webルーターが使われます。 つまりパスでパラメーターを渡すURIだけにルータースクリプトを用意すればOKです。

パラメーター

パスからパラメーターを取得するためにAuraルーターは様々な方法が用意されています。

カスタムマッチング

下のスクリプトは{date}が適切なフォーマットの時だけルートします。

$map->route('/calendar/from', '/calendar/from/{date}')
    ->tokens([
        'date' => function ($date, $route, $request) {
            try {
                new \DateTime($date);
                return true;
            } catch(\Exception $e) {
                return false;
            }
        }
    ]);

オプション

オプションのパラメーターを指定するためにはパスに{/attribute1,attribute2,attribute3}の表記を加えます。

例)

$map->route('archive', '/archive{/year,month,day}')
    ->tokens([
        'year' => '\d{4}',
        'month' => '\d{2}',
        'day' => '\d{2}',
    ]);

プレイスホルダーの内側に最初のスラッシュがあるのに注意してください。 そうすると下のパスは全て’archive’にルートされパラメーターの値が付加されます。

  • /archive : ['year' => null, 'month' => null, 'day' = null]
  • /archive/1979 : ['year' => '1979', 'month' => null, 'day' = null]
  • /archive/1979/11 : ['year' => '1979', 'month' => '11', 'day' = null]
  • /archive/1979/11/07 : ['year' => '1979', 'month' => '11', 'day' = '07']

オプションパラメーターは並ぶ順にオプションです。つまり”month”なしで”day”を指定することはできません。

ワイルドカード

任意の長さのパスの末尾パラメーターとして格納したいときにはwildcard()メソッドを使います。

$map->route('wild', '/wild')
    ->wildcard('card');

スラッシュで区切られたパスの値が配列になりwildcard()で指定したパラメーターに格納されます。

  • /wild : ['card' => []]
  • /wild/foo : ['card' => ['foo']]
  • /wild/foo/bar : ['card' => ['foo', 'bar']]
  • /wild/foo/bar/baz : ['card' => ['foo', 'bar', 'baz']]

その他の高度なルートに関してはAura Routerのdefining-routesをご覧ください。

リバースルーティング

ルートの名前とパラメーターの値からURIを生成することができます。

use BEAR\Sunday\Extension\Router\RouterInterface;

class Index extends ResourceObject
{
    /**
     * @var RouterInterface
     */
    private $router;

    public function __construct(RouterInterface $router)
    {
        $this->router = $router;
    }

    public function onGet(): static
    {
        $userLink = $this->router->generate('/user', ['name' => 'bear']);
        // '/user/bear'

リクエストメソッド

リクエストメソッドを指定する必要はありません。

リクエストヘッダー

通常リクエストヘッダーはAura.Routerに渡されていませんが RequestHeaderModuleをインストールするとAura.Routerでヘッダーを使ったマッチングが可能になります。

$this->install(new RequestHeaderModule());

独自のルーターコンポーネント

プロダクション

BEAR.Sunday既定のprod束縛に対して、アプリケーションがそれぞれのディプロイ環境に応じたモジュールをカスタマイズして束縛を行ます。

既定のProdModule

既定のprod束縛では以下のインターフェイスの束縛がされています。

  • エラーページ生成ファクトリー
  • PSRロガーインターフェイス
  • ローカルキャッシュ
  • 分散キャッシュ

詳細はBEAR.PackageのProdModule.php参照。

アプリケーションのProdModule

既定のProdModuleに対してアプリケーションのProdModulesrc/Module/ProdModule.phpに設置してカスタマイズします。特にエラーページと分散キャッシュは重要です。

<?php
namespace MyVendor\Todo\Module;

use BEAR\Package\Context\ProdModule as PackageProdModule;
use BEAR\QueryRepository\CacheVersionModule;
use BEAR\Resource\Module\OptionsMethodModule;
use BEAR\Package\AbstractAppModule;

class ProdModule extends AbstractModule
{
    /**
     * {@inheritdoc}
     */
    protected function configure()
    {
        $this->install(new PackageProdModule);       // デフォルトのprod設定
        $this->override(new OptionsMethodModule);    // OPTIONSメソッドをプロダクションでも有効に
        $this->install(new CacheVersionModule('1')); // リソースキャッシュのバージョン指定

        // 独自のエラーページ
        $this->bind(ErrorPageFactoryInterface::class)->to(MyErrorPageFactory::class);
    }
}

キャッシュ

キャッシュはローカルキャッシュと、複数のWebサーバー間でシェアをする分散キャッシュの2種類があります。 どちらのキャッシュもデフォルトはPhpFileCacheです。

ローカルキャッシュ

ローカルキャッシュはdeploy後に変更のないアノテーション等のキャシュ例に使われ、分散キャッシュはリソース状態の保存に使われます。

分散キャッシュ

2つ以上のWebサーバーでサービスを行うためには分散キャッシュの構成が必要です。 代表的なmemcachedRedisのキャッシュエンジンのそれぞれのモジュールが用意されています。

Memcached

<?php
namespace BEAR\HelloWorld\Module;

use BEAR\QueryRepository\StorageMemcachedModule;
use BEAR\Resource\Module\ProdLoggerModule;
use BEAR\Package\Context\ProdModule as PackageProdModule;
use BEAR\Package\AbstractAppModule;
use Ray\Di\Scope;

class ProdModule extends AbstractModule
{
    protected function configure()
    {
        // memcache
        // {host}:{port}:{weight},...
        $memcachedServers = 'mem1.domain.com:11211:33,mem2.domain.com:11211:67';
        $this->install(new StorageMemcachedModule(memcachedServers);

        // Prodロガーのインストール
        $this->install(new ProdLoggerModule);
        // デフォルトのProdModuleのインストール
        $this->install(new PackageProdModule);
    }
}

Redis

// redis
$redisServer = 'localhost:6379'; // {host}:{port}
$this->install(new StorageRedisModule($redisServer);

リソースの状態保存は単にTTLによる時間更新のキャッシュとの他に、TTL時間では消えない永続的なストレージとして(CQRS)の運用も可能です。 その場合にはRedisで永続処理を行うか、Cassandraなどの他KVSのストレージアダプターを独自で用意する必要があります。

キャッシュ時間の指定

デフォルトのTTLを変更する場合StorageExpiryModuleをインストールします。

// Cache time
$short = 60;
$medium = 3600;
$long = 24 * 3600;
$this->install(new StorageExpiryModule($short, $medium, $long);

キャッシュバージョンの指定

リソースのスキーマが代わり、互換性が失われる時にはキャッシュバージョンを変更します。特にTTL時間で消えないCQRS運用の場合に重要です。

$this->install(new CacheVersionModule($cacheVersion));

ディプロイの度にリソースキャッシュを破棄するためには$cacheVersionに時刻や乱数の値を割り当てると変更が不要で便利です。

ログ

ProdLoggerModuleはプロダクション用のリソース実行ログモジュールです。インストールするとGET以外のリクエストをPsr\Log\LoggerInterfaceにバインドされているロガーでログします。 特定のリソースや特定の状態でログしたい場合は、カスタムのログをBEAR\Resource\LoggerInterfaceにバインドします。

use BEAR\Resource\LoggerInterface;
use Ray\Di\AbstractModule;

final class MyProdLoggerModule extends AbstractModule
{
    protected function configure(): void
    {
        $this->bind(LoggerInterface::class)->to(MyProdLogger::class);
    }
}

LoggerInterface__invokeメソッドでリソースのURIとリソース状態がResourceObjectオブジェクトとして渡されるのでその内容で必要な部分をログします。 作成には既存の実装 ProdLoggerを参考にしてください。

デプロイ

⚠️ 上書き更新を避ける

サーバーにディプロイする場合

  • 駆動中のプロジェクトフォルダをrsyncなどで上書きするのはキャッシュやオンデマンドで生成されるファイルの不一致や、高負荷のサイトではキャパシティを超えるリスクがあります。 安全のために別のディレクトリでセットアップを行いそのセットアップが成功すれば切り替えるようにします。
  • DeployerBEAR.Sundayレシピを利用する事ができます。

クラウドにディプロイする時には

  • コンパイルが成功すると0、依存関係の問題を見つけるとコンパイラはexitコード1を出力します。それを利用してCIにコンパイルを組み込む事を推奨します。

コンパイル推奨

セットアップを行う際にvendor/bin/bear.compileスクリプトを使ってプロジェクトをウオームアップすることができます。 コンパイルスクリプトはDI/AOP用の動的に作成されるファイルやアノテーションなどの静的なキャッシュファイルを全て事前に作成し、最適化されたautoload.phpファイルとpreload.phpを出力します。

  • コンパイルをすれば全てのクラスでインジェクションを行うのでランタイムでDIのエラーが出る可能性が極めて低くなります。
  • .envには含まれた内容はPHPファイルに取り込まれるのでコンパイル後に.envを消去可能です。

コンテントネゴシエーションを行う場合など(ex. api-app, html-app)1つのアプリケーションで複数コンテキストのコンパイルを行うときにはファイルの退避が必要です。

mv autoload.php api.autoload.php

composer.jsonを編集してcomposer compileの内容を変更します。

autoload.php

{project_path}/autoload.phpに最適化されたautoload.phpファイルが出力されます。 composer dumpa-autoload --optimizeで出力されるvendor/autoload.phpよりずっと高速です。

注意:preload.phpを利用する場合、ほとんどの利用クラスが読み込まれた状態で起動するのでコンパイルされたautoload.phpは不要です。composerが生成するvendor/autload.phpをご利用ください。

preload.php

{project_path}/preload.phpに最適化されたpreload.phpファイルが出力されます。 preloadを有効にするためにはphp.iniでopcache.preloadopcache.preloadを指定する必要があります。PHP 7.4でサポートされた機能ですが、7.4初期のバージョンでは不安定です。7.4.4以上の最新版を使いましょう。

例)

opcache.preload=/path/to/project/preload.php
opcache.preload_user=www-data

Note: パフォーマンスベンチマークは[bechmark](https://github.com/bearsunday/BEAR.HelloworldBenchmark/wiki/Intel-Core-i5-3.8-GHz-iMac-(Retina-5K,-27-inch,-2017)-

インポート

BEARのアプリケーションは、マイクロサービスにすることなく複数のBEARアプリケーションを協調して1つのシステムにすることができます。また、他のアプリケーションからBEARのリソースを利用するのも容易です。

composer インストール

利用するBEARアプリケーションをcomposerパッケージにしてインストールします。

composer.json

{
  "require": {
    "bear/package": "^1.13",
    "my-vendor/weekday": "dev-master"
  },
  "repositories": [
    {
      "type": "vcs",
      "url": "https://github.com/bearsunday/tutorial1.git"
    }
  ]
}

bear/package ^1.13が必要です。

モジュールインストール

インポートするホスト名とアプリケーション名(namespace)、コンテキストを指定してImportAppModuleで他のアプリケーションをインストールします。

AppModule.php

+use BEAR\Package\Module\ImportAppModule;
+use BEAR\Package\Module\Import\ImportApp;

class AppModule extends AbstractAppModule
{
    protected function configure(): void
    {
        // ...
+        $this->install(new ImportAppModule([
+            new ImportApp('foo', 'MyVendor\Weekday', 'prod-app')
+        ]));
        $this->install(new PackageModule());
    }
}

ImportAppModuleBEAR\ResourceではなくBEAR\Packageのものであることに注意してください。

リクエスト

インポートしたリソースは指定したホスト名を指定して利用します。

class Index extends ResourceObject
{
    use ResourceInject;

    public function onGet(string $name = 'BEAR.Sunday'): static
    {
        $weekday = $this->resource->get('app://foo/weekday?year=2022&month=1&day=1');
        $this->body = [
            'greeting' => 'Hello ' . $name,
            'weekday' => $weekday
        ];

        return $this;
    }
}

#[Embed]#[Link]も同様に利用できます。

他のシステムから

他のフレームワークやCMSからBEARのリソースを利用するのも容易です。

同じようにパッケージとしてインストールして、Injector::getInstanceでrequireしたアプリケーションのリソースクライアントを取得してリクエストします。


use BEAR\Package\Injector;
use BEAR\Resource\ResourceInterface;

$resource = Injector::getInstance(
    'MyVendor\Weekday',
    'prod-api-app',
    dirname(__DIR__) . '/vendor/my-vendor/weekday'
)->getInstance(ResourceInterface::class);
$weekdday = $resource->get('/weekday', ['year' => '2022', 'month' => '1', 'day' => 1]);

echo $weekdday->body['weekday'] . PHP_EOL;

環境変数

環境変数はグローバルです。アプリケーション間でコンフリクトしないようにプリフィックスを付与するなどして注意する必要があります。インポートするアプリケーションは.envファイルを使うのではなく、プロダクションと同じようにシェルの環境変数を取得します。

システム境界

大きなアプリケーションを小さな複数のアプリケーションの集合体として構築できる点はマイクロサービスと同じですが、インフラストラクチャのオーバーヘッドの増加などのマイクロサービスのデメリットがありません。 またモジュラーモノリスよりもコンポーネントの独立性や境界が明確です。

このページのコードは bearsunday/example-app-import にあります。

多言語フレームワーク

BEAR.Thriftを使うと、Apache Thriftを使って他の言語や異なるバージョンのPHPやBEARアプリケーションからリソースにアクセスできます。 Apache Thriftは、異なる言語間での効率的な通信を可能にするフレームワークです。

AaaS (Application as a Service)

作成したAPIアプリケーションはWebやコンソール(バッチ)からアクセスできますが、他のPHPプロジェクトからライブラリとしてアクセスする事もできます。 このチュートリアルで作成したリポジトリはhttps://github.com/bearsunday/Tutorial2.gitにpushしてあります。

このプロジェクトをライブラリとして利用してみましょう。まず最初に新しいプロジェクトフォルダを作ってcomposer.jsonを用意します。

mkdir app
cd app
mkdir -p ticket/log
mkdir ticket/tmp

composer.json

{
    "name": "my-vendor/app",
    "description": "A BEAR.Sunday application",
    "type": "project",
    "license": "proprietary",
    "require": {
        "my-vendor/ticket": "dev-master"
    },
    "repositories": [
        {
            "type": "vcs",
            "url": "https://github.com/bearsunday/Tutorial2.git"
        }
    ]
}

composer installでプロジェクトがライブラリとしてインストールされます。

composer install

Ticket APIはプロジェクトフォルダにある.envを読むように設定されてました。vendor/my-vendor/app/.envに保存出来なくもないですが、ここでは別の方法で環境変数をセットアップしましょう。

このようなapp/.envファイルを用意します。

export TKT_DB_HOST=localhost
export TKT_DB_NAME=ticket
export TKT_DB_USER=root
export TKT_DB_PASS=''
export TKT_DB_SLAVE=''
export TKT_DB_DSN=mysql:host=${TKT_DB_HOST}\;dbname=${TKT_DB_NAME}

sourceコマンドで環境変数にexportすることができます。

source .env

Ticket APIを他のプロジェクトから利用する最も簡単なスクリプトは以下のようなものです。 アプリケーション名とコンテキストを指定してアプリケーションオブジェクト$ticketを取得してリソースアクセスします。

<?php
use BEAR\Package\Bootstrap;

require __DIR__ . '/vendor/autoload.php';

$ticket = (new Bootstrap)->getApp('MyVendor\Ticket', 'app');
$response = $ticket->resource->post('app://self/ticket',
    ['title' => 'run']
);

echo $response->code . PHP_EOL;


index.phpと保存して実行してみましょう。

php index.php
201

APIを他のメソッドに渡したり、他のフレームワークなどののコンテナに格納するためにはcallableオブジェクトにします。 $createTicketは普通の関数のように扱うことができます。

<?php
use BEAR\Package\Bootstrap;

require __DIR__ . '/vendor/autoload.php';

$ticket = (new Bootstrap)->getApp('MyVendor\Ticket', 'app');
$createTicket = $ticket->resource->post->uri('app://self/ticket');
// invoke callable object
$response = $createTicket(['title' => 'run']);
echo $response->code . PHP_EOL;

うまく動きましたか?しかし、このままではtmp/ logディレクトリはvendorの下のアプリが使われてしまいますね。 このようにアプリケーションのメタ情報を変更するとディレクトリの位置を変更することができます。

<?php

use BEAR\AppMeta\Meta;
use BEAR\Package\Bootstrap;

require __DIR__ . '/vendor/autoload.php';

$meta = new Meta('MyVendor\Ticket', 'app');
$meta->tmpDir = __DIR__ . '/ticket/tmp';
$meta->logDir = __DIR__ . '/ticket/log';
$ticket = (new Bootstrap)->newApp($meta, 'app');

Ticket APIはREST APIとしてHTTPやコンソールからアクセスできるだけでなく、BEAR.Sundayではない他のプロジェクトのライブラリとしても使えるようになりました!


from tutorial1

アプリケーションのインポート

BEAR.Sundayで作られたリソースは再利用性に優れています。複数のアプリケーションを同時に動作させ、他のアプリケーションのリソースを利用することができます。別々のWebサーバーを立てる必要はありません。

他のアプリケーションのリソースを利用して見ましょう。

通常はアプリケーションをパッケージとして利用しますが、ここではチュートリアルのためにmy-vendorに新規でアプリケーションを作成して手動でオートローダーを設定します。

mkdir my-vendor
cd my-vendor
composer create-project bear/skeleton Acme.Blog

composer.jsonautoloadのセクションにAcme\\Blogを追加します。

"autoload": {
    "psr-4": {
        "MyVendor\\Weekday\\": "src/",
        "Acme\\Blog\\": "my-vendor/Acme.Blog/src/"
    }
},

autoloadをダンプします。

composer dump-autoload

これでAcme\Blogアプリケーションが配置できました。

次にアプリケーションをインポートするためにsrc/Module/AppModule.phpImportAppModuleを上書き(override)インストールします。

<?php
// ...
use BEAR\Resource\Module\ImportAppModule; // add this line
use BEAR\Resource\ImportApp; // add this line
use BEAR\Package\Context; // add this line

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ...
        $importConfig = [
            new ImportApp('blog', 'Acme\Blog', 'prod-hal-app') // host, name, context
        ];
        $this->override(new ImportAppModule($importConfig , Context::class));
    }
}

これはAcme\Blogアプリケーションをprod-hal-appコンテキストで作成したリソースをblogというホストで使用することができます。

src/Resource/App/Import.phpにImportリソースを作成して確かめてみましょう。

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

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

class Import extends ResourceObject
{
    use ResourceInject;

    public function onGet()
    {
        $this->body =[
            'blog' => $this->resource->uri('page://blog/index')['greeting']
        ];

        return $this;
    }
}

page://blog/indexリソースのgreetingblogに代入されているはずです。@Embedも同様に使えます。

php bin/app.php get /import
200 OK
content-type: application/hal+json

{
    "blog": "Hello BEAR.Sunday",
    "_links": {
        "self": {
            "href": "/import"
        }
    }
}

他のアプリケーションのリソースを利用することができました!データ取得をHTTP越しにする必要もありません。

合成されたアプリケーションも他からみたら1つのアプリケーションの1つのレイヤーです。 レイヤードシステムはRESTの特徴の1つです。

それでは最後に作成したアプリケーションのリソースを呼び出す最小限のスクリプトをコーディングして見ましょう。bin/test.phpを作成します。

use BEAR\Package\Bootstrap;

require dirname(__DIR__) . '/autoload.php';

$api = (new Bootstrap)->getApp('MyVendor\Weekday', 'prod-hal-app');

$blog = $api->resource->uri('app://self/import')['blog'];
var_dump($blog);

MyVendor\Weekdayアプリをprod-hal-appで起動してapp://self/importリソースのblogをvar_dumpするコードです。

試して見ましょう。

php bin/import.php
string(17) "Hello BEAR.Sunday"

他にも

$weekday = $api->resource->uri('app://self/weekday')(['year' => 2000, 'month'=>1, 'day'=>1]);
var_dump($weekday->body); // as array
//array(1) {
//    ["weekday"]=>
//  string(3) "Sat"
//}

echo $weekday; // as string
//{
//    "weekday": "Sat",
//    "_links": {
//    "self": {
//        "href": "/weekday/2000/1/1"
//        }
//    }
//}
$html = (new Bootstrap)->getApp('MyVendor\Weekday', 'prod-html-app');
$index = $html->resource->uri('page://self/index')(['year' => 2000, 'month'=>1, 'day'=>1]);
var_dump($index->code);
//int(200)

echo $index;
//<!DOCTYPE html>
//<html>
//<body>
//The weekday of 2000/1/1 is Sat.
//</body>
//</html>

ステートレスなリクエストでレスポンスが返ってくるRESTのリソースはPHPの関数のようなものです。bodyで値を取得したり(string)でJSONやHTMLなどの表現にすることができます。autoloadの部分を除けば二行、連結すればたった一行のスクリプトで アプリケーションのどのリソースでも操作することができます。

このようにBEAR.Sundayで作られたリソースは他のCMSやフレームワークからも簡単に利用することができます。複数のアプリケーションの値を一度に扱うことができます。

データベース

データベースの利用のために、問題解決方法の異なった以下のモジュールが用意されています。いずれもPDO をベースにしたSQLのための独立ライブラリです。

静的なSQLはファイルにすると7、管理や他のSQLツールでの検証などの使い勝手もよくなります。 Aura.SqlQueryは動的にクエリーを組み立てる事ができますが、その他は基本静的なSQLの実行のためのライブラリです。 また、Ray.MediaQueryではSQLの一部をビルダーで組み立てたものに入れ替えることもできます。

モジュール

必要なライブラリに応じたモジュールをインストールします。

Ray.AuraSqlModuleはAura.SqlQueryとAura.SqlQueryを含みます。 Ray.MediaQueryはユーザーが用意したインターフェイスとSQLから、SQL実行オブジェクトを生成しインジェクトする8高機能なDBアクセスフレームワークです。

その他

DBALはDoctrine、CakeDBはCakePHPのDBライブラリです。Ray.QueryModuleはRay.MediaQueryの以前のライブラリでSQLを無名関数に変換します。


Ray.AuraSqlModule

Ray.AuraSqlModuleはPDO拡張のAura.SqlとクエリビルダーAura.SqlQuery、その他にデータベースクエリー結果のページネーションのためのライブラリを提供します。

インストール

composerでray/aura-sql-moduleをインストールします。

composer require ray/aura-sql-module

アプリケーションモジュールsrc/Module/AppModule.phpAuraSqlModuleをインストールします。

use BEAR\Package\AbstractAppModule;
use BEAR\AppMeta\AppMeta;
use BEAR\Package\PackageModule;
use Ray\AuraSqlModule\AuraSqlModule; // この行を追加

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ...
        $this->install(
          new AuraSqlModule(
            'mysql:host=localhost;dbname=test' // またはgetenv('PDO_DSN')
            'username',
            'password',
          )
        );  // この行を追加
        $this->install(new PackageModule));
    }
}

設定時に直接値を指定するのではなく、実行時に毎回環境変数から取得するためにはAuraSqlEnvModuleを使います。 接続先と認証情報の値を直接指定する代わりに、該当する環境変数のキーを渡します。

        $this->install(
            new AuraSqlEnvModule(
                'PDO_DSN',             // getenv('PDO_DSN')
                'PDO_USER',            // getenv('PDO_USER')
                'PDO_PASSWORD',        // getenv('PDO_PASSWORD')
                'PDO_SLAVE'            // getenv('PDO_SLAVE')
                $options,              // optional key=>value array of driver-specific connection options
                $queris                // Queries to execute after the connection.
        );

Aura.Sql

Aura.SqlはPHPのPDOを拡張したデータベースライブラリです。 コンストラクタインジェクションやAuraSqlInjectトレイトを利用してPDOを拡張したDBオブジェクトExtendedPDOを受け取ります。

use Aura\Sql\ExtendedPdoInterface;

class Index
{
    public function __construct(
        private readonly ExtendedPdoInterface $pdo
    ) {}
}
use Ray\AuraSqlModule\AuraSqlInject;

class Index
{
    use AuraSqlInject;

    public function onGet()
    {
        return $this->pdo; // \Aura\Sql\ExtendedPdo
    }
}

Ray.AuraSqlModuleAura.SqlQueryを含んでいてMySQLやPostgresなどのSQLを組み立てるのに利用できます。

perform() メソッド

perform()メソッドは、1つのプレイスホルダーしかないSQLに配列の値をバインドすることが出来ます。

$stm = 'SELECT * FROM test WHERE foo IN (:foo)'
$array = ['foo', 'bar', 'baz'];

既存のPDOの場合

// the native PDO way does not work (PHP Notice:  Array to string conversion)
// ネイティブのPDOでは`:foo`に配列を指定することは出来ません
$sth = $pdo->prepare($stm);
$sth->bindValue('foo', $array);

Aura.SqlのExtendedPDOの場合

$stm = 'SELECT * FROM test WHERE foo IN (:foo)'
$values = ['foo' => ['foo', 'bar', 'baz']];
$sth = $pdo->perform($stm, $values);

:foo['foo', 'bar', 'baz']がバインドがされます。queryStringで実際のクエリーを調べることが出来ます。

echo $sth->queryString;
// the query string has been modified by ExtendedPdo to become
// "SELECT * FROM test WHERE foo IN ('foo', 'bar', 'baz')"

fetch*() メソッド

prepare()bindValue()execute()を繰り返してデータベースから値を取得する代わりにfetch*()メソッドを使うとボイラープレートコードを減らすことができます。 (内部ではperform()メソッドを実行しているので配列のプレイスホルダーもサポートしています)

$stm  = 'SELECT * FROM test WHERE foo = :foo AND bar = :bar';
$bind = array('foo' => 'baz', 'bar' => 'dib');
// ネイティブのPDOで"fetch all"を行う場合
$pdo = new PDO(...);
$sth = $pdo->prepare($stm);
$sth->execute($bind);
$result = $sth->fetchAll(PDO::FETCH_ASSOC);

// ExtendedPdoで"fetch all"を行う場合
$pdo = new ExtendedPdo(...);
$result = $pdo->fetchAll($stm, $bind);

// fetchAssoc()は全ての行がコラム名のキーを持つ連想配列が返ります。
$result = $pdo->fetchAssoc($stm, $bind);

// fetchGroup() is like fetchAssoc() except that the values aren't wrapped in
// arrays. Instead, single column values are returned as a single dimensional
// array and multiple columns are returned as an array of arrays
// Set style to PDO::FETCH_NAMED when values are an array
// (i.e. there are more than two columns in the select)
$result = $pdo->fetchGroup($stm, $bind, $style = PDO::FETCH_COLUMN)

// fetchOne()は最初の行をキーをコラム名にした連想配列で返します。
$result = $pdo->fetchOne($stm, $bind);

// fetchPairs()は最初の列の値をキーに二番目の列の値を値にした連想配列を返します  
$result = $pdo->fetchPairs($stm, $bind);

// fetchValue()は最初の列の値を返します。
$result = $pdo->fetchValue($stm, $bind);

// fetchAffected()は影響を受けた行数を返します。
$stm = "UPDATE test SET incr = incr + 1 WHERE foo = :foo AND bar = :bar";
$row_count = $pdo->fetchAffected($stm, $bind);
?>

fetchAll(), fetchAssoc(), fetchCol(), 及び fetchPairs()のメソッドは三番目のオプションの引数に、それぞれの列に適用されるコールバックを指定することができます。

$result = $pdo->fetchAssoc($stm, $bind, function (&$row) {
    // add a column to the row
    $row['my_new_col'] = 'Added this column from the callable.';
});
?>

yield*() メソッド

メモリを節約するためにyield*()メソッドを使うことができます。 fetch*()メソッドは全ての行を一度に取得しますが、 yield*()メソッドはイテレーターが返ります。

$stm  = 'SELECT * FROM test WHERE foo = :foo AND bar = :bar';
$bind = array('foo' => 'baz', 'bar' => 'dib');

// fetchAll()のように行は連想配列です
foreach ($pdo->yieldAll($stm, $bind) as $row) {
    // ...
}

// fetchAssoc()のようにキーが最初の列名で行が連想配列です。
foreach ($pdo->yieldAssoc($stm, $bind) as $key => $row) {
    // ...
}

// fetchCol()のように最初の列が値になった値を返します。
foreach ($pdo->yieldCol($stm, $bind) as $val) {
    // ...
}

// fetchPairs()と同様に最初の列からキー/バリューのペアの値を返します。
foreach ($pdo->yieldPairs($stm, $bind) as $key => $val) {
    // ...
}

リプリケーション

マスター/スレーブ構成のデータベース接続を行うためには4つ目の引数にスレーブDBのホストを指定します。

$this->install(
  new AuraSqlModule(
    'mysql:host=localhost;dbname=test',
    'username',
    'password',
    'slave1,slave2' // スレーブのホストをカンマ区切りで指定
  )
);

これでHTTPリクエストがGETの時がスレーブDB、その他のメソッドの時はマスターDBのDBオブジェクトがコンスタラクタに渡されます。

use Aura\Sql\ExtendedPdoInterface;
use BEAR\Resource\ResourceObject;
use PDO;

class User extends ResourceObject
{
    public $pdo;

    public function __construct(ExtendedPdoInterface $pdo)
    {
        $this->pdo = $pdo;
    }

    public function onGet()
    {
         $this->pdo; // slave db
    }

    public function onPost($todo)
    {
         $this->pdo; // master db
    }
}

@ReadOnlyConnection@WriteConnectionでアノテートされたメソッドはメソッド名に関わらず、呼ばれた時にアノテーションに応じたDBオブジェクトが$this->pdoに上書きされます。

use Ray\AuraSqlModule\Annotation\ReadOnlyConnection;  // important
use Ray\AuraSqlModule\Annotation\WriteConnection;     // important

class User
{
    public $pdo; // @ReadOnlyConnectionや@WriteConnectionのメソッドが呼ばれた時に上書きされる

    public function onPost($todo)
    {
         $this->read();
    }

    /**
     * @ReadOnlyConnection
     */
    public function read()
    {
         $this->pdo; // slave db
    }

    /**
     * @WriteConnection
     */
    public function write()
    {
         $this->pdo; // master db
    }
}

複数データベースの接続

接続先の異なるデータベースのPDOインスタンスをインジェクトするには識別子9をつけます。

    public function __constrcut(
        private readonly #[Log] ExtendedPdoInterface $logDb,
        private readonly #[Mail] ExtendedPdoInterface $mailDb,
    ){}

NamedPdoModuleでその識別子と接続情報を指定してインストールします。

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ...
        $this->install(new NamedPdoModule(Log::class, 'mysql:host=localhost;dbname=log', 'username', 
        $this->install(new NamedPdoModule(Mail::class, 'mysql:host=localhost;dbname=mail', 'username', 
    }
}

接続情報を環境変数から都度取得するときはNamedPdoEnvModuleを使います。

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ...
        $this->install(new NamedPdoEnvModule(Log::class, 'LOG_DSN', 'LOG_USERNAME',  
        $this->install(new NamedPdoEnvModule(Mail::class, 'MAIL_DSN', 'MAIL_USERNAME', 
    }
}

トランザクション

#[Transactional]アトリビュートを追加したメソッドはトランザクション管理されます。

use Ray\AuraSqlModule\Annotation\Transactional;

// ....
    #[Transactional]
    public function write()
    {
         // 例外発生したら\Ray\AuraSqlModule\Exception\RollbackExceptionに
    }

複数接続したデータベースのトランザクションを行うためには@Transactionalアノテーションにプロパティを指定します。 指定しない場合は{"pdo"}になります。

#[Transactional({"pdo", "userDb"})]
public function write()

以下のように実行されます。

$this->pdo->beginTransaction()
$this->userDb->beginTransaction()

// ...

$this->pdo->commit();
$this->userDb->commit();

Aura.SqlQuery

Aura.SqlはPDOを拡張したライブラリですが、Aura.SqlQueryは MySQL、Postgres,、SQLiteあるいは Microsoft SQL Serverといったデータベース固有のSQLのビルダーを提供します。

データベースを指定してアプリケーションモジュールsrc/Module/AppModule.phpでインストールします。

// ...
$this->install(new AuraSqlQueryModule('mysql')); // pgsql, sqlite, or sqlsrv

SELECT

リソースではDBクエリービルダオブジェクトを受け取り、下記のメソッドを使ってSELECTクエリーを組み立てます。 メソッドに特定の順番はなく複数回呼ぶことこともできます。

use Aura\Sql\ExtendedPdoInterface;
use Aura\SqlQuery\Common\SelectInterface;

class User extend ResourceObject
{
    public function __construct(
        private readonly ExtendedPdoInterface $pdo,
        private readonly SelectInterface $select
    ) {}

    public function onGet()
    {
        $this->select
            ->distinct()                    // SELECT DISTINCT
            ->cols([                        // select these columns
                'id',                       // column name
                'name AS namecol',          // one way of aliasing
                'col_name' => 'col_alias',  // another way of aliasing
                'COUNT(foo) AS foo_count'   // embed calculations directly
            ])
            ->from('foo AS f')              // FROM these tables
            ->fromSubselect(                // FROM sub-select AS my_sub
                'SELECT ...',
                'my_sub'
            )
            ->join(                         // JOIN ...
                'LEFT',                     // left/inner/natural/etc
                'doom AS d'                 // this table name
                'foo.id = d.foo_id'         // ON these conditions
            )
            ->joinSubSelect(                // JOIN to a sub-select
                'INNER',                    // left/inner/natural/etc
                'SELECT ...',               // the subselect to join on
                'subjoin'                   // AS this name
                'sub.id = foo.id'           // ON these conditions
            )
            ->where('bar > :bar')           // AND WHERE these conditions
            ->where('zim = ?', 'zim_val')   // bind 'zim_val' to the ? placeholder
            ->orWhere('baz < :baz')         // OR WHERE these conditions
            ->groupBy(['dib'])              // GROUP BY these columns
            ->having('foo = :foo')          // AND HAVING these conditions
            ->having('bar > ?', 'bar_val')  // bind 'bar_val' to the ? placeholder
            ->orHaving('baz < :baz')        // OR HAVING these conditions
            ->orderBy(['baz'])              // ORDER BY these columns
            ->limit(10)                     // LIMIT 10
            ->offset(40)                    // OFFSET 40
            ->forUpdate()                   // FOR UPDATE
            ->union()                       // UNION with a followup SELECT
            ->unionAll()                    // UNION ALL with a followup SELECT
            ->bindValue('foo', 'foo_val')   // bind one value to a placeholder
            ->bindValues([                  // bind these values to named placeholders
                'bar' => 'bar_val',
                'baz' => 'baz_val',
            ]);

        $sth = $this->pdo->prepare($this->select->getStatement());

        // bind the values and execute
        $sth->execute($this->select->getBindValues());
        $result = $sth->fetch(\PDO::FETCH_ASSOC);
        // or
        // $result = $this->pdo->fetchAssoc($stm, $bind);

組み立てたクエリーはgetStatement()で文字列にしてクエリーを行います。

INSERT

単一行のINSERT

class User extend ResourceObject
{
    public function __construct(
        private readonly ExtendedPdoInterface $pdo,
        private readonly SelectInterface $select
    ) {}

    public function onPost()
    {
        $this->insert
            ->into('foo')                   // INTO this table
            ->cols([                        // bind values as "(col) VALUES (:col)"
                'bar',
                'baz',
            ])
            ->set('ts', 'NOW()')            // raw value as "(ts) VALUES (NOW())"
            ->bindValue('foo', 'foo_val')   // bind one value to a placeholder
            ->bindValues([                  // bind these values
                'bar' => 'foo',
                'baz' => 'zim',
            ]);

        $sth = $this->pdo->prepare($this->insert->getStatement());
        $sth->execute($this->insert->getBindValues());
        // or
        // $sth = $this->pdo->perform($this->insert->getStatement(), this->insert->getBindValues());

        // get the last insert ID
        $name = $insert->getLastInsertIdName('id');
        $id = $pdo->lastInsertId($name);

cols()メソッドはキーがコラム名、値をバインドする値にした連想配列を渡すこともできます。

        $this->insert
            ->into('foo')                   // insert into this table
            ->cols([                        // insert these columns and bind these values
                'foo' => 'foo_value',
                'bar' => 'bar_value',
                'baz' => 'baz_value',
            ]);

複数行のINSERT

複数の行のINSERTを行うためには、最初の行の最後でaddRow()メソッドを使います。その後に次のクエリーを組み立てます。

        // insert into this table
        $this->insert->into('foo');

        // set up the first row
        $this->insert->cols([
            'bar' => 'bar-0',
            'baz' => 'baz-0'
        ]);
        $this->insert->set('ts', 'NOW()');

        // set up the second row. the columns here are in a different order
        // than in the first row, but it doesn't matter; the INSERT object
        // keeps track and builds them the same order as the first row.
        $this->insert->addRow();
        $this->insert->set('ts', 'NOW()');
        $this->insert->cols([
            'bar' => 'bar-1',
            'baz' => 'baz-1'
        ]);

        // set up further rows ...
        $this->insert->addRow();
        // ...

        // execute a bulk insert of all rows
        $sth = $this->pdo->prepare($insert->getStatement());
        $sth->execute($insert->getBindValues());

注:最初の行で始めて現れた列の値を指定しないで、行を追加しようとすると例外が投げられます。 addRow()に列の連想配列を渡すと次の行で使われます。つまり最初の行でcol()cols()を指定しないこともできます。

        // set up the first row
        $insert->addRow([
            'bar' => 'bar-0',
            'baz' => 'baz-0'
        ]);
        $insert->set('ts', 'NOW()');

        // set up the second row
        $insert->addRow([
            'bar' => 'bar-1',
            'baz' => 'baz-1'
        ]);
        $insert->set('ts', 'NOW()');

        // etc.

addRows()を使ってデータベースを一度にセットすることもできます。

        $rows = [
            [
                'bar' => 'bar-0',
                'baz' => 'baz-0'
            ],
            [
                'bar' => 'bar-1',
                'baz' => 'baz-1'
            ],
        ];
        $this->insert->addRows($rows);

UPDATE

下記のメソッドを使ってUPDATEクエリーを組み立てます。 メソッドに特定の順番はなく複数回呼ぶことこともできます。

        $this->update
            ->table('foo')                  // update this table
            ->cols([                        // bind values as "SET bar = :bar"
                'bar',
                'baz',
            ])
            ->set('ts', 'NOW()')            // raw value as "(ts) VALUES (NOW())"
            ->where('zim = :zim')           // AND WHERE these conditions
            ->where('gir = ?', 'doom')      // bind this value to the condition
            ->orWhere('gir = :gir')         // OR WHERE these conditions
            ->bindValue('bar', 'bar_val')   // bind one value to a placeholder
            ->bindValues([                  // bind these values to the query
                'baz' => 99,
                'zim' => 'dib',
                'gir' => 'doom',
            ]);
        $sth = $this->pdo->prepare($update->getStatement())
        $sth->execute($this->update->getBindValues());
        // or
        // $sth = $this->pdo->perform($this->update->getStatement(), $this->update->getBindValues());

キーを列名、値をバインドされた値(RAW値ではなりません)にした連想配列をcols()に渡すこともできます。


        $this-update->table('foo')          // update this table
            ->cols([                        // update these columns and bind these values
                'foo' => 'foo_value',
                'bar' => 'bar_value',
                'baz' => 'baz_value',
            ]);
?>

DELETE

下記のメソッドを使ってDELETEクエリーを組み立てます。 メソッドに特定の順番はなく複数回呼ぶことこともできます。

        $this->delete
            ->from('foo')                   // FROM this table
            ->where('zim = :zim')           // AND WHERE these conditions
            ->where('gir = ?', 'doom')      // bind this value to the condition
            ->orWhere('gir = :gir')         // OR WHERE these conditions
            ->bindValue('bar', 'bar_val')   // bind one value to a placeholder
            ->bindValues([                  // bind these values to the query
                'baz' => 99,
                'zim' => 'dib',
                'gir' => 'doom',
            ]);
        $sth = $this->pdo->prepare($update->getStatement())
        $sth->execute($this->delete->getBindValues());

パジネーション

ray/aura-sql-moduleはRay.Sqlの生SQL、Ray.AuraSqlQueryのクエリービルダー双方でパジネーション(ページ分割)をサポートしています。 バインドする値と1ページあたりのアイテム数、それに{page}をページ番号にしたuri_templateでページャーファクトリーをnewInstance()で生成して、ページ番号で配列アクセスします。

Aura.Sql用

AuraSqlPagerFactoryInterface

/* @var $factory \Ray\AuraSqlModule\Pagerfanta\AuraSqlPagerFactoryInterface */
$pager = $factory->newInstance($pdo, $sql, $params, 10, '/?page={page}&category=sports'); // 10 items per page
$page = $pager[2]; // page 2
/* @var $page \Ray\AuraSqlModule\Pagerfanta\Page */
// $page->data // sliced data (array|\Traversable)
// $page->current; (int)
// $page->total (int)
// $page->hasNext (bool)
// $page->hasPrevious (bool)
// $page->maxPerPage; (int)
// (string) $page // pager html (string)

Aura.SqlQuery用

AuraSqlQueryPagerFactoryInterface

// for Select
/* @var $factory \Ray\AuraSqlModule\Pagerfanta\AuraSqlQueryPagerFactoryInterface */
$pager = $factory->newInstance($pdo, $select, 10, '/?page={page}&category=sports');
$page = $pager[2]; // page 2
/* @var $page \Ray\AuraSqlModule\Pagerfanta\Page */

注:Aura.Sqlは生SQLを直接編集していますが現在MySql形式のLIMIT句しか対応していません。

$pageはイテレータブルです。

foreach ($page as $row) {
 // 各行の処理
}

ページャーのリンクHTMLのテンプレートを変更するにはTemplateInterfaceの束縛を変更します。 テンプレート詳細に関してはPagerfantaをご覧ください。

use Pagerfanta\View\Template\TemplateInterface;
use Pagerfanta\View\Template\TwitterBootstrap3Template;
use Ray\AuraSqlModule\Annotation\PagerViewOption;

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ..
        $this->bind(TemplateInterface::class)->to(TwitterBootstrap3Template::class);
        $this->bind()->annotatedWith(PagerViewOption::class)->toInstance($pagerViewOption);
    }
}

CakeDb

CakeDbはアクティブレコードとデータマッパーパターンのアイデアを使ったORMで、素早くシンプルにORMを使うことができます。CakePHP3で提供されているORMと同じものです。

composerでRay.CakeDbModuleをインストールします。

composer require ray/cake-database-module ~1.0

インストールの方法についてはRay.CakeDbModuleを、ORMの利用にはCakePHP3 Database Access & ORMをご覧ください。

Ray.CakeDbModuleはCakePHP3のORMを開発したJose(@lorenzo)さんにより提供されています。

Doctrine DBAL

Doctrine DBALはDoctrineが提供しているデータベースの抽象化レイヤーです。

composerでRay.DbalModuleをインストールします。

composer require ray/dbal-module

アプリケーションモジュールでDbalModuleをインストールします。

use Ray\DbalModule\DbalModule;
use BEAR\Package\AbstractAppModule;

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ...
        $this->install(new DbalModule('driver=pdo_sqlite&memory=true');
    }
}

これでDIの設定が整いました。 DbalInjectトレイトを利用すると$this->dbにDBオブジェクトがインジェクトされます。

use Ray\DbalModule\DbalInject;

class Index
{
    use DbalInject;

    public function onGet()
    {
        return $this->db; // \Doctrine\DBAL\Driver\Connection
    }
}

複数DB

複数のデータベースの接続には二番目の引数に識別子を指定します。

$this->install(new DbalModule($logDsn, 'log_db');
$this->install(new DbalModule($jobDsn, 'job_db');
/**
 * @Inject
 * @Named("log_db")
 */
public function setLogDb(Connection $logDb)

MasterSlaveConnectionというリプリケーションのためのマスター/スレーブ接続が標準で用意されています。

Ray.MediaQuery

Ray.MediaQueryはDBやWeb APIなどの外部メディアのクエリーのインターフェイスから、クエリー実行オブジェクトを生成しインジェクトします。

  • ドメイン層とインフラ層の境界を明確にします。
  • ボイラープレートコードを削減します。
  • 外部メディアの実体には無関係なので、後からストレージを変更することができます。並列開発やスタブ作成が容易です。

インストール

$ composer require ray/media-query

利用方法

メディアアクセスするインターフェイスを定義します。

データベースの場合

DbQuery属性でSQLのIDを指定します。

interface TodoAddInterface
{
    #[DbQuery('user_add')]
    public function add(string $id, string $title): void;
}

Web APIの場合

WebQuery属性でWeb APIのIDを指定します。

interface PostItemInterface
{
    #[WebQuery('user_item')]
    public function get(string $id): array;
}

APIパスリストのファイルをmedia_query.jsonとして作成します。

{
    "$schema": "https://ray-di.github.io/Ray.MediaQuery/schema/web_query.json",
    "webQuery": [
        {"id": "user_item", "method": "GET", "path": "https://{domain}/users/{id}"}
    ]
}

MediaQueryModuleは、DbQueryConfigWebQueryConfig、またはその両方の設定でSQLやWeb APIリクエストの実行をインターフェイスに束縛します。

use Ray\AuraSqlModule\AuraSqlModule;
use Ray\MediaQuery\ApiDomainModule;
use Ray\MediaQuery\DbQueryConfig;
use Ray\MediaQuery\MediaQueryModule;
use Ray\MediaQuery\Queries;
use Ray\MediaQuery\WebQueryConfig;

protected function configure(): void
{
    $this->install(
        new MediaQueryModule(
            Queries::fromDir('/path/to/queryInterface'),[
                new DbQueryConfig('/path/to/sql'),
                new WebQueryConfig('/path/to/web_query.json', ['domain' => 'api.exmaple.com'])
            ],
        ),
    );
    $this->install(new AuraSqlModule('mysql:host=localhost;dbname=test', 'username', 'password'));
}

MediaQueryModuleはAuraSqlModuleのインストールが必要です。

注入

インターフェイスからオブジェクトが直接生成され、インジェクトされます。実装クラスのコーディングが不要です。

class Todo
{
    public function __construct(
        private TodoAddInterface $todoAdd
    ) {}

    public function add(string $id, string $title): void
    {
        $this->todoAdd->add($id, $title);
    }
}

DbQuery

SQL実行がメソッドにマップされ、IDで指定されたSQLをメソッドの引数でバインドして実行します。 例えばIDがtodo_itemの指定ではtodo_item.sqlSQL文に['id => $id]をバインドして実行します。

  • $sqlDirディレクトリにSQLファイルを用意します。
  • SQLファイルには複数のSQL文が記述できます。最後の行のSELECTが返り値になります。

Entity

  • SQL実行結果を用意したエンティティクラスをentityで指定して変換 (hydrate)することができます。
interface TodoItemInterface
{
    #[DbQuery('todo_item', entity: Todo::class)]
    public function getItem(string $id): Todo;
}
final class Todo
{
    public string $id;
    public string $title;
}

プロパティをキャメルケースに変換する場合にはCameCaseTraitを使います。

use Ray\MediaQuery\CamelCaseTrait;

class Invoice
{
    use CamelCaseTrait;

    public $userName;
}

コンストラクタがあると、フェッチしたデータでコールされます。

final class Todo
{
    public function __construct(
        public string $id,
        public string $title
    ) {}
}

type: ‘row’

SQL実行の戻り値が単一行ならのtype: 'row'のアトリビュートを指定します。しかしインターフェイスの戻り値がエンティティクラスなら省略することができます。10

/** 返り値がEntityの場合 */
interface TodoItemInterface
{
    #[DbQuery('todo_item', entity: Todo::class)]
    public function getItem(string $id): Todo;
}
/** 返り値がarrayの場合 */
interface TodoItemInterface
{
    #[DbQuery('todo_item', entity: Todo::class, type: 'row')]
    public function getItem(string $id): array;
}

Web API

  • メソッドの引数が uriで指定されたURI templateにバインドされ、Web APIリクエストオブジェクトが生成されます。
  • 認証のためのヘッダーなどのカスタムはGuzzleのClinetInterfaceをバインドして行います。
$this->bind(ClientInterface::class)->toProvider(YourGuzzleClientProvider::class);

パラメーター

日付時刻

パラメーターにバリューオブジェクトを渡すことができます。 例えば、DateTimeInterfaceオブジェクトをこのように指定できます。

interface TaskAddInterface
{
    #[DbQuery('task_add')]
    public function __invoke(string $title, DateTimeInterface $cratedAt = null): void;
}

値はSQL実行時やWeb APIリクエスト時に日付フォーマットされた文字列に変換されます。

INSERT INTO task (title, created_at) VALUES (:title, :createdAt); # 2021-2-14 00:00:00

値を渡さないとバインドされている現在時刻がインジェクションされます。 SQL内部でNOW()とハードコーディングする事や、毎回現在時刻を渡す手間を省きます。

テスト時刻

テストの時には以下のようにDateTimeInterfaceの束縛を1つの時刻にする事もできます。

$this->bind(DateTimeInterface::class)->to(UnixEpochTime::class);

VO

DateTime以外のバリューオブジェクトが渡されるとtoScalarインターフェイスを実装したtoScalar()メソッド、もしくは__toString()メソッドの返り値が引数になります。

interface MemoAddInterface
{
    public function __invoke(string $memo, UserId $userId = null): void;
}
class UserId implements ToScalarInterface
{
    public function __construct(
        private LoginUser $user;
    ){}
    
    public function toScalar(): int
    {
        return $this->user->id;
    }
}
INSERT INTO memo (user_id, memo) VALUES (:user_id, :memo);

パラメーターインジェクション

バリューオブジェクトの引数のデフォルトの値のnullがSQLやWebリクエストで使われることは無い事に注意してください。値が渡されないと、nullの代わりにパラメーターの型でインジェクトされたバリューオブジェクトのスカラー値が使われます。

public function __invoke(Uuid $uuid = null): void; // UUIDが生成され渡される

ページネーション

DBの場合、#[Pager]属性でSELECTクエリーをページングする事ができます。

use Ray\MediaQuery\PagesInterface;

interface TodoList
{
    #[DbQuery, Pager(perPage: 10, template: '/{?page}')]
    public function __invoke(): PagesInterface;
}

count()で件数が取得でき、ページ番号で配列アクセスをするとページオブジェクトが取得できます。 PagesはSQL遅延実行オブジェクトです。

$pages = ($todoList)();
$cnt = count($pages); // count()をした時にカウントSQLが生成されクエリーが行われます。
$page = $pages[2]; // 配列アクセスをした時にそのページのDBクエリーが行われます。

// $page->data // sliced data
// $page->current;
// $page->total
// $page->hasNext
// $page->hasPrevious
// $page->maxPerPage;
// (string) $page // pager html

SqlQuery

SqlQueryはSQLファイルのIDを指定してSQLを実行します。 実装クラスを用意して詳細な実装を行う時に使用します。

class TodoItem implements TodoItemInterface
{
    public function __construct(
        private SqlQueryInterface $sqlQuery
    ){}

    public function __invoke(string $id) : array
    {
        return $this->sqlQuery->getRow('todo_item', ['id' => $id]);
    }
}

Get* メソッド

SELECT結果を取得するためには取得する結果に応じたget*を使います。

$sqlQuery->getRow($queryId, $params); // 結果が単数行
$sqlQuery->getRowList($queryId, $params); // 結果が複数行
$statement = $sqlQuery->getStatement(); // PDO Statementを取得
$pages = $sqlQuery->getPages(); // ページャーを取得

Ray.MediaQueryはRay.AuraSqlModule を含んでいます。 さらに低レイヤーの操作が必要な時はAura.SqlのQuery Builder やPDOを拡張したAura.Sql のExtended PDOをお使いください。 doctrine/dbal も利用できます。

Parameter Injectionと同様、DateTimeIntetfaceオブジェクトを渡すと日付フォーマットされた文字列に変換されます。

$sqlQuery->exec('memo_add', ['memo' => 'run', 'created_at' => new DateTime()]);

他のオブジェクトが渡されるとtoScalar()または__toString()の値に変換されます。

プロファイラー

メディアアクセスはロガーで記録されます。標準ではテストに使うメモリロガーがバインドされています。

public function testAdd(): void
{
    $this->sqlQuery->exec('todo_add', $todoRun);
    $this->assertStringContainsString('query: todo_add({"id":"1","title":"run"})', (string) $this->log);
}

独自のMediaQueryLoggerInterfaceを実装して、 各メディアクエリーのベンチマークを行ったり、インジェクトしたPSRロガーでログをする事もできます。

アノテーション / アトリビュート

属性を表すのにdoctrineアノテーションアトリビュート どちらも利用できます。 次の2つは同じものです。

use Ray\MediaQuery\Annotation\DbQuery;

#[DbQuery('user_add')]
public function add1(string $id, string $title): void;

/** @DbQuery("user_add") */
public function add2(string $id, string $title): void;

データベース

データベースライブラリの利用のためAura.SqlDoctrine DBAL, CakeDBなどのモジュールが用意されています。

Aura.Sql

Aura.SqlはPHPのPDOを拡張したデータベースライブラリです。

インストール

composerでRay.AuraSqlModuleをインストールします。

composer require ray/aura-sql-module

アプリケーションモジュールsrc/Module/AppModule.phpAuraSqlModuleをインストールします。

use BEAR\Package\AbstractAppModule;
use BEAR\AppMeta\AppMeta;
use BEAR\Package\PackageModule;
use Ray\AuraSqlModule\AuraSqlModule; // この行を追加

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ...
        $this->install(
          new AuraSqlModule(
            'mysql:host=localhost;dbname=test',
            'username',
            'password',
            // $options,
            // $attributes
          )
        );  // この行を追加
        $this->install(new PackageModule));
    }
}

これでDIの設定が整いました。コンストラクタやAuraSqlInjectトレイトを利用してPDOを拡張したDBオブジェクトExtendedPDOを受け取ります。

use Aura\Sql\ExtendedPdoInterface;

class Index
{
    public function __construct(ExtendedPdoInterface $pdo)
    {
        return $this->pdo; // \Aura\Sql\ExtendedPdo
    }
}
use Ray\AuraSqlModule\AuraSqlInject;

class Index
{
    use AuraSqlInject;

    public function onGet()
    {
        return $this->pdo; // \Aura\Sql\ExtendedPdo
    }
}

Ray.AuraSqlModuleAura.SqlQueryを含んでいてMySQLやPostgresなどのSQLを組み立てるのに利用できます。

perform() メソッド

perform()メソッドは、1つのプレイスホルダーしかないSQLに配列の値をバインドすることが出来ます。

$stm = 'SELECT * FROM test WHERE foo IN (:foo)'
$array = ['foo', 'bar', 'baz'];

既存のPDOの場合

// the native PDO way does not work (PHP Notice:  Array to string conversion)
// ネイティブのPDOでは`:foo`に配列を指定することは出来ません
$sth = $pdo->prepare($stm);
$sth->bindValue('foo', $array);

Aura.SqlのExtendedPDOの場合

$stm = 'SELECT * FROM test WHERE foo IN (:foo)'
$values = ['foo' => ['foo', 'bar', 'baz']];
$sth = $pdo->perform($stm, $values);

:foo['foo', 'bar', 'baz']がバインドがされます。queryStringで実際のクエリーを調べることが出来ます。

echo $sth->queryString;
// the query string has been modified by ExtendedPdo to become
// "SELECT * FROM test WHERE foo IN ('foo', 'bar', 'baz')"

fetch*() メソッド

prepare()bindValue()execute()を繰り返してデータベースから値を取得する代わりにfetch*()メソッドを使うとボイラープレートコードを減らすことができます。 (内部ではperform()メソッドを実行しているので配列のプレースフォルもサポートしています)

$stm  = 'SELECT * FROM test WHERE foo = :foo AND bar = :bar';
$bind = array('foo' => 'baz', 'bar' => 'dib');
// ネイティブのPDOで"fetch all"を行う場合
$pdo = new PDO(...);
$sth = $pdo->prepare($stm);
$sth->execute($bind);
$result = $sth->fetchAll(PDO::FETCH_ASSOC);

// ExtendedPdoで"fetch all"を行う場合
$pdo = new ExtendedPdo(...);
$result = $pdo->fetchAll($stm, $bind);

// fetchAssoc()は全ての行がコラム名のキーを持つ連想配列が返ります。
$result = $pdo->fetchAssoc($stm, $bind);

// fetchGroup() is like fetchAssoc() except that the values aren't wrapped in
// arrays. Instead, single column values are returned as a single dimensional
// array and multiple columns are returned as an array of arrays
// Set style to PDO::FETCH_NAMED when values are an array
// (i.e. there are more than two columns in the select)
$result = $pdo->fetchGroup($stm, $bind, $style = PDO::FETCH_COLUMN)

// fetchOne()は最初の行をキーをコラム名にした連想配列で返します。
$result = $pdo->fetchOne($stm, $bind);

// fetchPairs()は最初の列の値をキーに二番目の列の値を値にした連想配列を返します  
$result = $pdo->fetchPairs($stm, $bind);

// fetchValue()は最初の列の値を返します。
$result = $pdo->fetchValue($stm, $bind);

// fetchAffected()は影響を受けた行数を返します。
$stm = "UPDATE test SET incr = incr + 1 WHERE foo = :foo AND bar = :bar";
$row_count = $pdo->fetchAffected($stm, $bind);
?>

fetchAll(), fetchAssoc(), fetchCol(), 及び fetchPairs()のメソッドは三番目のオプションの引数に、それぞれの列に適用されるコールバックを指定することができます。

$result = $pdo->fetchAssoc($stm, $bind, function (&$row) {
    // add a column to the row
    $row['my_new_col'] = 'Added this column from the callable.';
});
?>

yield*() メソッド

メモリを節約するためにyield*()メソッドを使うことができます。 fetch*()メソッドは全ての行を一度に取得しますが、 yield*()メソッドはイテレーターが返ります。

$stm  = 'SELECT * FROM test WHERE foo = :foo AND bar = :bar';
$bind = array('foo' => 'baz', 'bar' => 'dib');

// fetchAll()のように行は連想配列です
foreach ($pdo->yieldAll($stm, $bind) as $row) {
    // ...
}

// fetchAssoc()のようにキーが最初の列名で行が連想配列です。
foreach ($pdo->yieldAssoc($stm, $bind) as $key => $row) {
    // ...
}

// fetchCol()のように最初の列が値になった値を返します。
foreach ($pdo->yieldCol($stm, $bind) as $val) {
    // ...
}

// fetchPairs()と同様に最初の列からキー/バリューのペアの値を返します。
foreach ($pdo->yieldPairs($stm, $bind) as $key => $val) {
    // ...
}

リプリケーション

マスター/スレーブの接続を自動で行うためには4つ目の引数にスレーブDBのIPを指定します。

$this->install(
  new AuraSqlModule(
    'mysql:host=localhost;dbname=test',
    'username',
    'password',
    'slave1,slave2' // スレーブIPをカンマ区切りで指定
  )
);

これでHTTPリクエストがGETの時がスレーブDB、その他のメソッドの時はマスターDBのDBオブジェクトがコンスタラクタに渡されます。

use Aura\Sql\ExtendedPdoInterface;
use BEAR\Resource\ResourceObject;
use PDO;

class User extends ResourceObject
{
    public $pdo;

    public function __construct(ExtendedPdoInterface $pdo)
    {
        $this->pdo = $pdo;
    }

    public function onGet()
    {
         $this->pdo; // slave db
    }

    public function onPost($todo)
    {
         $this->pdo; // master db
    }
}

@ReadOnlyConnection@WriteConnectionでアノテートされたメソッドはメソッド名に関わらず、呼ばれた時にアノテーションに応じたDBオブジェクトが$this->pdoに上書きされます。

use Ray\AuraSqlModule\Annotation\ReadOnlyConnection;  // important
use Ray\AuraSqlModule\Annotation\WriteConnection;     // important

class User
{
    public $pdo; // @ReadOnlyConnectionや@WriteConnectionのメソッドが呼ばれた時に上書きされる

    public function onPost($todo)
    {
         $this->read();
    }

    /**
     * @ReadOnlyConnection
     */
    public function read()
    {
         $this->pdo; // slave db
    }

    /**
     * @WriteConnection
     */
    public function write()
    {
         $this->pdo; // master db
    }
}

複数DB

接続先の違う複数のPdoExtendedInterfaceオブジェクトを受け取るためには @Namedアノテーションで指定します。

/**
 * @Inject
 * @Named("log_db")
 */
public function setLoggerDb(ExtendedPdoInterface $pdo)
{
    // ...
}

モジュールではNamedPdoModuleで識別子を指定して束縛します。

$this->install(
  new NamedPdoModule(
    'log_db', // @Namedで指定するデータベースの種類
    'mysql:host=localhost;dbname=log',
    'username',
    'pass',
    'slave1,slave12'
  )
);

トランザクション

@Transactionalとアノテートしたメソッドはトランザクション管理されます。

use Ray\AuraSqlModule\Annotation\Transactional;

// ....
    /**
     * @Transactional
     */
    public function write()
    {
         // 例外発生したら\Ray\AuraSqlModule\Exception\RollbackExceptionに
    }

複数接続したデータベースのトランザクションを行うためには@Transactionalアノテーションにプロパティを指定します。 指定しない場合は{"pdo"}になります。

/**
 * @Transactional({"pdo", "userDb"})
 */
public function write()

以下のように実行されます。

$this->pdo->beginTransaction()
$this->userDb->beginTransaction()

// ...

$this->pdo->commit();
$this->userDb->commit();

Aura.SqlQuery

Aura.SqlはPDOを拡張したライブラリですが、Aura.SqlQueryは MySQL、Postgres,、SQLiteあるいは Microsoft SQL Serverといったデータベース固有のSQLのビルダーを提供します。

データベースを指定してアプリケーションモジュールsrc/Module/AppModule.phpでインストールします。

// ...
$this->install(new AuraSqlQueryModule('mysql')); // pgsql, sqlite, or sqlsrv

SELECT

リソースではDBクエリービルダオブジェクトを受け取り、下記のメソッドを使ってSELECTクエリーを組み立てます。 メソッドに特定の順番はなく複数回呼ぶことこともできます。

use Ray\AuraSqlModule\AuraSqlInject;
use Ray\AuraSqlModule\AuraSqlSelectInject;

class User extend ResourceObject
{
    use AuraSqlInject;
    use AuraSqlSelectInject;

    public function onGet()
    {
        $this->select
            ->distinct()                    // SELECT DISTINCT
            ->cols([                        // select these columns
                'id',                       // column name
                'name AS namecol',          // one way of aliasing
                'col_name' => 'col_alias',  // another way of aliasing
                'COUNT(foo) AS foo_count'   // embed calculations directly
            ])
            ->from('foo AS f')              // FROM these tables
            ->fromSubselect(                // FROM sub-select AS my_sub
                'SELECT ...',
                'my_sub'
            )
            ->join(                         // JOIN ...
                'LEFT',                     // left/inner/natural/etc
                'doom AS d'                 // this table name
                'foo.id = d.foo_id'         // ON these conditions
            )
            ->joinSubSelect(                // JOIN to a sub-select
                'INNER',                    // left/inner/natural/etc
                'SELECT ...',               // the subselect to join on
                'subjoin'                   // AS this name
                'sub.id = foo.id'           // ON these conditions
            )
            ->where('bar > :bar')           // AND WHERE these conditions
            ->where('zim = ?', 'zim_val')   // bind 'zim_val' to the ? placeholder
            ->orWhere('baz < :baz')         // OR WHERE these conditions
            ->groupBy(['dib'])              // GROUP BY these columns
            ->having('foo = :foo')          // AND HAVING these conditions
            ->having('bar > ?', 'bar_val')  // bind 'bar_val' to the ? placeholder
            ->orHaving('baz < :baz')        // OR HAVING these conditions
            ->orderBy(['baz'])              // ORDER BY these columns
            ->limit(10)                     // LIMIT 10
            ->offset(40)                    // OFFSET 40
            ->forUpdate()                   // FOR UPDATE
            ->union()                       // UNION with a followup SELECT
            ->unionAll()                    // UNION ALL with a followup SELECT
            ->bindValue('foo', 'foo_val')   // bind one value to a placeholder
            ->bindValues([                  // bind these values to named placeholders
                'bar' => 'bar_val',
                'baz' => 'baz_val',
            ]);

        $sth = $this->pdo->prepare($this->select->getStatement());

        // bind the values and execute
        $sth->execute($this->select->getBindValues());
        $result = $sth->fetch(\PDO::FETCH_ASSOC);
        // or
        // $result = $this->pdo->fetchAssoc($stm, $bind);

組み立てたクエリーはgetStatement()で文字列にしてクエリーを行います。

INSERT

単一行のINSERT

use Ray\AuraSqlModule\AuraSqlInject;
use Ray\AuraSqlModule\AuraSqlInsertInject;

class User extend ResourceObject
{
    use AuraSqlInject;
    use AuraSqlInsertInject;

    public function onPost()
    {
        $this->insert
            ->into('foo')                   // INTO this table
            ->cols([                        // bind values as "(col) VALUES (:col)"
                'bar',
                'baz',
            ])
            ->set('ts', 'NOW()')            // raw value as "(ts) VALUES (NOW())"
            ->bindValue('foo', 'foo_val')   // bind one value to a placeholder
            ->bindValues([                  // bind these values
                'bar' => 'foo',
                'baz' => 'zim',
            ]);

        $sth = $this->pdo->prepare($this->insert->getStatement());
        $sth->execute($this->insert->getBindValues());
        // or
        // $sth = $this->pdo->perform($this->insert->getStatement(), this->insert->getBindValues());

        // get the last insert ID
        $name = $insert->getLastInsertIdName('id');
        $id = $pdo->lastInsertId($name);

cols()メソッドはキーがコラム名、値をバインドする値にした連想配列を渡すこともできます。

        $this->insert
            ->into('foo')                   // insert into this table
            ->cols([                        // insert these columns and bind these values
                'foo' => 'foo_value',
                'bar' => 'bar_value',
                'baz' => 'baz_value',
            ]);

複数行のINSERT

複数の行のINSERTを行うためには、最初の行の最後でaddRow()メソッドを使います。その後に次のクエリーを組み立てます。

        // insert into this table
        $this->insert->into('foo');

        // set up the first row
        $this->insert->cols([
            'bar' => 'bar-0',
            'baz' => 'baz-0'
        ]);
        $this->insert->set('ts', 'NOW()');

        // set up the second row. the columns here are in a different order
        // than in the first row, but it doesn't matter; the INSERT object
        // keeps track and builds them the same order as the first row.
        $this->insert->addRow();
        $this->insert->set('ts', 'NOW()');
        $this->insert->cols([
            'bar' => 'bar-1',
            'baz' => 'baz-1'
        ]);

        // set up further rows ...
        $this->insert->addRow();
        // ...

        // execute a bulk insert of all rows
        $sth = $this->pdo->prepare($insert->getStatement());
        $sth->execute($insert->getBindValues());

注:最初の行で始めて現れた列の値を指定しないで、行を追加しようとすると例外が投げられます。 addRow()に列の連想配列を渡すと次の行で使われます。つまり最初の行でcol()cols()を指定しないこともできます。

        // set up the first row
        $insert->addRow([
            'bar' => 'bar-0',
            'baz' => 'baz-0'
        ]);
        $insert->set('ts', 'NOW()');

        // set up the second row
        $insert->addRow([
            'bar' => 'bar-1',
            'baz' => 'baz-1'
        ]);
        $insert->set('ts', 'NOW()');

        // etc.

addRows()を使ってデータベースを一度にセットすることもできます。

        $rows = [
            [
                'bar' => 'bar-0',
                'baz' => 'baz-0'
            ],
            [
                'bar' => 'bar-1',
                'baz' => 'baz-1'
            ],
        ];
        $this->insert->addRows($rows);

UPDATE

下記のメソッドを使ってUPDATEクエリーを組み立てます。 メソッドに特定の順番はなく複数回呼ぶことこともできます。

        $this->update
            ->table('foo')                  // update this table
            ->cols([                        // bind values as "SET bar = :bar"
                'bar',
                'baz',
            ])
            ->set('ts', 'NOW()')            // raw value as "(ts) VALUES (NOW())"
            ->where('zim = :zim')           // AND WHERE these conditions
            ->where('gir = ?', 'doom')      // bind this value to the condition
            ->orWhere('gir = :gir')         // OR WHERE these conditions
            ->bindValue('bar', 'bar_val')   // bind one value to a placeholder
            ->bindValues([                  // bind these values to the query
                'baz' => 99,
                'zim' => 'dib',
                'gir' => 'doom',
            ]);
        $sth = $this->pdo->prepare($update->getStatement())
        $sth->execute($this->update->getBindValues());
        // or
        // $sth = $this->pdo->perform($this->update->getStatement(), $this->update->getBindValues());

キーを列名、値をバインドされた値(RAW値ではなりません)にした連想配列をcols()に渡すこともできます。


        $this-update->table('foo')          // update this table
            ->cols([                        // update these columns and bind these values
                'foo' => 'foo_value',
                'bar' => 'bar_value',
                'baz' => 'baz_value',
            ]);
?>

DELETE

下記のメソッドを使ってDELETEクエリーを組み立てます。 メソッドに特定の順番はなく複数回呼ぶことこともできます。

        $this->delete
            ->from('foo')                   // FROM this table
            ->where('zim = :zim')           // AND WHERE these conditions
            ->where('gir = ?', 'doom')      // bind this value to the condition
            ->orWhere('gir = :gir')         // OR WHERE these conditions
            ->bindValue('bar', 'bar_val')   // bind one value to a placeholder
            ->bindValues([                  // bind these values to the query
                'baz' => 99,
                'zim' => 'dib',
                'gir' => 'doom',
            ]);
        $sth = $this->pdo->prepare($update->getStatement())
        $sth->execute($this->delete->getBindValues());

パジネーション

ray/aura-sql-moduleはRay.Sqlの生SQL、Ray.AuraSqlQueryのクエリービルダー双方でパジネーション(ページ分割)をサポートしています。 バインドする値と1ページあたりのアイテム数、それに{page}をページ番号にしたuri_templateでページャーファクトリーをnewInstance()で生成して、ページ番号で配列アクセスします。

Aura.Sql用

AuraSqlPagerFactoryInterface

/* @var $factory \Ray\AuraSqlModule\Pagerfanta\AuraSqlPagerFactoryInterface */
$pager = $factory->newInstance($pdo, $sql, $params, 10, '/?page={page}&category=sports'); // 10 items per page
$page = $pager[2]; // page 2
/* @var $page \Ray\AuraSqlModule\Pagerfanta\Page */
// $page->data // sliced data (array|\Traversable)
// $page->current; (int)
// $page->total (int)
// $page->hasNext (bool)
// $page->hasPrevious (bool)
// $page->maxPerPage; (int)
// (string) $page // pager html (string)

Aura.SqlQuery用

AuraSqlQueryPagerFactoryInterface

// for Select
/* @var $factory \Ray\AuraSqlModule\Pagerfanta\AuraSqlQueryPagerFactoryInterface */
$pager = $factory->newInstance($pdo, $select, 10, '/?page={page}&category=sports');
$page = $pager[2]; // page 2
/* @var $page \Ray\AuraSqlModule\Pagerfanta\Page */

注:Aura.Sqlは生SQLを直接編集していますが現在MySql形式のLIMIT句しか対応していません。

$pageはイテレータブルです。

foreach ($page as $row) {
 // 各行の処理
}

ページャーのリンクHTMLのテンプレートを変更するにはTemplateInterfaceの束縛を変更します。 テンプレート詳細に関してはPagerfantaをご覧ください。

use Pagerfanta\View\Template\TemplateInterface;
use Pagerfanta\View\Template\TwitterBootstrap3Template;
use Ray\AuraSqlModule\Annotation\PagerViewOption;

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ..
        $this->bind(TemplateInterface::class)->to(TwitterBootstrap3Template::class);
        $this->bind()->annotatedWith(PagerViewOption::class)->toInstance($pagerViewOption);
    }
}

バリデーション

  • JSONスキーマでリソースAPIを定義する事ができます。
  • @Valid, @OnValidateアノテーションでバリデーションコードを分離する事ができます。
  • Webフォームによるバリデーションはフォームをご覧ください。

JSONスキーマ

JSON スキーマとは、JSON objectの記述と検証のための標準です。#[JsonSchema]アトリビュートが付加されたリソースクラスのメソッドが返すリソースbodyに対してJSONスキーマによる検証が行われます。

インストール

全てのコンテキストで常にバリデーションを行うならAppModule、開発中のみバリデーションを行うならDevModuleなどのクラスを作成してその中でインストールします。

use BEAR\Resource\Module\JsonSchemaModule; // この行を追加
use BEAR\Package\AbstractAppModule;

class AppModule extends AbstractAppModule
{
    protected function configure(): void
    {
        // ...
        $this->install(
            new JsonSchemaModule(
                $appDir . '/var/json_schema',
                $appDir . '/var/json_validate'
            )
        );   // この行を追加
    }
}

ディレクトリ作成

mkdir var/json_schema
mkdir var/json_validate

var/json_schema/にリソースのbodyの仕様となるJSONスキーマファイル、var/json_validate/には入力バリデーションのためのJSONスキーマファイルを格納します。

#[JsonSchema]アトリビュート

リソースクラスのメソッドで#[JsonSchema]のアトリビュートを加えます。schemaプロパティにはJSONスキーマファイル名を指定します。

schema

src/Resource/App/User.php


use BEAR\Resource\Annotation\JsonSchema; // この行を追加

class User extends ResourceObject
{
    #[JsonSchema('user.json')]
    public function onGet(): static
    {
        $this->body = [
            'firstName' => 'mucha',
            'lastName' => 'alfons',
            'age' => 12
        ];

        return $this;
    }
}

JSONスキーマを設置します。

/var/json_schema/user.json

{
  "type": "object",
  "properties": {
    "firstName": {
      "type": "string",
      "maxLength": 30,
      "pattern": "[a-z\\d~+-]+"
    },
    "lastName": {
      "type": "string",
      "maxLength": 30,
      "pattern": "[a-z\\d~+-]+"
    }
  },
  "required": ["firstName", "lastName"]
}

key

bodyにインデックスキーがある場合にはアノテーションのkeyプロパティで指定します。


use BEAR\Resource\Annotation\JsonSchema; // Add this line

class User extends ResourceObject
{
    #[JsonSchema(key:'user', schema:'user.json')]
    public function onGet()
    {
        $this->body = [
            'user' => [
                'firstName' => 'mucha',
                'lastName' => 'alfons',
                'age' => 12
            ]
        ];        

        return $this;
    }
}

params

params プロパティには引数のバリデーションのためのJSONスキーマファイル名を指定します。

use BEAR\Resource\Annotation\JsonSchema; // この行を追加

class Todo extends ResourceObject
{
    #[JsonSchema(key:'user', schema:'user.json', params:'todo.post.json')]
    public function onPost(string $title)

JSONスキーマを設置します。

/var/json_validate/todo.post.json

{
  "$schema": "http://json-schema.org/draft-04/schema#",
  "title": "/todo POST request validation",
  "properties": {
    "title": {
      "type": "string",
      "minLength": 1,
      "maxLength": 40
    }
}

独自ドキュメントの代わりに標準化された方法で常に検証することで、その仕様が人間にもマシンにも理解できる確実なものになります。

target

ResourceObjectのbodyに対してでなく、リソースオブジェクトの表現(レンダリングされた結果)に対してスキーマバリデーションを適用にするにはtarget='view'オプションを指定します。 HALフォーマットで_linkのスキーマが記述できます。

#[JsonSchema(schema: 'user.json', target: 'view')]

関連リンク

@Validアノテーション

@Validアノテーションは入力のためのバリデーションです。メソッドの実行前にバリデーションメソッドが実行され、 エラーを検知すると例外が発生されエラー処理のためのメソッドを呼ぶこともできます。

分離したバリデーションのコードは可読性に優れテストが容易です。バリデーションのライブラリはAura.FilterRespect\Validation、あるいはPHP標準のFilterを使います。

インストール

composerインストール

composer require ray/validate-module

アプリケーションモジュールsrc/Module/AppModule.phpValidateModuleをインストールします。

use Ray\Validation\ValidateModule;

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ...
        $this->install(new ValidateModule);
    }
}

アノテーション

バリデーションのために@Valid@OnValidate@OnFailureの3つのアノテーションが用意されています。

まず、バリデーションを行いたいメソッドに@Validとアノテートします。

use Ray\Validation\Annotation\Valid;
// ...
    /**
     * @Valid
     */
    public function createUser($name)
    {

@OnValidateとアノテートしたメソッドでバリデーションを行います。引数は元のメソッドと同じにします。メソッド名は自由です。

use Ray\Validation\Annotation\OnValidate;
// ...
    /**
     * @OnValidate
     */
    public function onValidate($name)
    {
        $validation = new Validation;
        if (! is_string($name)) {
            $validation->addError('name', 'name should be string');
        }

        return $validation;
    }

バリデーション失敗した要素には要素名エラーメッセージを指定してValidationオブジェクトにaddError()し、最後にValidationオブジェクトを返します。

バリデーションが失敗すればRay\Validation\Exception\InvalidArgumentException例外が投げられますが、 @OnFailureメソッドが用意されていればそのメソッドの結果が返されます。

use Ray\Validation\Annotation\OnFailure;
// ...
    /**
     * @OnFailure
     */
    public function onFailure(FailureInterface $failure)
    {
        // original parameters
        list($this->defaultName) = $failure->getInvocation()->getArguments();

        // errors
        foreach ($failure->getMessages() as $name => $messages) {
            foreach ($messages as $message) {
                echo "Input '{$name}': {$message}" . PHP_EOL;
            }
        }
    }

@OnFailureメソッドには$failureが渡され($failure->getMessages()でエラーメッセージや$failure->getInvocation()でオリジナルメソッド実行のオブジェクトが取得できます。

複数のバリデーション

1つのクラスに複数のバリデーションメソッドが必要なときは以下のようにバリデーションの名前を指定します。

use Ray\Validation\Annotation\Valid;
use Ray\Validation\Annotation\OnValidate;
use Ray\Validation\Annotation\OnFailure;
// ...

    /**
     * @Valid("foo")
     */
    public function fooAction($name, $address, $zip)
    {

    /**
     * @OnValidate("foo")
     */
    public function onValidateFoo($name, $address, $zip)
    {

    /**
     * @OnFailure("foo")
     */
    public function onFailureFoo(FailureInterface $failure)
    {

その他のバリデーション

複雑なバリデーションの時は別にバリデーションクラスをインジェクトして、onValidateメソッドから呼び出してバリデーションを行います。DIなのでコンテキストによってバリデーションを変えることもできます。

これはBEAR.Sundayの全てのマニュアルページを一つにまとめたページです。

BEAR.Sundayとは

BEAR.Sundayは、クリーンなオブジェクト指向設計と、Webの基本原則に沿ったリソース指向アーキテクチャを組み合わせたPHPのアプリケーションフレームワークです。 このフレームワークは標準への準拠、長期的な視点、高効率、柔軟性、自己記述性に加え、シンプルさを重視します。

フレームワーク

BEAR.Sundayは3つのフレームワークで構成されています。

Ray.Di依存性逆転の原則に基づいてオブジェクトの依存をインターフェイスで結びます。

Ray.Aopアスペクト指向プログラミングで本質的関心と横断的関心を結びます。

BEAR.Resourceはアプリケーションのデータや機能をリソースにしてREST制約で結びます。

フレームワークは、アプリケーション全体に適用される制約と設計原則です。一貫性のある設計と実装を促進し、高品質でクリーンなアプリケーションの構築の力になります。

ライブラリ

BEAR.Sunday はフルスタック フレームワークとは異なり、認証やデータベースなどの特定のタスクのための独自のライブラリは提供しません。その代わりに、高品質なサードパーティ製のライブラリを使用することを好みます。

このアプローチは2つの設計思想に基づいています。1つ目は「フレームワークは変わらないがライブラリは変わる」という考え方です。フレームワークがアプリケーションの基盤として安定した構造を提供し続ける一方で、ライブラリは時間の経過とともに進化し、アプリケーションの特定のニーズを満たします。

2つ目は「ライブラリを選択する権利と責任はアプリケーションアーキテクトにある」というものです。アプリケーションアーキテクトは、アプリケーションの要件、制約、および目的に最も適したライブラリを選択する能力と責任を委ねられています。

BEAR.Sundayは、フレームワークとライブラリの違いを”不易流行”(変わらぬ基本原則と時代と共に進化する要素)として明確に区別し、アプリケーション制約としてのフレームワークの役割を重視します。

アーキテクチャ

BEAR.Sundayは、従来のMVC(Model-View-Controller)アーキテクチャとは異なり、リソース指向アーキテクチャ(ROA)を採用しています。このアーキテクチャでは、アプリケーションの設計において、データとビジネスロジックを統一してリソースとして扱い、それらに対するリンクと操作を中心に設計を行います。リソース指向アーキテクチャはREST APIの設計で広く使用されていますが、BEAR.SundayはそれをWebアプリケーション全体の設計にも適用しています。

長期的な視点

BEAR.Sunday は、アプリケーションの長期的な維持を念頭に置いて設計されています。

  • 制約: DI、AOP、RESTの制約に従った一貫したアプリケーション制約は、時間の経過とともに変わることがありません。

  • 永遠の1.x: 2015年の最初のリリース以来、BEAR.Sundayは後方互換性のない変更を導入することなく、継続的に進化してきました。開発者にはフレームワークの定期的な互換性破壊への対応とそのテストが必要という将来の技術負債がありません。

  • 標準準拠:HTTP標準、JsonSchema などの標準に従い、DIはGoogle Guice、AOPはJavaのAop Allianceに基づいています。

接続性

BEAR.Sundayは、Webアプリケーションを超えて、さまざまなクライアントとのシームレスな統合を可能にします。

  • HTTPクライアント: HTTPを使用して全てのリソースにアクセスすることが可能です。MVCのモデルやコントローラーと違い、BEAR.Sundayのリソースはクライアントから直接のアクセスが可能です。

  • composerパッケージ: composerでvendor下にインストールしたアプリケーションのリソースを直接呼び出す事ができます。マイクロサービスを使わずに複数のアプリケーションを協調する事ができます。

  • 多言語フレームワーク: BEAR.Thriftを使用して、PHP以外の言語や異なるバージョンのPHPとの連携を可能にします。

Webキャッシュ

リソース指向アーキテクチャとモダンなCDNの技術を組み合わせることにより、従来のサーバーサイドのTTLキャッシュを超えるWeb本来の分散キャッシングを実現します。BEAR.Sundayの設計思想は、Webの基本原則に沿っており、CDNを中心に配置した分散キャッシュシステムを活用することで、高いパフォーマンスと可用性を実現します。

  • 分散キャッシュ: キャッシュをクライアント、CDN、サーバーサイドに保存することで、CPU コストとネットワークコストの両方を削減します。

  • 同一性確認: ETagを使用してキャッシュされたコンテンツの同一性を確認し、コンテンツの変更があった場合にのみ再取得することで、ネットワーク効率を向上させます。

  • 耐障害性: イベントドリブンコンテンツの採用により、キャッシュに有効期限を設けないCDNキャッシュを基本にしたシステムは、PHPやDBがダウンした場合でもコンテンツを提供し続けます。

パフォーマンス

BEAR.Sundayは、最大限の柔軟性を保ちながら、パフォーマンスと効率性に重点を置いて設計されています。 極めて最適化されたブートストラップが実現され、ユーザー体験とシステムリソースの両方に好影響を与えています。 パフォーマンスはいつもBEAR.Sundayの最大関心事の一つであり、設計と開発の決定において常に中心的な役割を果たしています。

Because Everything is a Resource

「全てがリソース」のBEAR.Sundayは、Webの本質であるリソースを中心に設計されたPHPのWebアプリケーションフレームワークです。その真の価値は、オブジェクト指向原則とREST原則に基づいた優れた制約をアプリケーション全体の制約として提供することにあります。

この制約は、開発者に一貫性のある設計と実装を促し、長期的な視点に立ったアプリケーションの品質を高めます。同時に、この制約は開発者に自由をもたらし、アプリケーション構築の創造性を高めます。

AOP

アスペクト指向プログラミングは、横断的関心事の問題を解決します。対象メソッドの前後に任意の処理をインターセプターで織り込むことができます。 対象となるメソッドはビジネスロジックなど本質的関心事のみに関心を払い、インターセプターはログや検証などの横断的関心事に関心を払います。

BEAR.SundayはAOP Allianceに準拠したアスペクト指向プログラミングをサポートします。

インターセプター

インターセプターのinvokeメソッドで$invocationメソッド実行変数を受け取り、メソッドの前後に処理を加えます。 この変数は、インターセプター元メソッドを実行するためだけの変数です。前後にログやトランザクションなどの横断的処理を記述します。

use Ray\Aop\MethodInterceptor;
use Ray\Aop\MethodInvocation;

class MyInterceptor implements MethodInterceptor
{
    public function invoke(MethodInvocation $invocation)
    {
        // メソッド実行前の処理
        // ...

        // メソッド実行
        $result = $invocation->proceed();

        // メソッド実行後の処理
        // ...

        return $result;
    }
}

束縛

モジュールで対象となるクラスとメソッドをMatcherで”検索”して、マッチするメソッドにインターセプターを束縛します。

$this->bindInterceptor(
    $this->matcher->any(),                   // どのクラスでも
    $this->matcher->startsWith('delete'),    // "delete"で始まるメソッド名のメソッドには
    [Logger::class]                          // Loggerインターセプターを束縛
);

$this->bindInterceptor(
    $this->matcher->subclassesOf(AdminPage::class),  // AdminPageの継承または実装クラスの
    $this->matcher->annotatedWith(Auth::class),      // @Authアノテーションがアノテートされているメソッドには
    [AdminAuthentication::class]                     // AdminAuthenticationインターセプターを束縛
);

Matcherは他にこのような指定もできます。

インターセプターに渡されるMethodInvocationで対象のメソッド実行に関連するオブジェクトやメソッド、引数にアクセスすることができます。

リフレクションのメソッドでアノテーションを取得することができます。

$method = $invocation->getMethod();
$class = $invocation->getMethod()->getDeclaringClass();
  • $method->getAnnotations() - メソッドアノテーションの取得
  • $method->getAnnotation($name)
  • $class->getAnnotations() - クラスアノテーションの取得
  • $class->getAnnotation($name)

カスタムマッチャー

独自のカスタムマッチャーを作成するためにはAbstractMatchermatchesClassmatchesMethodを実装したクラスを作成します。

contains マッチャーを作成するためには、2つのメソッドを持つクラスを提供する必要があります。 1つはクラスのマッチを行うmatchesClassメソッド、もう1つはメソッドのマッチを行うmatchesMethodメソッドです。いずれもマッチしたかどうかをboolで返します。

use Ray\Aop\AbstractMatcher;

/**
 * 特定の文字列が含まれているか
 */
class ContainsMatcher extends AbstractMatcher
{
    /**
     * {@inheritdoc}
     */
    public function matchesClass(\ReflectionClass $class, array $arguments) : bool
    {
        list($contains) = $arguments;

        return (strpos($class->name, $contains) !== false);
    }

    /**
     * {@inheritdoc}
     */
    public function matchesMethod(\ReflectionMethod $method, array $arguments) : bool
    {
        list($contains) = $arguments;

        return (strpos($method->name, $contains) !== false);
    }
}

モジュール

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        $this->bindInterceptor(
            $this->matcher->any(),
            new ContainsMatcher('user'), // 'user'がメソッド名に含まれてるか
            [UserLogger::class]
        );
    }
};

リソース

BEAR.SundayアプリケーションはRESTfulなリソースの集合です。

サービスとしてのオブジェクト

ResourceObjectはHTTPのメソッドがPHPのメソッドにマップされたリソースのサービスのためのオブジェクト(Object-as-a-service)です。 ステートレスリクエストから、リソースの状態がリソース表現として生成され、クライアントに転送されます。(Representational State Transfer)

以下は、ResourceObjectの例です。

class Index extends ResourceObject
{
    public $code = 200;
    public $headers = [];

    public function onGet(int $a, int $b): static
    {
        $this->body = [
            'sum' => $a + $b  // $_GET['a'] + $_GET['b']
        ];

        return $this;
    }
}
class Todo extends ResourceObject
{
    public function onPost(string $id, string $todo): static
    {
        $this->code = 201; // ステータスコード
        $this->headers = [ // ヘッダー
            'Location' => '/todo/new_id'
        ];

        return $this;
    }
}

PHPのリソースクラスはWebのURIと同じようなpage://self/indexなどのURIを持ち、HTTPのメソッドに準じたonGet, onPostなどのonメソッドを持ちます。onメソッドで与えられたパラメーターから自身のリソース状態code,headers,bodyを決定し$thisを返します。

URI

URIはPHPのクラスにマップされています。アプリケーションではクラス名の代わりにURIを使ってリソースにアクセスします。

URI Class
   

リソースパラメーター

基本

ResourceObjectが必要なHTTPリクエストやCookieなどのWebのランタイムの値は、メソッドの引数に直接渡されます。

HTTPからリクエストではonGetonPostメソッドの引数にはそれぞれ$_GET$_POSTが変数名に応じて渡されます。例えば下記の$id$_GET['id']が渡されます。入力がHTTPの場合に文字列として渡された引数は指定した型にキャストされます。

class Index extends ResourceObject
{
    public function onGet(int $id): static
    {
        // ....

パラメーターの型

スカラーパラメーター

HTTPで渡されるパラメーターは全て文字列ですがintなど文字列以外の型を指定するとキャストされます。

配列パラメーター

パラメーターはネストされたデータ 1 でも構いません。JSONやネストされたクエリ文字列で送信されたデータは配列で受け取る事ができます。

class Index extends ResourceObject
{
    public function onPost(array $user):static
    {
        $name = $user['name']; // bear

クラスパラメーター

パラメータ専用のInputクラスで受け取ることもできます。

class Index extends ResourceObject
{
    public function onPost(User $user): static
    {
        $name = $user->name; // bear

Inputクラスは事前にパラメーターをpublicプロパティにしたものを定義しておきます。

<?php

namespace Vendor\App\Input;

final class User
{
    public int $id;
    public string $name;
}

この時、コンストラクタがあるとコールされます。2

<?php

namespace Vendor\App\Input;

final class User
{
    public function __constrcut(
        public readonly int $id,
        public readonly string $name
    } {}
}

ネームスペースは任意です。Inputクラスでは入力データをまとめたり検証したりするメソッドを実装する事ができます。

列挙型パラメーター

PHP8.1の列挙型を指定して取り得る値を制限することができます。

enum IceCreamId: int
{
    case VANILLA = 1;
    case PISTACHIO = 2;
}
class Index extends ResourceObject
{
    public function onGet(IceCreamId $iceCreamId): static
    {
        $id = $iceCreamId->value // 1 or 2

上記の場合1か2以外が渡されるとParameterInvalidEnumExceptionが発生します。

Webコンテキスト束縛

$_GET$_COOKIEなどのPHPのスーパーグローバルの値をメソッド内で取得するのではなく、メソッドの引数に束縛することができます。

use Ray\WebContextParam\Annotation\QueryParam;

class News extends ResourceObject
{
    public function foo(
    	  #[QueryParam('id')] string $id
    ): static {
       // $id = $_GET['id'];

その他$_ENV$_POST$_SERVERの値を束縛することでできます。

use Ray\WebContextParam\Annotation\QueryParam;
use Ray\WebContextParam\Annotation\CookieParam;
use Ray\WebContextParam\Annotation\EnvParam;
use Ray\WebContextParam\Annotation\FormParam;
use Ray\WebContextParam\Annotation\ServerParam;

class News extends ResourceObject
{
    public function onGet(
        #[QueryParam('id')] string $userId,            // $_GET['id'];
        #[CookieParam('id')] string $tokenId = "0000", // $_COOKIE['id'] or "0000" when unset;
        #[EnvParam('app_mode')] string $app_mode,      // $_ENV['app_mode'];
        #[FormParam('token')] string $token,           // $_POST['token'];
        #[ServerParam('SERVER_NAME') string $server    // $_SERVER['SERVER_NAME'];
    ): static {

クライアントが値を指定した時は指定した値が優先され、束縛した値は無効になります。テストの時に便利です。

リソース束縛

#[ResourceParam]アノテーションを使えば他のリソースリクエストの結果をメソッドの引数に束縛できます。

use BEAR\Resource\Annotation\ResourceParam;

class News extends ResourceObject
{
    public function onGet(
        #[ResourceParam('app://self//login#nickname') string $name
    ): static {

この例ではメソッドが呼ばれるとloginリソースにgetリクエストを行い$body['nickname']$nameで受け取ります。

コンテントネゴシエーション

HTTPリクエストのcontent-typeヘッダーがサポートされていてます。 application/jsonx-www-form-urlencodedメディアタイプを判別してパラメーターに値が渡されます。3

     
GET onGet $_GET
POST onPost $_POST または 標準入力
PUT onPut ※標準入力
PATCH onPatch ※標準入力
DELETE onDelete ※標準入力 

リクエストのメディアタイプは以下の2つが利用できます。

  • application/x-www-form-urlencoded // param1=one&param2=two
  • application/json // {“param1”: “one”, “param2”: “one”} (POSTの時は標準入力の値が使われます)

PHPマニュアルのPUT メソッドのサポートもご覧ください。

メソッドオーバーライド

HTTP PUT トラフィックや HTTP DELETE トラフィックを許可しないファイアウォールがあります。 この制約に対応するため、次の2つの方法でこれらの要求を送ることができます。

  • X-HTTP-Method-Override POSTリクエストのヘッダーフィールドを使用してPUTリクエストやDELETEリクエストを送る。
  • _method URI パラメーターを使用する。例)POST /users?…&_method=PUT

Auraルーター

リクエストのパスをパラメーターとして受け取る場合はAura Routerを使用します。

composer require bear/aura-router-module ^2.0

ルータースクリプトのパスを指定してAuraRouterModuleをインストールします。

use BEAR\Package\AbstractAppModule;
use BEAR\Package\Provide\Router\AuraRouterModule;

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ...
        $this->install(new AuraRouterModule($appDir . '/var/conf/aura.route.php'));
    }
}

キャッシュされているDIファイルを消去します。

rm -rf var/tmp/*

ルータースクリプト

ルータースクリプトではグローバルで渡されたMapオブジェクトに対してルートを設定します。 ルーティングにメソッドを指定する必要はありません。1つ目の引数はルート名としてパス、2つ目の引数に名前付きトークンのプレイスフォルダーを含んだパスを指定します。

var/conf/aura.route.php

<?php
/* @var \Aura\Router\Map $map */
$map->route('/blog', '/blog/{id}');
$map->route('/blog/comment', '/blog/{id}/comment');
$map->route('/user', '/user/{name}')->tokens(['name' => '[a-z]+']);
  • 最初の行では/blog/bearとアクセスがあるとpage://self/blog?id=bearとしてアクセスされます。 (=BlogクラスのonGet($id)メソッドに$id=bearの値でコールされます。)

  • /blog/{id}/commentBlog\Commentクラスにルートされます。

  • token()はパラメーターを正規表現で制限するときに使用します。

優先ルーター

Auraルーターでルートされない場合は、Webルーターが使われます。 つまりパスでパラメーターを渡すURIだけにルータースクリプトを用意すればOKです。

パラメーター

パスからパラメーターを取得するためにAuraルーターは様々な方法が用意されています。

カスタムマッチング

下のスクリプトは{date}が適切なフォーマットの時だけルートします。

$map->route('/calendar/from', '/calendar/from/{date}')
    ->tokens([
        'date' => function ($date, $route, $request) {
            try {
                new \DateTime($date);
                return true;
            } catch(\Exception $e) {
                return false;
            }
        }
    ]);

オプション

オプションのパラメーターを指定するためにはパスに{/attribute1,attribute2,attribute3}の表記を加えます。

例)

$map->route('archive', '/archive{/year,month,day}')
    ->tokens([
        'year' => '\d{4}',
        'month' => '\d{2}',
        'day' => '\d{2}',
    ]);

プレイスホルダーの内側に最初のスラッシュがあるのに注意してください。 そうすると下のパスは全て’archive’にルートされパラメーターの値が付加されます。

  • /archive : ['year' => null, 'month' => null, 'day' = null]
  • /archive/1979 : ['year' => '1979', 'month' => null, 'day' = null]
  • /archive/1979/11 : ['year' => '1979', 'month' => '11', 'day' = null]
  • /archive/1979/11/07 : ['year' => '1979', 'month' => '11', 'day' = '07']

オプションパラメーターは並ぶ順にオプションです。つまり”month”なしで”day”を指定することはできません。

ワイルドカード

任意の長さのパスの末尾パラメーターとして格納したいときにはwildcard()メソッドを使います。

$map->route('wild', '/wild')
    ->wildcard('card');

スラッシュで区切られたパスの値が配列になりwildcard()で指定したパラメーターに格納されます。

  • /wild : ['card' => []]
  • /wild/foo : ['card' => ['foo']]
  • /wild/foo/bar : ['card' => ['foo', 'bar']]
  • /wild/foo/bar/baz : ['card' => ['foo', 'bar', 'baz']]

その他の高度なルートに関してはAura Routerのdefining-routesをご覧ください。

リバースルーティング

ルートの名前とパラメーターの値からURIを生成することができます。

use BEAR\Sunday\Extension\Router\RouterInterface;

class Index extends ResourceObject
{
    /**
     * @var RouterInterface
     */
    private $router;

    public function __construct(RouterInterface $router)
    {
        $this->router = $router;
    }

    public function onGet(): static
    {
        $userLink = $this->router->generate('/user', ['name' => 'bear']);
        // '/user/bear'

リクエストメソッド

リクエストメソッドを指定する必要はありません。

リクエストヘッダー

通常リクエストヘッダーはAura.Routerに渡されていませんが RequestHeaderModuleをインストールするとAura.Routerでヘッダーを使ったマッチングが可能になります。

$this->install(new RequestHeaderModule());

独自のルーターコンポーネント

プロダクション

BEAR.Sunday既定のprod束縛に対して、アプリケーションがそれぞれのディプロイ環境に応じたモジュールをカスタマイズして束縛を行ます。

既定のProdModule

既定のprod束縛では以下のインターフェイスの束縛がされています。

  • エラーページ生成ファクトリー
  • PSRロガーインターフェイス
  • ローカルキャッシュ
  • 分散キャッシュ

詳細はBEAR.PackageのProdModule.php参照。

アプリケーションのProdModule

既定のProdModuleに対してアプリケーションのProdModulesrc/Module/ProdModule.phpに設置してカスタマイズします。特にエラーページと分散キャッシュは重要です。

<?php
namespace MyVendor\Todo\Module;

use BEAR\Package\Context\ProdModule as PackageProdModule;
use BEAR\QueryRepository\CacheVersionModule;
use BEAR\Resource\Module\OptionsMethodModule;
use BEAR\Package\AbstractAppModule;

class ProdModule extends AbstractModule
{
    /**
     * {@inheritdoc}
     */
    protected function configure()
    {
        $this->install(new PackageProdModule);       // デフォルトのprod設定
        $this->override(new OptionsMethodModule);    // OPTIONSメソッドをプロダクションでも有効に
        $this->install(new CacheVersionModule('1')); // リソースキャッシュのバージョン指定

        // 独自のエラーページ
        $this->bind(ErrorPageFactoryInterface::class)->to(MyErrorPageFactory::class);
    }
}

キャッシュ

キャッシュはローカルキャッシュと、複数のWebサーバー間でシェアをする分散キャッシュの2種類があります。 どちらのキャッシュもデフォルトはPhpFileCacheです。

ローカルキャッシュ

ローカルキャッシュはdeploy後に変更のないアノテーション等のキャシュ例に使われ、分散キャッシュはリソース状態の保存に使われます。

分散キャッシュ

2つ以上のWebサーバーでサービスを行うためには分散キャッシュの構成が必要です。 代表的なmemcachedRedisのキャッシュエンジンのそれぞれのモジュールが用意されています。

Memcached

<?php
namespace BEAR\HelloWorld\Module;

use BEAR\QueryRepository\StorageMemcachedModule;
use BEAR\Resource\Module\ProdLoggerModule;
use BEAR\Package\Context\ProdModule as PackageProdModule;
use BEAR\Package\AbstractAppModule;
use Ray\Di\Scope;

class ProdModule extends AbstractModule
{
    protected function configure()
    {
        // memcache
        // {host}:{port}:{weight},...
        $memcachedServers = 'mem1.domain.com:11211:33,mem2.domain.com:11211:67';
        $this->install(new StorageMemcachedModule(memcachedServers);

        // Prodロガーのインストール
        $this->install(new ProdLoggerModule);
        // デフォルトのProdModuleのインストール
        $this->install(new PackageProdModule);
    }
}

Redis

// redis
$redisServer = 'localhost:6379'; // {host}:{port}
$this->install(new StorageRedisModule($redisServer);

リソースの状態保存は単にTTLによる時間更新のキャッシュとの他に、TTL時間では消えない永続的なストレージとして(CQRS)の運用も可能です。 その場合にはRedisで永続処理を行うか、Cassandraなどの他KVSのストレージアダプターを独自で用意する必要があります。

キャッシュ時間の指定

デフォルトのTTLを変更する場合StorageExpiryModuleをインストールします。

// Cache time
$short = 60;
$medium = 3600;
$long = 24 * 3600;
$this->install(new StorageExpiryModule($short, $medium, $long);

キャッシュバージョンの指定

リソースのスキーマが代わり、互換性が失われる時にはキャッシュバージョンを変更します。特にTTL時間で消えないCQRS運用の場合に重要です。

$this->install(new CacheVersionModule($cacheVersion));

ディプロイの度にリソースキャッシュを破棄するためには$cacheVersionに時刻や乱数の値を割り当てると変更が不要で便利です。

ログ

ProdLoggerModuleはプロダクション用のリソース実行ログモジュールです。インストールするとGET以外のリクエストをPsr\Log\LoggerInterfaceにバインドされているロガーでログします。 特定のリソースや特定の状態でログしたい場合は、カスタムのログをBEAR\Resource\LoggerInterfaceにバインドします。

use BEAR\Resource\LoggerInterface;
use Ray\Di\AbstractModule;

final class MyProdLoggerModule extends AbstractModule
{
    protected function configure(): void
    {
        $this->bind(LoggerInterface::class)->to(MyProdLogger::class);
    }
}

LoggerInterface__invokeメソッドでリソースのURIとリソース状態がResourceObjectオブジェクトとして渡されるのでその内容で必要な部分をログします。 作成には既存の実装 ProdLoggerを参考にしてください。

デプロイ

⚠️ 上書き更新を避ける

サーバーにディプロイする場合

  • 駆動中のプロジェクトフォルダをrsyncなどで上書きするのはキャッシュやオンデマンドで生成されるファイルの不一致や、高負荷のサイトではキャパシティを超えるリスクがあります。 安全のために別のディレクトリでセットアップを行いそのセットアップが成功すれば切り替えるようにします。
  • DeployerBEAR.Sundayレシピを利用する事ができます。

クラウドにディプロイする時には

  • コンパイルが成功すると0、依存関係の問題を見つけるとコンパイラはexitコード1を出力します。それを利用してCIにコンパイルを組み込む事を推奨します。

コンパイル推奨

セットアップを行う際にvendor/bin/bear.compileスクリプトを使ってプロジェクトをウオームアップすることができます。 コンパイルスクリプトはDI/AOP用の動的に作成されるファイルやアノテーションなどの静的なキャッシュファイルを全て事前に作成し、最適化されたautoload.phpファイルとpreload.phpを出力します。

  • コンパイルをすれば全てのクラスでインジェクションを行うのでランタイムでDIのエラーが出る可能性が極めて低くなります。
  • .envには含まれた内容はPHPファイルに取り込まれるのでコンパイル後に.envを消去可能です。

コンテントネゴシエーションを行う場合など(ex. api-app, html-app)1つのアプリケーションで複数コンテキストのコンパイルを行うときにはファイルの退避が必要です。

mv autoload.php api.autoload.php

composer.jsonを編集してcomposer compileの内容を変更します。

autoload.php

{project_path}/autoload.phpに最適化されたautoload.phpファイルが出力されます。 composer dumpa-autoload --optimizeで出力されるvendor/autoload.phpよりずっと高速です。

注意:preload.phpを利用する場合、ほとんどの利用クラスが読み込まれた状態で起動するのでコンパイルされたautoload.phpは不要です。composerが生成するvendor/autload.phpをご利用ください。

preload.php

{project_path}/preload.phpに最適化されたpreload.phpファイルが出力されます。 preloadを有効にするためにはphp.iniでopcache.preloadopcache.preloadを指定する必要があります。PHP 7.4でサポートされた機能ですが、7.4初期のバージョンでは不安定です。7.4.4以上の最新版を使いましょう。

例)

opcache.preload=/path/to/project/preload.php
opcache.preload_user=www-data

Note: パフォーマンスベンチマークは[bechmark](https://github.com/bearsunday/BEAR.HelloworldBenchmark/wiki/Intel-Core-i5-3.8-GHz-iMac-(Retina-5K,-27-inch,-2017)-

インポート

BEARのアプリケーションは、マイクロサービスにすることなく複数のBEARアプリケーションを協調して1つのシステムにすることができます。また、他のアプリケーションからBEARのリソースを利用するのも容易です。

composer インストール

利用するBEARアプリケーションをcomposerパッケージにしてインストールします。

composer.json

{
  "require": {
    "bear/package": "^1.13",
    "my-vendor/weekday": "dev-master"
  },
  "repositories": [
    {
      "type": "vcs",
      "url": "https://github.com/bearsunday/tutorial1.git"
    }
  ]
}

bear/package ^1.13が必要です。

モジュールインストール

インポートするホスト名とアプリケーション名(namespace)、コンテキストを指定してImportAppModuleで他のアプリケーションをインストールします。

AppModule.php

+use BEAR\Package\Module\ImportAppModule;
+use BEAR\Package\Module\Import\ImportApp;

class AppModule extends AbstractAppModule
{
    protected function configure(): void
    {
        // ...
+        $this->install(new ImportAppModule([
+            new ImportApp('foo', 'MyVendor\Weekday', 'prod-app')
+        ]));
        $this->install(new PackageModule());
    }
}

ImportAppModuleBEAR\ResourceではなくBEAR\Packageのものであることに注意してください。

リクエスト

インポートしたリソースは指定したホスト名を指定して利用します。

class Index extends ResourceObject
{
    use ResourceInject;

    public function onGet(string $name = 'BEAR.Sunday'): static
    {
        $weekday = $this->resource->get('app://foo/weekday?year=2022&month=1&day=1');
        $this->body = [
            'greeting' => 'Hello ' . $name,
            'weekday' => $weekday
        ];

        return $this;
    }
}

#[Embed]#[Link]も同様に利用できます。

他のシステムから

他のフレームワークやCMSからBEARのリソースを利用するのも容易です。

同じようにパッケージとしてインストールして、Injector::getInstanceでrequireしたアプリケーションのリソースクライアントを取得してリクエストします。


use BEAR\Package\Injector;
use BEAR\Resource\ResourceInterface;

$resource = Injector::getInstance(
    'MyVendor\Weekday',
    'prod-api-app',
    dirname(__DIR__) . '/vendor/my-vendor/weekday'
)->getInstance(ResourceInterface::class);
$weekdday = $resource->get('/weekday', ['year' => '2022', 'month' => '1', 'day' => 1]);

echo $weekdday->body['weekday'] . PHP_EOL;

環境変数

環境変数はグローバルです。アプリケーション間でコンフリクトしないようにプリフィックスを付与するなどして注意する必要があります。インポートするアプリケーションは.envファイルを使うのではなく、プロダクションと同じようにシェルの環境変数を取得します。

システム境界

大きなアプリケーションを小さな複数のアプリケーションの集合体として構築できる点はマイクロサービスと同じですが、インフラストラクチャのオーバーヘッドの増加などのマイクロサービスのデメリットがありません。 またモジュラーモノリスよりもコンポーネントの独立性や境界が明確です。

このページのコードは bearsunday/example-app-import にあります。

多言語フレームワーク

BEAR.Thriftを使うと、Apache Thriftを使って他の言語や異なるバージョンのPHPやBEARアプリケーションからリソースにアクセスできます。 Apache Thriftは、異なる言語間での効率的な通信を可能にするフレームワークです。

AaaS (Application as a Service)

作成したAPIアプリケーションはWebやコンソール(バッチ)からアクセスできますが、他のPHPプロジェクトからライブラリとしてアクセスする事もできます。 このチュートリアルで作成したリポジトリはhttps://github.com/bearsunday/Tutorial2.gitにpushしてあります。

このプロジェクトをライブラリとして利用してみましょう。まず最初に新しいプロジェクトフォルダを作ってcomposer.jsonを用意します。

mkdir app
cd app
mkdir -p ticket/log
mkdir ticket/tmp

composer.json

{
    "name": "my-vendor/app",
    "description": "A BEAR.Sunday application",
    "type": "project",
    "license": "proprietary",
    "require": {
        "my-vendor/ticket": "dev-master"
    },
    "repositories": [
        {
            "type": "vcs",
            "url": "https://github.com/bearsunday/Tutorial2.git"
        }
    ]
}

composer installでプロジェクトがライブラリとしてインストールされます。

composer install

Ticket APIはプロジェクトフォルダにある.envを読むように設定されてました。vendor/my-vendor/app/.envに保存出来なくもないですが、ここでは別の方法で環境変数をセットアップしましょう。

このようなapp/.envファイルを用意します。

export TKT_DB_HOST=localhost
export TKT_DB_NAME=ticket
export TKT_DB_USER=root
export TKT_DB_PASS=''
export TKT_DB_SLAVE=''
export TKT_DB_DSN=mysql:host=${TKT_DB_HOST}\;dbname=${TKT_DB_NAME}

sourceコマンドで環境変数にexportすることができます。

source .env

Ticket APIを他のプロジェクトから利用する最も簡単なスクリプトは以下のようなものです。 アプリケーション名とコンテキストを指定してアプリケーションオブジェクト$ticketを取得してリソースアクセスします。

<?php
use BEAR\Package\Bootstrap;

require __DIR__ . '/vendor/autoload.php';

$ticket = (new Bootstrap)->getApp('MyVendor\Ticket', 'app');
$response = $ticket->resource->post('app://self/ticket',
    ['title' => 'run']
);

echo $response->code . PHP_EOL;


index.phpと保存して実行してみましょう。

php index.php
201

APIを他のメソッドに渡したり、他のフレームワークなどののコンテナに格納するためにはcallableオブジェクトにします。 $createTicketは普通の関数のように扱うことができます。

<?php
use BEAR\Package\Bootstrap;

require __DIR__ . '/vendor/autoload.php';

$ticket = (new Bootstrap)->getApp('MyVendor\Ticket', 'app');
$createTicket = $ticket->resource->post->uri('app://self/ticket');
// invoke callable object
$response = $createTicket(['title' => 'run']);
echo $response->code . PHP_EOL;

うまく動きましたか?しかし、このままではtmp/ logディレクトリはvendorの下のアプリが使われてしまいますね。 このようにアプリケーションのメタ情報を変更するとディレクトリの位置を変更することができます。

<?php

use BEAR\AppMeta\Meta;
use BEAR\Package\Bootstrap;

require __DIR__ . '/vendor/autoload.php';

$meta = new Meta('MyVendor\Ticket', 'app');
$meta->tmpDir = __DIR__ . '/ticket/tmp';
$meta->logDir = __DIR__ . '/ticket/log';
$ticket = (new Bootstrap)->newApp($meta, 'app');

Ticket APIはREST APIとしてHTTPやコンソールからアクセスできるだけでなく、BEAR.Sundayではない他のプロジェクトのライブラリとしても使えるようになりました!

Ray.AuraSqlModule

Ray.AuraSqlModuleはPDO拡張のAura.SqlとクエリビルダーAura.SqlQuery、その他にデータベースクエリー結果のページネーションのためのライブラリを提供します。

インストール

composerでray/aura-sql-moduleをインストールします。

composer require ray/aura-sql-module

アプリケーションモジュールsrc/Module/AppModule.phpAuraSqlModuleをインストールします。

use BEAR\Package\AbstractAppModule;
use BEAR\AppMeta\AppMeta;
use BEAR\Package\PackageModule;
use Ray\AuraSqlModule\AuraSqlModule; // この行を追加

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ...
        $this->install(
          new AuraSqlModule(
            'mysql:host=localhost;dbname=test' // またはgetenv('PDO_DSN')
            'username',
            'password',
          )
        );  // この行を追加
        $this->install(new PackageModule));
    }
}

設定時に直接値を指定するのではなく、実行時に毎回環境変数から取得するためにはAuraSqlEnvModuleを使います。 接続先と認証情報の値を直接指定する代わりに、該当する環境変数のキーを渡します。

        $this->install(
            new AuraSqlEnvModule(
                'PDO_DSN',             // getenv('PDO_DSN')
                'PDO_USER',            // getenv('PDO_USER')
                'PDO_PASSWORD',        // getenv('PDO_PASSWORD')
                'PDO_SLAVE'            // getenv('PDO_SLAVE')
                $options,              // optional key=>value array of driver-specific connection options
                $queris                // Queries to execute after the connection.
        );

Aura.Sql

Aura.SqlはPHPのPDOを拡張したデータベースライブラリです。 コンストラクタインジェクションやAuraSqlInjectトレイトを利用してPDOを拡張したDBオブジェクトExtendedPDOを受け取ります。

use Aura\Sql\ExtendedPdoInterface;

class Index
{
    public function __construct(
        private readonly ExtendedPdoInterface $pdo
    ) {}
}
use Ray\AuraSqlModule\AuraSqlInject;

class Index
{
    use AuraSqlInject;

    public function onGet()
    {
        return $this->pdo; // \Aura\Sql\ExtendedPdo
    }
}

Ray.AuraSqlModuleAura.SqlQueryを含んでいてMySQLやPostgresなどのSQLを組み立てるのに利用できます。

perform() メソッド

perform()メソッドは、1つのプレイスホルダーしかないSQLに配列の値をバインドすることが出来ます。

$stm = 'SELECT * FROM test WHERE foo IN (:foo)'
$array = ['foo', 'bar', 'baz'];

既存のPDOの場合

// the native PDO way does not work (PHP Notice:  Array to string conversion)
// ネイティブのPDOでは`:foo`に配列を指定することは出来ません
$sth = $pdo->prepare($stm);
$sth->bindValue('foo', $array);

Aura.SqlのExtendedPDOの場合

$stm = 'SELECT * FROM test WHERE foo IN (:foo)'
$values = ['foo' => ['foo', 'bar', 'baz']];
$sth = $pdo->perform($stm, $values);

:foo['foo', 'bar', 'baz']がバインドがされます。queryStringで実際のクエリーを調べることが出来ます。

echo $sth->queryString;
// the query string has been modified by ExtendedPdo to become
// "SELECT * FROM test WHERE foo IN ('foo', 'bar', 'baz')"

fetch*() メソッド

prepare()bindValue()execute()を繰り返してデータベースから値を取得する代わりにfetch*()メソッドを使うとボイラープレートコードを減らすことができます。 (内部ではperform()メソッドを実行しているので配列のプレイスホルダーもサポートしています)

$stm  = 'SELECT * FROM test WHERE foo = :foo AND bar = :bar';
$bind = array('foo' => 'baz', 'bar' => 'dib');
// ネイティブのPDOで"fetch all"を行う場合
$pdo = new PDO(...);
$sth = $pdo->prepare($stm);
$sth->execute($bind);
$result = $sth->fetchAll(PDO::FETCH_ASSOC);

// ExtendedPdoで"fetch all"を行う場合
$pdo = new ExtendedPdo(...);
$result = $pdo->fetchAll($stm, $bind);

// fetchAssoc()は全ての行がコラム名のキーを持つ連想配列が返ります。
$result = $pdo->fetchAssoc($stm, $bind);

// fetchGroup() is like fetchAssoc() except that the values aren't wrapped in
// arrays. Instead, single column values are returned as a single dimensional
// array and multiple columns are returned as an array of arrays
// Set style to PDO::FETCH_NAMED when values are an array
// (i.e. there are more than two columns in the select)
$result = $pdo->fetchGroup($stm, $bind, $style = PDO::FETCH_COLUMN)

// fetchOne()は最初の行をキーをコラム名にした連想配列で返します。
$result = $pdo->fetchOne($stm, $bind);

// fetchPairs()は最初の列の値をキーに二番目の列の値を値にした連想配列を返します  
$result = $pdo->fetchPairs($stm, $bind);

// fetchValue()は最初の列の値を返します。
$result = $pdo->fetchValue($stm, $bind);

// fetchAffected()は影響を受けた行数を返します。
$stm = "UPDATE test SET incr = incr + 1 WHERE foo = :foo AND bar = :bar";
$row_count = $pdo->fetchAffected($stm, $bind);
?>

fetchAll(), fetchAssoc(), fetchCol(), 及び fetchPairs()のメソッドは三番目のオプションの引数に、それぞれの列に適用されるコールバックを指定することができます。

$result = $pdo->fetchAssoc($stm, $bind, function (&$row) {
    // add a column to the row
    $row['my_new_col'] = 'Added this column from the callable.';
});
?>

yield*() メソッド

メモリを節約するためにyield*()メソッドを使うことができます。 fetch*()メソッドは全ての行を一度に取得しますが、 yield*()メソッドはイテレーターが返ります。

$stm  = 'SELECT * FROM test WHERE foo = :foo AND bar = :bar';
$bind = array('foo' => 'baz', 'bar' => 'dib');

// fetchAll()のように行は連想配列です
foreach ($pdo->yieldAll($stm, $bind) as $row) {
    // ...
}

// fetchAssoc()のようにキーが最初の列名で行が連想配列です。
foreach ($pdo->yieldAssoc($stm, $bind) as $key => $row) {
    // ...
}

// fetchCol()のように最初の列が値になった値を返します。
foreach ($pdo->yieldCol($stm, $bind) as $val) {
    // ...
}

// fetchPairs()と同様に最初の列からキー/バリューのペアの値を返します。
foreach ($pdo->yieldPairs($stm, $bind) as $key => $val) {
    // ...
}

リプリケーション

マスター/スレーブ構成のデータベース接続を行うためには4つ目の引数にスレーブDBのホストを指定します。

$this->install(
  new AuraSqlModule(
    'mysql:host=localhost;dbname=test',
    'username',
    'password',
    'slave1,slave2' // スレーブのホストをカンマ区切りで指定
  )
);

これでHTTPリクエストがGETの時がスレーブDB、その他のメソッドの時はマスターDBのDBオブジェクトがコンスタラクタに渡されます。

use Aura\Sql\ExtendedPdoInterface;
use BEAR\Resource\ResourceObject;
use PDO;

class User extends ResourceObject
{
    public $pdo;

    public function __construct(ExtendedPdoInterface $pdo)
    {
        $this->pdo = $pdo;
    }

    public function onGet()
    {
         $this->pdo; // slave db
    }

    public function onPost($todo)
    {
         $this->pdo; // master db
    }
}

@ReadOnlyConnection@WriteConnectionでアノテートされたメソッドはメソッド名に関わらず、呼ばれた時にアノテーションに応じたDBオブジェクトが$this->pdoに上書きされます。

use Ray\AuraSqlModule\Annotation\ReadOnlyConnection;  // important
use Ray\AuraSqlModule\Annotation\WriteConnection;     // important

class User
{
    public $pdo; // @ReadOnlyConnectionや@WriteConnectionのメソッドが呼ばれた時に上書きされる

    public function onPost($todo)
    {
         $this->read();
    }

    /**
     * @ReadOnlyConnection
     */
    public function read()
    {
         $this->pdo; // slave db
    }

    /**
     * @WriteConnection
     */
    public function write()
    {
         $this->pdo; // master db
    }
}

複数データベースの接続

接続先の異なるデータベースのPDOインスタンスをインジェクトするには識別子9をつけます。

    public function __constrcut(
        private readonly #[Log] ExtendedPdoInterface $logDb,
        private readonly #[Mail] ExtendedPdoInterface $mailDb,
    ){}

NamedPdoModuleでその識別子と接続情報を指定してインストールします。

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ...
        $this->install(new NamedPdoModule(Log::class, 'mysql:host=localhost;dbname=log', 'username', 
        $this->install(new NamedPdoModule(Mail::class, 'mysql:host=localhost;dbname=mail', 'username', 
    }
}

接続情報を環境変数から都度取得するときはNamedPdoEnvModuleを使います。

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ...
        $this->install(new NamedPdoEnvModule(Log::class, 'LOG_DSN', 'LOG_USERNAME',  
        $this->install(new NamedPdoEnvModule(Mail::class, 'MAIL_DSN', 'MAIL_USERNAME', 
    }
}

トランザクション

#[Transactional]アトリビュートを追加したメソッドはトランザクション管理されます。

use Ray\AuraSqlModule\Annotation\Transactional;

// ....
    #[Transactional]
    public function write()
    {
         // 例外発生したら\Ray\AuraSqlModule\Exception\RollbackExceptionに
    }

複数接続したデータベースのトランザクションを行うためには@Transactionalアノテーションにプロパティを指定します。 指定しない場合は{"pdo"}になります。

#[Transactional({"pdo", "userDb"})]
public function write()

以下のように実行されます。

$this->pdo->beginTransaction()
$this->userDb->beginTransaction()

// ...

$this->pdo->commit();
$this->userDb->commit();

Aura.SqlQuery

Aura.SqlはPDOを拡張したライブラリですが、Aura.SqlQueryは MySQL、Postgres,、SQLiteあるいは Microsoft SQL Serverといったデータベース固有のSQLのビルダーを提供します。

データベースを指定してアプリケーションモジュールsrc/Module/AppModule.phpでインストールします。

// ...
$this->install(new AuraSqlQueryModule('mysql')); // pgsql, sqlite, or sqlsrv

SELECT

リソースではDBクエリービルダオブジェクトを受け取り、下記のメソッドを使ってSELECTクエリーを組み立てます。 メソッドに特定の順番はなく複数回呼ぶことこともできます。

use Aura\Sql\ExtendedPdoInterface;
use Aura\SqlQuery\Common\SelectInterface;

class User extend ResourceObject
{
    public function __construct(
        private readonly ExtendedPdoInterface $pdo,
        private readonly SelectInterface $select
    ) {}

    public function onGet()
    {
        $this->select
            ->distinct()                    // SELECT DISTINCT
            ->cols([                        // select these columns
                'id',                       // column name
                'name AS namecol',          // one way of aliasing
                'col_name' => 'col_alias',  // another way of aliasing
                'COUNT(foo) AS foo_count'   // embed calculations directly
            ])
            ->from('foo AS f')              // FROM these tables
            ->fromSubselect(                // FROM sub-select AS my_sub
                'SELECT ...',
                'my_sub'
            )
            ->join(                         // JOIN ...
                'LEFT',                     // left/inner/natural/etc
                'doom AS d'                 // this table name
                'foo.id = d.foo_id'         // ON these conditions
            )
            ->joinSubSelect(                // JOIN to a sub-select
                'INNER',                    // left/inner/natural/etc
                'SELECT ...',               // the subselect to join on
                'subjoin'                   // AS this name
                'sub.id = foo.id'           // ON these conditions
            )
            ->where('bar > :bar')           // AND WHERE these conditions
            ->where('zim = ?', 'zim_val')   // bind 'zim_val' to the ? placeholder
            ->orWhere('baz < :baz')         // OR WHERE these conditions
            ->groupBy(['dib'])              // GROUP BY these columns
            ->having('foo = :foo')          // AND HAVING these conditions
            ->having('bar > ?', 'bar_val')  // bind 'bar_val' to the ? placeholder
            ->orHaving('baz < :baz')        // OR HAVING these conditions
            ->orderBy(['baz'])              // ORDER BY these columns
            ->limit(10)                     // LIMIT 10
            ->offset(40)                    // OFFSET 40
            ->forUpdate()                   // FOR UPDATE
            ->union()                       // UNION with a followup SELECT
            ->unionAll()                    // UNION ALL with a followup SELECT
            ->bindValue('foo', 'foo_val')   // bind one value to a placeholder
            ->bindValues([                  // bind these values to named placeholders
                'bar' => 'bar_val',
                'baz' => 'baz_val',
            ]);

        $sth = $this->pdo->prepare($this->select->getStatement());

        // bind the values and execute
        $sth->execute($this->select->getBindValues());
        $result = $sth->fetch(\PDO::FETCH_ASSOC);
        // or
        // $result = $this->pdo->fetchAssoc($stm, $bind);

組み立てたクエリーはgetStatement()で文字列にしてクエリーを行います。

INSERT

単一行のINSERT

class User extend ResourceObject
{
    public function __construct(
        private readonly ExtendedPdoInterface $pdo,
        private readonly SelectInterface $select
    ) {}

    public function onPost()
    {
        $this->insert
            ->into('foo')                   // INTO this table
            ->cols([                        // bind values as "(col) VALUES (:col)"
                'bar',
                'baz',
            ])
            ->set('ts', 'NOW()')            // raw value as "(ts) VALUES (NOW())"
            ->bindValue('foo', 'foo_val')   // bind one value to a placeholder
            ->bindValues([                  // bind these values
                'bar' => 'foo',
                'baz' => 'zim',
            ]);

        $sth = $this->pdo->prepare($this->insert->getStatement());
        $sth->execute($this->insert->getBindValues());
        // or
        // $sth = $this->pdo->perform($this->insert->getStatement(), this->insert->getBindValues());

        // get the last insert ID
        $name = $insert->getLastInsertIdName('id');
        $id = $pdo->lastInsertId($name);

cols()メソッドはキーがコラム名、値をバインドする値にした連想配列を渡すこともできます。

        $this->insert
            ->into('foo')                   // insert into this table
            ->cols([                        // insert these columns and bind these values
                'foo' => 'foo_value',
                'bar' => 'bar_value',
                'baz' => 'baz_value',
            ]);

複数行のINSERT

複数の行のINSERTを行うためには、最初の行の最後でaddRow()メソッドを使います。その後に次のクエリーを組み立てます。

        // insert into this table
        $this->insert->into('foo');

        // set up the first row
        $this->insert->cols([
            'bar' => 'bar-0',
            'baz' => 'baz-0'
        ]);
        $this->insert->set('ts', 'NOW()');

        // set up the second row. the columns here are in a different order
        // than in the first row, but it doesn't matter; the INSERT object
        // keeps track and builds them the same order as the first row.
        $this->insert->addRow();
        $this->insert->set('ts', 'NOW()');
        $this->insert->cols([
            'bar' => 'bar-1',
            'baz' => 'baz-1'
        ]);

        // set up further rows ...
        $this->insert->addRow();
        // ...

        // execute a bulk insert of all rows
        $sth = $this->pdo->prepare($insert->getStatement());
        $sth->execute($insert->getBindValues());

注:最初の行で始めて現れた列の値を指定しないで、行を追加しようとすると例外が投げられます。 addRow()に列の連想配列を渡すと次の行で使われます。つまり最初の行でcol()cols()を指定しないこともできます。

        // set up the first row
        $insert->addRow([
            'bar' => 'bar-0',
            'baz' => 'baz-0'
        ]);
        $insert->set('ts', 'NOW()');

        // set up the second row
        $insert->addRow([
            'bar' => 'bar-1',
            'baz' => 'baz-1'
        ]);
        $insert->set('ts', 'NOW()');

        // etc.

addRows()を使ってデータベースを一度にセットすることもできます。

        $rows = [
            [
                'bar' => 'bar-0',
                'baz' => 'baz-0'
            ],
            [
                'bar' => 'bar-1',
                'baz' => 'baz-1'
            ],
        ];
        $this->insert->addRows($rows);

UPDATE

下記のメソッドを使ってUPDATEクエリーを組み立てます。 メソッドに特定の順番はなく複数回呼ぶことこともできます。

        $this->update
            ->table('foo')                  // update this table
            ->cols([                        // bind values as "SET bar = :bar"
                'bar',
                'baz',
            ])
            ->set('ts', 'NOW()')            // raw value as "(ts) VALUES (NOW())"
            ->where('zim = :zim')           // AND WHERE these conditions
            ->where('gir = ?', 'doom')      // bind this value to the condition
            ->orWhere('gir = :gir')         // OR WHERE these conditions
            ->bindValue('bar', 'bar_val')   // bind one value to a placeholder
            ->bindValues([                  // bind these values to the query
                'baz' => 99,
                'zim' => 'dib',
                'gir' => 'doom',
            ]);
        $sth = $this->pdo->prepare($update->getStatement())
        $sth->execute($this->update->getBindValues());
        // or
        // $sth = $this->pdo->perform($this->update->getStatement(), $this->update->getBindValues());

キーを列名、値をバインドされた値(RAW値ではなりません)にした連想配列をcols()に渡すこともできます。


        $this-update->table('foo')          // update this table
            ->cols([                        // update these columns and bind these values
                'foo' => 'foo_value',
                'bar' => 'bar_value',
                'baz' => 'baz_value',
            ]);
?>

DELETE

下記のメソッドを使ってDELETEクエリーを組み立てます。 メソッドに特定の順番はなく複数回呼ぶことこともできます。

        $this->delete
            ->from('foo')                   // FROM this table
            ->where('zim = :zim')           // AND WHERE these conditions
            ->where('gir = ?', 'doom')      // bind this value to the condition
            ->orWhere('gir = :gir')         // OR WHERE these conditions
            ->bindValue('bar', 'bar_val')   // bind one value to a placeholder
            ->bindValues([                  // bind these values to the query
                'baz' => 99,
                'zim' => 'dib',
                'gir' => 'doom',
            ]);
        $sth = $this->pdo->prepare($update->getStatement())
        $sth->execute($this->delete->getBindValues());

パジネーション

ray/aura-sql-moduleはRay.Sqlの生SQL、Ray.AuraSqlQueryのクエリービルダー双方でパジネーション(ページ分割)をサポートしています。 バインドする値と1ページあたりのアイテム数、それに{page}をページ番号にしたuri_templateでページャーファクトリーをnewInstance()で生成して、ページ番号で配列アクセスします。

Aura.Sql用

AuraSqlPagerFactoryInterface

/* @var $factory \Ray\AuraSqlModule\Pagerfanta\AuraSqlPagerFactoryInterface */
$pager = $factory->newInstance($pdo, $sql, $params, 10, '/?page={page}&category=sports'); // 10 items per page
$page = $pager[2]; // page 2
/* @var $page \Ray\AuraSqlModule\Pagerfanta\Page */
// $page->data // sliced data (array|\Traversable)
// $page->current; (int)
// $page->total (int)
// $page->hasNext (bool)
// $page->hasPrevious (bool)
// $page->maxPerPage; (int)
// (string) $page // pager html (string)

Aura.SqlQuery用

AuraSqlQueryPagerFactoryInterface

// for Select
/* @var $factory \Ray\AuraSqlModule\Pagerfanta\AuraSqlQueryPagerFactoryInterface */
$pager = $factory->newInstance($pdo, $select, 10, '/?page={page}&category=sports');
$page = $pager[2]; // page 2
/* @var $page \Ray\AuraSqlModule\Pagerfanta\Page */

注:Aura.Sqlは生SQLを直接編集していますが現在MySql形式のLIMIT句しか対応していません。

$pageはイテレータブルです。

foreach ($page as $row) {
 // 各行の処理
}

ページャーのリンクHTMLのテンプレートを変更するにはTemplateInterfaceの束縛を変更します。 テンプレート詳細に関してはPagerfantaをご覧ください。

use Pagerfanta\View\Template\TemplateInterface;
use Pagerfanta\View\Template\TwitterBootstrap3Template;
use Ray\AuraSqlModule\Annotation\PagerViewOption;

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ..
        $this->bind(TemplateInterface::class)->to(TwitterBootstrap3Template::class);
        $this->bind()->annotatedWith(PagerViewOption::class)->toInstance($pagerViewOption);
    }
}

データベース

データベースライブラリの利用のためAura.SqlDoctrine DBAL, CakeDBなどのモジュールが用意されています。

Aura.Sql

Aura.SqlはPHPのPDOを拡張したデータベースライブラリです。

インストール

composerでRay.AuraSqlModuleをインストールします。

composer require ray/aura-sql-module

アプリケーションモジュールsrc/Module/AppModule.phpAuraSqlModuleをインストールします。

use BEAR\Package\AbstractAppModule;
use BEAR\AppMeta\AppMeta;
use BEAR\Package\PackageModule;
use Ray\AuraSqlModule\AuraSqlModule; // この行を追加

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ...
        $this->install(
          new AuraSqlModule(
            'mysql:host=localhost;dbname=test',
            'username',
            'password',
            // $options,
            // $attributes
          )
        );  // この行を追加
        $this->install(new PackageModule));
    }
}

これでDIの設定が整いました。コンストラクタやAuraSqlInjectトレイトを利用してPDOを拡張したDBオブジェクトExtendedPDOを受け取ります。

use Aura\Sql\ExtendedPdoInterface;

class Index
{
    public function __construct(ExtendedPdoInterface $pdo)
    {
        return $this->pdo; // \Aura\Sql\ExtendedPdo
    }
}
use Ray\AuraSqlModule\AuraSqlInject;

class Index
{
    use AuraSqlInject;

    public function onGet()
    {
        return $this->pdo; // \Aura\Sql\ExtendedPdo
    }
}

Ray.AuraSqlModuleAura.SqlQueryを含んでいてMySQLやPostgresなどのSQLを組み立てるのに利用できます。

perform() メソッド

perform()メソッドは、1つのプレイスホルダーしかないSQLに配列の値をバインドすることが出来ます。

$stm = 'SELECT * FROM test WHERE foo IN (:foo)'
$array = ['foo', 'bar', 'baz'];

既存のPDOの場合

// the native PDO way does not work (PHP Notice:  Array to string conversion)
// ネイティブのPDOでは`:foo`に配列を指定することは出来ません
$sth = $pdo->prepare($stm);
$sth->bindValue('foo', $array);

Aura.SqlのExtendedPDOの場合

$stm = 'SELECT * FROM test WHERE foo IN (:foo)'
$values = ['foo' => ['foo', 'bar', 'baz']];
$sth = $pdo->perform($stm, $values);

:foo['foo', 'bar', 'baz']がバインドがされます。queryStringで実際のクエリーを調べることが出来ます。

echo $sth->queryString;
// the query string has been modified by ExtendedPdo to become
// "SELECT * FROM test WHERE foo IN ('foo', 'bar', 'baz')"

fetch*() メソッド

prepare()bindValue()execute()を繰り返してデータベースから値を取得する代わりにfetch*()メソッドを使うとボイラープレートコードを減らすことができます。 (内部ではperform()メソッドを実行しているので配列のプレースフォルもサポートしています)

$stm  = 'SELECT * FROM test WHERE foo = :foo AND bar = :bar';
$bind = array('foo' => 'baz', 'bar' => 'dib');
// ネイティブのPDOで"fetch all"を行う場合
$pdo = new PDO(...);
$sth = $pdo->prepare($stm);
$sth->execute($bind);
$result = $sth->fetchAll(PDO::FETCH_ASSOC);

// ExtendedPdoで"fetch all"を行う場合
$pdo = new ExtendedPdo(...);
$result = $pdo->fetchAll($stm, $bind);

// fetchAssoc()は全ての行がコラム名のキーを持つ連想配列が返ります。
$result = $pdo->fetchAssoc($stm, $bind);

// fetchGroup() is like fetchAssoc() except that the values aren't wrapped in
// arrays. Instead, single column values are returned as a single dimensional
// array and multiple columns are returned as an array of arrays
// Set style to PDO::FETCH_NAMED when values are an array
// (i.e. there are more than two columns in the select)
$result = $pdo->fetchGroup($stm, $bind, $style = PDO::FETCH_COLUMN)

// fetchOne()は最初の行をキーをコラム名にした連想配列で返します。
$result = $pdo->fetchOne($stm, $bind);

// fetchPairs()は最初の列の値をキーに二番目の列の値を値にした連想配列を返します  
$result = $pdo->fetchPairs($stm, $bind);

// fetchValue()は最初の列の値を返します。
$result = $pdo->fetchValue($stm, $bind);

// fetchAffected()は影響を受けた行数を返します。
$stm = "UPDATE test SET incr = incr + 1 WHERE foo = :foo AND bar = :bar";
$row_count = $pdo->fetchAffected($stm, $bind);
?>

fetchAll(), fetchAssoc(), fetchCol(), 及び fetchPairs()のメソッドは三番目のオプションの引数に、それぞれの列に適用されるコールバックを指定することができます。

$result = $pdo->fetchAssoc($stm, $bind, function (&$row) {
    // add a column to the row
    $row['my_new_col'] = 'Added this column from the callable.';
});
?>

yield*() メソッド

メモリを節約するためにyield*()メソッドを使うことができます。 fetch*()メソッドは全ての行を一度に取得しますが、 yield*()メソッドはイテレーターが返ります。

$stm  = 'SELECT * FROM test WHERE foo = :foo AND bar = :bar';
$bind = array('foo' => 'baz', 'bar' => 'dib');

// fetchAll()のように行は連想配列です
foreach ($pdo->yieldAll($stm, $bind) as $row) {
    // ...
}

// fetchAssoc()のようにキーが最初の列名で行が連想配列です。
foreach ($pdo->yieldAssoc($stm, $bind) as $key => $row) {
    // ...
}

// fetchCol()のように最初の列が値になった値を返します。
foreach ($pdo->yieldCol($stm, $bind) as $val) {
    // ...
}

// fetchPairs()と同様に最初の列からキー/バリューのペアの値を返します。
foreach ($pdo->yieldPairs($stm, $bind) as $key => $val) {
    // ...
}

リプリケーション

マスター/スレーブの接続を自動で行うためには4つ目の引数にスレーブDBのIPを指定します。

$this->install(
  new AuraSqlModule(
    'mysql:host=localhost;dbname=test',
    'username',
    'password',
    'slave1,slave2' // スレーブIPをカンマ区切りで指定
  )
);

これでHTTPリクエストがGETの時がスレーブDB、その他のメソッドの時はマスターDBのDBオブジェクトがコンスタラクタに渡されます。

use Aura\Sql\ExtendedPdoInterface;
use BEAR\Resource\ResourceObject;
use PDO;

class User extends ResourceObject
{
    public $pdo;

    public function __construct(ExtendedPdoInterface $pdo)
    {
        $this->pdo = $pdo;
    }

    public function onGet()
    {
         $this->pdo; // slave db
    }

    public function onPost($todo)
    {
         $this->pdo; // master db
    }
}

@ReadOnlyConnection@WriteConnectionでアノテートされたメソッドはメソッド名に関わらず、呼ばれた時にアノテーションに応じたDBオブジェクトが$this->pdoに上書きされます。

use Ray\AuraSqlModule\Annotation\ReadOnlyConnection;  // important
use Ray\AuraSqlModule\Annotation\WriteConnection;     // important

class User
{
    public $pdo; // @ReadOnlyConnectionや@WriteConnectionのメソッドが呼ばれた時に上書きされる

    public function onPost($todo)
    {
         $this->read();
    }

    /**
     * @ReadOnlyConnection
     */
    public function read()
    {
         $this->pdo; // slave db
    }

    /**
     * @WriteConnection
     */
    public function write()
    {
         $this->pdo; // master db
    }
}

複数DB

接続先の違う複数のPdoExtendedInterfaceオブジェクトを受け取るためには @Namedアノテーションで指定します。

/**
 * @Inject
 * @Named("log_db")
 */
public function setLoggerDb(ExtendedPdoInterface $pdo)
{
    // ...
}

モジュールではNamedPdoModuleで識別子を指定して束縛します。

$this->install(
  new NamedPdoModule(
    'log_db', // @Namedで指定するデータベースの種類
    'mysql:host=localhost;dbname=log',
    'username',
    'pass',
    'slave1,slave12'
  )
);

トランザクション

@Transactionalとアノテートしたメソッドはトランザクション管理されます。

use Ray\AuraSqlModule\Annotation\Transactional;

// ....
    /**
     * @Transactional
     */
    public function write()
    {
         // 例外発生したら\Ray\AuraSqlModule\Exception\RollbackExceptionに
    }

複数接続したデータベースのトランザクションを行うためには@Transactionalアノテーションにプロパティを指定します。 指定しない場合は{"pdo"}になります。

/**
 * @Transactional({"pdo", "userDb"})
 */
public function write()

以下のように実行されます。

$this->pdo->beginTransaction()
$this->userDb->beginTransaction()

// ...

$this->pdo->commit();
$this->userDb->commit();

Aura.SqlQuery

Aura.SqlはPDOを拡張したライブラリですが、Aura.SqlQueryは MySQL、Postgres,、SQLiteあるいは Microsoft SQL Serverといったデータベース固有のSQLのビルダーを提供します。

データベースを指定してアプリケーションモジュールsrc/Module/AppModule.phpでインストールします。

// ...
$this->install(new AuraSqlQueryModule('mysql')); // pgsql, sqlite, or sqlsrv

SELECT

リソースではDBクエリービルダオブジェクトを受け取り、下記のメソッドを使ってSELECTクエリーを組み立てます。 メソッドに特定の順番はなく複数回呼ぶことこともできます。

use Ray\AuraSqlModule\AuraSqlInject;
use Ray\AuraSqlModule\AuraSqlSelectInject;

class User extend ResourceObject
{
    use AuraSqlInject;
    use AuraSqlSelectInject;

    public function onGet()
    {
        $this->select
            ->distinct()                    // SELECT DISTINCT
            ->cols([                        // select these columns
                'id',                       // column name
                'name AS namecol',          // one way of aliasing
                'col_name' => 'col_alias',  // another way of aliasing
                'COUNT(foo) AS foo_count'   // embed calculations directly
            ])
            ->from('foo AS f')              // FROM these tables
            ->fromSubselect(                // FROM sub-select AS my_sub
                'SELECT ...',
                'my_sub'
            )
            ->join(                         // JOIN ...
                'LEFT',                     // left/inner/natural/etc
                'doom AS d'                 // this table name
                'foo.id = d.foo_id'         // ON these conditions
            )
            ->joinSubSelect(                // JOIN to a sub-select
                'INNER',                    // left/inner/natural/etc
                'SELECT ...',               // the subselect to join on
                'subjoin'                   // AS this name
                'sub.id = foo.id'           // ON these conditions
            )
            ->where('bar > :bar')           // AND WHERE these conditions
            ->where('zim = ?', 'zim_val')   // bind 'zim_val' to the ? placeholder
            ->orWhere('baz < :baz')         // OR WHERE these conditions
            ->groupBy(['dib'])              // GROUP BY these columns
            ->having('foo = :foo')          // AND HAVING these conditions
            ->having('bar > ?', 'bar_val')  // bind 'bar_val' to the ? placeholder
            ->orHaving('baz < :baz')        // OR HAVING these conditions
            ->orderBy(['baz'])              // ORDER BY these columns
            ->limit(10)                     // LIMIT 10
            ->offset(40)                    // OFFSET 40
            ->forUpdate()                   // FOR UPDATE
            ->union()                       // UNION with a followup SELECT
            ->unionAll()                    // UNION ALL with a followup SELECT
            ->bindValue('foo', 'foo_val')   // bind one value to a placeholder
            ->bindValues([                  // bind these values to named placeholders
                'bar' => 'bar_val',
                'baz' => 'baz_val',
            ]);

        $sth = $this->pdo->prepare($this->select->getStatement());

        // bind the values and execute
        $sth->execute($this->select->getBindValues());
        $result = $sth->fetch(\PDO::FETCH_ASSOC);
        // or
        // $result = $this->pdo->fetchAssoc($stm, $bind);

組み立てたクエリーはgetStatement()で文字列にしてクエリーを行います。

INSERT

単一行のINSERT

use Ray\AuraSqlModule\AuraSqlInject;
use Ray\AuraSqlModule\AuraSqlInsertInject;

class User extend ResourceObject
{
    use AuraSqlInject;
    use AuraSqlInsertInject;

    public function onPost()
    {
        $this->insert
            ->into('foo')                   // INTO this table
            ->cols([                        // bind values as "(col) VALUES (:col)"
                'bar',
                'baz',
            ])
            ->set('ts', 'NOW()')            // raw value as "(ts) VALUES (NOW())"
            ->bindValue('foo', 'foo_val')   // bind one value to a placeholder
            ->bindValues([                  // bind these values
                'bar' => 'foo',
                'baz' => 'zim',
            ]);

        $sth = $this->pdo->prepare($this->insert->getStatement());
        $sth->execute($this->insert->getBindValues());
        // or
        // $sth = $this->pdo->perform($this->insert->getStatement(), this->insert->getBindValues());

        // get the last insert ID
        $name = $insert->getLastInsertIdName('id');
        $id = $pdo->lastInsertId($name);

cols()メソッドはキーがコラム名、値をバインドする値にした連想配列を渡すこともできます。

        $this->insert
            ->into('foo')                   // insert into this table
            ->cols([                        // insert these columns and bind these values
                'foo' => 'foo_value',
                'bar' => 'bar_value',
                'baz' => 'baz_value',
            ]);

複数行のINSERT

複数の行のINSERTを行うためには、最初の行の最後でaddRow()メソッドを使います。その後に次のクエリーを組み立てます。

        // insert into this table
        $this->insert->into('foo');

        // set up the first row
        $this->insert->cols([
            'bar' => 'bar-0',
            'baz' => 'baz-0'
        ]);
        $this->insert->set('ts', 'NOW()');

        // set up the second row. the columns here are in a different order
        // than in the first row, but it doesn't matter; the INSERT object
        // keeps track and builds them the same order as the first row.
        $this->insert->addRow();
        $this->insert->set('ts', 'NOW()');
        $this->insert->cols([
            'bar' => 'bar-1',
            'baz' => 'baz-1'
        ]);

        // set up further rows ...
        $this->insert->addRow();
        // ...

        // execute a bulk insert of all rows
        $sth = $this->pdo->prepare($insert->getStatement());
        $sth->execute($insert->getBindValues());

注:最初の行で始めて現れた列の値を指定しないで、行を追加しようとすると例外が投げられます。 addRow()に列の連想配列を渡すと次の行で使われます。つまり最初の行でcol()cols()を指定しないこともできます。

        // set up the first row
        $insert->addRow([
            'bar' => 'bar-0',
            'baz' => 'baz-0'
        ]);
        $insert->set('ts', 'NOW()');

        // set up the second row
        $insert->addRow([
            'bar' => 'bar-1',
            'baz' => 'baz-1'
        ]);
        $insert->set('ts', 'NOW()');

        // etc.

addRows()を使ってデータベースを一度にセットすることもできます。

        $rows = [
            [
                'bar' => 'bar-0',
                'baz' => 'baz-0'
            ],
            [
                'bar' => 'bar-1',
                'baz' => 'baz-1'
            ],
        ];
        $this->insert->addRows($rows);

UPDATE

下記のメソッドを使ってUPDATEクエリーを組み立てます。 メソッドに特定の順番はなく複数回呼ぶことこともできます。

        $this->update
            ->table('foo')                  // update this table
            ->cols([                        // bind values as "SET bar = :bar"
                'bar',
                'baz',
            ])
            ->set('ts', 'NOW()')            // raw value as "(ts) VALUES (NOW())"
            ->where('zim = :zim')           // AND WHERE these conditions
            ->where('gir = ?', 'doom')      // bind this value to the condition
            ->orWhere('gir = :gir')         // OR WHERE these conditions
            ->bindValue('bar', 'bar_val')   // bind one value to a placeholder
            ->bindValues([                  // bind these values to the query
                'baz' => 99,
                'zim' => 'dib',
                'gir' => 'doom',
            ]);
        $sth = $this->pdo->prepare($update->getStatement())
        $sth->execute($this->update->getBindValues());
        // or
        // $sth = $this->pdo->perform($this->update->getStatement(), $this->update->getBindValues());

キーを列名、値をバインドされた値(RAW値ではなりません)にした連想配列をcols()に渡すこともできます。


        $this-update->table('foo')          // update this table
            ->cols([                        // update these columns and bind these values
                'foo' => 'foo_value',
                'bar' => 'bar_value',
                'baz' => 'baz_value',
            ]);
?>

DELETE

下記のメソッドを使ってDELETEクエリーを組み立てます。 メソッドに特定の順番はなく複数回呼ぶことこともできます。

        $this->delete
            ->from('foo')                   // FROM this table
            ->where('zim = :zim')           // AND WHERE these conditions
            ->where('gir = ?', 'doom')      // bind this value to the condition
            ->orWhere('gir = :gir')         // OR WHERE these conditions
            ->bindValue('bar', 'bar_val')   // bind one value to a placeholder
            ->bindValues([                  // bind these values to the query
                'baz' => 99,
                'zim' => 'dib',
                'gir' => 'doom',
            ]);
        $sth = $this->pdo->prepare($update->getStatement())
        $sth->execute($this->delete->getBindValues());

パジネーション

ray/aura-sql-moduleはRay.Sqlの生SQL、Ray.AuraSqlQueryのクエリービルダー双方でパジネーション(ページ分割)をサポートしています。 バインドする値と1ページあたりのアイテム数、それに{page}をページ番号にしたuri_templateでページャーファクトリーをnewInstance()で生成して、ページ番号で配列アクセスします。

Aura.Sql用

AuraSqlPagerFactoryInterface

/* @var $factory \Ray\AuraSqlModule\Pagerfanta\AuraSqlPagerFactoryInterface */
$pager = $factory->newInstance($pdo, $sql, $params, 10, '/?page={page}&category=sports'); // 10 items per page
$page = $pager[2]; // page 2
/* @var $page \Ray\AuraSqlModule\Pagerfanta\Page */
// $page->data // sliced data (array|\Traversable)
// $page->current; (int)
// $page->total (int)
// $page->hasNext (bool)
// $page->hasPrevious (bool)
// $page->maxPerPage; (int)
// (string) $page // pager html (string)

Aura.SqlQuery用

AuraSqlQueryPagerFactoryInterface

// for Select
/* @var $factory \Ray\AuraSqlModule\Pagerfanta\AuraSqlQueryPagerFactoryInterface */
$pager = $factory->newInstance($pdo, $select, 10, '/?page={page}&category=sports');
$page = $pager[2]; // page 2
/* @var $page \Ray\AuraSqlModule\Pagerfanta\Page */

注:Aura.Sqlは生SQLを直接編集していますが現在MySql形式のLIMIT句しか対応していません。

$pageはイテレータブルです。

foreach ($page as $row) {
 // 各行の処理
}

ページャーのリンクHTMLのテンプレートを変更するにはTemplateInterfaceの束縛を変更します。 テンプレート詳細に関してはPagerfantaをご覧ください。

use Pagerfanta\View\Template\TemplateInterface;
use Pagerfanta\View\Template\TwitterBootstrap3Template;
use Ray\AuraSqlModule\Annotation\PagerViewOption;

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ..
        $this->bind(TemplateInterface::class)->to(TwitterBootstrap3Template::class);
        $this->bind()->annotatedWith(PagerViewOption::class)->toInstance($pagerViewOption);
    }
}

バリデーション

  • JSONスキーマでリソースAPIを定義する事ができます。
  • @Valid, @OnValidateアノテーションでバリデーションコードを分離する事ができます。
  • Webフォームによるバリデーションはフォームをご覧ください。

JSONスキーマ

JSON スキーマとは、JSON objectの記述と検証のための標準です。#[JsonSchema]アトリビュートが付加されたリソースクラスのメソッドが返すリソースbodyに対してJSONスキーマによる検証が行われます。

インストール

全てのコンテキストで常にバリデーションを行うならAppModule、開発中のみバリデーションを行うならDevModuleなどのクラスを作成してその中でインストールします。

use BEAR\Resource\Module\JsonSchemaModule; // この行を追加
use BEAR\Package\AbstractAppModule;

class AppModule extends AbstractAppModule
{
    protected function configure(): void
    {
        // ...
        $this->install(
            new JsonSchemaModule(
                $appDir . '/var/json_schema',
                $appDir . '/var/json_validate'
            )
        );   // この行を追加
    }
}

ディレクトリ作成

mkdir var/json_schema
mkdir var/json_validate

var/json_schema/にリソースのbodyの仕様となるJSONスキーマファイル、var/json_validate/には入力バリデーションのためのJSONスキーマファイルを格納します。

#[JsonSchema]アトリビュート

リソースクラスのメソッドで#[JsonSchema]のアトリビュートを加えます。schemaプロパティにはJSONスキーマファイル名を指定します。

schema

src/Resource/App/User.php


use BEAR\Resource\Annotation\JsonSchema; // この行を追加

class User extends ResourceObject
{
    #[JsonSchema('user.json')]
    public function onGet(): static
    {
        $this->body = [
            'firstName' => 'mucha',
            'lastName' => 'alfons',
            'age' => 12
        ];

        return $this;
    }
}

JSONスキーマを設置します。

/var/json_schema/user.json

{
  "type": "object",
  "properties": {
    "firstName": {
      "type": "string",
      "maxLength": 30,
      "pattern": "[a-z\\d~+-]+"
    },
    "lastName": {
      "type": "string",
      "maxLength": 30,
      "pattern": "[a-z\\d~+-]+"
    }
  },
  "required": ["firstName", "lastName"]
}

key

bodyにインデックスキーがある場合にはアノテーションのkeyプロパティで指定します。


use BEAR\Resource\Annotation\JsonSchema; // Add this line

class User extends ResourceObject
{
    #[JsonSchema(key:'user', schema:'user.json')]
    public function onGet()
    {
        $this->body = [
            'user' => [
                'firstName' => 'mucha',
                'lastName' => 'alfons',
                'age' => 12
            ]
        ];        

        return $this;
    }
}

params

params プロパティには引数のバリデーションのためのJSONスキーマファイル名を指定します。

use BEAR\Resource\Annotation\JsonSchema; // この行を追加

class Todo extends ResourceObject
{
    #[JsonSchema(key:'user', schema:'user.json', params:'todo.post.json')]
    public function onPost(string $title)

JSONスキーマを設置します。

/var/json_validate/todo.post.json

{
  "$schema": "http://json-schema.org/draft-04/schema#",
  "title": "/todo POST request validation",
  "properties": {
    "title": {
      "type": "string",
      "minLength": 1,
      "maxLength": 40
    }
}

独自ドキュメントの代わりに標準化された方法で常に検証することで、その仕様が人間にもマシンにも理解できる確実なものになります。

target

ResourceObjectのbodyに対してでなく、リソースオブジェクトの表現(レンダリングされた結果)に対してスキーマバリデーションを適用にするにはtarget='view'オプションを指定します。 HALフォーマットで_linkのスキーマが記述できます。

#[JsonSchema(schema: 'user.json', target: 'view')]

関連リンク

@Validアノテーション

@Validアノテーションは入力のためのバリデーションです。メソッドの実行前にバリデーションメソッドが実行され、 エラーを検知すると例外が発生されエラー処理のためのメソッドを呼ぶこともできます。

分離したバリデーションのコードは可読性に優れテストが容易です。バリデーションのライブラリはAura.FilterRespect\Validation、あるいはPHP標準のFilterを使います。

インストール

composerインストール

composer require ray/validate-module

アプリケーションモジュールsrc/Module/AppModule.phpValidateModuleをインストールします。

use Ray\Validation\ValidateModule;

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ...
        $this->install(new ValidateModule);
    }
}

アノテーション

バリデーションのために@Valid@OnValidate@OnFailureの3つのアノテーションが用意されています。

まず、バリデーションを行いたいメソッドに@Validとアノテートします。

use Ray\Validation\Annotation\Valid;
// ...
    /**
     * @Valid
     */
    public function createUser($name)
    {

@OnValidateとアノテートしたメソッドでバリデーションを行います。引数は元のメソッドと同じにします。メソッド名は自由です。

use Ray\Validation\Annotation\OnValidate;
// ...
    /**
     * @OnValidate
     */
    public function onValidate($name)
    {
        $validation = new Validation;
        if (! is_string($name)) {
            $validation->addError('name', 'name should be string');
        }

        return $validation;
    }

バリデーション失敗した要素には要素名エラーメッセージを指定してValidationオブジェクトにaddError()し、最後にValidationオブジェクトを返します。

バリデーションが失敗すればRay\Validation\Exception\InvalidArgumentException例外が投げられますが、 @OnFailureメソッドが用意されていればそのメソッドの結果が返されます。

use Ray\Validation\Annotation\OnFailure;
// ...
    /**
     * @OnFailure
     */
    public function onFailure(FailureInterface $failure)
    {
        // original parameters
        list($this->defaultName) = $failure->getInvocation()->getArguments();

        // errors
        foreach ($failure->getMessages() as $name => $messages) {
            foreach ($messages as $message) {
                echo "Input '{$name}': {$message}" . PHP_EOL;
            }
        }
    }

@OnFailureメソッドには$failureが渡され($failure->getMessages()でエラーメッセージや$failure->getInvocation()でオリジナルメソッド実行のオブジェクトが取得できます。

複数のバリデーション

1つのクラスに複数のバリデーションメソッドが必要なときは以下のようにバリデーションの名前を指定します。

use Ray\Validation\Annotation\Valid;
use Ray\Validation\Annotation\OnValidate;
use Ray\Validation\Annotation\OnFailure;
// ...

    /**
     * @Valid("foo")
     */
    public function fooAction($name, $address, $zip)
    {

    /**
     * @OnValidate("foo")
     */
    public function onValidateFoo($name, $address, $zip)
    {

    /**
     * @OnFailure("foo")
     */
    public function onFailureFoo(FailureInterface $failure)
    {

その他のバリデーション

複雑なバリデーションの時は別にバリデーションクラスをインジェクトして、onValidateメソッドから呼び出してバリデーションを行います。DIなのでコンテキストによってバリデーションを変えることもできます。

バージョン

サポートするPHP

Continuous Integration

BEAR.SundayはサポートされているPHP(Supported Versions) のバージョンのPHPをサポートします。

  • 8.1 (古い安定板 25 Nov 2021 - 25 Nov 2024)
  • 8.2 (古い安定板 8 Dec 2022 - 8 Dec 2025)
  • 8.3 (現在の安定板 23 Nov 2022 - 26 Nov 2026)

End of life (EOL)

  • 5.5 (21 Jul 2016)
  • 5.6 (31 Dec 2018)
  • 7.0 (3 Dec 2018)
  • 7.1 (1 Dec 2019)
  • 7.2 (30 Nov 2020)
  • 7.3 (6 Dec 2021)
  • 7.4 (28 Nov 2022)
  • 8.0 (26 Nov 2023)

新規のオプションパッケージは現在の安定板をベースに開発されます。機能とパフォーマンスそれにセキュリティの観点から現在の安定板のPHPを使うことを勧めします。BEAR.SupportedVersionsのCIで各バージョンのテストが確認できます。

Semver

BEAR.Sundayはセマンティックバージョニングに従います。バージョン番号が0.1増えるだけのマイナーバージョンアップではアプリケーションコードの修正は必要ありません。

バージョニング・ポリシー

  • フレームワークのコアパッケージはユーザーコードに変更が必要な破壊的変更(breaking change)を行いません。11

  • サポートするPHPがEOLを迎え、必要とするPHPがメジャーバージョンアップ(5.67.0)してもフレームワークのメジャーバージョンアップは行いません。後方互換性は保たれます。

  • 新しいモジュールを使うために必要なPHPのバージョン番号が上がることはあっても、そのために破壊的変更が行われる事はありません。

  • 破壊的変更を行わないために、古く不要な機能も削除しないで12、新しい機能は(置き換えではなく)常に追加されます。

BEAR.Sundayは堅牢で進化可能1なメンテナンス性の良いコードが長期的に利用できることを重視します。

パッケージのバージョン

フレームワークのバージョンはライブラリのバージョンの固定を行いません。ライブラリはフレームワークのバージョンと無関係にアップデートできます。composer updateで常に依存を最新にする事を勧めます。


HTML

HTML表現のために以下のテンプレートエンジンが利用可能です。

Twig vs Qiq

Twigは最初のリリースが2009年にされ多くのユーザーがいます。Qiqは2021年にリリースされた新しいテンプレートエンジンです。

Twigが暗黙的エスケープをデフォルトにし制御構造などをTwig独自構文にしています。それに対して、Qiqは明示的なエスケープを要求し、PHP構文が基本のテンプレートです。 Twigのコードベースは大きく機能も豊富ですがそれに対してQiqはコンパクトでシンプルです。 (冗長になりますがQiqを完全なPHP構文で記述するとIDEや静的解析フレンドリーになります。)

構文比較

PHP

<?= $var ?>
<?= htmlspecialchars($var, ENT_QUOTES|ENT_DISALLOWED, 'utf-8') ?>
<?= htmlspecialchars(helper($var, ENT_QUOTES|ENT_DISALLOWED, 'utf-8')) ?>
<?php foreach ($users => $user): ?>
 * <?= $user->name; ?>
<?php endforeach; ?>

Twig

{{ var | raw }}
{{ var }}
{{ var | helper }}
{% for user in users %}
  * {{ user.name }}
{% endfor %}

Qiq

{{% var }}
{{h $var }}
{{h helper($var) }}
{{ foreach($users => $user) }}
  * {{h $user->name }}
{{ endforeach }}

{{ var }} // 表示されない
<?php /** @var Template $this */ ?>
<?= $this->h($var) ?>

レンダラー

RenderInetrfaceにバインドされResourceObjectにインジェクトされるレンダラーがリソースの表現を生成します。リソース自身はその表現に関して無関心です。

リソース単位でインジェクトされるので、複数のテンプレートエンジンを同時に使うこともできます。

開発用のハローUI

開発時にハロー(Halo, 後光) 13 と呼ばれる開発用のUIをレンダリングされたリソースの周囲に表示することができます。ハローはリソースの状態、表現、及び適用されたインターセプターなどについての情報を提供します。また、該当するリソースクラスやリソーステンプレートがPHPStormで開かれるリンクも提供します。

ハローがリソース状態を表示

  • ハローホーム(ボーターとツール表示)
  • リソース状態
  • リソース表現
  • プロファイル
  • プロファイル

demoでハローのモックを試すことができます。

パフォーマンスモニタリング

ハローにはリソースのパフォーマンス情報も表示されます。リソースの実行時間、メモリ使用量、プロファイラへのリンクが表示されます。

ハローがパフォーマンスを表示

インストール

プロファイリングにはxhprofのインストールが必要です。パフォーマンスのボトルネックを特定するのに役立ちます。

pecl install xhprof
// 加えてphp.iniファイルに'extension=xhprof.so'を追加

コールグラフを可視化してグラフィック表示するためには、graphvizのインストールが必要です。 例)コールグラフデモ

// macOS
brew install graphviz

// Windows
// graphvizのWebサイトからインストーラをダウンロードしてインストール

// Linux (Ubuntu)
sudo apt-get install graphviz

アプリケーションではアプリケーションのDevコンテキストモジュールなどを作成してHaloModuleをインストールします。

class DevModule extends AbstractModule
{
    protected function configure(): void
    {
        $this->install(new HaloModule($this));
    }
}

HTML (Qiq)

セットアップ

QiqでHTML表示をするためにcomposerでbear/qiq-moduleをインストールします。

composer require bear/qiq-module

次にテンプレートやヘルパーを格納するディレクトリを用意します。

cd /path/to/project
cp -r vendor/bear/qiq-module/var/qiq var

htmlコンテキストファイルsrc/Module/HtmlModule.phpを用意してQiqModuleをインストールします。

namespace MyVendor\MyPackage\Module;

use BEAR\Package\AbstractAppModule;
use BEAR\QiqModule\QiqModule;


class HtmlModule extends AbstractAppModule
{
    protected function configure()
    {
        $this->install(new QiqModule($this->appMeta->appDir . '/var/qiq/template'));
    }
}

コンテキスト変更

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

$context = 'cli-html-app';

テンプレート

Indexリソースのテンプレートをvar/qiq/template/Page/Index.phpに用意します。

<h1>{{h $this->greeting }}</h1>

ResourceObjectの$bodyがテンプレートに$thisとしてアサインされます。

php bin/page.php get /
200 OK
content-type: text/html; charset=utf-8

<h1>Hello BEAR.Sunday</h1>

カスタムヘルパー

カスタムヘルパーQiq\Helper\のnamespaceで作成します。例: Qiq\Helper\Foo

composer.jsonのautoloadQiq\Helperを指定し(例: composer.json) 、composer dump-autoloadを実行してヘルパークラスをオートロード可能にします。指定ディレクトリに設置するとカスタムヘルパーが利用可能になります。

ProdModule

プロダクション用にエラーページをHTMLにし、コンパイラキャッシュを有効にするためのモジュールをProdModuleでインストールします。

class ProdModule extends AbstractModule
{
    protected function configure()
    {
        $this->install(new QiqErrorModule);
        $this->install(new QiqProdModule($this->appDir . '/var/tmp');
    }
}

HTML (Twig v1)

HTML表示のためにcomposerでmadapaja/twig-moduleをインストールします。

composer require madapaja/twig-module ^1.0

次にhtmlコンテキストファイルsrc/Module/HtmlModule.phpを用意してTwigModuleをインストールします。

namespace MyVendor\MyPackage\Module;

use BEAR\AppMeta\AppMeta;
use Madapaja\TwigModule\TwigModule;
use Ray\Di\AbstractModule;

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

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

$context = 'cli-html-app';

リソースのPHPファイル名に.html.twig拡張子をつけたファイルでテンプレートを用意します。 Page/Index.phpに対応するのはPage/Index.html.twigになります。

<h1>{{ greeting }}</h1>

$bodyがテンプレートにアサインされて出力されます。

php bin/page.php get /
200 OK
content-type: text/html; charset=utf-8

<h1>Hello BEAR.Sunday</h1>

レイアウトや部分的なテンプレートファイルはvar/lib/twigに設置します。

カスタム設定

コンテンキストに応じてオプション等を設定したり、テンプレートのパスを追加したりする場合は @TwigPaths@TwigOptionsに設定値を束縛します。

namespace MyVendor\MyPackage\Module;

use Madapaja\TwigModule\Annotation\TwigOptions;
use Madapaja\TwigModule\Annotation\TwigPaths;
use Madapaja\TwigModule\TwigModule;
use Ray\Di\AbstractModule;

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

        // twig テンプレートパスを追加
        $appDir = dirname(dirname(__DIR__));
        $paths = [
            $appDir . '/src/Resource',
            $appDir . '/var/lib/twig'
        ];
        $this->bind()->annotatedWith(TwigPaths::class)->toInstance($paths);

        // 環境のオプションを設定することも可能
        // @see http://twig.sensiolabs.org/doc/api.html#environment-options
        $options = [
            'debug' => false,
            'cache' => $appDir . '/tmp'
        ];
        $this->bind()->annotatedWith(TwigOptions::class)->toInstance($options);
    }
}

他のテンプレートエンジン

テンプレートエンジンは選択できるだけでなく、複数のテンプレートエンジンをリソース単位で選択することもできます。

HTML (Twig v2)

インストール

HTML表示のためにcomposerでTwig v2のモジュールをインストールします。

composer require madapaja/twig-module ^2.0

次にhtmlコンテキストファイルsrc/Module/HtmlModule.phpを用意してTwigModuleをインストールします。

namespace MyVendor\MyPackage\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);
    }
}

TwigErrorPageModuleはエラー表示をHTMLで行うオプションです。HtmlModuleでインストールしないでProdModuleでインストールして開発時のエラー表示はJSONにすることも出来ます。

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

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

bin/page.phppublic/index.phpのコンテキストを変更してhtmlを有効にします。

$context = 'cli-html-app'; // 'html-app'

テンプレート

1つのリソースクラスに1つのテンプレートファイルがvar/templatesフォルダに必要です。 例えばsrc/Page/Index.phpにはvar/templates/Page/Index.html.twig が必要です。

テンプレートにリソースの bodyがアサインされます。

例)

src/Page/Index.php

class Index extends ResourceObject
{
    public $body = [
        'greeting' => 'Hello BEAR.Sunday'
    ];
}

src/Page/Index.twig.php または var/templates/Page/Index.twig.php

<h1>{{ greeting }}</h1>

出力

php bin/page.php get /
200 OK
content-type: text/html; charset=utf-8

<h1>Hello BEAR.Sunday</h1>

テンプレートファイルの選択

どのテンプレートを使用するかはリソースでは選択しません。リソースの状態によってincludeします。

{% if user.is_login %}
    {{ include('member.html.twig') }}
{% else %}
    {{ include('guest.html.twig') }}
{% endif %}

リソースクラスはリソース状態だけに関心を持ち、テンプレートだけがリソース表現に関心を持ちます。 このような設計原則を関心の分離(SoC)といいます。

エラーページ

var/templates/error.html.twigを編集します。エラーページには以下の値がアサインされています。

変数 意味 キー
     
status HTTP ステータス code, message
e 例外 code, message, class
logref ログID n/a

{% extends 'layout/base.html.twig' %}
{% block title %}{{ status.code }} {{ status.message }}{% endblock %}
{% block content %}
    <h1>{{ status.code }} {{ status.message }}</h1>
    {% if status.code == 404 %}
        <p>The requested URL was not found on this server.</p>
    {% else %}
        <p>The server is temporarily unable to service your request.</p>
        <p>refference number: {{ logref }}</p>
    {% endif %}
{% endblock %}

リソースのアサイン

リソースクラスのプロパティを参照するにはリソース全体がアサインされる_roを参照します。

例)

Todos.php

class Todos extend ResourceObject
{
    public $code = 200;

    public $text = [
        'name' => 'BEAR'
    ];

    public $body = [
        ['title' => 'run']
    ];
}

Todos.html.twig

{{ _ro.code }} // 200
{{ _ro.text.name }} // 'BEAR'
{% for todo in _ro.body %}
  {{ todo.title }} // 'run'
{% endfor %}

ビューの階層構造

リソースクラス単位でビューを持つ事ができます。構造を良く表し、キャッシュもリソース単位で行われるので効率的です。

例)app://self/todosを読み込むpage://self/index

app://self/todos

class Todos extends ResourceObject
{
    use AuraSqlInject;
    use QueryLocatorInject;

    public function onGet(): static
    {
        $this->body = $this->pdo->fetchAll($this->query['todos_list']);
        return $this;
    }
}
{% for todo in _ro.body %}
  {{ todo.title }}</td>
{% endfor %}

page://self/index

class Index extends ResourceObject
{
    /**
     * @Embed(rel="todos", src="app://self/todos")
     */
    public function onGet(): static
    {
        return $this;
    }
}
{% extends 'layout/base.html.twig' %}
{% block content %}
  {{ todos|raw }}
{% endblock %}

拡張

TwigをaddExtension()メソッドで拡張する場合には、拡張を行うTwigのProviderクラスを用意しTwig_EnvironmentクラスにProvider束縛します。

use Ray\Di\Di\Named;
use Ray\Di\ProviderInterface;

class MyTwigProvider implements ProviderInterface
{
    private $twig;

    /**
     * @Named("original")
     */
    public function __construct(\Twig_Environment $twig)
    {
        // $twig is an original \Twig_Environment instance
        $this->twig = $twig;
    }

    public function get()
    {
        // Extending Twig
        $this->twig->addExtension(new MyTwigExtension());

        return $this->twig;
    }
}
class HtmlModule extends AbstractModule
{
    protected function configure()
    {
        $this->install(new TwigModule);
        $this->bind(\Twig_Environment::class)->toProvider(MyTwigProvider::class)->in(Scope::SINGLETON);
    }
}

モバイル

モバイルサイト専用のテンプレートを使用するためにはMobileTwigModuleを加えてインストールします。

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

index.html.twigの代わりにIndex.mobile.twig存在すれば優先して使用されます。変更の必要なテンプレートだけを用意する事ができます。

カスタム設定

コンテンキストに応じてオプション等を設定したり、テンプレートのパスを追加する場合は@TwigPaths@TwigOptionsに設定値を束縛します。

注)キャッシュを常にvar/tmpフォルダに生成するので特にプロダクション用の設定などは特に必要ありません。

namespace MyVendor\MyPackage\Module;

use BEAR\Package\AbstractAppModule;
use Madapaja\TwigModule\Annotation\TwigDebug;
use Madapaja\TwigModule\Annotation\TwigOptions;
use Madapaja\TwigModule\Annotation\TwigPaths;
use Madapaja\TwigModule\TwigModule;
use Ray\Di\AbstractModule;

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ...
        $this->install(new TwigModule);

        // テンプレートパスの指定
        $appDir = $this->appMeta->appDir;
        $paths = [
            $appDir . '/src/Resource',
            $appDir . '/var/templates'
        ];
        $this->bind()->annotatedWith(TwigPaths::class)->toInstance($paths);

        // オプション
        // @see http://twig.sensiolabs.org/doc/api.html#environment-options
        $options = [
            'debug' => false,
            'cache' => $appDir . '/tmp'
        ];
        $this->bind()->annotatedWith(TwigOptions::class)->toInstance($options);
        
        // debugオプションのみを指定する場合
        $this->bind()->annotatedWith(TwigDebug::class)->toInstance(true);
    }
}

フォーム

Aura.InputAura.Filterを使ったWebフォーム機能は 関連する機能が単体のクラスに集約され、テストや変更が容易です。1つのクラスでWebフォームとバリデーションのみの両方の用途に使えます。

インストール

Aura.Inputを使ったフォーム処理を追加するのにcomposerでray/web-form-moduleをインストールします。

composer require ray/web-form-module

アプリケーションモジュールsrc/Module/AppModule.phpAuraInputModuleをインストールします。

use BEAR\Package\AbstractAppModule;
use Ray\WebFormModule\WebFormModule;

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ...
        $this->install(new AuraInputModule);
    }
}

Webフォーム

フォーム要素の登録やルールを定めたフォームクラスを作成して、@FormValidationアノテーションを使って特定のメソッドと束縛します。 メソッドは送信されたデータがバリデーションOKのときのみ実行されます。

use Ray\WebFormModule\AbstractForm;
use Ray\WebFormModule\SetAntiCsrfTrait;

class MyForm extends AbstractForm
{
    /**
     * {@inheritdoc}
     */
    public function init()
    {
        // set input fields
        $this->setField('name', 'text')
             ->setAttribs([
                 'id' => 'name'
             ]);
        // set rules and user defined error message
        $this->filter->validate('name')->is('alnum');
        $this->filter->useFieldMessage('name', 'Name must be alphabetic only.');
    }
}

フォームクラスのinit()メソッドでフォームのinput要素を登録し、バリデーションのフィルターやサニタイズのルールを適用します。バリデーションルールに関してはAura.FilterのRules To Validate Fields、サニタイズに関してはRules To Sanitize Fieldsをご覧ください。

メソッドの引数を連想配列にしたもをバリデーションします。入力を変更したいときは SubmitInterfaceインターフェイスのsubmit()メソッドを実装して入力にする値を返します。

@FormValidationアノテーション

フォームのバリデーションを行うメソッドを@FormValidationでアノテートすると、実行前にformプロパティのフォームオブジェクトでバリデーションが行われます。 バリデーションに失敗するとメソッド名にValidationFailedサフィックスをつけたメソッドが呼ばれます。

use Ray\Di\Di\Inject;
use Ray\Di\Di\Named;
use Ray\WebFormModule\Annotation\FormValidation;
use Ray\WebFormModule\FormInterface;

class MyController
{
    /**
     * @var FormInterface
     */
    protected $form;

    /**
     * @Inject
     * @Named("contact_form")
     */
    public function setForm(FormInterface $form)
    {
        $this->form = $form;
    }

    /**
     * @FormValidation
     * // または
     * @FormValidation(form="form", onFailure="onPostValidationFailed")
     */
    public function onPost($name, $age)
    {
        // validation success
    }

    public function onPostValidationFailed($name, $age)
    {
        // validation failed
    }
}

@FormValidationアノテーションのform,onValidationFailedプロパティを変更してformプロパティの名前やメソッドの名前を明示的に指定こともできます。

onPostValidationFailedにはサブミットされた値が渡されます。

ビュー

フォームのinput要素やエラーメッセージを取得するには要素名を指定します。

  $form->input('name'); // <input id="name" type="text" name="name" size="20" maxlength="20" />
  $form->error('name'); // 文字列「名前には全角文字またはアルファベットを入力して下さい。」またはブランク

テンプレートにTwigを使った場合でも同様です。

{{ form.input('name') }}
{{ form.error('name') }}

CSRF

CSRF(クロスサイトリクエストフォージェリ)対策を行うためにはフォームにCSRFオブジェクトをセットします。

use Ray\WebFormModule\SetAntiCsrfTrait;

class MyForm extends AbstractAuraForm
{
    use SetAntiCsrfTrait;

セキュリティレベルを高めるためには、ユーザーの認証を含んだカスタムCsrfクラスを作成してフォームクラスにセットします。 詳しくはAura.InputのApplying CSRF Protectionsをご覧ください。

@InputValidation

@FormValidationの代わりに@InputValidationとアノテートするとバリデーションが失敗したときにRay\WebFormModule\Exception\ValidationExceptionが投げられるようになります。 この場合はHTML表現は使われません。Web APIに便利です。

キャッチした例外のerrorプロパティをechoするとapplication/vnd.error+jsonメディアタイプの表現が出力されます。

http_response_code(400);
echo $e->error;

// {
//     "message": "Validation failed",
//     "path": "/path/to/error",
//     "validation_messages": {
//         "name": [
//             "名前には全角文字またはアルファベットを入力して下さい。"
//         ]
//     }
// }

@VndErrorアノテーションでvnd.error+jsonに必要な情報を加えることができます。

/**
 * @FormValidation(form="contactForm")
 * @VndError(
 *   message="foo validation failed",
 *   logref="a1000", path="/path/to/error",
 *   href={"_self"="/path/to/error", "help"="/path/to/help"}
 * )
 */
 public function onPost()

Vnd Error

Ray\WebFormModule\FormVndErrorModuleをインストールすると@FormValidationフォームとアノートしたメソッドも@InputValidationとアノテートしたメソッドと同じように例外を投げるようになります。 作成したPageリソースをAPIとして使うことが出来ます。

use BEAR\Package\AbstractAppModule;
use Ray\WebFormModule\FormVndErrorModule;

class FooModule extends AbstractModule
{
    protected function configure()
    {
        $this->install(new AuraInputModule);
        $this->override(new FormVndErrorModule);
    }
}

デモ

MyVendor.ContactFormアプリケーションでフォームのデモを実行して試すことができます。 確認付きのフォームページや、複数のフォームを1ページに設置したときの例などが用意されています。

コンテントネゴシエーション

HTTPにおいてコンテントネゴシエーション (content negotiation) は、同じ URL に対してさまざまなバージョンのリソースを提供するために使用する仕組みです。BEAR.Sundayではその内のメディアタイプのAcceptと言語のAccept-Languageのサーバーサイドのコンテントネゴシエーションをサポートします。アプリケーション単位またはリソース単位で指定することができます。

インストール

composerでBEAR.Acceptをインストールします。

composer require bear/accept ^0.1

次にAccept*リクエストヘッダーに応じたコンテキストを/var/locale/available.phpに保存します。

<?php
return [
    'Accept' => [
        'text/hal+json' => 'hal-app',
        'application/json' => 'app',
        'cli' => 'cli-hal-app'
    ],
    'Accept-Language' => [ // キーを小文字で
        'ja-jp' => 'ja',
        'ja' => 'ja',
        'en-us' => 'en',
        'en' => 'en'
    ]
];

Acceptキー配列はメディアタイプをキーにしてコンテキストが値にした配列を指定します。cliはコンソールアクセスでのコンテキストでwebアクセスで使われることはありません。

Accept-Languageキー配列は言語をキーにしてコンテキストキーを値した配列を指定します。

アプリケーション

アプリケーション全体でコンテントネゴシエーションを有効にするためにpublic/index.phpを変更します。

<?php
use BEAR\Accept\Accept;

require dirname(__DIR__) . '/vendor/autoload.php';

$accept = new Accept(require dirname(__DIR__) . '/var/locale/available.php');
list($context, $vary) = $accept($_SERVER);

require dirname(__DIR__) . '/bootstrap/bootstrap.php';

上記の設定で例えば以下のAccept*ヘッダーのアクセスのコンテキストはprod-hal-ja-appになります。

Accept: application/hal+json
Accept-Language: ja-JP

この時JaModuleで日本語テキストのための束縛が必要です。詳しくはデモアプリケーションMyVendor.Localeをごらんください。

リソース

リソース単位でコンテントネゴシエーションを行う場合はAcceptModuleモジュールをインストールして@Producesアノテーションを使います。

モジュール

protected function configure()
{
    // ...
    $available = $appDir . '/var/locale/available.php';
    $this->install(new AcceptModule(available));
}

@Producesアノテーション

use BEAR\Accept\Annotation\Produces;

/**
 * @Produces({"application/hal+json", "text/csv"})
 */
public function onGet()

利用可能なメディアタイプを左から優先順位でアノテートします。対応したコンテキストのレンダラーがAOPでセットされ表現が変わります。アプリケーション単位でのネゴシエーションの時と違って、Varyヘッダーを手動で付加する必要はありません。

curlを使ったアクセス

-HオプションでAccept*ヘッダーを指定します。

curl -H 'Accept-Language: en' http://127.0.0.1:8080/
curl -i -H 'Accept-Language: en' -H 'Accept: application/hal+json' http://127.0.0.1:8080/
HTTP/1.1 200 OK
Host: 127.0.0.1:8080
Date: Fri, 11 Aug 2017 08:32:33 +0200
Connection: close
X-Powered-By: PHP/7.1.4
Vary: Accept, Accept-Language
content-type: application/hal+json

{
    "greeting": "Hello BEAR.Sunday",
    "_links": {
        "self": {
            "href": "/index"
        }
    }
}

ハイパーメディアAPI

HAL

BEAR.SundayはHALハイパーメディア(application/hal+json)APIをサポートします。

HALのリソースモデルは以下の要素で構成されます。

  • リンク
  • 埋め込みリソース
  • 状態

HALは従来のリソースの状態のみを表すJSONにリンクの_linksと他リソースを埋め込む_embeddedを加えたものです。HALはAPIを探索可能にしてそのAPIドキュメントをAPI自体から発見することができます。

以下は有効なHALの例です。自身(self)のURIへのリンクを持っています。

{
    "_links": {
        "self": { "href": "/user" }
    }
}

リンクにはrel(relation)があり、どの様な関係でリンクされているかを表ます。HTMLの<link>タグや<a>タグで使われるrelと同様です。

{
    "_links": {
        "next": { "href": "/page=2" }
    }
}

HALについてさらに詳しくはhttp://stateless.co/hal_specification.htmlをご覧ください。

リソースクラス

アノテーションでリンクを貼ったり他のリソースを埋め込むことができます。

リンクが静的なものは#[Link]属性で表し、動的なものはbody['_links']に代入します。宣言的に記述できる#[Link]属性が良いでしょう。

#[Link(rel="user", href="/user")]
#[Link(rel="latest-post", href="/latest-post", title="latest post entrty")]
public function onGet()

or

public function onGet() {
    // 権限のある場合のみリンクを貼る
    if ($hasCommentPrivilege) {
        $this->body += [
            '_links' => [
                'comment' => [
                    'href' => '/comments/{post-id}',
                    'templated' => true
                ]
            ]
        ];
    }
}

#[Embed]

他のリソースを静的に埋め込むには@Embedアノテーションを使い、動的に埋め込むにはbodyにリクエストを代入します。

#[Embed(rel="todos", src="/todos{?status}")]
#[Embed(rel="me", src="/me")]
public function onGet(string $status): static

or

$this->body['_embedded']['todos'] = $this->resource->uri('app://self/todos');

APIドキュメント

Curiesの設置されたAPIサーバーをAPIドキュメントサーバーにもすることができます。APIドキュメントの作成の手間や実際のAPIとのずれやその検証、メンテナンスといった問題を解決します。

サービスするためにはbear/api-docをインストールしてBEAR\ApiDoc\ApiDocページクラスを継承して設置します。

composer require bear/api-doc
<?php
namespace MyVendor\MyPorject\Resource\Page\Rels;

use BEAR\ApiDoc\ApiDoc;

class Index extends ApiDoc
{
}

Json Schemaのフォルダをwebに公開します。

ln -s var/json_schema public/schemas

DocblockコメントとJson Shcemaを使ってAPIドキュメントが自動生成されます。ページクラスは独自のレンダラーを持ち$contextの影響を受けないで、人のためのドキュメント(text/html) をサービスします。$contextの影響を受けないのでAppPageどちらでも設置可能です。

CURIEsがルートに設置されていれば、API自体がハイパーメディアではない生JSONの場合でも利用可能です。リアルタイムに生成されるドキュメントは常にプロパティ情報やバリデーション制約が正確に反映されます。

デモ

git clone https://github.com/koriym/Polidog.Todo.git
cd Polidog.Todo/
composer install
composer setup
composer doc

docs/index.mdにAPI docが作成されます。

ブラウズ可能

HALで記述されたAPIセットはヘッドレスのRESTアプリケーションとして機能します。

WebベースのHAL BrowserやコンソールのCURLコマンドでWebサイトと同じようにルートからリンクを辿って始めて全てのリソースにアクセスできます。

Siren

Sirenハイパーメディア(application/vnd.siren+json)をサポートしたSirenモジュール も利用可能です。

PSR-7

PSR7 HTTP message interface11を使ってサーバーサイドリクエストの情報を取得したり、BEAR.SundayアプリケーションをPSR7ミドルウエアとして実行する事ができます。

HTTPリクエスト

PHPには$_SERVER$_COOKIEなどのスーパーグローバルがありますが、 それらの代わりにPSR7 HTTP message interfaceを使ってサーバーサイドリクエストの情報($_COOKIE, $_GET, $_POST, $_FILES, $_SERVER)を受け取ることができます。

ServerRequest (サーバーリクエスト全般)

class Index extends ResourceObject
{
    public function __construct(ServerRequestInterface $serverRequest)
    {
        // retrieve cookies
        $cookie = $serverRequest->getCookieParams(); // $_COOKIE
    }
}

アップロードファイル


use Psr\Http\Message\UploadedFileInterface;
use Ray\HttpMessage\Annotation\UploadFiles;

class Index extends ResourceObject
{
    /**
     * @UploadFiles
     */
    public function __construct(array $files)
    {
        // retrieve file name
        $file = $files['my-form']['details']['avatar'][0]
        /* @var UploadedFileInterface $file */
        $name = $file->getClientFilename(); // my-avatar3.png
    }
}

URI


use Psr\Http\Message\UriInterface;

class Index extends ResourceObject
{
    public function __construct(UriInterface $uri)
    {
        // retrieve host name
        $host = $uri->getHost();
    }
}

PSR7ミドルウエア

既存のBEAR.Sundayアプリケーションは特別な変更無しにPSR-7ミドルウエアとして動作させることができます。

以下のコマンドでbear/middlewareを追加して、ミドルウエアとして動作させるためのbootstrapスクリプトに置き換えます。

composer require bear/middleware
cp vendor/bear/middleware/bootstrap/bootstrap.php bootstrap/bootstrap.php

次にスクリプトの__PACKAGE__\__VENDOR__をアプリケーションの名前に変更すれば完了です。

php -S 127.0.0.1:8080 -t public

ストリーム

ミドルウエアに対応したBEAR.Sundayのリソースはストリームの出力に対応しています。

HTTP出力はStreamTransferが標準です。詳しくはストリーム出力をご覧ください。

新規プロジェクト

新規でPSR-7のプロジェクトを始めることもできます。

composer create-project bear/project my-awesome-project
cd my-awesome-project/
php -S 127.0.0.1:8080 -t public

PSR-7ミドルウエア


Javascript UI

ビューのレンダリングをTwigなどのPHPのテンプレートエンジン等が行う代わりに、サーバーサイドのJavaScriptが行います。 PHP側は認証/認可/初期状態/APIの提供を行い、JSがUIをレンダリングします。

既存のプロジェクトの構造で、アノテートされたリソースのみに適用されるので導入が容易です。

前提条件

Note: V8JsがインストールされていないとNode.jsでJSが実行されます。

用語

  • CSR クライアントサイドレンダリング (Webブラウザで描画)
  • SSR サーバーサイドレンダリング (サーバーサイドのV8またはNode.jsが描画)

JavaScript

インストール

プロジェクトにkoriym/ssr-moduleをインストールします。

// composer create-project bear/skeleton MyVendor.MyProject; cd MyVendor.MyProject // 新規の場合
composer require bear/ssr-module

UIスケルトンアプリkoriym/js-ui-skeletonをインストールします。

composer require koriym/js-ui-skeleton 1.x-dev
cp -r vendor/koriym/js-ui-skeleton/ui .
cp -r vendor/koriym/js-ui-skeleton/package.json .
yarn install

UIアプリケーションの実行

まずはデモアプリケーションを動かして見ましょう。 現れたWebページからレンダリング方法を選択してJSアプリケーションを実行します。

yarn run ui

このアプリケーションの入力はui/dev/config/の設定ファイルで設定します。

<?php
$app = 'index';                   // =index.bundle.js
$state = [                        // アプリケーションステート
    'hello' =>['name' => 'World']
];
$metas = [                        // SSRでのみ必要な値
    'title' =>'page-title'
];

return [$app, $state, $metas];

設定ファイルをコピーして、入力値を変えてみましょう。

cp ui/dev/config/index.php ui/dev/config/myapp.php

ブラウザをリロードして新しい設定を試します。 このようにJavascriptや本体のPHPアプリケーションを変更しないでUIのデータを変更して動作を確認することができます。

このセクションで編集したPHPの設定ファイルはあくまでyarn run uiで実行する時のみに使用されます。 PHP側が必要とするのはバンドルされて出力されたJSのみです。

UIアプリケーションの作成

PHPから渡された引数を使ってレンダリングした文字列を返すrender関数を作成します。

const render = (state, metas) => (
  __AWESOME_UI__ // SSR対応のライブラリやJSのテンプレートエンジンを使って文字列を返す
)

stateはドキュメントルートに必要な値、metasはそれ以外の値、例えば<head>で使う値などです。renderという関数名は固定です。

ここでは名前を受け取って挨拶を返す関数を作成します。

const render = state => (
  `Hello ${state.name}`
)

ui/src/page/index/hello/server.jsとして保存して、webpackのエントリーポイントをui/entry.jsに登録します。

module.exports = {
  hello: 'src/page/hello/server',
};

これでhello.bundle.jsというバンドルされたファイルが出力されるようになりました。

このhelloアプリケーションをテスト実行するためのファイルをui/dev/config/myapp.phpに作成します。

<?php
$app = 'hello';
$state = [
    ['name' => 'World']
];
$metas = [];

return [$app, $state, $metas];

以上です!ヴラウザをリロードして試してください。

render関数の中の処理をReactやVue.jsなどのUIフレームワークを使ってリッチなUIを作成できます。 通常のアプリケーションでは依存を最小限にするためにserver.jsエントリーファイルは以下のようにrenderモジュールを読み込むようにします。

import render from './render';
global.render = render;

ここまでPHP側の作業はありません。SSRのアプリケーション開発はPHP開発と独立して行うことができます。

PHP

モジュールインストール

AppModuleにSsrModuleモジュールをインストールします。

<?php
use BEAR\SsrModule\SsrModule;

class AppModule extends AbstractAppModule
{
    protected function configure()
    {
        // ...
        $build = dirname(__DIR__, 2) . '/var/www/build';
        $this->install(new SsrModule($build));
    }
}

$buildフォルダはJSのファイルがあるディレクトリです。(ui/ui.config.jsで指定するwebpackの出力先)

@Ssrアノテーション

リソースをSSRするメソッドに@Ssrとアノテートします。appにJSアプリケーション名が必要です。

<?php

namespace MyVendor\MyRedux\Resource\Page;

use BEAR\Resource\ResourceObject;
use BEAR\SsrModule\Annotation\Ssr;

class Index extends ResourceObject
{
    /**
     * @Ssr(app="index_ssr")
     */
    public function onGet($name = 'BEAR.Sunday')
    {
        $this->body = [
            'hello' => ['name' => $name]
        ];

        return $this;
    }
}

$this->bodyrender関数に1つ目の引数として渡されます。 CSRとSSRの値を区別して渡したい場合はstatemetasでbodyのキーを指定します。

/**
 * @Ssr(
 *   app="index_ssr",
 *   state={"name", "age"},
 *   metas={"title"}
 * )
 */
public function onGet()
{
    $this->body = [
        'name' => 'World',
        'age' => 4.6E8;
        'title' => 'Age of the World'
    ];

    return $this;
}

実際statemetasをどのようにして渡してSSRを実現するかはui/src/page/index/serverのサンプルアプリケーションをご覧ください。影響を受けるのはアノテートしたメソッドだけで、APIやHTMLのレンダリングの設定はそのままです。

PHPアプリケーションの実行設定

ui/ui.config.jsを編集して、publicにweb公開ディレクトリをbuildにwebpackのbuild先を指定します。 buildはSsrModuleのインストールで指定したディレクトリと同じです。

const path = require('path');

module.exports = {
  public: path.join(__dirname, '../var/www'),
  build: path.join(__dirname, '../var/www/build')
};

PHPアプリケーションの実行

yarn run dev

ライブアップデートで実行します。 PHPファイルの変更があれば自動でリロードされ、Reactのコンポーネントに変更があればリロードなしでコンポーネントをアップデートします。ライブアップデートなしで実行する場合にはyarn run startを実行します。

linttestなどの他のコマンドはコマンドをご覧ください。

パフォーマンス

V8のスナップショットをApc保存する機能を使ってパフォーマンスの大幅な向上が可能です。 ProdModuleApcSsrModuleをインストールしてください。 ReactJsやアプリケーションのスナップショットがAPCuに保存され再利用されます。V8jsが必要です。

$this->install(new ApcSsrModule);

Apc以外のキャッシュを利用するにはApcSsrModuleのコードを参考にモジュールを作成してください。 PSR16対応のキャッシュが利用可能です。

さらなる高速化のためにはV8をコンパイルする時点でJSコード(ReactJsなど)のスナップショットを取り込みます。 詳しくは以下をご覧ください。

デバック

  • Chromeプラグイン React developer toolsRedux devToolsが利用できます。
  • 500エラーが帰ってくる場合はvar/logcurl でアクセスしてレスポンス詳細を見てみましょう

リファレンス

その他ビューライブラリ

ストリーム出力

通常リソースはレンダラーでレンダリングされて1つの文字列になり最終的にechoで出力されますが、それではサイズがPHPのメモリの限界を超えるようなコンテンツは出力できません。StreamRendererを使うとHTTP出力をストリームでき、メモリ消費を低く抑えられます。ストリーム出力は既存のレンダラーと共存して使うこともできます。

トランスファーとレンダラーの変更

ストリーム出力用のレンダラーとレスポンダーをインジェクトするために、ページにStreamTransferInjectトレイトをuseします。このダウンロードページの例では$bodyをストリームのリソース変数にしているので、インジェクトされたレンダラーは無視されリソースがストリーム出力されます。

use BEAR\Streamer\StreamTransferInject;

class Download extends ResourceObject
{
    use StreamTransferInject;

    public $headers = [
        'Content-Type' => 'image/jpeg',
        'Content-Disposition' => 'attachment; filename="image.jpg"'
    ];

    public function onGet(): static
    {
        $fp = fopen(__DIR__ . '/BEAR.jpg', 'r');
        $this->body = $fp;

        return $this;
    }
}

レンダラーとの共存

ストリーム出力は従来のレンダラーと共存可能です。通常、TwigレンダラーやJSONレンダラーは文字列を生成しますが、その一部にストリームをアサインすると全体がストリームとして出力されます。

これはTwigテンプレートに文字列とresource変数をアサインして、インライン画像のページを生成する例です。

テンプレート

<!DOCTYPE html>
<html lang="en">
<body>
<p>Hello, {{ name }}</p>
<img src="data:image/jpg;base64,{{ image }}">
</body>
</html>

nameには通常通り文字列をアサインしていますが、imageに画像ファイルのファイルポインタリソースのresource変数をbase64-encodeフィルターを通してアサインしています。

class Image extends ResourceObject
{
    use StreamTransferInject;

    public function onGet(string $name = 'inline image'): static
    {
        $fp = fopen(__DIR__ . '/image.jpg', 'r');
        stream_filter_append($fp, 'convert.base64-encode'); // image base64 format
        $this->body = [
            'name' => $name,
            'image' => $fp
        ];

        return $this;
    }
}

ストリーミングのバンドワイズやタイミングをコントロールしたり、クラウドにアップロードしたり等ストリーミングを更にコントロールする場合にはStreamResponderを参考にして作成して束縛します。

ストリーム出力のdemoがMyVendor.Streamにあります。

Cache

There are only two hard things in Computer Science: cache invalidation and naming things.

– Phil Karlton

概要

優れたキャッシュシステムはユーザー体験の本質的な質を向上させ、資源利用コストと環境負荷を下げます。 BEAR.Sundayは従来のTTLによる単純なキャッシュに加えて、以下のキャッシュ機能をサポートします。

  • イベント駆動のキャッシュ無効化
  • キャッシュの依存解決
  • ドーナッツキャッシュとドーナッツの穴キャッシュ
  • CDNコントロール
  • 条件付きリクエスト

分散キャッシュフレームワーク

REST制約に従った分散キャッシュシステムは、計算資源だけでなくネットワーク資源も節約します。

PHPが直接扱うRedisやAPCなどのサーバーサイドキャッシュ、コンテンツ配信ネットワーク(CDN)として知られる共有キャッシュ、WebブラウザやAPIクライアントでキャッシュされるクライアントサイドキャッシュ、BEAR.SundayはこれらのキャッシュとモダンCDNを統合したキャッシングフレームワークを提供します。

distributed cache

タグベースでのキャッシュ無効化

dependency graph 2021-10-19 21 38 02

コンテンツキャッシュには依存性の問題があります。コンテンツAがコンテンツBに依存し、BがCに依存している場合、Cが更新されるとCのキャッシュとETagだけでなく、Cに依存するBのキャッシュとETag、Bに依存するAのキャッシュとETagも更新されなければなりません。

BEAR.Sundayはそれぞれのリソースが依存リソースのURIをタグとして保持する事でこの問題を解決します。#[Embed]で埋め込まれたリソースに変更があると、関係する全てのリソースのキャッシュとETagが無効化され、次のリクエストのためにキャッシュの再生性が行われます。

ドーナッツキャッシュ

donut caching

ドーナツキャッシュはキャッシュの最適化のための部分キャッシュ技術の1つです。コンテンツをキャッシュ可能な箇所とそうでない箇所に分けて合成します。

例えば”Welcome to $name“というキャッシュできないリソースが含まれるコンテンツを考えてみてください。キャッシュできない(do-not cache)部分と、その他のキャッシュ可能な部分と合成して出力します。

image

この場合コンテンツ全体としては動的なので、ドーナッツ全体はキャッシュされません。そのためETagも出力されません。

ドーナッツの穴キャッシュ

image

ドーナッツの穴部分がキャッシュ可能の時もドーナッツキャッシュと同じように扱えます。

上記の例では、1時間に一度変更される天気予報のリソースがキャッシュされニュースリソースに含まれます。この場合、ドーナツ全体(ニュース)としてのコンテンツは静的なので全体もキャッシュされETagも付与されます。

この時にキャッシュの依存性が発生します。 ドーナッツの穴部分のコンテンツが更新された時に、キャッシュされたドーナッツ全体も再生成される必要があります。

素晴らしい事にこの依存解決は自動で行われます。その時に計算資源を最小化するためにドーナツ部分の計算は再利用されます。穴の部分(天気リソース)が更新されると全体のコンテンツのキャッシュとETagも自動で更新されます。

リカーシブ・ドーナッツ

recursive donut 2021-10-19 21 27 06

ドーナッツ構造は再起され適用されます。 例えば、AがBを含みBがCを含むコンテンツのCが変更された時に、変更されたCの部分を除いてAのキャッシュとBのキャッシュは再利用されます。AとBのキャッシュ、ETagは再生成されますが、A、Bのコンテンツ取得のためのDBアクセスやビューのレンダリングは行われません。 例えば、AがBを含み、BがCを含むコンテンツの場合に、Cが変更された時に、変更されたCの部分を除いてAのキャッシュとBのキャッシュは再利用されます。AやBのコンテンツ取得のためのDBアクセスやビューのレンダリングは行われません。新しく部分合成されたAとBのキャッシュ、ETagは再生成されます。

最適化された構造の部分キャッシュが、最小のコストでコンテンツ再生成を行います。クライアントはコンテンツのキャッシュ構造について知る必要がありません。

イベントドリブン型コンテンツ

従来、CDNはアプリケーションロジックを必要とするコンテンツは「動的」であり、したがってCDNではキャッシュはできないと考えられてきました。しかしFastlyやAkamaiなどの一部のCDNは即時または数秒以内でのタグベースでのキャッシュ無効化が可能になり、この考えは過去のものになろうとしています。

BEAR.Sundayの依存解決はサーバーサイドだけでなく共有キャッシュでも行われます。AOPが変更を検知し共有キャッシュにPURGEリクエストを行うことで、サーバーサイドと同じように共有キャッシュ上の関連キャッシュの無効化が行われます。

条件付きリクエスト

conditional request

コンテンツの変更はAOPで管理され、コンテンツのエンティティタグ(ETag)は自動で更新されます。ETagを使ったHTTPの条件付きリクエストは計算資源の利用を最小化するだけでなく、304 Not Modifiedを返すだけの応答はネットワーク資源の利用も最小化します。

利用法

キャッシュ対象のクラスにドーナッツキャッシュの場合(埋め込みコンテンツがキャッシュ不可能な場合)は#[DonutCache]、それ以外の場合は#[CacheableResponse]とアトリビュートを付与します。

use BEAR\RepositoryModule\Annotation\CacheableResponse;

#[CacheableResponse]
class BlogPosting extends ResourceObject
{
    public $headers = [
        RequestHeader::CACHE_CONTROL => CacheControl::NO_CACHE
    ];

    #[Embed(rel: "comment", src: "page://self/html/comment")]
    public function onGet(int $id = 0): static
    {
        $this->body['article'] = 'hello world';

        return $this;
    }

    public function onDelete(int $id = 0): static
    {
        return $this;
    }
}

キャッシュ対象メソッドを選択したい場合はクラスに属性を指定しないで、メソッドに指定します。その場合はキャッシュ変更メソッドに#[RefreshCache]という属性を付与します。

class Todo extends ResourceObject
{
    #[CacheableResponse]
    public function onPut(int $id = 0, string $todo): static
    {
    }

    #[RefreshCache]
    public function onDelete(int $id = 0): static
    {
    }	
}

どちらかの方法でアトリビュートを付与すると、概要で紹介した全ての機能が適用されます。 イベントドリブン型コンテンツを想定してデフォルトでは時間(TTL)によるキャッシュの無効化は行われません

#[DonutCache]の場合はコンテンツ全体はキャッシュされず、 #[CacheableResponse]の場合はされる事に注意してください。

TTL

TTLの指定はDonutRepositoryInterface::put()で行います。 ttlはドーナツの穴以外のキャッシュ時間、sMaxAgeはCDNのキャッシュ時間です。

use BEAR\RepositoryModule\Annotation\CacheableResponse;

#[CacheableResponse]
class BlogPosting extends ResourceObject
{
    public function __construct(private DonutRepositoryInterface $repository)
    {}

    #[Embed(rel: "comment", src: "page://self/html/comment")]
    public function onGet(): static
    {
        // process ...
        $this->repository->put($this, ttl:10, sMaxAge:100); 

        return $this;
    }
}

TTLの既定値

イベントドリブン型コンテンツでは、コンテンツが変更されたらキャッシュにすぐに反映されなければなりません。そのため、既定値のTTLはCDNのモジュールのインストールによって変わります。CDNがタグベースでのキャッシュ化を無効化をサポートしていればTTLは無期限(1年間)で、サポートの無い場合には10秒です。

キャッシュ反映時間は、Fastlyなら即時、Akamaiなら数秒、それ以外なら10秒が期待される時間です。

カスタマイズするにはCdnCacheControlHeaderを参考にCdnCacheControlHeaderSetterInterfaceを実装して束縛します。

キャッシュ無効化

手動でキャッシュを無効化するにはDonutRepositoryInterfaceのメソッドを用います。 指定されたキャッシュだけでなく、そのETag、依存にしている他のリソースののキャッシュとそのETagがサーバーサイド、及び可能な場合はCDN上のキャッシュも共に無効化されます。

interface DonutRepositoryInterface
{
    public function purge(AbstractUri $uri): void;
    public function invalidateTags(array $tags): void;
}

URIによる無効化

// example
$this->repository->purge(new Uri('app://self/blog/comment'));

タグによる無効化

$this->repository->invalidateTags(['template_a', 'campaign_b']);

CDNでタグの無効化

CDNでタグベースでのキャッシュ無効化を有効にするためにはPurgerInterfaceを実装して束縛する必要があります。

use BEAR\QueryRepository\PurgerInterface;

interface PurgerInterface
{
    public function __invoke(string $tag): void;
}

依存タグの指定

PURGE用のキーを指定するためにはSURROGATE_KEYヘッダーで指定します。複数文字列の時はスペースをセパレータに使います。

use BEAR\QueryRepository\Header;

class Foo
{
    public $headers = [
        Header::SURROGATE_KEY => 'template_a campaign_b'
    ];

template_aまたはcampaign_bのタグによるキャッシュの無効化が行われた場合、FooのキャッシュとFooのETagはサーバーサイド、CDN共に無効になります。

リソースの依存

UriTagInterfaceを使ってURIを依存タグ文字列に変換します。

public function __construct(private UriTagInterface $uriTag)
{}
$this->headers[Header::SURROGATE_KEY] = ($this->uriTag)(new Uri('app://self/foo'));

app://self/fooに変更があった場合にこのキャッシュはサーバーサイド、CDN共に無効化されます。

連想配列をリソースの依存に

// bodyの内容
[
    ['id' => '1', 'name' => 'a'],
    ['id' => '2', 'name' => 'b'],
]

上記のようなbody連想配列から、依存するURIタグリストを生成する場合はfromAssoc()メソッドでURIテンプレートを指定します。

$this->headers[Header::SURROGATE_KEY] = $this->uriTag->fromAssoc(
    uriTemplate: 'app://self/item{?id}',
    assoc: $this->body
);

上記の場合、app://self/item?id=1及びapp://self/item?id=2に変更があった場合に、このキャッシュはサーバーサイド、CDN共に無効化されます。

CDN

特定CDN対応のモジュールをインストールするとベンダー固有のヘッダーが出力されます。

$this->install(new FastlyModule())
$this->install(new AkamaiModule())

マルチCDN

CDNを多段構成にして、役割に応じたTTLを設定することもできます。例えばこの図では上流に多機能なCDNを配置して、下流にはコンベンショナルなCDNを配置しています。コンテンツのインバリデーションなどは上流のCDNに対して行い、下流のCDNはそれを利用するようにします。

multi cdn diagram

レスポンスヘッダー

CDNのキャッシュコントロールについてはBEAR.Sundayが自動で行いCDN用のヘッダーを出力します。クライアントのキャッシュコントロールはコンテンツに応じてResourceObjectの$headerに記述します。

セキュリティやメンテナンスの観点からこのセクションは重要です。 全てのResourceObjectでCache-Controlを指定するようにしましょう。

キャッシュ不可

キャッシュができないコンテンツは必ず指定しましょう。

ResponseHeader::CACHE_CONTROL => CacheControl::NO_STORE

条件付きリクエスト

サーバーにコンテンツ変更がないかを確認してから、キャッシュを利用します。サーバーサイドのコンテンツの変更は検知され反映されます。

ResponseHeader::CACHE_CONTROL => CacheControl::NO_CACHE

クライアントキャッシュ時間の指定

クライントでキャッシュされます。最も効率的なキャッシュですが、サーバーサイドでコンテンツが変更されても指定した時間に反映されません。

またブラウザのリロード動作ではこのキャッシュは利用されません。<a>タグで遷移、またはURL入力した場合にキャッシュが利用されます。

ResponseHeader::CACHE_CONTROL => 'max-age=60'

レスポンス速度を重視する場合には、SWRの指定も検討しましょう。

ResponseHeader::CACHE_CONTROL => 'max-age=30 stale-while-revalidate=10'

この場合、max-ageの30秒を超えた時にオリジンサーバーからフレッシュなレスポンス取得が完了するまで、SWRで指定された最大10秒間はそれまでの古いキャッシュ(stale)レスポンスを返します。つまりキャッシュが更新されるのは最後のキャッシュ更新から30秒から40秒間の間のいずれかになりますが、どのリクエストもキャッシュからの応答になり高速です。

RFC7234対応クライアント

APIでクライアントキャッシュを利用する場合にはRFC7234対応APIクライアントを利用します。

プライベート

キャッシュを他のクライアントと共有しない時にはprivateを指定します。クライアントサイドのみキャッシュ保存されます。この場合サーバーサイドではキャッシュ指定をしないようにします。

ResponseHeader::CACHE_CONTROL => 'private, max-age=30'

共用キャッシュを利用する場合でもほとんどの場合においてpublicを指定する必要はありません。

キャッシュ設計

API(またはコンテンツ)は情報API (Information API)、計算API (Computation API)は2つに分けることができます。計算APIは再現が難しく真に動的でキャッシュに不適なコンテンツです。一方の情報APIはDBから読み出され、PHPで加工されたとしても本質的には静的なコンテンツのAPIです。

適切なキャシュを適用するためにコンテンツを分析します。

  • 情報APIか計算APIか
  • 依存関係は
  • 内包関係は
  • 無効化はイベントがトリガーか、それともTTLか
  • イベントはアプリケーションが検知可能か、監視が必要か
  • TTLは予測可能か不可能か

キャッシュ設計をアプリケーション設計プロセスの一部にして、仕様にする事も検討しましょう。 ライフサイクルを通してプロジェクトの安全性にも寄与するはずです。

アダプティブ TTL

コンテンツの生存期間が予測可能で、その期間にイベントによる更新が行われない時はそれをクライントやCDNに正しく伝えます。例えば株価のAPIを扱う時、現在が金曜日の夜だとすると月曜の取引開始時間までは情報更新が行われない事が分かっています。その時間までの秒数を計算してTTLとして指定し、取引時間の時には適切なTTLを指定します。

クライアントは更新がないと分かっているリソースにリクエストする必要はありません。

#[Cacheable]

従来の#[Cacheable]によるTTLキャッシュもサポートされます。

例)サーバーサイドで30秒キャシュ、クライアントでも30秒キャシュ。

サーバーサイドで指定してるのでクライアントサイドでも同じ秒数でキャッシュされます。

use BEAR\RepositoryModule\Annotation\Cacheable;

#[Cacheable(expirySecond: 30)]
class CachedResource extends ResourceObject
{

例)指定した有効期限($body['expiry_at']の日付)までサーバー、クライアント供にキャッシュ

use BEAR\RepositoryModule\Annotation\Cacheable;

#[Cacheable(expiryAt: 'expiry_at')]
class CachedResource extends ResourceObject
{

その他はHTTPキャッシュページをご覧ください。

結論

Webのコンテンツは情報(データ)型のものと計算(プロセス)型のものがあります。前者は本質的には静的ですが、コンテンツの変更や依存性の管理の問題で完全に静的コンテンツとして扱うのが難しく、コンテンツの変更が発生していないのにTTLによるキャッシュの無効化が行われていました。 BEAR.Sundayのキャッシングフレームワークは情報型のコンテンツを可能な限り静的に扱い、キャッシュの力を最大化します。

用語

Swoole

SwooleとはC/C++で書かれたPHP拡張の1つで、イベント駆動の非同期&コルーチンベースの並行処理ネットワーキング通信エンジンです。 Swooleを使ってコマンドラインから直接BEAR.Sundayウエブアプリケーションを実行することができます。パフォーマンスが大幅に向上します。

インストール

Swooleのインストール

PECL経由

pecl install swoole

ソースから

git clone https://github.com/swoole/swoole-src.git && \
cd swoole-src && \
phpize && \
./configure && \
make && make install

php.iniextension=swoole.soを追加してください。

BEAR.Swooleのインストール

composer require bear/swoole ^0.4

AppModuleでのインストールは必要ありません。

bin/swoole.phpにスクリプトを設置します。

<?php
require dirname(__DIR__) . '/autoload.php';
exit((require dirname(__DIR__) . '/vendor/bear/swoole/bootstrap.php')(
    'prod-hal-app',       // context
    'MyVendor\MyProject', // application name
    '127.0.0.1',          // IP
    8080                  // port
));

実行

サーバーをスタートさせます。

php bin/swoole.php
Swoole http server is started at http://127.0.0.1:8088

ベンチマークサイト

特定環境でベンチマークテストをするためのBEAR.HelloworldBenchmarkが用意されています。

 * The benchmark result

コーディングガイド

プロジェクト

vendorは会社の名前やチームの名前または個人の名前(excite,koriym等)を指定して、packageにはアプリケーション(サービス)の名前(blog, news等)を指定します。 プロジェクトはアプリケーション単位で作成し、Web APIとHTMLを別ホストでサービスする場合でも1つのプロジェクトにします。

スタイル

PSR1, PSR2, PSR4に準拠します。

<?php
namespace Koriym\Blog\Resource\App;

use BEAR\RepositoryModule\Annotation\Cacheable;
use BEAR\Resource\Annotation\Embed;
use BEAR\Resource\Annotation\Link;
use BEAR\Resource\Code;
use BEAR\Resource\ResourceObject;

#[CacheableResponse]
class Entry extends ResourceObject
{
    public function __construct(
        private readonly ExtendPdoInterface $pdo,
        private readonly ResourceInterface $resource
    ) {}

    #[Embed(rel: "author", src: "/author{?author_id}")]
    public function onGet(string $author_id, string $slug): static
    {
        // ...

        return $this;
    }

    #[Link(rel: "next_action1", href: "/next_action1")]
    public function onPost (
        string $tile,
        string $body,
        string $uid,
        string $slug
    ): static {
        // ...
        $this->code = Code::CREATED;

        return $this;
    }
}

リソースのdocBlockコメントはオプションです。リソースURIや引数名だけで説明不十分な時にメソッドの要約(一行)、説明(複数行可)、@paramsを付加します。

/**
 * A summary informing the user what the associated element does.
 *
 * A *description*, that can span multiple lines, to go _in-depth_ into the details of this element
 * and to provide some background information or textual references.
 *
 * @param string $arg1 *description*
 * @param string $arg2 *description*
*/

リソース

リソースについてのベストプラクティスはリソースのベストプラクティスをご覧ください。

グローバル

グローバルな値をリソースやアプリケーションのクラスで参照することは推奨されません。(Modulesでのみ使用します)

  • スーパーグローバルの値を参照しない
  • defineは使用しない
  • 設定値を保持するConfigクラスを作成しない
  • グローバルなオブジェクトコンテナ(サービスロケータ)を使用しない [1], [2]
  • date関数やDateTimeクラスで現在時刻を直接取得することは推奨されません。外部から時刻をインジェクトします。14
  • スタティックメソッドなどのグローバルなメソッドコールも推奨されません。

  • アプリケーションコードが必要とする値は設定ファイルなどから取得するのではなく、全てインジェクトします。15

クラスとオブジェクト

  • トレイトは推奨されません。16
  • 親クラスのメソッドを子クラスが使うことは推奨されません。共通する機能は継承やtraitで共有ではなくクラスにしてインジェクトして使います。継承より合成します。

スクリプトコマンド

  • composer setupコマンドでアプリケーションのセットアップが完了することが推奨されます。このスクリプトではデータベースの初期化、必要ライブラリの確認が含まれます。.envの設定などマニュアルな操作が必要な場合はその手順が画面表示されることが推奨されます。

リソース

  • onPostで作成したリソースのURIはLocationヘッダーで示します。
public function onPost(string $title): static
{
    // ...
    $this->code = 201;
    $this->headers['Location'] = "/task?id={$id}";

    return $this;
}

リソースのベストプラクティスはリソースベストプラクティスもご覧ください。

コード

適切なステータスコードを返します。テストが容易になり、botやクローラーにも正しい情報が伝えることができます。

  • 100 Continue 複数のリクエストの継続
  • 200 OK
  • 201 Created リソース作成
  • 202 Accepted キュー/バッチ 受付
  • 204 No Content bodyがない場合
  • 304 Not Modified 未更新
  • 400 Bad Request リクエストに不備
  • 401 Unauthorized 認証が必要
  • 403 Forbidden 禁止
  • 404 Not Found
  • 405 Method Not Allowed
  • 503 Service Unavailable サーバーサイドでの一時的エラー

304#[Cacheable]アトリビュートを使っていると自動設定されます。404はリソースクラスがない場合、405はリソースのメソッドがない場合に自動設定されます。またDBの接続エラーなどは必ず503で返しクローラーに伝えます。[1]

HTMLのFormメソッド

BEAR.SundayはHTMLのWebフォームでPOSTリクエストの時にX-HTTP-Method-Overrideヘッダーや_methodクエリーを用いてメソッドを上書きする事ができますが、推奨しているわけではありません。PageリソースではonGetonPost以外を実装しない方針でも問題ありません。[1],[2]

ハイパーリンク

  • リンクを持つリソースは#[Link]で示すことが推奨されます。
  • リソースは意味のまとまりのグラフにして#[Embed]で埋め込む事が推奨されます。

DI

  • 実行コンテキスト(prod, devなど)の値そのものをインジェクトしてはいけません。代わりにコンテキストに応じたインスタンスをインジェクトします。アプリケーションはどのコンテキストで動作しているのか無知にします。
  • ライブラリコードではセッターインジェクションは推奨されません。
  • Provider束縛を可能な限り避けtoConstructor束縛を優先することが推奨されます。
  • Moduleで条件に応じて束縛をすることを避けます。 (AvoidConditionalLogicInModules)
  • モジュールのconfigure()から環境変数を参照しないで、コンストラクタインジェクションにします。

AOP

  • インターセプターの適用を必須にしてはいけません。例えばログやDBのトランザクションなどはインターセプターの有無でプログラムの本質的な動作は変わりません。
  • メソッド内の依存をインターセプターがインジェクトしないようにします。メソッド実装時にしか決定できない値は@Assistedインジェクションで引数にインジェクトします。
  • 複数のインタセプターがある場合にその実行順に可能な限り依存しないようにします。
  • 無条件に全メソッドに適用するインターセプターであればbootstrap.phpでの記述を考慮してください。
  • 横断的関心事と、本質的関心事を分けるために使われるものです。特定のメソッドのhackのためにインターセプトするような使い方は推奨されません。

環境

  • Webだけでしか動作しないアプリケーションは推奨されません。テスト可能にするためにコンソールでも動作するようにします。
  • .envファイルをプロジェクトリポジトリに含まない事が推奨されます。
  • .envの代わりにスキーマを記述するKoriym.EnvJsonの利用を検討してください。

テスト

  • リソースクライアントを使ったリソーステストを中心にし、必要があればリソースの表現のテスト(HTMLなど)を加えます。
  • ハイパーメディアテストはユースケースをテストとして残すことができます。
  • prodはプロダクション用のコンテキストです。テストでprodコンテキストの利用は最低限、できれば無しにしましょう。

HTMLテンプレート

  • 大きなループ文を避けます。ループの中のif文はジェネレーター で置き換えれないか検討しましょう。

クイックスタート

インストールは composer で行います。

composer create-project -n bear/skeleton MyVendor.MyProject
cd MyVendor.MyProject

次にPageリソースを作成します。PageリソースはWebページに対応したクラスです。 src/Resource/Page/Hello.phpに作成します。

<?php

namespace MyVendor\MyProject\Resource\Page;

use BEAR\Resource\ResourceObject;

class Hello extends ResourceObject
{
    public function onGet(string $name = 'BEAR.Sunday'): static
    {
        $this->body = [
            'greeting' => 'Hello ' . $name
        ];

        return $this;
    }
}

GETメソッドでリクエストされると$name$_GET['name']が渡されるので、挨拶をgreetingにセットし$thisを返します。

作成したアプリケーションはコンソールでもWebサーバーでも動作します。

php bin/page.php get /hello
php bin/page.php get '/hello?name=World'
200 OK
Content-Type: application/hal+json

{
    "greeting": "Hello World",
    "_links": {
        "self": {
            "href": "/hello?name=World"
        }
    }
}

ビルトインウェブサーバーを起動し

php -S 127.0.0.1:8080 -t public

webブラウザまたはcurlコマンドでhttp://127.0.0.1:8080/helloをリクエストします。

curl -i 127.0.0.1:8080/hello

タイプ

phpのネイティブではサポートされていない型もPHPdoc typeを記述すれば、静的解析ツールが検査を行い対応するエディターでは補完もされます。 また将来のPHPで採用予定の型も先に利用できます。

例)リソースクラスのbodyの連想配列を”Object-like array”で指定

/** @var array{greeting: string} */
public $body;
/** @var list<array{name: string, age:int}> */
public $body;

リソースクライアントで取得したオブジェクトはassert()するとツールが型を理解します。

$user = $this->resource->get('/user', []);
assert($user instanceof User);
$name = $user->body['name']; // nameキーが補間
$user->body['__invalid__']; // 未定義キーのアクセスはエラーに

リファレンス

アトミック型

これ以上分割できない型をアトミック型と呼びます。PHP7の型は全てこの型です。ユニオン型や交差型ではアトミック型を組み合わせて利用します。

スカラー型

/** @param int $i */
/** @param float $f */
/** @param string $str */
/** @param class-string $class */
/** @param class-string<AbstractFoo> $fooClass */
/** @param callable-string $callable */
/** @param numeric-string $num */ 
/** @param bool $isSet */
/** @param array-key $key */
/** @param numeric $num */
/** @param scalar $a */

オブジェクト型

ジェネリックオブジェクト

/** @return ArrayObject<int, string> */

ジェネレーター

/** @return Generator<int, string, mixed, void> */

Callable型

/** @return callable(Type1, OptionalType2=, SpreadType3...): ReturnType */
/** @return Closure(bool):int */

値型

/** @return null */
/** @return true */
/** @return 42 */
/** Foo\Bar::MY_SCALAR_CONST $a */
/** @param A::class|B::class $s */

配列型

ジェネリック配列

/** @return array<TKey, TValue> */
/** @return array<int, Foo> */
/** @return array<string, int|string> */

オブジェクト風配列 (Object-like arrays)

/** @return array{0: string, 1: string, foo: stdClass, 28: false} */
/** @return array{foo: string, bar: int} */
/** @return array{optional?: string, bar: int} */

リスト

/** @param list<string> $stringList */

PHPDoc配列

/** @param string[] $strings */

レガシーシンタックスのPHPDoc配列表記はarray<array-key, ValueType>とジェネリック配列型として扱われます。

その他のアトミック型

/** @return iterable */
/** @return void */
/** @return empty */
/** @return mixed */

交差型

/** @return A&B */

ユニオン型

/** @return int|false */
/** @return 0|1 */
/** @return 'a'|'b' */

-

関連 PHPStormプラグイン

テスト

適切なテストは、ソフトウェアを継続性のあるより良いものにします。 全ての依存がインジェクトされ、横断的関心事がAOPで提供されるBEAR.Sundayのクリーンなアプリケーションはテストフレンドリーです。

テスト実行

composerコマンドが用意されています。

composer test     // phpunitテスト
composer tests    // test + sa + cs
composer coverge  // testカバレッジ
composer pcov     // testカバレッジ (pcov)
composer sa       // 静的解析
composer cs       // コーディングスタンダード検査
composer cs-fix   // コーディングスタンダード修復

リソース テストケース作成

全てがリソースのBEAR.Sundayではリソース操作がテストの基本です。Injector::getInstanceでリソースクライアントを取得してリソースの入出力テストを行います。

<?php

use BEAR\Resource\ResourceInterface;

class TodoTest extends TestCase
{
    private ResourceInterface $resource;
    
    protected function setUp(): void
    {
        $injector = Injector::getInstance('test-html-app');
        $this->resource = $injector->getInstance(ResourceInterface::class);
    }

    public function testOnPost(): void
    {
        $page = $this->resource->post('page://self/todo', ['title' => 'test']);
        $this->assertSame(StatusCode::CREATED, $page->code);
    }
}

テストダブル

テストダブル (Test Double) とは、ソフトウェアテストでテスト対象が依存しているコンポーネントを置き換える代用品のことです。テストダブルは以下のパターンがあります。

  • スタブ (テスト対象に「間接的な入力」を提供)
  • モック (テスト対象からの「間接的な出力」をテストダブルの内部で検証)
  • スパイ (テスト対象からの「間接的な出力」を記録)
  • フェイク (実際のオブジェクトに近い働きのより単純な実装)
  • ダミー(テスト対象の生成に必要だが呼び出しが行われない)

テスト対象のシステム(SUT)がテストダブルの出力を使用するのがスタブです。例えばいつもtrueを返すようなメソッドを持つテストダブルはスタブです。モックはSUTからテストダブルへの間接的出力の検証をテストコードではなく、テストダブル内部で行います。スパイはモックと同じようにSUTの間接的出力の検証を行うためのものですが、その検証をテストコードで行うためにテストコードから読み取り可能な記録が行われます。

テストダブルの束縛

テスト用に束縛を変更する方法は2つあります。コンテキストモジュールで全テストの束縛を横断的に変更する方法と、1テストの中だけで一時的に特定目的だけで束縛を変える方法です。

コンテキストモジュール

TestModuleを作成してbootstrapでtestコンテキストを利用可能にします。

class TestModule extends AbstractModule
{
    public function configure(): void
    {
        $this->bind(DateTimeInterface::class)->toInstance(new DateTimeImmutable('1970-01-01 00:00:00'));
        $this->bind(Auth::class)->to(FakeAuth::class);    
    }
}

テスト用束縛が上書きされたインジェクター

$injector = Injector::getInstance('test-hal-app', $module);

一時的束縛変更

1つのテストのための一時的な束縛の変更はInjector::getOverrideInstanceで上書きする束縛を指定します。

スタブ、フェイク

public function testBindStub(): void
{
    $module = new class extends AbstractModule {
        protected function configure(): void
        {
            $this->bind(FooInterface::class)->to(FakeFoo::class);
        }
    };
    $injector = Injector::getOverrideInstance('hal-app', $module);
}

モック

アサーションをテストダブル内部で実行します。

public function testBindMock(): void
{  
    $mock = $this->createMock(FooInterface::class);
    //  update() が一度だけコールされ、その際のパラメータは文字列 'something' となるということを期待
    $mock->expects($this->once())
             ->method('update')
             ->with($this->equalTo('something'));
    $module = new class($mock) extends AbstractModule {
        public function __constcuct(
            private FooInterface $foo
        ){}
        protected function configure(): void
        {
            $this->bind(FooInterface::class)->toInstance($this->foo);
        }
    };
    $injector = Injector::getOverrideInstance('hal-app', $module);
}

スパイ

スパイ対象のインターフェイスまたはクラス名を指定してSpyModuleをインストールします。17 スパイ対象が含まれるSUTを動作させた後に、スパイログで呼び出し回数や呼び出しの値を検証します。

public function testBindSpy(): void
{
    $module = new class extends AbstractModule {
        protected function configure(): void
        {
            $this->install(new SpyModule([FooInterface::class]));
        }
    };
    $injector = Injector::getOverrideInstance('hal-app', $module);
    $resource = $injector->getInstance(ResourceInterface::class);
    // 直接、間接に関わらずFooInterfaceオブジェクトのSpyログが記録されます。
    $resource->get('/');
    // Spyログの取り出し
    $spyLog = $injector->getInstance(\Ray\TestDouble\LoggerInterface::class);
    // @var array<int, Log> $addLog
    $addLog = $spyLog->getLogs(FooInterface, 'add');   
    $this->assertSame(1, count($addLog), 'Should have received once');
    // SUTからの引数の検証
    $this->assertSame([1, 2], $addLog[0]->arguments);
    $this->assertSame(1, $addLog[0]->namedArguments['a']);
}

ダミー

インターフェイスにNullオブジェクトを束縛するにはNull束縛を使います。

ハイパーメディアテスト

リソーステストは各エンドポイントの入出力テストです。対してハイパーメディアテストはそのエンドポイントどう繋ぐかというワークフローの振る舞いをテストします。

WorkflowテストはHTTPテストに継承され、1つのコードでPHPとHTTP双方のレベルでテストされます。その際HTTPのテストはcurlで行われ、そのリクエスト・レスポンスはログファイルに記録されます。

良いテストのために

  • 実装ではなく、インターフェイスをテストします。
  • モックライブラリを利用するよりフェイククラスを作成しましょう。
  • テストは仕様です。書きやすさよりも読みやすさを。

参考URL


Examples

Coding Guideに従って作られたアプリケーションの例です。

Polidog.Todo

https://github.com/koriym/Polidog.Todo

基本的なCRUDのアプリケーションです。var/sqlディレクトリのSQLファイルでDBアクセスをしています。ハイパーリンクを使ったREST APIとテスト、それにフォームのバリデーションテストも含まれます。

MyVendor.ContactForm

https://github.com/bearsunday/MyVendor.ContactForm

各種のフォームページのサンプルです。

  • 最小限のフォーム
  • 複数のフォーム
  • INPUTエレメントをループで生成したフォーム
  • チェックボックス、ラジオボタンを含んだプレビュー付きのフォーム

アトリビュート

BEAR.SundayはBEAR.Package^1.10.3から従来のアノテーションに加えて、PHP8のアトリビュートをサポートします。

アノテーション

/**
 * @Inject
 * @Named('admin')
 */
public function setLogger(LoggerInterface $logger)

アトリビュート

#[Inject, Named('admin')]
public function setLogger(LoggerInterface $logger)
#[Embed(rel: 'weather', src: 'app://self/weather{?date}')]
#[Link(rel: 'event', href: 'app://self/event{?news_date}')]
public function onGet(string $date): self

引数に適用

アノテーションはメソッドにしか適用できず引数名を名前で指定する必要があるものがありましたが、 PHP8では直接、引数のアトリビュートで指定することができます。

public __construct(#[Named('payment')] LoggerInterface $paymentLogger, #[Named('debug')] LoggerInterface $debugLogger)
public function onGet($id, #[Assisted] DbInterface $db = null)
public function onGet(#[CookieParam('id')]string $tokenId): void
public function onGet(#[ResourceParam(uri: 'app://self/login#nickname')] string $nickname = null): static

互換性

アトリビュートとアノテーションは1つのプロジェクトに混在する事もできます。11 このマニュアルに表記されている全てのアノテーションはアトリビュートに変更しても動作します。

パフォーマンス

最適化されるため、プロダクション用にアノテーション/アトリビュート読み込みコストがかかることはほとんどありませんが 以下のようにアトリビュートリーダーしか使用しないと宣言すると開発時の速度が向上します。

// tests/bootstap.php 

use Ray\ServiceLocator\ServiceLocator;

ServiceLocator::setReader(new AttributeReader());
// DevModule
 
$this->install(new AttributeModule());

API Doc

BEAR.ApiDocは、アプリケーションからAPIドキュメントを生成します。

コードとJSONスキーマから自動生成されるドキュメントは、手間を減らし正確なAPIドキュメントを維持し続けることができます。

利用方法

BEAR.ApiDocをインストールします。

composer require bear/api-doc --dev

設定ファイルをコピーします。

cp ./vendor/bear/api-doc/apidoc.xml.dist ./apidoc.xml

ソース

BEAR.ApiDocはphpdoc、メソッドシグネチャ、JSONスキーマから情報を取得してドキュメントを生成します。

PHPDOC

phpdocでは以下の部分が取得されます。 認証などリソースに横断的に適用される情報は別のドキュメントページを用意して@linkでリンクします。

/**
 * {title}
 *
 * {description}
 *
 * {@link htttp;//example.com/docs/auth 認証}
 */
 class Foo extends ResourceObject
 {
 }
/**
 * {title}
 *
 * {description}
 *
 * @param string $id ユーザーID
 */
 public function onGet(string $id ='kuma'): static
 {
 }
  • メソッドのphpdocに@param記述が無い場合、メソッドシグネチャーから引数の情報を取得します。
  • 情報取得の優先順はphpdoc、JSONスキーマ、プロファイルの順です。

設定ファイル

設定はXMLで記述されます。 最低限の指定は以下の通りです。

<?xml version="1.0" encoding="UTF-8"?>
<apidoc
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="https://bearsunday.github.io/BEAR.ApiDoc/apidoc.xsd">
    <appName>MyVendor\MyProject</appName>
    <scheme>app</scheme>
    <docDir>docs</docDir>
    <format>html</format>
</apidoc>

必須属性

appName

アプリケーションの名前空間

scheme

APIドキュメントにするスキーマ名。pageまたはapp

docDir

出力ディレクトリ名

format

出力フォーマット。HTMLまたはMD(Mark down

オプション属性

title

APIタイトル

<title>MyBlog API</title>

description

APIディスクリプション

<description>MyBlog API description</description>

リンク。hrefでリンク先URL、relでその内容を表します。

<links>
    <link href="https://www.example.com/issue" rel="issue" />
    <link href="https://www.example.com/help" rel="help" />
</links>

alps

APIで使われる語句を定義する”ALPSプロファイル”を指定します。

<alps>alps/profile.json</alps>

プロファイル

BEAR.ApiDocはアプリケーションに追加情報を与えるRFC 6906 プロファイルALPSフォーマットをサポートします。

APIのリクエストやレスポンスのキーで使う語句をセマンティックディスクリプタ(意味的記述子)と呼びますが、プロファイルそのの辞書を作っておけばリクエスト毎に語句を説明する必要がなくなります。 語句の定義が集中することで表記揺れを防ぎ、理解共有を助けます。

以下はfirstName,familyNameというディスクリプタをそれぞれtitledefで定義した例です。 titleは言葉を記述して意味を明らかにしますが、defSchema.org などのボキャブラリサイトで定義されたスタンダードな語句をリンクします。

ALPSプロファイルはXMLまたはJSONで記述します。

profile.xml

<?xml version="1.0" encoding="UTF-8"?>
<alps
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="https://alps-io.github.io/schemas/alps.xsd">
    <!-- Ontology -->
    <descriptor id="firstName" title="The person's first name."/>
    <descriptor id="familyName" def="https://schema.org/familyName"/>
</alps>

profile.json

{
  "$schema": "https://alps-io.github.io/schemas/alps.json",
  "alps": {
    "descriptor": [
      {"id": "firstName", "title": "The person's first name."},
      {"id": "familyName", "def": "https://schema.org/familyName"},
    ]
  }
}

ApiDocに登場する語句の説明はphpdoc > JsonSchema > ALPSの順で優先します。

リンク

リファレンス

アトリビュート

アトリビュート 説明
#[CacheableResponse] キャッシュ可能なレスポンスを指定するアトリビュート。
#[Cacheable(int $expirySecond = 0)] リソースのキャッシュ可能性を指定するアトリビュート。$expirySecond はキャッシュの有効期間(秒)。
#[CookieParam(string $name)] クッキーからパラメータを受け取るためのアトリビュート。$name はクッキーの名前。
#[DonutCache] ドーナツキャッシュを指定するアトリビュート。
#[Embed(src: string $src, rel: string $rel)] 他のリソースを埋め込むことを指定するアトリビュート。$src は埋め込むリソースのURI、$rel はリレーション名。
#[EnvParam(string $name)] 環境変数からパラメータを受け取るためのアトリビュート。$name は環境変数の名前。
#[FormParam(string $name)] フォームデータからパラメータを受け取るためのアトリビュート。$name はフォームフィールドの名前。
#[Inject] セッターインジェクションを指定するアトリビュート。
#[InputValidation] 入力バリデーションを行うことを指定するアトリビュート。
#[JsonSchema(key: string $key = null, schema: string $schema = null, params: string $params = null)] リソースの入力/出力のJSONスキーマを指定するアトリビュート。$key はスキーマのキー、$schema はスキーマファイル名、$params はパラメータのスキーマファイル名。
#[Link(rel: string $rel, href: string $href, method: string $method = null)] リソース間のリンクを指定するアトリビュート。$rel はリレーション名、$href はリンク先のURI、$method はHTTPメソッド。
#[Named(string $name)] 名前付きバインディングを指定するアトリビュート。$name はバインディングの名前。
#[OnFailure(string $name = null)] バリデーション失敗時のメソッドを指定するアトリビュート。$name はバリデーションの名前。
#[OnValidate(string $name = null)] バリデーションメソッドを指定するアトリビュート。$name はバリデーションの名前。
#[Produces(array $mediaTypes)] リソースの出力メディアタイプを指定するアトリビュート。$mediaTypes は出力可能なメディアタイプの配列。
#[QueryParam(string $name)] クエリパラメータを受け取るためのアトリビュート。$name はクエリパラメータの名前。
#[RefreshCache] キャッシュのリフレッシュを指定するアトリビュート。
#[ResourceParam(uri: string $uri, param: string $param)] 他のリソースの結果をパラメータとして受け取るためのアトリビュート。$uri はリソースのURI、$param はパラメータ名。
#[ReturnCreatedResource] 作成されたリソースを返すことを指定するアトリビュート。
#[ServerParam(string $name)] サーバー変数からパラメータを受け取るためのアトリビュート。$name はサーバー変数の名前。
#[Ssr(app: string $appName, state: array $state = [], metas: array $metas = [])] サーバーサイドレンダリングを指定するアトリビュート。$appName はJSアプリケーション名、$state はアプリケーションの状態、$metas はメタ情報の配列。
#[Transactional(array $props = ['pdo'])] メソッドをトランザクション内で実行することを指定するアトリビュート。$props はトランザクションを適用するプロパティの配列。
#[UploadFiles] アップロードされたファイルを受け取るためのアトリビュート。
#[Valid(form: string $form = null, onFailure: string $onFailure = null)] リクエストの検証を行うことを指定するアトリビュート。$form はフォームクラス名、$onFailure は検証失敗時のメソッド名。

モジュール

モジュール名 説明
ApcSsrModule APCuを使用したサーバーサイドレンダリング用のモジュール。
ApiDoc APIドキュメントを生成するためのモジュール。
AppModule アプリケーションのメインモジュール。他のモジュールのインストールや設定を行う。
AuraSqlModule Aura.Sqlを使用したデータベース接続用のモジュール。
AuraSqlQueryModule Aura.SqlQueryを使用したクエリビルダー用のモジュール。
CacheVersionModule キャッシュのバージョン管理を行うモジュール。
CliModule コマンドラインインターフェース用のモジュール。
DoctrineOrmModule Doctrine ORMを使用したデータベース接続用のモジュール。
FakeModule テスト用のフェイクモジュール。
HalModule HAL (Hypertext Application Language) 用のモジュール。
HtmlModule HTMLレンダリング用のモジュール。
ImportAppModule 他のアプリケーションを読み込むためのモジュール。
JsonSchemaModule JSONスキーマを使用したリソースの入力/出力バリデーション用のモジュール。
JwtAuthModule JSON Web Token (JWT) を使用した認証用のモジュール。
NamedPdoModule 名前付きのPDOインスタンスを提供するモジュール。
PackageModule BEAR.Packageが提供する基本的なモジュールをまとめてインストールするためのモジュール。
ProdModule 本番環境用の設定を行うモジュール。
QiqModule Qiqテンプレートエンジン用のモジュール。
ResourceModule リソースクラスに関する設定を行うモジュール。
AuraRouterModule Aura.Routerのルーティング用のモジュール。
SirenModule Siren (Hypermedia Specification) 用のモジュール。
SpyModule メソッドの呼び出しを記録するためのモジュール。
SsrModule サーバーサイドレンダリング用のモジュール。
TwigModule Twigテンプレートエンジン用のモジュール。
ValidationModule バリデーション用のモジュール。

チュートリアル

このチュートリアルではBEAR.Sundayの基本機能のDIAOPREST APIを紹介します。 tutorial1のコミットを参考にして進めましょう。

プロジェクト作成

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

composer create-project bear/skeleton MyVendor.Weekday

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

リソース

最初にアプリケーションリソースファイルを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 = (new 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: Tue, 04 May 2021 01:55:59 GMT
Connection: close
X-Powered-By: PHP/8.0.3
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コマンドでおこないます。

composer sa

これまでのコードで実行してみると、以下のエラーがphpstanで検出されました。

   
  15     Cannot call method format() on DateTimeImmutable|false.  
  

[^1]:このプロジェクトのソースコードは各セクション毎に[bearsunday/Tutorial](https://github.com/bearsunday/tutorial1/commits/v3)にコミットしています。適宜参照してください。
[^2]:通常は**vendor**名は個人またはチーム(組織)の名前を入力します。githubのアカウント名やチーム名が適当でしょう。**project**にはアプリケーション名を入力します。


# チュートリアル2

このチュートリアルでは以下のツールを用いて標準に基づいた高品質なREST(Hypermedia)アプリケーション開発を学びます。

* JSONのスキーマを定義しバリデーションやドキュメンテーションに利用する [Json Schema](https://json-schema.org/)
* ハイパーメディアタイプ [HAL (Hypertext Application Language)](https://stateless.group/hal_specification.html)  
* CakePHPが開発してるDBマイグレーションツール [Phinx](https://book.cakephp.org/3.0/ja/phinx.html) 
* PHPのインターフェイスとSQL文実行を束縛する [Ray.MediaQuery](https://github.com/ray-di/Ray.MediaQuery)

[tutorial2](https://github.com/bearsunday/tutorial2/commits/v2-php8.2)のコミットを参考にして進めましょう。

## プロジェクト作成

プロジェクトスケルトンを作成します。

composer create-project bear/skeleton MyVendor.Ticket


**vendor**名を`MyVendor`に**project**名を`Ticket`として入力します。

## マイグレーション

Phinxをインストールします。

composer require –dev robmorgan/phinx


プロジェクトルートフォルダの`.env.dist`ファイルにDB接続情報を記述します。

TKT_DB_HOST=127.0.0.1:3306 TKT_DB_NAME=ticket TKT_DB_USER=root TKT_DB_PASS=’’ TKT_DB_SLAVE=’’ TKT_DB_DSN=mysql:host=${TKT_DB_HOST}


`.env.dist`ファイルはこのようにして、実際の接続情報は`.env`に記述しましょう。[^1]

次にphinxが利用するフォルダを作成します。

```bash
mkdir -p var/phinx/migrations
mkdir var/phinx/seeds

.envの接続情報をphinxで利用するためにvar/phinx/phinx.phpを設置します。

<?php
use BEAR\Dotenv\Dotenv;

require_once dirname(__DIR__, 2) . '/vendor/autoload.php';

(new Dotenv())->load(dirname(__DIR__, 2));

$development = new PDO(getenv('TKT_DB_DSN'), getenv('TKT_DB_USER'), getenv('TKT_DB_PASS'));
$test = new PDO(getenv('TKT_DB_DSN') . '_test', getenv('TKT_DB_USER'), getenv('TKT_DB_PASS'));
return [
    'paths' => [
        'migrations' => __DIR__ . '/migrations',
    ],
    'environments' => [
        'development' => [
            'name' => $development->query("SELECT DATABASE()")->fetchColumn(),
            'connection' => $development
        ],
        'test' => [
            'name' => $test->query("SELECT DATABASE()")->fetchColumn(),
            'connection' => $test
        ]
    ]
];

setupスクリプト

データベース作成やマイグレーションを簡単に実行できるようにbin/setup.phpを編集します。

<?php
use BEAR\Dotenv\Dotenv;

require_once dirname(__DIR__) . '/vendor/autoload.php';

(new Dotenv())->load(dirname(__DIR__));

chdir(dirname(__DIR__));
passthru('rm -rf var/tmp/*');
passthru('chmod 775 var/tmp');
passthru('chmod 775 var/log');
// db
$pdo = new \PDO('mysql:host=' . getenv('TKT_DB_HOST'), getenv('TKT_DB_USER'), getenv('TKT_DB_PASS'));
$pdo->exec('CREATE DATABASE IF NOT EXISTS ' . getenv('TKT_DB_NAME'));
$pdo->exec('DROP DATABASE IF EXISTS ' . getenv('TKT_DB_NAME') . '_test');
$pdo->exec('CREATE DATABASE ' . getenv('TKT_DB_NAME') . '_test');
passthru('./vendor/bin/phinx migrate -c var/phinx/phinx.php -e development');
passthru('./vendor/bin/phinx migrate -c var/phinx/phinx.php -e test');

次にticketテーブルを作成するためにマイグレーションクラスを作成します。

./vendor/bin/phinx create Ticket -c var/phinx/phinx.php
Phinx by CakePHP - https://phinx.org.

...
created var/phinx/migrations/20210520124501_ticket.php

var/phinx/migrations/{current_date}_ticket.phpを編集してchange()メソッドを実装します。

<?php
use Phinx\Migration\AbstractMigration;

final class Ticket extends AbstractMigration
{
    public function change(): void
    {
        $table = $this->table('ticket', ['id' => false, 'primary_key' => ['id']]);
        $table->addColumn('id', 'uuid', ['null' => false])
            ->addColumn('title', 'string')
            ->addColumn('date_created', 'datetime')
            ->create();
    }
}

.env.distファイルを以下のように変更します。

 TKT_DB_USER=root
 TKT_DB_PASS=
 TKT_DB_SLAVE=
-TKT_DB_DSN=mysql:host=${TKT_DB_HOST}
+TKT_DB_DSN=mysql:host=${TKT_DB_HOST};dbname=${TKT_DB_NAME}

準備が完了したので、セットアップコマンドを実行してテーブルを作成します。

composer setup
> php bin/setup.php
...
All Done. Took 0.0248s

テーブルが作成されました。次回からこのプロジェクトのデータベース環境を整えるにはcomposer setupを実行するだけで行えます。

マイグレーションクラスの記述について詳しくはPhinxのマニュアル:マイグレーションを書くをご覧ください。

モジュール

モジュールをcomposerインストールします。

composer require ray/identity-value-module ray/media-query -w

AppModuleでパッケージをインストールします。

src/Module/AppModule.php

<?php
namespace MyVendor\Ticket\Module;

use BEAR\Dotenv\Dotenv;
use BEAR\Package\AbstractAppModule;
use BEAR\Package\PackageModule;

use BEAR\Resource\Module\JsonSchemaModule;
use Ray\AuraSqlModule\AuraSqlModule;
use Ray\IdentityValueModule\IdentityValueModule;
use Ray\MediaQuery\DbQueryConfig;
use Ray\MediaQuery\MediaQueryModule;
use Ray\MediaQuery\Queries;
use function dirname;

class AppModule extends AbstractAppModule
{
    protected function configure(): void
    {
        (new Dotenv())->load(dirname(__DIR__, 2));
        $this->install(
            new AuraSqlModule(
                (string) getenv('TKT_DB_DSN'),
                (string) getenv('TKT_DB_USER'),
                (string) getenv('TKT_DB_PASS'),
                (string) getenv('TKT_DB_SLAVE')
            )
        );
        $this->install(
            new MediaQueryModule(
                Queries::fromDir($this->appMeta->appDir . '/src/Query'), [
                   new DbQueryConfig($this->appMeta->appDir . '/var/sql'),
                ]
            )
        );
        $this->install(new IdentityValueModule());
        $this->install(
            new JsonSchemaModule(
                $this->appMeta->appDir . '/var/schema/response',
                $this->appMeta->appDir . '/var/schema/request'
            )
        );
        $this->install(new PackageModule());
    }
}

SQL

チケット用の3つのSQLをvar/sqlに保存します。18

var/sql/ticket_add.sql

/* ticket add */
INSERT INTO ticket (id, title, date_created)
VALUES (:id, :title, :dateCreated);

var/sql/ticket_list.sql

/* ticket list */
SELECT id, title, date_created
  FROM ticket
 LIMIT 3;

var/sql/ticket_item.sql

/* ticket item */
SELECT id, title, date_created
  FROM ticket
 WHERE id = :id

作成時に単体でそのSQLが動作するか確認しましょう。

例えば、PHPStormにはデータベースツールのDataGripが含まれていて、コード補完やSQLのリファクタリングなどSQL開発に必要な機能が揃っています。 DB接続などのセットアップを行えば、SQLファイルをIDEで直接実行できます。1219

JsonSchema

Ticket(チケットアイテム)、Tickets(チケットアイテムリスト)のリソース表現をJsonSchemaで定義し、それぞれ保存します。

var/schema/response/ticket.json

{
  "$id": "ticket.json",
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Ticket",
  "type": "object",
  "required": ["id", "title", "date_created"],
  "properties": {
    "id": {
      "description": "The unique identifier for a ticket.",
      "type": "string",
      "maxLength": 64
    },
    "title": {
      "description": "The unique identifier for a ticket.",
      "type": "string",
      "maxLength": 255
    },
    "date_created": {
      "description": "The date and time that the ticket was created",
      "type": "string",
      "format": "datetime"
    }
  }
}

var/schema/response/tickets.json

Ticketsはticketの配列です。

{
  "$id": "tickets.json",
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Tickets",
  "type": "object",
  "required": ["tickets"],
  "properties": {
    "tickets": {
      "type": "array",
      "items":{"$ref": "./ticket.json"}
    }
  }
}
  • $id - ファイル名を指定しますが、公開する場合はURLを記述します。
  • title - オブジェクト名としてAPIドキュメントで扱われます。
  • examples - 適宜、例を指定しましょう。オブジェクト全体のも指定できます。

PHPStormではエディタの右上に緑色のチェックが出ていて問題がない事が分かります。スキーマ作成時にスキーマ自身もバリデートしましょう。

クエリーインターフェイス

インフラストラクチャへのアクセスを抽象化したPHPのインターフェイスを作成します。

  • Ticketリソースを読み出す TicketQueryInterface
  • Ticketリソースを作成する TicketCommandInterface

src/Query/TicketQueryInterface.php

<?php

namespace MyVendor\Ticket\Query;

use MyVendor\Ticket\Entity\Ticket;
use Ray\MediaQuery\Annotation\DbQuery;

interface TicketQueryInterface
{
    #[DbQuery('ticket_item']
    public function item(string $id): Ticket|null;

    /** @return array<Ticket> */
    #[DbQuery('ticket_list']
    public function list(): array;
}

src/Query/TicketCommandInterface.php

<?php

namespace MyVendor\Ticket\Query;

use DateTimeInterface;
use Ray\MediaQuery\Annotation\DbQuery;

interface TicketCommandInterface
{
    #[DbQuery('ticket_add')]
    public function add(string $id, string $title, DateTimeInterface $dateCreated = null): void;
}

#[DbQuery]アトリビュートでSQL文を指定します。

このインターフェイスに対する実装を用意する必要はありません。 指定されたSQLのクエリーを行うオブジェクトが自動生成されます。

インターフェイスを副作用が発生するcommandまたは値を返すqueryという2つの関心に分けていますが、リポジトリパターンのように1つにまとめたり ADRパターンのように1インターフェイス1メソッドにしても構いません。アプリケーション設計者が方針を決定します。

エンティティ

メソッドの返り値にarrayを指定すると、データベースの結果はそのまま連想配列と得られますが、メソッドの返り値にエンティティの型を指定すると、その型にハイドレーションされます。

#[DbQuery('ticket_item']
public function item(string $id): array // 配列が得られる
#[DbQuery('ticket_item']
public function item(string $id): Ticket|null; // Ticketエンティティが得られる

複数行(row_list)の時は/** @return array<Ticket>*/とphpdocでTicketが配列で返ることを指定します。

/** @return array<Ticket> */
#[DbQuery('ticket_list')]
public function list(): array; // Ticketエンティティの配列が得られる

各行の値は名前引数でコンストラクタに渡されます。20

<?php

declare(strict_types=1);

namespace MyVendor\Ticket\Entity;

class Ticket
{
    public function __construct(
        public readonly string $id,
        public readonly string $title,
        public readonly string $dateCreated
    ) {}
}

リソース

リソースクラスはクエリーインターフェイスに依存します。

tikcetリソース

ticketリソースをsrc/Resource/App/Ticket.phpに作成します。

<?php

declare(strict_types=1);

namespace MyVendor\Ticket\Resource\App;

use BEAR\Resource\Annotation\JsonSchema;
use BEAR\Resource\ResourceObject;
use MyVendor\Ticket\Query\TicketQueryInterface;

class Ticket extends ResourceObject
{
    public function __construct(
        private TicketQueryInterface $query
    ){}
    
   #[JsonSchema("ticket.json")]
   public function onGet(string $id = ''): static
    {
        $this->body = (array) $this->query->item($id);

        return $this;
    }
}

アトリビュート#[JsonSchema]onGet()で出力される値がticket.jsonのスキーマで定義されている事を表します。 AOPによってリクエスト毎にバリデートされます。

シードを入力してリソースをリクエストしてみましょう。21

% mysql -u root -e "INSERT INTO ticket (id, title, date_created) VALUES ('1', 'foo', '1970-01-01 00:00:00')" ticket
% php bin/app.php get '/ticket?id=1'
200 OK
Content-Type: application/hal+json

{
    "id": "1",
    "title": "foo",
    "date_created": "1970-01-01 00:00:01",
    "_links": {
        "self": {
            "href": "/ticket?id=1"
        }
    }
}

Ray.MediaQuery

Ray.MediaQueryを使えば、ボイラープレートとなりやすい実装クラスをコーディングする事なく、インターフェイスから自動生成されたSQL実行オブジェクトがインジェクトされます。22

SQL文には;で区切った複数のSQL分を記述する事ができ、複数のSQLに同じパラメーターが名前でバインドされます。SELECT以外のクエリーではトランザクションも実行されます。

利用クラスはインターフェイスにしか依存していないので、動的にSQLを生成したい場合にはRay.MediaQueryの代わりにクエリービルダーをインジェクトしたSQL実行クラスで組み立てたSQLを実行すれば良いでしょう。 詳しくはマニュアルのデータベースをご覧ください。

埋め込みリンク

通常Webサイトのページは複数のリソースを内包します。例えばブログの記事ページであれば、記事以外にもおすすめや広告、カテゴリーリンクなどが含まれるかもしれません。 それらをクライアントがバラバラに取得する代わりに、独立したリソースとして埋め込みリンクで1つのリソースに束ねる事ができます。

HTMLとそこに記述される<img>タグをイメージしてください。どちらも独立したURLを持ちますが、画像リソースがHTMLリソースに埋めこ込まれていてHTMLを取得するとHTML内に画像が表示されます。 これらはハイパーメディアタイプのEmbedding links(LE)と呼ばれるもので、埋め込まれるリソースがリンクされています。

ticketリソースにprojectリソースを埋め込んでみましょう。Projectクラスを用意します。

src/Resource/App/Project.php

<?php

namespace MyVendor\Ticket\Resource\App;

use BEAR\Resource\ResourceObject;

class Project extends ResourceObject
{
    public function onGet(): static
    {
        $this->body = ['title' => 'Project A'];

        return $this;
    }
}

Ticketリソースにアトリビュート#[Embed]を追加します。

+use BEAR\Resource\Annotation\Embed;
+use BEAR\Resource\Request;
+
+   #[Embed(src: '/project', rel: 'project')]
    #[JsonSchema("ticket.json")]
    public function onGet(string $id = ''): static
    {
+        assert($this->body['project'] instanceof Request);
-        $this->body = (array) $this->query->item($id);
+        $this->body += (array) $this->query->item($id);

#[Embed]アトリビュートのsrcで指定されたリソースのリクエストがbodyプロパティのrelキーにインジェクトされ、レンダリング時に遅延評価され文字列表現になります。

例を簡単にするためにこの例ではパラメーターを渡していませんが、メソッド引数が受け取った値をURI templateを使って渡す事もできますし、インジェクトされたリクエストのパラメーターを修正、追加する事ができます。 詳しくはリソースをご覧ください。

もう一度リクエストすると_embeddedというプロパティにprojectリソースの状態が追加されているのが分かります。

% php bin/app.php get '/ticket?id=1'

{
    "id": "1",
    "title": "2",
    "date_created": "1970-01-01 00:00:01",
+    "_embedded": {
+        "project": {
+            "title": "Project A",
+        }
    },

埋め込みリソースはREST APIの重要な機能です。 コンテンツにツリー構造を与えHTTPリクエストコストを削減します。 情報が他の何の情報を含んでいるかはドメインの関心事です。クライアントで都度取得するのではなく、その関心事はサーバーサイドのLE(埋め込みリンク)でうまく表す事ができます。23

ticketsリソース

POSTで作成、GETでチケットリストが取得できるtikcetsリソースをsrc/resource/App/Tickets.phpに作成します。

<?php

declare(strict_types=1);

namespace MyVendor\Ticket\Resource\App;

use BEAR\Resource\Annotation\JsonSchema;
use BEAR\Resource\Annotation\Link;
use BEAR\Resource\ResourceObject;
use Koriym\HttpConstants\ResponseHeader;
use Koriym\HttpConstants\StatusCode;
use MyVendor\Ticket\Query\TicketCommandInterface;
use MyVendor\Ticket\Query\TicketQueryInterface;
use Ray\IdentityValueModule\UuidInterface;
use function uri_template;

class Tickets extends ResourceObject
{
    public function __construct(
        private TicketQueryInterface $query,
        private TicketCommandInterface $command,
        private UuidInterface $uuid
    ){}

    #[Link(rel: "doPost", href: '/tickets')]
    #[Link(rel: "goTicket", href: '/ticket{?id}')]
    #[JsonSchema("tickets.json")]
    public function onGet(): static
    {
        $this->body = [
            'tickets' => $this->query->list()
        ];
        
        return $this;
    }

    #[Link(rel: "goTickets", href: '/tickets')]
    public function onPost(string $title): static
    {
        $id = (string) $this->uuid;
        $this->command->add($id, $title);

        $this->code = StatusCode::CREATED;
        $this->headers[ResponseHeader::LOCATION] = uri_template('/ticket{?id}', ['id' => $id]);

        return $this;
    }
}

インジェクトされた$uuidは文字列にキャストする事でUUIDが得られます。また#Link[]は他のリソース(アプリケーション状態)へのリンクを表します。

add()メソッドで現在時刻を渡してない事に注目してください。 値が渡されない場合nullではなく、MySQLの現在時刻文字列がSQLにバインドされます。 なぜならDateTimeInterfaceに束縛された現在時刻DateTimeオブジェクトの文字列表現(現在時刻文字列)がSQLに束縛されているからです。

public function add(string $id, string $title, DateTimeInterface $dateCreated = null): void;

SQL内部でNOW()とハードコーディングする事や、メソッドに毎回現在時刻を渡す手間を省きます。 DateTimeオブジェクトを渡す事もできるし、テストのコンテキストでは固定のテスト用時刻を束縛することもできます。

このようにクエリーの引数にインターフェイスを指定するとそのオブジェクトをDIを使って取得、その文字列表現がSQLに束縛されます。 例えばログインユーザーIDなどを束縛してアプリケーションで横断的に利用できます。24

ハイパーメディアAPIテスト

REST(representational state transfer)という用語は、2000年にRoy Fieldingが博士論文の中で紹介、定義したもので「適切に設計されたWebアプリケーションの動作」をイメージさせることを目的としていてます。 それはWebリソースのネットワーク(仮想ステートマシン)であり、ユーザーはリソース識別子(URL)と、 GETやPOSTなどのリソース操作(アプリケーションステートの遷移)を選択することで、アプリケーションを進行させ、その結果、次のリソースの表現(次のアプリケーションステート)がエンドユーザーに転送されて使用されるというものです。

Wikipedia (REST)

RESTアプリケーションでは次のアクションがURLとしてサービスから提供され、クライアントはそれを選択します。

HTML Webアプリケーションは完全にRESTfulです。その操作は「(aタグなどで)提供されたURLに遷移する」 または 「提供されたフォームを埋めて送信する」この何れかでしかありません。

REST APIのテストも同様に記述します。

<?php

declare(strict_types=1);

namespace MyVendor\Ticket\Hypermedia;

use BEAR\Resource\ResourceInterface;
use BEAR\Resource\ResourceObject;
use Koriym\HttpConstants\ResponseHeader;
use MyVendor\Ticket\Injector;
use MyVendor\Ticket\Query\TicketQueryInterface;
use PHPUnit\Framework\TestCase;
use Ray\Di\InjectorInterface;
use function json_decode;

class WorkflowTest extends TestCase
{
    protected ResourceInterface $resource;
    protected InjectorInterface $injector;

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

    public function testIndex(): static
    {
        $index = $this->resource->get('/');
        $this->assertSame(200, $index->code);

        return $index;
    }

    /**
     * @depends testIndex
     */
    public function testGoTickets(ResourceObject $response): static
    {

        $json = (string) $response;
        $href = json_decode($json)->_links->{'goTickets'}->href;
        $ro = $this->resource->get($href);
        $this->assertSame(200, $ro->code);

        return $ro;
    }

    /**
     * @depends testGoTickets
     */
    public function testDoPost(ResourceObject $response): static
    {
        $json = (string) $response;
        $href = json_decode($json)->_links->{'doPost'}->href;
        $ro = $this->resource->post($href, ['title' => 'title1']);
        $this->assertSame(201, $ro->code);

        return $ro;
    }

    /**
     * @depends testDoPost
     */
    public function testGoTicket(ResourceObject $response): static
    {
        $href = $response->headers[ResponseHeader::LOCATION];
        $ro = $this->resource->get($href);
        $this->assertSame(200, $ro->code);

        return $ro;
    }
}

起点となるルートページも必要です。

src/Resource/App/Index.php

<?php

declare(strict_types=1);

namespace MyVendor\Ticket\Resource\App;

use BEAR\Resource\Annotation\Link;
use BEAR\Resource\ResourceObject;

class Index extends ResourceObject
{
    #[Link(rel: 'goTickets', href: '/tickets')]
    public function onGet(): static
    {
        return $this;
    }
}
  • setUpではリソースクライアントを生成、testIndex()でルートページをアクセスしています。
  • レスポンスを受け取ったtestGoTickets()メソッドではそのレスポンスオブジェクトをJSON表現にして、次のチケット一覧を取得するリンクgoTicketsを取得しています。
  • リソースボディのテストを記述する必要はありません。レスポンスのJsonSchemaバリデーションが通ったというが保証されているので、ステータスコードの確認だけでOKです。
  • RESTの統一インターフェイスに従い、次にアクセスするリクエストURLは常にレスポンスに含まれます。それを次々に検査します。

RESTの統一インターフェイス

1)リソースの識別、2)表現によるリソースの操作、3)自己記述メッセージ、 4)アプリケーション状態のエンジンとしてのハイパーメディア(HATEOAS)の4つのインターフェイス制約です。25

実行してみましょう

./vendor/bin/phpunit --testsuite hypermedia

ハイパーメディアAPIテスト(RESTアプリケーションテスト)はRESTアプリケーションがステートマシンであるという事をよく表し、ワークフローをユースケースとして記述する事ができます。 REST APIテストを見ればそのアプリケーションがどのように使われるか網羅されているのが理想です。

HTTPテスト

HTTPでREST APIのテストを行うためにはテスト全体を継承して、setUpでクライアントをHTTPテストクライアントにします。

class WorkflowTest extends Workflow
{
    protected function setUp(): void
    {
        $this->resource = new HttpResource('127.0.0.1:8080', __DIR__ . '/index.php', __DIR__ . '/log/workflow.log');
    }
}

このクライアントはリソースクライアントと同じインターフェイスを持ちますが、実際のリクエストはビルトインサーバーに対してHTTPリクエストで行われサーバーからのレスポンスを受け取ります。 1つ目の引数はビルトインサーバーのURLです。newされると二番目の引数で指定されたbootstrapスクリプトでビルトインサーバーが起動します。

テストサーバー用のbootstrapスクリプトもAPIコンテキストに変更します。

tests/Http/index.php

-exit((new Bootstrap())('hal-app', $GLOBALS, $_SERVER));
+exit((new Bootstrap())('hal-api-app', $GLOBALS, $_SERVER));

実行してみましょう。

./vendor/bin/phpunit --testsuite http

HTTPアクセスログ

curlで行われた実際のHTTPリクエスト/レスポンスログが三番目の引数のリソースログに記録されます。

curl -s -i 'http://127.0.0.1:8080/'

HTTP/1.1 200 OK
Host: 127.0.0.1:8080
Date: Fri, 21 May 2021 22:41:02 GMT
Connection: close
X-Powered-By: PHP/8.0.6
Content-Type: application/hal+json

{
    "_links": {
        "self": {
            "href": "/index"
        },
        "goTickets": {
            "href": "/tickets"
        }
    }
}
curl -s -i -H 'Content-Type:application/json' -X POST -d '{"title":"title1"}' http://127.0.0.1:8080/tickets

HTTP/1.1 201 Created
Host: 127.0.0.1:8080
Date: Fri, 21 May 2021 22:41:02 GMT
Connection: close
X-Powered-By: PHP/8.0.6
Location: /ticket?id=421d997c-9a0e-4018-a6c2-9b8758cac6d6

実際に記録されたJSONは、特に複雑な構造を持つ場合に確認するのに役に立ちます。APIドキュメントと併せて確認するのにもいいでしょう。 HTTPクライアントはE2Eテストにも利用する事ができます。

APIドキュメント

ResourceObjectではメソッドシグネチャーがAPIの入力パラメーターになっていて、レスポンスがスキーマ定義されています。 その自己記述性の高さからAPIドキュメントが自動生成する事ができます。

作成してみましょう。docsフォルダにドキュメントが出力されます。

composer doc

IDL(インターフェイス定義言語)を記述する労力を削減しますが、より価値があるのはドキュメントが最新のPHPコードに追従し常に正確な事です。 CIに組み込み常にコードとAPIドキュメントが同期している状態にするのがいいでしょう。

関連ドキュメントをリンクする事もできます。設定について詳しくはApiDocをご覧ください。

コード例

以下のコード例も用意しています。

  • Testコンテキストを追加してテスト毎にDBをクリアするTestModule 4e9704d
  • DBクエリーで連想配列を返す代わりにハイドレートされたエンティティクラスを返すRay.MediaQueryentityオプション 29f0a1f
  • 静的なSQLと動的なSQLを合成したクエリービルダー 9d095ac

REST framework

Web APIには以下の3つのスタイルがあります。

  • トンネル (SOAP, GraphQL)
  • URI (オブジェクト、CRUD)
  • ハイパーメディア (REST)

リソースを単なるRPCとして扱うURIスタイル26に対して、 このチュートリアルで学んだのはリソースがリンクされているRESTです。27 リソースは#LinkのLO(アウトバウンドリンク)で結ばれワークフローを表し、#[Embed]のLE(埋め込みリンクで)ツリー構造を表しています。

BEAR.Sundayは標準に基づいたクリーンなコードである事を重視します。

フレームワーク固有のバリデータよりJsonSchema。独自ORMより標準SQL。独自構造JSONよりIANA標準メディアタイプ28JSON。

アプリケーション設計は「実装が自由である」事ではなく「制約の選択が自由である」という事が重要です。 アプリケーションはその制約に基づき開発効率やパフォーマンス、後方互換性を壊さない進化可能性を目指すと良いでしょう。


コメントは説明になるだけでなくスロークエリーログ等からもSQLを特定しやすくなります。

※ 以前のPHP7対応のチュートリアルはtutorial2_v1にあります。

パッケージ

アプリケーションは独立したcomposerパッケージです。

フレームワークは依存としてcomposer installしますが、他のアプリケーションも依存パッケージとして使うことができます。

アプリケーション・パッケージ

構造

BEAR.Sundayアプリケーションのファイルレイアウトは php-pds/skeleton に準拠しています。

bin/

スクリプトで実行可能なコマンドを設置します。

BEARのリソースはコンソール入力とWebの双方からアクセスできます。 呼び出すスクリプトによってコンテキストが変わります。

php bin/app.php options '/todos' # APIアクセス (appリソース)
php bin/page.php get '/todos?id=1' # Webアクセス (pageリソース)
php -S 127.0.0.1 bin/app.php # PHPサーバー

コンテキストが変わるとアプリケーションの振る舞いが変わります。 ユーザーは独自のコンテキストを作成することができます。

src/

アプリケーション固有のクラスファイルを設置します。

publc/

Web公開フォルダです。

var/

log,tmpフォルダは書き込み可能にします。var/wwwはWebドキュメントの公開エリアです。 confなど可変のファイルを設置します。

実行シーケンス

  1. コンソール入力(bin/app.php, page.php)またはWebサーバーのエントリーファイル(public/index.php)がbootstrap.phpを実行します。
  2. bootstrap.phpでは実行コンテキストに応じたルートオブジェクト$appを作成します。
  3. $appに含まれるルーターは外部のHTTPまたはCLIリクエストをアプリケーション内部のリソースリクエストに変換します。
  4. リソースリクエストが実行され、結果がクライアントに転送されます。

フレームワーク・パッケージ

フレームワークは以下のパッケージから構成されます。

ray/aop

Scrutinizer Quality Score codecov Type Coverage Continuous Integration

Javeの AOPアライアンス に準拠したAOPフレームワークです。

ray/di

Scrutinizer Quality Score codecov Type Coverage Continuous Integration

google/guice スタイルのDIフレームワークです。ray/aopを含みます。

bear/resource

Scrutinizer Code Quality codecov Type Coverage Continuous Integration

PHPのオブジェクトをRESTサービスとして使用するRESTフレームワークです。ray/diを含みます。

bear/sunday

Scrutinizer Code Quality codecov Type Coverage Continuous Integration

フレームワークのインターフェイスパッケージです。bear/resourceを含みます。

bear/package

Scrutinizer Code Quality codecov Type Coverage Continuous Integration

bear/sundayの実装パッケージです。bear/sundayを含みます。

ライブラリ・パッケージ

必要なライブラリ・パッケージをcomposerインストールします。

Category Composer package Library
ルーター    
  bear/aura-router-module Aura.Router v2
データベース    
  ray/media-query  
  ray/aura-sql-module Aura.Sql v2
  ray/dbal-module Doctrine DBAL
  ray/cake-database-module CakePHP v3 database
  ray/doctrine-orm-module Doctrine ORM
ストレージ    
  bear/query-repository 読み書きリポジトリの分離(デフォルト)
  bear/query-module DBやWeb APIなどの外部アクセスの分離
Web    
  madapaja/twig-module Twigテンプレートエンジン
  ray/web-form-module Webフォーム & バリデーション
  ray/aura-web-module Aura.Web
  ray/aura-session-module Aura.Session
  ray/symfony-session-module Symfony Session
バリデーション    
  ray/validate-module Aura.Filter
  satomif/extra-aura-filter-module Aura.Filter
認証    
  ray/oauth-module OAuth
  kuma-guy/jwt-auth-module JSON Web Token
  ray/role-module Zend Acl  Zend Acl
  bear/acl-resource ACLベースのエンベドリソース
ハイパーメディア    
  kuma-guy/siren-module Siren
開発    
  ray/test-double テストダブル
非同期ハイパフォーマンス    
  MyVendor.Swoole Swoole

ベンダー・パッケージ

特定のパッケージやツールの組み合わせをモジュールだけのパッケージにして再利用し、同様のプロジェクトのモジュールを共通化する事ができます。11

Semver

全てのパッケージはセマンティックバージョニング に従います。マイナーバージョンアップでは後方互換性が破壊されることはありません。


アプリケーション

実行シーケンス

コンパイル、リクエスト、レスポンスの順でアプリケーションが実行されます。

0. コンパイル

コンテキストに応じたDIとAOPの設定で、アプリケーションの実行に必要なルートオブジェクト$appを生成します。$appはroutertransferなどアプリケーションの実行に必要なサービスオブジェクトで構成されます。29 $appはシリアライズされ各リクエストで再利用されます。

  • router - 外部入力をリソースリクエストに変換
  • resource - リソースクライアント
  • transfer - 出力

1. リクエスト

リクエストに基づき、リソースオブジェクトが作成されます。

リクエストに応じて onGetonPost などに応答するメソッドを持つリソースオブジェクトは、自身のリソースの状態として code または body プロパティを設定します。

リソースオブジェクトのメソッドは、リソースの状態を変更するためだけのものであり、表現そのもの(HTML、JSONなど)には関心がありません。

メソッドの前後では、ログや認証などメソッドに束縛されたアプリケーションロジックがAOPで実行されます。

2. レスポンス

リソースに注入されたレンダラーがJSONやHTMLなどのリソースの状態表現を作りクライアントに転送します。 (REpresentational State Transfer=REST)

bootスクリプト

public/bin/等のエントリーポイントに設置され、アプリケーションを実行します。 スクリプトではアプリケーション実行コンテキストを指定して実行します。

require dirname(__DIR__) . '/autoload.php';
exit((new Bootstrap())('app', $GLOBALS, $_SERVER));

デフォルトではWebサーバースクリプトです。

php -S 127.0.0.1:8080 public/index.php

cli コンテキストを付加するとコンソールアプリーケーションのスクリプトになります。

exit((new Bootstrap())('cli-app', $GLOBALS, $_SERVER));
php bin/app.php get /user/1

コンテキスト

コンテキストは特定の目的のためのDIとAOPの束縛のセットです。コードは同じでも束縛が変わることで、アプリケーションが違う振る舞いをします。 コンテキストはフレームワークが用意しているbuilt-inコンテキストとアプリケーションが作成するカスタムコンテキストがあります。

built-inコンテキスト

  • app ベースアプリケーション
  • api APIアプリケーション
  • cli コンソールアプリケーション
  • hal HALアプリケーション
  • prod プロダクション

appの場合、リソースはJSONでレンダリングされます。 apiはデフォルトのリソースのスキーマをpageからappに変更します。webのルートアクセス(GET /)はpage://self/からapp://self/へのアクセスになります。 cliにするとコンソールアプリケーションになります。 prodはキャッシュの設定などをプロダクション用にします。

コンテキスト名はそれぞれのモジュールに対応します。例えばappはAppModule, cliはCliModuleに対応します。 コンテキストは組み合わせて使う事ができます。例えばprod-hal-api-appならプロダクション用HALのAPIアプリケーションなどになります。

カスタムコンテキスト

アプリケーションのsrc/Module/に設置します。built-inコンテキストと同名にするとカスタムコンテキストが優先されます。カスタムコンテキストからbuilt-inコンテキストを呼び出すことで一部の束縛を上書きする事ができます。

コンテキスト無知

コンテキストの値はルートオブジェクトの作成のみに使われその後に消滅します。アプリケーションから参照可能なグローバルな”モード”は存在せず、アプリケーションは現在実行されているコンテキストを知ることはできません。外部の値を参照して振る舞いを変えるのではなく、インターフェイスのみに依存30コンテキストによる束縛の変更で振る舞いを変更します。


モジュール

モジュールはDIとAOPの束縛のセットです。 BEAR.Sundayではいわゆる設定ファイルや、Configクラス、実行モードなどがありません。各コンポーネントが必要とする値は依存性の注入で与えられます。モジュールがその依存性束縛を行います。

起点となるモジュールがAppModule (src/Module/AppModule.php)です。 AppModuleで他の必要なモジュールをinstallします。

モジュールが必要とする値(ランタイムではなく、コンパイルタイムで必要な値)は手動のコンストラクタインジェクションで束縛を行います。

class AppModule extends AbstractAppModule
{
    /**
     * {@inheritdoc}
     */
    protected function configure()
    {
        // 追加モジュール
        $this->install(new AuraSqlModule('mysql:host=localhost;dbname=test', getenv('db_username'), getenv('db_password'));
        $this->install(new TwigModule));
        // package標準のモジュール
        $this->install(new PackageModule));
    }
}

DIの束縛

代表的な束縛を以下に記します。

// クラスの束縛
$this->bind($interface)->to($class);
// プロバイダー(ファクトリー)の束縛
$this->bind($interface)->toProvider($provider);
// インスタンス束縛
$this->bind($interface)->toInstance($instance);
// 名前付き束縛
$this->bind($interface)->annotatedWith($annotation)->to($class);
// シングルトン
$this->bind($interface)->to($class)->in(Scope::SINGLETON);
// コンストラクタ束縛
$this->bind($interface)->toConstructor($class, $named);

詳しくはDIをご覧ください。

AOPの設定

AOPはクラスとメソッドをMatcherで”検索”して、マッチするメソッドにインターセプターを束縛します。

$this->bindInterceptor(
    $this->matcher->any(),                   // どのクラスの
    $this->matcher->startsWith('delete'),    // "delete"で始まるメソッド名のメソッドには
    [Logger::class]                          // Loggerインターセプターを束縛
);

$this->bindInterceptor(
    $this->matcher->SubclassesOf(AdminPage::class),  // AdminPageの継承または実装クラスの
    $this->matcher->annotatedWith(Auth::class),      // @Authアノテーションがアノテートされているメソッドには
    [AdminAuthentication::class]                     // AdminAuthenticationインターセプターを束縛
);

詳しくはAOPをご覧ください。

束縛の優先順位

同じモジュール内

先に束縛した方が優先します。この場合はFoo1が優先されます。

$this->bind(FooInterface::class)->to(Foo1::class);
$this->bind(FooInterface::class)->to(Foo2::class);

モジュールインストール

先にインストールしたモジュールが優先します。この場合はFoo1Moduleが優先されます。

$this->install(new Foo1Module);
$this->install(new Foo2Module);

後からのモジュールを優先する場合にはoverrideを使います。この場合はFoo2Moduleが優先されます。

$this->install(new Foo1Module);
$this->override(new Foo2Module);

コンテキスト文字列

左のモジュールの束縛が優先されます。prod-hal-appならAppModuleよりHalModule、HalModuleよりProdModuleが優先してインストールされます。

DI

依存性の注入(Dependency Injection)とは、基本的にオブジェクトが必要とするオブジェクト(依存)を、オブジェクト自身に構築させるのではなく、オブジェクトに提供することです。

依存性の注入では、オブジェクトはそのコンストラクタで依存性を受け取ります。オブジェクトを構築するには、まずそのオブジェクトの依存関係を構築しますが、それぞれの依存を構築するためにはそのまた依存が必要、とその繰り返しになります。つまり、オブジェクトを構築するにはオブジェクトグラフを構築する必要があるのです。

オブジェクトグラフとは?
オブジェクト指向のアプリケーションは相互に関係のある複雑なオブジェクト網を持ちます。オブジェクトはあるオブジェクトから所有されているか、他のオブジェクト(またはそのリファレンス)を含んでいるか、そのどちらかでお互いに接続されています。このオブジェクト網をオブジェクトグラフと呼びます。- Wikipedia (en)

オブジェクトグラフを手作業で構築することは、労力がかかり、ミスが発生しやすく、テストが困難になります。その代わりに、Dependency Injector (Ray.Di) がオブジェクトグラフを構築します。

Ray.Diは、BEAR.Sundayで使用されているDIフレームワークで、Google Guiceに大きく影響されています。詳しくはRay.Diのマニュアルをご覧ください。

  1. 例えばECサイトであれば、商品一覧、カートに入れる、注文、支払い、などそれぞれのアプリケーションステートの遷移をテストで表します。  2 3 4

  2. この時PHP8.xでは名前付き引数で呼ばれますが、PHP7.xでは順序引数でコールされます。  2

  3. APIリクエストをJSONで送信する場合にはcontent-typeヘッダーにapplication/jsonをセットしてください。  2

  4. out-bound links 例)htmlは関連した他のhtmlにリンクを張ることができます。 

  5. embedded links 例)htmlは独立した画像リソースを埋め込むことができます。 

  6. DIで依存関係のツリーがグラフになっているオブジェクトグラフと同様です。 

  7. query-locaterはSQLをファイルとして扱うライブラリです。Aura.Sqlと共に使うと便利です。 

  8. JavaのDBアクセスフレームワークDomaと仕組みが似ています。 

  9. 識別子(クオリファイアー)についてはRay.Diのマニュアルの束縛アトリビュートをご覧ください。  2

  10. 以前のバージョン0.5までは次のようにSQLファイル名で判別していました。”SQL実行の戻り値が単一行ならitem、複数行ならlistのpostfixを付けます。” 

  11. 参考モジュール Koriym.DbAppPackage  2 3 4

  12. PHPStorm データベースツールおよび SQL 2

  13. 名前はSmalltalkのフレームワーク Seasideの同様の機能が由来しています。 

  14. koriym/now 

  15. Web APIなど外部のシステムの値を利用する時には、クライアントクラスやWeb APIアクセスリソースなど1つにの場所に集中させDIやAOPでモッキングが容易にするようにします。 

  16. ResourceInjectなどのインジェクション用トレイトはインジェクションのボイラープレートコードを削減するために存在しましたが、PHP8で追加されたコンストラクタの引数をプロパティへ昇格させる機能により意味を失いました。コンストラクタインジェクションを使いましょう。 

  17. SpyModuleの利用にはray/test-doubleのインストールが必要です。 

  18. このSQLはSQLスタイルガイド に準拠しています。 PhpStormからはJoe Celkoとして設定できます。 

  19. データベース図などでクエリプランや実行計画を確認し、作成するSQLの質を高めます。 

  20. PHP 8.0+ 名前付き引数 ¶、PHP7.xの場合にはコラムの順番になります。 

  21. ここでは例としてmysqlから直接実行していますが、マイグレーションツールでseedを入力したりIDEのDBツールの利用方法も学びましょう。 

  22. Ray.MediaQueryはHTTP APIリクエストにも対応しています。 

  23. このようなコンテンツの階層構造の事を、IA(インフォメーションアーキテクチャ)ではタクソノミーと呼びます。Understanding Information Architecture参照 

  24. Ray.MediaQuery README 

  25. 広く誤解されていますが統一インターフェイスはHTTPメソッドの事ではありません。Uniform Interface参照 

  26. いわゆる”Restish API”。REST APIと紹介されている多くのAPIはこのURI/オブジェクトスタイルで、RESTが誤用されています。 

  27. チュートリアルからリンクを取り除けばURIスタイルになります。 

  28. https://www.iana.org/assignments/media-types/media-types.xhtml 

  29. オブジェクトは他のオブジェクトを保持しているか、保持されているかによって繋がっています。これを[Object Graph] (http://en.wikipedia.org/wiki/Object_graph)といいます。$appはそのルートオブジェクトです。 

  30. 依存性逆転の法則