これは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のリソースはクライアントから直接アクセスが可能です。
-
CLIツール: リソースをコマンドラインツールとして利用することができます。Homebrewを通じて配布可能で、PHPやBEAR.Sundayの知識がなくても通常のUNIXコマンドとして利用できます。アプリケーションのバージョン管理とは独立して、安定したCLIツールを提供することが可能です。
-
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
では以下のような指定も可能です:
- Matcher::any - 無制限
- Matcher::annotatedWith - アノテーション
- Matcher::subclassesOf - 継承または実装されたクラス
- Matcher::startsWith - 名前の始めの文字列
- Matcher::logicalOr - OR条件
- Matcher::logicalAnd - AND条件
- Matcher::logicalNot - NOT条件
インターセプターに渡されるMethodInvocation
では、対象のメソッド実行に関連するオブジェクトやメソッド、引数にアクセスすることができます。
- MethodInvocation::proceed - 対象メソッド実行
- MethodInvocation::getMethod - 対象メソッドリフレクションの取得
- MethodInvocation::getThis - 対象オブジェクトの取得
- MethodInvocation::getArguments - 呼び出し引数配列の取得
リフレクションのメソッドでアノテーションを取得することができます。
$method = $invocation->getMethod();
$class = $invocation->getMethod()->getDeclaringClass();
$method->getAnnotations()
- メソッドアノテーションの取得$method->getAnnotation($name)
$class->getAnnotations()
- クラスアノテーションの取得$class->getAnnotation($name)
カスタムマッチャー
独自のカスタムマッチャーを作成するには、AbstractMatcher
のmatchesClass
とmatchesMethod
を実装したクラスを作成します。
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リクエストではonGet
、onPost
メソッドの引数にはそれぞれ$_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 __construct(
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/json
とx-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]
他のリソースの状態を変えるときに#[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',
),
),
),
// ...
レンダリングと転送
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コンテナとは異なる点です。
- オブジェクトの依存関係をグラフで可視化できます。例)ルートオブジェクト
アスペクト指向プログラミング (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クエリの動作を直接観察し、問題の特定と修正を迅速に行うことができます。
他システムとの統合
BEAR.Sundayのリソースは様々なインターフェースから利用可能です。Webインターフェースに加え、コンソールからリソースに直接アクセスでき、ソースコードを変えずにWebとコマンドライン双方から同じリソースを利用できます。さらにBEAR.CLIを使用することで、リソースを独立したUNIXコマンドとして配布することも可能です。また、同一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がライブラリを含まないという方針により、開発者はコンポーネントの選択において高い柔軟性と自由度を得られます。
-
テストの容易性: DI(依存性の注入)とROA(リソース指向アーキテクチャ)の採用により、効果的かつ効率的なテストの実施が可能です。
ユーザーにとっての価値
-
高いパフォーマンス: 最適化された高速起動とCDNを中心としたキャッシュ戦略により、ユーザーには高速で応答性の優れたエクスペリエンスが提供されます。
-
信頼性と可用性: CDNを中心としたキャッシュ戦略により、単一障害点(SPOF)を最小化し、ユーザーに安定したサービスを提供し続けることができます。
-
使いやすさ: 優れた接続性により、他の言語やシステムとの円滑な連携が実現します。また、リソースをCLIツールとして提供することで、エンドユーザーは複雑な環境設定なしにアプリケーションの機能を利用できます。
ビジネスにとっての価値
-
開発コストの削減: 一貫性のあるガイドラインと構造の提供により、持続的で効率的な開発プロセスを実現し、開発コストを抑制します。
-
保守コストの削減: 後方互換性を重視するアプローチにより、技術的な継続性を高め、変更対応にかかる時間とコストを最小限に抑えます。
-
高い拡張性: DI(依存性の注入)やAOP(アスペクト指向プログラミング)などの技術により、コードの変更を最小限に抑えながら振る舞いを変更でき、ビジネスの成長や変化に合わせて柔軟にアプリケーションを拡張できます。
-
優れたユーザーエクスペリエンス(UX): 高いパフォーマンスと可用性の提供により、ユーザー満足度を向上させ、顧客ロイヤリティの強化と顧客基盤の拡大を通じて、ビジネスの成功に貢献します。
まとめ
優れた制約は不変です。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¶m2=twoapplication/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}/comment
はBlog\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.AuraRouterModuleを参考にRouterInterfaceを実装します。
プロダクション
BEAR.Sunday既定のprod
束縛に対して、アプリケーションがそれぞれのディプロイ環境に応じたモジュールをカスタマイズして束縛を行います。
既定のProdModule
既定のprod
束縛では以下のインターフェイスの束縛がされています。
- エラーページ生成ファクトリー
- PSRロガーインターフェイス
- ローカルキャッシュ
- 分散キャッシュ
詳細はBEAR.PackageのProdModule.php参照。
アプリケーションのProdModule
既定のProdModuleに対してアプリケーションのProdModule
をsrc/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サーバーでサービスを行うためには分散キャッシュの構成が必要です。代表的なmemcached、Redisのキャッシュエンジンのそれぞれのモジュールが用意されています。
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
などで上書きするのはキャッシュやオンデマンドで生成されるファイルの不一致や、高負荷のサイトではキャパシティを超えるリスクがあります。安全のために別のディレクトリでセットアップを行い、そのセットアップが成功すれば切り替えるようにします。 - DeployerのBEAR.Sundayレシピを利用することができます。
クラウドにディプロイする時には
- コンパイルが成功すると0、依存関係の問題を見つけるとコンパイラはexitコード1を出力します。それを利用してCIにコンパイルを組み込むことを推奨します。
コンパイル
推奨セットアップを行う際にvendor/bin/bear.compile
スクリプトを使ってプロジェクトをウォームアップすることができます。コンパイルスクリプトはDI/AOP用の動的に作成されるファイルやアノテーションなどの静的なキャッシュファイルを全て事前に作成し、最適化されたautoload.phpファイルとpreload.phpを出力します。
- コンパイルをすれば全てのクラスでインジェクションを行うのでランタイムでDIのエラーが出る可能性が極めて低くなります。
.env
には含まれた内容はPHPファイルに取り込まれるのでコンパイル後に.env
を消去可能です。コンテントネゴシエーションを行う場合など(例: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.preload、opcache.preload_userを指定する必要があります。
PHP 7.4でサポートされた機能ですが、7.4
初期のバージョンでは不安定です。7.4.4
以上の最新版を使いましょう。
例)
opcache.preload=/path/to/project/preload.php
opcache.preload_user=www-data
Note: パフォーマンスベンチマークはbenchmarkを参考にしてください。
.compile.php
実環境ではないと生成ができないクラス(例えば認証が成功しないとインジェクトが完了しないResourceObject)がある場合には、コンパイル時にのみ読み込まれるダミークラス読み込みをルートの.compile.php
に記述することによってコンパイルをすることができます。
.compile.php
<?php
require __DIR__ . '/tests/Null/AuthProvider.php'; // 常に生成可能なNullオブジェクト
$_SERVER[__REQUIRED_KEY__] = 'fake';
module.dot
コンパイルをすると”dotファイル”が出力されるのでgraphvizで画像ファイルに変換するか、GraphvizOnlineを利用すればオブジェクトグラフを表示することができます。スケルトンのオブジェクトグラフもご覧ください。
dot -T svg module.dot > module.svg
ブートストラップのパフォーマンスチューニング
immutable_cacheは、不変の値を共有メモリにキャッシュするためのPECLパッケージです。APCuをベースにしていますが、PHPのオブジェクトや配列などの不変の値を共有メモリに保存するため、APCuよりも高速です。また、APCuでもimmutable_cacheでも、PECLのIgbinaryをインストールすることでメモリ使用量が減り、さらなる高速化が期待できます。
現在、専用のキャッシュアダプターなどは用意されていません。ImmutableBootstrapを参考に、専用のBootstrapを作成し呼び出してください。初期化コストを最小限に抑え、最大のパフォーマンスを得ることができます。
php.ini
// エクステンション
extension="apcu.so"
extension="immutable_cache.so"
extension="igbinary.so"
// シリアライザーの指定
apc.serializer=igbinary
immutable_cache.serializer=igbinary
インポート
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());
}
}
ImportAppModule
はBEAR\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);
$weekday = $resource->get('/weekday', ['year' => '2022', 'month' => '1', 'day' => 1]);
echo $weekday->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.json
でautoload
のセクションにAcme\\Blog
を追加します。
"autoload": {
"psr-4": {
"MyVendor\\Weekday\\": "src/",
"Acme\\Blog\\": "my-vendor/Acme.Blog/src/"
}
},
autoload
をダンプします。
composer dump-autoload
これでAcme\Blog
アプリケーションが配置できました。
次にアプリケーションをインポートするためにsrc/Module/AppModule.php
でImportAppModule
を上書き(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
リソースのgreeting
がblog
に代入されているはずです。@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のための独立ライブラリです。
- PDOをextendしたExtendedPdo (Aura.sql)
- クエリービルダー (Aura.SqlQuery)
- PHPのインターフェイスとSQL実行を束縛 (Ray.MediaQuery)
静的なSQLはファイルにすると7、管理や他のSQLツールでの検証などの使い勝手もよくなります。Aura.SqlQueryは動的にクエリーを組み立てることができますが、その他は基本静的なSQLの実行のためのライブラリです。また、Ray.MediaQueryではSQLの一部をビルダーで組み立てたものに入れ替えることもできます。
モジュール
必要なライブラリに応じたモジュールをインストールします。
Ray.AuraSqlModule
はAura.Sqlと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.php
でAuraSqlModule
をインストールします。
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
$queries // 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.AuraSqlModule
はAura.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 = ['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()はfetchAssoc()のような動作ですが、値は配列にラップされません。
// 代わりに、単一カラムの値は1次元配列として、
// 複数カラムは配列の配列として返されます。
// 値が配列の場合(つまり、SELECTに2つ以上のカラムがある場合)は、
// スタイルをPDO::FETCH_NAMEDに設定します。
$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) {
// 行にカラムを追加
$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 = ['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 __construct(
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 extends 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);
// または
// $result = $this->pdo->fetchAssoc($stm, $bind);
}
}
組み立てたクエリーはgetStatement()
で文字列にしてクエリーを行います。
INSERT
単一行のINSERT
class User extends ResourceObject
{
public function __construct(
private readonly ExtendedPdoInterface $pdo,
private readonly InsertInterface $insert
) {}
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());
// または
// $sth = $this->pdo->perform($this->insert->getStatement(), $this->insert->getBindValues());
// get the last insert ID
$name = $this->insert->getLastInsertIdName('id');
$id = $this->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()
メソッドを使います。その後に次のクエリーを組み立てます。
// テーブルの指定
$this->insert->into('foo');
// 1行目のセットアップ
$this->insert->cols([
'bar' => 'bar-0',
'baz' => 'baz-0'
]);
$this->insert->set('ts', 'NOW()');
// 2行目のセットアップ
// ここでのカラムの順序は1行目と異なりますが、問題ありません。
// INSERTオブジェクトが最初の行と同じ順序で構築します。
$this->insert->addRow();
$this->insert->set('ts', 'NOW()');
$this->insert->cols([
'bar' => 'bar-1',
'baz' => 'baz-1'
]);
// さらに行を追加...
$this->insert->addRow();
// ...
// 全ての行を一度にインサート
$sth = $this->pdo->prepare($insert->getStatement());
$sth->execute($insert->getBindValues());
注: 最初の行で初めて現れた列の値を指定しないで行を追加しようとすると例外が投げられます。
addRow()
に列の連想配列を渡すと次の行で使われます。つまり最初の行でcol()
やcols()
を指定しないこともできます。
// 1行目のセットアップ
$insert->addRow([
'bar' => 'bar-0',
'baz' => 'baz-0'
]);
$insert->set('ts', 'NOW()');
// 2行目のセットアップ
$insert->addRow([
'bar' => 'bar-1',
'baz' => 'baz-1'
]);
$insert->set('ts', 'NOW()');
// など
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 "SET ts = 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($this->update->getStatement());
$sth->execute($this->update->getBindValues());
// または
// $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, // 10 items per page
'/?page={page}&category=sports'
);
$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は、DbQueryConfig
やWebQueryConfig
、またはその両方の設定で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.sql
SQL文に['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;
}
プロパティをキャメルケースに変換する場合にはCamelCaseTrait
を使います。
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'
のアトリビュートを指定します。ただし、インターフェイスの戻り値がエンティティクラスなら省略することができます。
/** 返り値が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 $createdAt = 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
以外のバリューオブジェクトが渡されるとToScalarInterface
を実装したtoScalar()
メソッド、もしくは__toString()
メソッドの返り値が引数になります。
interface MemoAddInterface
{
public function __invoke(string $memo, UserId $userId = null): void;
}
class UserId implements ToScalarInterface
{
public function __construct(
private readonly 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; // 1ページあたりの最大件数
// (string) $page // ページャー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も利用できます。
パラメーターインジェクションと同様、DateTimeInterface
オブジェクトを渡すと日付フォーマットされた文字列に変換されます。
$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.Sql
、Doctrine DBAL
, CakeDB
などのモジュールが用意されています。
Aura.Sql
Aura.SqlはPHPのPDOを拡張したデータベースライブラリです。
インストール
composerでRay.AuraSqlModule
をインストールします。
composer require ray/aura-sql-module
アプリケーションモジュールsrc/Module/AppModule.php
でAuraSqlModule
をインストールします。
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.AuraSqlModule
はAura.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);
}
}
バリデーション
- BEAR.SundayのバリデーションはJSONスキーマで行います。
- Webフォームによるバリデーションはフォームをご覧ください。
JSONスキーマによるバリデーション
概要
JSON Schemaを使用して、リソースAPIの入出力仕様を定義し検証することができます。 これにより、APIの仕様を人間とマシンの両方が理解できる形式で管理できます。またApiDocとしてAPIドキュメントを出力することもできます。
セットアップ
モジュールの設定
バリデーションの適用範囲に応じて、以下のいずれかの方法で設定します:
- すべての環境でバリデーションを行う場合:
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' // バリデーション用
)
);
}
}
2. 必要なディレクトリの作成
mkdir -p var/json_schema
mkdir -p var/json_validate
基本的な使用方法
1. リソースクラスの定義
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;
}
}
2. 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
パラメータで指定します:
class User extends ResourceObject
{
#[JsonSchema(key: 'user', schema: 'user.json')]
public function onGet(): static
{
$this->body = [
'user' => [
'firstName' => 'mucha',
'lastName' => 'alfons',
'age' => 12
]
];
return $this;
}
}
引数のバリデーション
メソッドの引数をバリデーションする場合、params
パラメータでスキーマを指定します:
class Todo extends ResourceObject
{
#[JsonSchema(
key: 'user',
schema: 'user.json',
params: 'todo.post.json'
)]
public function onPost(string $title)
{
// メソッドの処理
}
}
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')]
スキーマ作成支援ツール
JSONスキーマの作成には以下のツールが便利です:
これは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のリソースはクライアントから直接アクセスが可能です。
-
CLIツール: リソースをコマンドラインツールとして利用することができます。Homebrewを通じて配布可能で、PHPやBEAR.Sundayの知識がなくても通常のUNIXコマンドとして利用できます。アプリケーションのバージョン管理とは独立して、安定したCLIツールを提供することが可能です。
-
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
では以下のような指定も可能です:
- Matcher::any - 無制限
- Matcher::annotatedWith - アノテーション
- Matcher::subclassesOf - 継承または実装されたクラス
- Matcher::startsWith - 名前の始めの文字列
- Matcher::logicalOr - OR条件
- Matcher::logicalAnd - AND条件
- Matcher::logicalNot - NOT条件
インターセプターに渡されるMethodInvocation
では、対象のメソッド実行に関連するオブジェクトやメソッド、引数にアクセスすることができます。
- MethodInvocation::proceed - 対象メソッド実行
- MethodInvocation::getMethod - 対象メソッドリフレクションの取得
- MethodInvocation::getThis - 対象オブジェクトの取得
- MethodInvocation::getArguments - 呼び出し引数配列の取得
リフレクションのメソッドでアノテーションを取得することができます。
$method = $invocation->getMethod();
$class = $invocation->getMethod()->getDeclaringClass();
$method->getAnnotations()
- メソッドアノテーションの取得$method->getAnnotation($name)
$class->getAnnotations()
- クラスアノテーションの取得$class->getAnnotation($name)
カスタムマッチャー
独自のカスタムマッチャーを作成するには、AbstractMatcher
のmatchesClass
とmatchesMethod
を実装したクラスを作成します。
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リクエストではonGet
、onPost
メソッドの引数にはそれぞれ$_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 __construct(
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/json
とx-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]
他のリソースの状態を変えるときに#[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',
),
),
),
// ...
レンダリングと転送
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コンテナとは異なる点です。
- オブジェクトの依存関係をグラフで可視化できます。例)ルートオブジェクト
アスペクト指向プログラミング (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クエリの動作を直接観察し、問題の特定と修正を迅速に行うことができます。
他システムとの統合
BEAR.Sundayのリソースは様々なインターフェースから利用可能です。Webインターフェースに加え、コンソールからリソースに直接アクセスでき、ソースコードを変えずにWebとコマンドライン双方から同じリソースを利用できます。さらにBEAR.CLIを使用することで、リソースを独立したUNIXコマンドとして配布することも可能です。また、同一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がライブラリを含まないという方針により、開発者はコンポーネントの選択において高い柔軟性と自由度を得られます。
-
テストの容易性: DI(依存性の注入)とROA(リソース指向アーキテクチャ)の採用により、効果的かつ効率的なテストの実施が可能です。
ユーザーにとっての価値
-
高いパフォーマンス: 最適化された高速起動とCDNを中心としたキャッシュ戦略により、ユーザーには高速で応答性の優れたエクスペリエンスが提供されます。
-
信頼性と可用性: CDNを中心としたキャッシュ戦略により、単一障害点(SPOF)を最小化し、ユーザーに安定したサービスを提供し続けることができます。
-
使いやすさ: 優れた接続性により、他の言語やシステムとの円滑な連携が実現します。また、リソースをCLIツールとして提供することで、エンドユーザーは複雑な環境設定なしにアプリケーションの機能を利用できます。
ビジネスにとっての価値
-
開発コストの削減: 一貫性のあるガイドラインと構造の提供により、持続的で効率的な開発プロセスを実現し、開発コストを抑制します。
-
保守コストの削減: 後方互換性を重視するアプローチにより、技術的な継続性を高め、変更対応にかかる時間とコストを最小限に抑えます。
-
高い拡張性: DI(依存性の注入)やAOP(アスペクト指向プログラミング)などの技術により、コードの変更を最小限に抑えながら振る舞いを変更でき、ビジネスの成長や変化に合わせて柔軟にアプリケーションを拡張できます。
-
優れたユーザーエクスペリエンス(UX): 高いパフォーマンスと可用性の提供により、ユーザー満足度を向上させ、顧客ロイヤリティの強化と顧客基盤の拡大を通じて、ビジネスの成功に貢献します。
まとめ
優れた制約は不変です。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メソッド | 渡される値 |
PHP-7.4.4)を参考にしてください。 |
.compile.php
実環境ではないと生成ができないクラス(例えば認証が成功しないとインジェクトが完了しないResourceObject)がある場合には、コンパイル時にのみ読み込まれるダミークラス読み込みをルートの.compile.php
に記述することによってコンパイルをすることができます。
.compile.php
<?php
require __DIR__ . '/tests/Null/AuthProvider.php'; // 常に生成可能なNullオブジェクト
$_SERVER[__REQUIRED_KEY__] = 'fake';
module.dot
コンパイルをすると”dotファイル”が出力されるのでgraphvizで画像ファイルに変換するか、GraphvizOnlineを利用すればオブジェクトグラフを表示することができます。スケルトンのオブジェクトグラフもご覧ください。
dot -T svg module.dot > module.svg
ブートストラップのパフォーマンスチューニング
immutable_cacheは、不変の値を共有メモリにキャッシュするためのPECLパッケージです。APCuをベースにしていますが、PHPのオブジェクトや配列などの不変の値を共有メモリに保存するため、APCuよりも高速です。また、APCuでもimmutable_cacheでも、PECLのIgbinaryをインストールすることでメモリ使用量が減り、さらなる高速化が期待できます。
現在、専用のキャッシュアダプターなどは用意されていません。ImmutableBootstrapを参考に、専用のBootstrapを作成し呼び出してください。初期化コストを最小限に抑え、最大のパフォーマンスを得ることができます。
php.ini
// エクステンション
extension="apcu.so"
extension="immutable_cache.so"
extension="igbinary.so"
// シリアライザーの指定
apc.serializer=igbinary
immutable_cache.serializer=igbinary
インポート
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());
}
}
ImportAppModule
はBEAR\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);
$weekday = $resource->get('/weekday', ['year' => '2022', 'month' => '1', 'day' => 1]);
echo $weekday->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.json
でautoload
のセクションにAcme\\Blog
を追加します。
"autoload": {
"psr-4": {
"MyVendor\\Weekday\\": "src/",
"Acme\\Blog\\": "my-vendor/Acme.Blog/src/"
}
},
autoload
をダンプします。
composer dump-autoload
これでAcme\Blog
アプリケーションが配置できました。
次にアプリケーションをインポートするためにsrc/Module/AppModule.php
でImportAppModule
を上書き(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
リソースのgreeting
がblog
に代入されているはずです。@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のための独立ライブラリです。
- PDOをextendしたExtendedPdo (Aura.sql)
- クエリービルダー (Aura.SqlQuery)
- PHPのインターフェイスとSQL実行を束縛 (Ray.MediaQuery)
静的なSQLはファイルにすると7、管理や他のSQLツールでの検証などの使い勝手もよくなります。Aura.SqlQueryは動的にクエリーを組み立てることができますが、その他は基本静的なSQLの実行のためのライブラリです。また、Ray.MediaQueryではSQLの一部をビルダーで組み立てたものに入れ替えることもできます。
モジュール
必要なライブラリに応じたモジュールをインストールします。
Ray.AuraSqlModule
はAura.Sqlと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.php
でAuraSqlModule
をインストールします。
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
$queries // 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.AuraSqlModule
はAura.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 = ['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()はfetchAssoc()のような動作ですが、値は配列にラップされません。
// 代わりに、単一カラムの値は1次元配列として、
// 複数カラムは配列の配列として返されます。
// 値が配列の場合(つまり、SELECTに2つ以上のカラムがある場合)は、
// スタイルをPDO::FETCH_NAMEDに設定します。
$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) {
// 行にカラムを追加
$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 = ['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 __construct(
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 extends 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);
// または
// $result = $this->pdo->fetchAssoc($stm, $bind);
}
}
組み立てたクエリーはgetStatement()
で文字列にしてクエリーを行います。
INSERT
単一行のINSERT
class User extends ResourceObject
{
public function __construct(
private readonly ExtendedPdoInterface $pdo,
private readonly InsertInterface $insert
) {}
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());
// または
// $sth = $this->pdo->perform($this->insert->getStatement(), $this->insert->getBindValues());
// get the last insert ID
$name = $this->insert->getLastInsertIdName('id');
$id = $this->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()
メソッドを使います。その後に次のクエリーを組み立てます。
// テーブルの指定
$this->insert->into('foo');
// 1行目のセットアップ
$this->insert->cols([
'bar' => 'bar-0',
'baz' => 'baz-0'
]);
$this->insert->set('ts', 'NOW()');
// 2行目のセットアップ
// ここでのカラムの順序は1行目と異なりますが、問題ありません。
// INSERTオブジェクトが最初の行と同じ順序で構築します。
$this->insert->addRow();
$this->insert->set('ts', 'NOW()');
$this->insert->cols([
'bar' => 'bar-1',
'baz' => 'baz-1'
]);
// さらに行を追加...
$this->insert->addRow();
// ...
// 全ての行を一度にインサート
$sth = $this->pdo->prepare($insert->getStatement());
$sth->execute($insert->getBindValues());
注: 最初の行で初めて現れた列の値を指定しないで行を追加しようとすると例外が投げられます。
addRow()
に列の連想配列を渡すと次の行で使われます。つまり最初の行でcol()
やcols()
を指定しないこともできます。
// 1行目のセットアップ
$insert->addRow([
'bar' => 'bar-0',
'baz' => 'baz-0'
]);
$insert->set('ts', 'NOW()');
// 2行目のセットアップ
$insert->addRow([
'bar' => 'bar-1',
'baz' => 'baz-1'
]);
$insert->set('ts', 'NOW()');
// など
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 "SET ts = 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($this->update->getStatement());
$sth->execute($this->update->getBindValues());
// または
// $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, // 10 items per page
'/?page={page}&category=sports'
);
$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は、DbQueryConfig
やWebQueryConfig
、またはその両方の設定で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.sql
SQL文に['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;
}
プロパティをキャメルケースに変換する場合にはCamelCaseTrait
を使います。
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'
のアトリビュートを指定します。ただし、インターフェイスの戻り値がエンティティクラスなら省略することができます。
/** 返り値が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 $createdAt = 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
以外のバリューオブジェクトが渡されるとToScalarInterface
を実装したtoScalar()
メソッド、もしくは__toString()
メソッドの返り値が引数になります。
interface MemoAddInterface
{
public function __invoke(string $memo, UserId $userId = null): void;
}
class UserId implements ToScalarInterface
{
public function __construct(
private readonly 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; // 1ページあたりの最大件数
// (string) $page // ページャー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も利用できます。
パラメーターインジェクションと同様、DateTimeInterface
オブジェクトを渡すと日付フォーマットされた文字列に変換されます。
$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.Sql
、Doctrine DBAL
, CakeDB
などのモジュールが用意されています。
Aura.Sql
Aura.SqlはPHPのPDOを拡張したデータベースライブラリです。
インストール
composerでRay.AuraSqlModule
をインストールします。
composer require ray/aura-sql-module
アプリケーションモジュールsrc/Module/AppModule.php
でAuraSqlModule
をインストールします。
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.AuraSqlModule
はAura.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);
}
}
バージョン
サポートするPHP
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)
Semver
BEAR.Sundayはセマンティックバージョニングに従います。マイナーバージョンアップ(バージョン番号が0.1
増加)ではアプリケーションコードの修正は不要です。
バージョニング・ポリシー
- フレームワークのコアパッケージは破壊的変更を行いません。10
- PHPのサポート要件が変更され、必要なPHPバージョンが上がっても(例:
5.6
→7.0
)、フレームワークのメジャーバージョンアップは行いません。後方互換性は維持されます。 - 新しいモジュールの導入によりPHPバージョンの要件が上がることはありますが、それに伴う破壊的変更は行いません。
- 後方互換性維持のため、古い機能は削除せず11、新機能は既存機能の置き換えではなく追加として実装されます。
BEAR.Sundayは堅牢で進化可能1な、長期的な保守性を重視したフレームワークを目指しています。
パッケージのバージョン
フレームワークは依存ライブラリのバージョンを固定しません。ライブラリはフレームワークのバージョンに関係なくアップデート可能です。composer update
による定期的な依存関係の更新を推奨します。
HTML
BEAR.Sundayでは、複数のテンプレートエンジンを活用してHTML表示を実現できます。
テンプレートエンジンの選択
対応テンプレートエンジン
特徴比較
機能 | Qiq | Twig |
---|---|---|
エスケープ方式 | 明示的 | 暗黙的 |
構文 | PHP準拠 | 独自構文 |
コードベース | 軽量 | 豊富な機能 |
IDE対応 | 優れている | 一般的 |
構文比較
PHP:
<?= $var ?>
<?= htmlspecialchars($var, ENT_QUOTES|ENT_DISALLOWED, 'utf-8') ?>
<?= htmlspecialchars(helper($var, ENT_QUOTES|ENT_DISALLOWED, 'utf-8')) ?>
<?php foreach ($users as $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 }} // 表示されない
Or
<?php /** @var Template $this */ ?>
<?= $this->h($var) ?>
レンダラー
RenderInterface
にバインドされResourceObjectにインジェクトされるレンダラーがリソースの表現を生成します。リソース自身はその表現に関して無関心です。
リソース単位でインジェクトされるため、複数のテンプレートエンジンを同時に使用することも可能です。
開発用のハローUI
開発時にハロー(Halo, 後光) 12 と呼ばれる開発用の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
をインストールします: ```php 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`を有効にします:
```bash
$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のautoload
にQiq\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.php
やpublic/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'
];
}
var/templates/Page/Index.twig.php
:
<h1></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>reference number: {{ logref }}</p>
{% endif %}
{% endblock %}
リソースのアサイン
リソースクラスのプロパティを参照するにはリソース全体がアサインされる_ro
を参照します。
例)Todos.php
:
class Todos extends 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 }}
{% 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 は元の \Twig_Environment インスタンス
$this->twig = $twig;
}
public function get()
{
// 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.InputとAura.Filterを使用したWebフォーム機能は、関連する機能が単一のクラスに集約され、テストや変更が容易です。1つのクラスでWebフォームとバリデーションの両方の用途に使用できます。
インストール
Aura.Inputを使用したフォーム処理を追加するために、composerでray/web-form-module
をインストールします:
composer require ray/web-form-module
アプリケーションモジュールsrc/Module/AppModule.php
でAuraInputModule
をインストールします:
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()
{
// フォームフィールドの設定
$this->setField('name', 'text')
->setAttribs([
'id' => 'name'
]);
// バリデーションルールとエラーメッセージの設定
$this->filter->validate('name')->is('alnum');
$this->filter->useFieldMessage('name', '名前は英数字のみ使用できます。');
}
}
フォームクラスのinit()
メソッドでフォームのinput要素を登録し、バリデーションのフィルターやサニタイズのルールを適用します。
バリデーションルールについては以下を参照してください:
メソッドの引数を連想配列にしたものをバリデーションします。入力を変更したい場合は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)
{
// バリデーション成功時の処理
}
public function onPostValidationFailed($name, $age)
{
// バリデーション失敗時の処理
}
}
@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自体から発見することができます。
Links
以下は有効なHALの例です。自身(self
)のURIへのリンクを持っています:
{
"_links": {
"self": { "href": "/user" }
}
}
Link Relations
リンクにはrel
(relation)があり、どのような関係でリンクされているかを表します。HTMLの<link>
タグや<a>
タグで使われるrel
と同様です:
{
"_links": {
"next": { "href": "/page=2" }
}
}
HALについてさらに詳しくはhttp://stateless.co/hal_specification.htmlをご覧ください。
リソースクラス
アノテーションを使用してリンクを貼ったり、他のリソースを埋め込んだりすることができます。
#[Link]
リンクが静的なものは#[Link]
属性で表し、動的なものはbody['_links']
に代入します。宣言的に記述できる#[Link]
属性の使用を推奨します:
#[Link(rel="user", href="/user")]
#[Link(rel="latest-post", href="/latest-post", title="latest post entry")]
public function onGet()
または:
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
または:
$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\MyProject\Resource\Page\Rels;
use BEAR\ApiDoc\ApiDoc;
class Index extends ApiDoc
{
}
JSON Schemaのフォルダをwebに公開します:
ln -s var/json_schema public/schemas
DocblockコメントとJSON Schemaを使ってAPIドキュメントが自動生成されます。ページクラスは独自のレンダラーを持ち、$context
の影響を受けずに人のためのドキュメント(text/html
)をサービスします。
$context
の影響を受けないため、App
、Page
どちらにも設置可能です。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
PSR-7 HTTP message interface10を使って、サーバーサイドリクエストの情報を取得したり、BEAR.SundayアプリケーションをPSR-7ミドルウェアとして実行したりすることができます。
HTTPリクエスト
PHPには$_SERVER
や$_COOKIE
などのスーパーグローバルがありますが、それらの代わりにPSR-7 HTTP message interfaceを使ってサーバーサイドリクエストの情報($_COOKIE
、$_GET
、$_POST
、$_FILES
、$_SERVER
)を受け取ることができます。
ServerRequest(サーバーリクエスト全般)
class Index extends ResourceObject
{
public function __construct(ServerRequestInterface $serverRequest)
{
// クッキーの取得
$cookie = $serverRequest->getCookieParams(); // $_COOKIE
}
}
アップロードファイル
use Psr\Http\Message\UploadedFileInterface;
use Ray\HttpMessage\Annotation\UploadFiles;
class Index extends ResourceObject
{
/**
* @UploadFiles
*/
public function __construct(array $files)
{
// ファイル名の取得
$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)
{
// ホスト名の取得
$host = $uri->getHost();
}
}
PSR-7ミドルウェア
既存の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の提供を行い、JavaScriptがUIをレンダリングします。既存のプロジェクトの構造で、アノテーションが付与されたリソースのみに適用されるため、導入が容易です。
前提条件
注:V8Jsがインストールされていない場合、Node.jsでJavaScriptが実行されます。
用語
- 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ページからレンダリング方法を選択して、JavaScriptアプリケーションを実行します:
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側が必要とするのは、バンドルされて出力されたJavaScriptファイルのみです。
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
フォルダはJavaScriptファイルがあるディレクトリです(ui/ui.config.js
で指定するwebpackの出力先)。
@Ssrアノテーション
リソースをSSRするメソッドに@Ssr
とアノテートします。app
にJavaScriptアプリケーション名を指定する必要があります:
<?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->body
がrender
関数の第1引数として渡されます。
CSRとSSRの値を区別して渡したい場合は、state
とmetas
で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;
}
実際にstate
とmetas
をどのように渡してSSRを実現するかは、ui/src/page/index/server
のサンプルアプリケーションをご覧ください。
影響を受けるのはアノテートしたメソッドだけで、APIやHTMLのレンダリングの設定はそのままです。
PHPアプリケーションの実行設定
ui/ui.config.js
を編集して、public
にWeb公開ディレクトリを、build
にwebpackのビルド先を指定します。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
を実行します。
lint
やtest
などの他のコマンドについては、コマンドをご覧ください。
パフォーマンス
V8のスナップショットをAPCuに保存する機能を使って、パフォーマンスの大幅な向上が可能です。ProdModule
でApcSsrModule
をインストールしてください。ReactやアプリケーションのスナップショットがAPCu
に保存され再利用されます。V8Jsが必要です:
$this->install(new ApcSsrModule);
APCu以外のキャッシュを利用するには、ApcSsrModule
のコードを参考にモジュールを作成してください。PSR-16対応のキャッシュが利用可能です。
さらなる高速化のためには、V8をコンパイルする時点でJavaScriptコード(Reactなど)のスナップショットを取り込みます。詳しくは以下をご覧ください:
- 20x performance boost with V8Js snapshots
- v8js - Possibility to Improve Performance with Precompiled Templates/Classes?
デバッグ
- ChromeプラグインReact Developer Tools、Redux DevToolsが利用できます。
- 500エラーが返ってくる場合は、
var/log
やcurl
でアクセスしてレスポンスの詳細を確認してみましょう。
リファレンス
- ECMAScript 6
- Airbnb JavaScript スタイルガイド
- React
- Redux
- Redux GitHub
- Redux DevTools
- Karma テストランナー
- Mocha テストフレームワーク
- Chai アサーションライブラリ
- Yarn パッケージマネージャー
- Webpack モジュールバンドラー
その他ビューライブラリ
以前のReact JSページはReactJsをご覧ください。
ストリーム出力
通常、リソースはレンダラーでレンダリングされて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
には画像ファイルのファイルポインタリソースを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'); // 画像をbase64形式に変換
$this->body = [
'name' => $name,
'image' => $fp
];
return $this;
}
}
ストリーミングの帯域幅やタイミングをコントロールしたり、クラウドにアップロードしたりするなど、ストリーミングをさらに制御する場合は、StreamResponderを参考にして作成し、束縛します。
ストリーム出力のデモは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を統合したキャッシングフレームワークを提供します。
タグベースでのキャッシュ無効化
コンテンツキャッシュには依存性の問題があります。コンテンツAがコンテンツBに依存し、BがCに依存している場合、Cが更新されるとCのキャッシュとETagだけでなく、Cに依存するBのキャッシュとETag、Bに依存するAのキャッシュとETagも更新されなければなりません。
BEAR.Sundayはそれぞれのリソースが依存リソースのURIをタグとして保持することで、この問題を解決します。#[Embed]
で埋め込まれたリソースに変更があると、関係する全てのリソースのキャッシュとETagが無効化され、次のリクエストのためにキャッシュの再生成が行われます。
ドーナッツキャッシュ
ドーナッツキャッシュは、キャッシュの最適化のための部分キャッシュ技術の1つです。コンテンツをキャッシュ可能な箇所とそうでない箇所に分けて合成します。
例えば「Welcome to $name
」というキャッシュできないリソースが含まれるコンテンツを考えてみてください。キャッシュできない(do-not cache)部分と、その他のキャッシュ可能な部分を合成して出力します。
この場合、コンテンツ全体としては動的なので、ドーナッツ全体はキャッシュされません。そのため、ETagも出力されません。
ドーナッツの穴キャッシュ
ドーナッツの穴部分がキャッシュ可能な場合も、ドーナッツキャッシュと同じように扱えます。上記の例では、1時間に一度変更される天気予報のリソースがキャッシュされ、ニュースリソースに含まれます。
この場合、ドーナッツ全体(ニュース)としてのコンテンツは静的なので、全体もキャッシュされ、ETagも付与されます。このとき、キャッシュの依存性が発生します。ドーナッツの穴部分のコンテンツが更新された時に、キャッシュされたドーナッツ全体も再生成される必要があります。
この依存解決は自動で行われます。計算資源を最小化するため、ドーナッツ部分の計算は再利用されます。穴の部分(天気リソース)が更新されると、全体のコンテンツのキャッシュとETagも自動で更新されます。
リカーシブ・ドーナッツ
ドーナッツ構造は再帰的に適用されます。例えば、AがBを含み、BがCを含むコンテンツの場合、Cが変更されたときに、変更されたCの部分を除いて、AのキャッシュとBのキャッシュは再利用されます。AとBのキャッシュ、ETagは再生成されますが、A、Bのコンテンツ取得のためのDBアクセスやビューのレンダリングは行われません。
最適化された構造の部分キャッシュが、最小のコストでコンテンツ再生成を行います。クライアントはコンテンツのキャッシュ構造について知る必要がありません。
イベントドリブン型コンテンツ
従来、CDNはアプリケーションロジックを必要とするコンテンツは「動的」であり、したがってCDNではキャッシュできないと考えられてきました。しかし、FastlyやAkamaiなどの一部のCDNは、即時または数秒以内でのタグベースでのキャッシュ無効化が可能になり、この考えは過去のものになろうとしています。
BEAR.Sundayの依存解決は、サーバーサイドだけでなく共有キャッシュでも行われます。AOPが変更を検知し、共有キャッシュにPURGEリクエストを行うことで、サーバーサイドと同じように共有キャッシュ上の関連キャッシュの無効化が行われます。
条件付きリクエスト
コンテンツの変更は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はそれを利用するようにします。
レスポンスヘッダー
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クライアントを利用します:
- iOS: NSURLCache
- Android: HttpResponseCache
- PHP: guzzle-cache-middleware
- JavaScript(Node): cacheable-request
- Go: lox/httpcache
- Ruby: faraday-http-cache
- Python: requests-cache
プライベートキャッシュ
他のクライアントと共有しない場合にはprivate
を指定します。クライアントサイドのみキャッシュが保存されます。この場合、サーバーサイドではキ
ResponseHeader::CACHE_CONTROL => 'private, max-age=30'
キャッシュ設計
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.ini
でextension=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が用意されています。
コーディングガイド
プロジェクト
vendor
は会社の名前やチームの名前または個人の名前(excite
, koriym
等)を指定して、package
にはアプリケーション(サービス)の名前(blog
, news
等)を指定します。
プロジェクトはアプリケーション単位で作成し、Web APIとHTMLを別ホストでサービスする場合でも1つのプロジェクトにします。
スタイル
<?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*
*/
リソース
リソースについてのベストプラクティスはリソースのベストプラクティスをご覧ください。
コード
適切なステータスコードを返します。テストが容易になり、botやクローラーにも正しい情報が伝えることができます。
100
Continue 複数のリクエストの継続200
OK201
Created リソース作成202
Accepted キュー/バッチ 受付204
No Content bodyがない場合304
Not Modified 未更新400
Bad Request リクエストに不備401
Unauthorized 認証が必要403
Forbidden 禁止404
Not Found405
Method Not Allowed503
Service Unavailable サーバーサイドでの一時的エラー
304
は#[Cacheable]
アトリビュートを使っていると自動設定されます。404
はリソースクラスがない場合、405
はリソースのメソッドがない場合に自動設定されます。またDBの接続エラーなどは必ず503
で返しクローラーに伝えます。
HTMLのFormメソッド
BEAR.SundayはHTMLのWebフォームでPOST
リクエストの時にX-HTTP-Method-Override
ヘッダーや_method
クエリーを用いてメソッドを上書きする事ができますが、推奨しているわけではありません。PageリソースではonGet
とonPost
以外を実装しない方針でも問題ありません。
ハイパーリンク
- リンクを持つリソースは
#[Link]
で示すことが推奨されます。 - リソースは意味のまとまりのグラフにして
#[Embed]
で埋め込む事が推奨されます。
グローバル
グローバルな値をリソースやアプリケーションのクラスで参照することは推奨されません。(Modulesでのみ使用します)
- スーパーグローバルの値を参照しない
- defineは使用しない
- 設定値を保持する
Config
クラスを作成しない - グローバルなオブジェクトコンテナ(サービスロケータ)を使用しない
- date関数やDateTimeクラスで現在時刻を直接取得することは推奨されません。外部から時刻をインジェクトします。13
- スタティックメソッドなどのグローバルなメソッドコールも推奨されません。
- アプリケーションコードが必要とする値は設定ファイルなどから取得するのではなく、全てインジェクトします。14
クラスとオブジェクト
DI
- 実行コンテキスト(prod, devなど)の値そのものをインジェクトしてはいけません。代わりにコンテキストに応じたインスタンスをインジェクトします。アプリケーションはどのコンテキストで動作しているのか無知にします。
- ライブラリコードではセッターインジェクションは推奨されません。
Provider
束縛を可能な限り避けtoConstructor
束縛を優先することが推奨されます。Module
で条件に応じて束縛をすることを避けます。(AvoidConditionalLogicInModules)- モジュールの
configure()
から環境変数を参照しないで、コンストラクタインジェクションにします。
AOP
- インターセプターの適用を必須にしてはいけません。例えばログやDBのトランザクションなどはインターセプターの有無でプログラムの本質的な動作は変わりません。
- メソッド内の依存をインターセプターがインジェクトしないようにします。メソッド実装時にしか決定できない値は
@Assisted
インジェクションで引数にインジェクトします。 - 複数のインタセプターがある場合にその実行順に可能な限り依存しないようにします。
- 無条件に全メソッドに適用するインターセプターであれば
bootstrap.php
での記述を考慮してください。 - 横断的関心事と、本質的関心事を分けるために使われるものです。特定のメソッドのhackのためにインターセプトするような使い方は推奨されません。
スクリプトコマンド
composer setup
コマンドでアプリケーションのセットアップが完了することが推奨されます。このスクリプトではデータベースの初期化、必要ライブラリの確認が含まれます。.env
の設定などマニュアルな操作が必要な場合はその手順が画面表示されることが推奨されます。
環境
- 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
PHPDocタイプ
PHPは動的型付け言語ですが、psalmやphpstanといった静的解析ツールとPHPDocを使用することで、高度な型概念を表現し、静的解析時の型チェックの恩恵を受けることができます。このリファレンスでは、PHPDocで使用可能な型や関連する他の概念について説明します。
目次
アトミック型
これ以上分割できない基本的な型です。
スカラー型
/** @param int $i */
/** @param float $f */
/** @param string $str */
/** @param lowercase-string $lowercaseStr */
/** @param non-empty-string $nonEmptyStr */
/** @param non-empty-lowercase-string $nonEmptyLowercaseStr */
/** @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 */
/** @param positive-int $positiveInt */
/** @param negative-int $negativeInt */
/** @param int-range<0, 100> $percentage */
/** @param int-mask<1, 2, 4> $flags */
/** @param int-mask-of<MyClass::CLASS_CONSTANT_*> $classFlags */
/** @param trait-string $trait */
/** @param enum-string $enum */
/** @param literal-string $literalStr */
/** @param literal-int $literalInt */
複合型や高度な型システムでこれらの型を組み合わせて使用できます。
オブジェクト型
/** @param object $obj */
/** @param stdClass $std */
/** @param Foo\Bar $fooBar */
/** @param object{foo: string, bar?: int} $objWithProperties */
/** @return ArrayObject<int, string> */
/** @param Collection<User> $users */
/** @return Generator<int, string, mixed, void> */
オブジェクト型はジェネリック型と組み合わせて使用することができます。
配列型
ジェネリック配列
/** @return array<TKey, TValue> */
/** @return array<int, Foo> */
/** @return array<string, int|string> */
/** @return non-empty-array<string, int> */
ジェネリック配列はジェネリック型の概念を使用しています。
オブジェクト風配列
/** @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 */
/** @param non-empty-list<int> $nonEmptyIntList */
PHPDoc配列(レガシー表記)
/** @param string[] $strings */
/** @param int[][] $nestedInts */
Callable型
/** @return callable(Type1, OptionalType2=, SpreadType3...): ReturnType */
/** @return Closure(bool):int */
/** @param callable(int): string $callback */
Callable型は高階関数で特に重要です。
値型
/** @return null */
/** @return true */
/** @return false */
/** @return 42 */
/** @return 3.14 */
/** @return "specific string" */
/** @param Foo\Bar::MY_SCALAR_CONST $const */
/** @param A::class|B::class $classNames */
特殊型
/** @return void */
/** @return never */
/** @return empty */
/** @return mixed */
/** @return resource */
/** @return closed-resource */
/** @return iterable<TKey, TValue> */
複合型
複数のアトミック型を組み合わせて作成される型です。
ユニオン型
/** @param int|string $id */
/** @return string|null */
/** @var array<string|int> $mixedArray */
/** @return 'success'|'error'|'pending' */
交差型
/** @param Countable&Traversable $collection */
/** @param Renderable&Serializable $object */
交差型はデザインパターンの実装で役立つことがあります。
高度な型システムと使用パターン
より複雑で柔軟な型表現を可能にする高度な機能です。
ジェネリック型
/**
* @template T
* @param array<T> $items
* @param callable(T): bool $predicate
* @return array<T>
*/
function filter(array $items, callable $predicate): array {
return array_filter($items, $predicate);
}
ジェネリック型は高階関数と組み合わせて使用されることが多いです。
テンプレート型
/**
* @template T of object
* @param class-string<T> $className
* @return T
*/
function create(string $className)
{
return new $className();
}
テンプレート型は型の制約と組み合わせて使用できます。
条件付き型
/**
* @template T
* @param T $value
* @return (T is string ? int : string)
*/
function processValue($value) {
return is_string($value) ? strlen($value) : strval($value);
}
条件付き型はユニオン型と組み合わせて使用されることがあります。
型エイリアス
/**
* @psalm-type UserId = positive-int
* @psalm-type UserData = array{id: UserId, name: string, email: string}
*/
/**
* @param UserData $userData
* @return UserId
*/
function createUser(array $userData): int {
// ユーザー作成ロジック
return $userData['id'];
}
型エイリアスは複雑な型定義を簡略化するのに役立ちます。
型の制約
型パラメータに制約を加えることで、より具体的な型の要件を指定できます。
/**
* @template T of \DateTimeInterface
* @param T $date
* @return T
*/
function cloneDate($date) {
return clone $date;
}
// 使用例
$dateTime = new DateTime();
$clonedDateTime = cloneDate($dateTime);
この例では、T
は\DateTimeInterface
を実装したクラスに制限されています。
共変性と反変性
ジェネリック型を扱う際に、共変性(covariance)と反変性(contravariance)の概念が重要になります。
/**
* @template-covariant T
*/
interface Producer {
/** @return T */
public function produce();
}
/**
* @template-contravariant T
*/
interface Consumer {
/** @param T $item */
public function consume($item);
}
// 使用例
/** @var Producer<Dog> $dogProducer */
/** @var Consumer<Animal> $animalConsumer */
共変性は、より派生した型(サブタイプ)を使用できることを意味し、反変性はより基本的な型(スーパータイプ)を使用できることを意味します。
型の演算子
型の演算子を使用して、既存の型から新しい型を生成できます。psalmではユーティリティ型と呼んでいます。
キー取得型と値取得型
key-of
は、指定された配列またはオブジェクトのすべてのキーの型を取得し、value-of
はその値の型を取得します。
/**
* @param key-of<UserData> $key
* @return value-of<UserData>
*/
function getUserData(string $key) {
$userData = ['id' => 1, 'name' => 'John', 'email' => 'john@example.com'];
return $userData[$key] ?? null;
}
/**
* @return ArrayIterator<key-of<UserData>, value-of<UserData>>
*/
function getUserDataIterator() {
$userData = ['id' => 1, 'name' => 'John', 'email' => 'john@example.com'];
return new ArrayIterator($userData);
}
プロパティ取得型
properties-of
は、クラスのすべてのプロパティの型を表します。これは、クラスのプロパティを動的に扱う場合に有用です。
class User {
public int $id;
public string $name;
public ?string $email;
}
/**
* @param User $user
* @param key-of<properties-of<User>> $property
* @return value-of<properties-of<User>>
*/
function getUserProperty(User $user, string $property) {
return $user->$property;
}
// 使用例
$user = new User();
$propertyValue = getUserProperty($user, 'name'); // $propertyValue は string 型
properties-of
には以下のバリアントがあります:
public-properties-of<T>
: 公開プロパティのみを対象とします。protected-properties-of<T>
: 保護されたプロパティのみを対象とします。private-properties-of<T>
: プライベートプロパティのみを対象とします。
これらのバリアントを使用することで、特定のアクセス修飾子を持つプロパティのみを扱うことができます。
クラス名マッピング型
class-string-map
は、クラス名をキーとし、そのインスタンスを値とする配列を表します。これは、依存性注入コンテナやファクトリーパターンの実装に役立ちます。
/**
* @template T of object
* @param class-string-map<T, T> $map
* @param class-string<T> $className
* @return T
*/
function getInstance(array $map, string $className) {
return $map[$className] ?? new $className();
}
// 使用例
$container = [
UserRepository::class => new UserRepository(),
ProductRepository::class => new ProductRepository(),
];
$userRepo = getInstance($container, UserRepository::class);
インデックスアクセス型
インデックスアクセス型(T[K]
)は、型 T
のインデックス K
の要素を表します。これは、配列やオブジェクトのプロパティにアクセスする際の型を正確に表現するのに役立ちます。
/**
* @template T of array
* @template K of key-of<T>
* @param T $data
* @param K $key
* @return T[K]
*/
function getArrayValue(array $data, $key) {
return $data[$key];
}
// 使用例
$config = ['debug' => true, 'version' => '1.0.0'];
$debugMode = getArrayValue($config, 'debug'); // $debugMode は bool 型
これらのユーティリティ型はpsalm固有のもので高度な型システムの一部として考えることができます。
関数型プログラミングの概念
PHPDocは、関数型プログラミングの影響を受けた重要な概念をサポートしています。これらの概念を使用することで、コードの予測可能性と信頼性を向上させることができます。
純粋関数
純粋関数は、副作用がなく、同じ入力に対して常に同じ出力を返す関数です。
/**
* @pure
*/
function add(int $a, int $b): int
{
return $a + $b;
}
関数の副作用がないこと、そして関数の結果が入力のみに依存することを明示できます。
不変オブジェクト
不変オブジェクトは、作成後に状態が変更されないオブジェクトです。
/**
* @immutable
* - すべてのプロパティは実質的に`readonly`として扱われます。
* - すべてのメソッドは暗黙的に`@psalm-mutation-free`として扱われます。
*/
class Point {
public function __construct(
private float $x,
private float $y
) {}
public function withX(float $x): static
{
return new self($x, $this->y);
}
public function withY(float $y): static
{
return new self($this->x, $y);
}
}
@psalm-mutation-free
このアノテーションは、メソッドがクラスの内部状態も外部の状態も変更しないことを示します。@immutable
クラスのメソッドは暗黙的にこの性質を持ちますが、非イミュータブルクラスの特定のメソッドに対しても使用できます。
class Calculator {
private float $lastResult = 0;
/**
* @psalm-mutation-free
*/
public function add(float $a, float $b): float {
return $a + $b;
}
public function addAndStore(float $a, float $b): float {
$this->lastResult = $a + $b; // これは@psalm-mutation-freeでは許可されません
return $this->lastResult;
}
}
@psalm-external-mutation-free
このアノテーションは、メソッドがクラスの外部の状態を変更しないことを示します。内部状態の変更は許可されます。
class Logger {
private array $logs = [];
/**
* @psalm-external-mutation-free
*/
public function log(string $message): void {
$this->logs[] = $message; // クラス内部の状態変更は許可されます
}
public function writeToFile(string $filename): void {
file_put_contents($filename, implode("\n", $this->logs)); // これは外部状態を変更するため、@psalm-external-mutation-freeでは使用できません
}
}
不変性アノテーションの使用ガイドライン
- クラス全体が不変である場合は
@immutable
を使用します。 - 特定のメソッドが状態を変更しない場合は
@psalm-mutation-free
を使用します。 - メソッドが外部の状態は変更しないが、内部状態を変更する可能性がある場合は
@psalm-external-mutation-free
を使用します。
不変性を適切に表現することで、並行処理での安全性向上、副作用の減少、コードの理解しやすさの向上など、多くの利点を得ることができます。
副作用の注釈
関数が副作用を持つ場合、それを明示的に注釈することで、その関数の使用に注意を促すことができます。
/**
* @side-effect This function writes to the database
*/
function logMessage(string $message): void {
// データベースにメッセージを書き込む処理
}
高階関数
高階関数は、関数を引数として受け取るか、関数を返す関数です。PHPDocを使用して、高階関数の型を正確に表現できます。
/**
* @param callable(int): bool $predicate
* @param list<int> $numbers
* @return list<int>
*/
function filter(callable $predicate, array $numbers): array {
return array_filter($numbers, $predicate);
}
高階関数はCallable型と密接に関連しています。
アサート注釈
アサート注釈は、静的解析ツールに対して特定の条件が満たされていることを伝えるために使用されます。
/**
* @psalm-assert string $value
* @psalm-assert-if-true string $value
* @psalm-assert-if-false null $value
*/
function isString($value): bool {
return is_string($value);
}
/**
* @psalm-assert !null $value
*/
function assertNotNull($value): void {
if ($value === null) {
throw new \InvalidArgumentException('Value must not be null');
}
}
/**
* @psalm-assert-if-true positive-int $number
*/
function isPositiveInteger($number): bool {
return is_int($number) && $number > 0;
}
これらのアサート注釈は、以下のように使用されます:
@psalm-assert
: 関数が正常に終了した場合(例外をスローせずに)、アサーションが真であることを示します。@psalm-assert-if-true
: 関数がtrue
を返した場合、アサーションが真であることを示します。@psalm-assert-if-false
: 関数がfalse
を返した場合、アサーションが真であることを示します。
アサート注釈は型の制約と組み合わせて使用されることがあります。
セキュリティ注釈
セキュリティ注釈は、コード内のセキュリティに関連する重要な部分を明示し、潜在的な脆弱性を追跡するために使用されます。主に以下の3つの注釈があります:
@psalm-taint-source
: 信頼できない入力源を示します。@psalm-taint-sink
: セキュリティ上重要な操作が行われる場所を示します。@psalm-taint-escape
: データが安全にエスケープまたはサニタイズされた場所を示します。
以下は、これらの注釈の使用例です:
/**
* @psalm-taint-source input
*/
function getUserInput(): string {
return $_GET['user_input'] ?? '';
}
/**
* @psalm-taint-sink sql
*/
function executeQuery(string $query): void {
// SQLクエリを実行
}
/**
* @psalm-taint-escape sql
*/
function escapeForSql(string $input): string {
return addslashes($input);
}
// 使用例
$userInput = getUserInput();
$safeSqlInput = escapeForSql($userInput);
executeQuery("SELECT * FROM users WHERE name = '$safeSqlInput'");
これらの注釈を使用することで、静的解析ツールは信頼できない入力の流れを追跡し、潜在的なセキュリティ問題(SQLインジェクションなど)を検出できます。
例:デザインパターンでの型の使用
型システムを活用して、一般的なデザインパターンをより型安全に実装できます。
ビルダーパターン
/**
* @template T
*/
interface BuilderInterface {
/**
* @return T
*/
public function build();
}
/**
* @template T
* @template-implements BuilderInterface<T>
*/
abstract class AbstractBuilder implements BuilderInterface {
/** @var array<string, mixed> */
protected $data = [];
/** @param mixed $value */
public function set(string $name, $value): static {
$this->data[$name] = $value;
return $this;
}
}
/**
* @extends AbstractBuilder<User>
*/
class UserBuilder extends AbstractBuilder {
public function build(): User {
return new User($this->data);
}
}
// 使用例
$user = (new UserBuilder())
->set('name', 'John Doe')
->set('email', 'john@example.com')
->build();
リポジトリパターン
/**
* @template T
*/
interface RepositoryInterface {
/**
* @param int $id
* @return T|null
*/
public function find(int $id);
/**
* @param T $entity
*/
public function save($entity): void;
}
/**
* @implements RepositoryInterface<User>
*/
class UserRepository implements RepositoryInterface {
public function find(int $id): ?User {
// データベースからユーザーを取得するロジック
}
public function save(User $user): void {
// ユーザーをデータベースに保存するロジック
}
}
まとめ
PHPDocの型システムを深く理解して適切に使用することで、コードの自己文書化、静的解析による早期のバグ検出、IDEによる強力なコード補完と支援、コードの意図と構造の明確化、セキュリティリスクの軽減などの利点が得られ、より堅牢で保守性の高いPHPコードを書くことができます。以下は利用可能な型を網羅した例です。
<?php
namespace App\Comprehensive\Types;
/**
* アトミック型、スカラー型、ユニオン型、交差型、ジェネリック型を網羅するクラス
*
* @psalm-type UserId = int
* @psalm-type HtmlContent = string
* @psalm-type PositiveFloat = float&positive
* @psalm-type Numeric = int|float
* @psalm-type QueryResult = array<string, mixed>
*/
class TypeExamples {
/**
* @param UserId|non-empty-string $id
* @return HtmlContent
*/
public function getUserContent(int|string $id): string {
return "<p>User ID: {$id}</p>";
}
/**
* @param PositiveFloat $amount
* @return bool
*/
public function processPositiveAmount(float $amount): bool {
return $amount > 0;
}
}
/**
* イミュータブルクラス、関数型プログラミング、純粋関数の例
*
* @immutable
*/
class ImmutableUser {
/** @var non-empty-string */
private string $name;
/** @var positive-int */
private int $age;
/**
* @param non-empty-string $name
* @param positive-int $age
*/
public function __construct(string $name, int $age) {
$this->name = $name;
$this->age = $age;
}
/**
* @psalm-pure
* @return ImmutableUser
*/
public function withAdditionalYears(int $additionalYears): self {
return new self($this->name, $this->age + $additionalYears);
}
}
/**
* テンプレート型、ジェネリック型、条件付き型、共変性と反変性の例
*
* @template T
* @template-covariant U
*/
class StorageContainer {
/** @var array<T, U> */
private array $items = [];
/**
* @param T $key
* @param U $value
*/
public function add(mixed $key, mixed $value): void {
$this->items[$key] = $value;
}
/**
* @param T $key
* @return U|null
*/
public function get(mixed $key): mixed {
return $this->items[$key] ?? null;
}
/**
* @template V
* @param T $key
* @return (T is string ? string : U|null)
*/
public function get(mixed $key): mixed {
return is_string($key) ? "default_string_value" : ($this->items[$key] ?? null);
}
}
/**
* 型の制約、ユーティリティ型、関数型プログラミング、アサート注釈の例
*
* @template T of array-key
*/
class UtilityExamples {
/**
* @template T of array-key
* @psalm-param array<T, mixed> $array
* @psalm-return list<T>
* @psalm-assert array<string, mixed> $array
*/
public function getKeys(array $array): array {
return array_keys($array);
}
/**
* @template T of object
* @psalm-param class-string-map<T, array-key> $classes
* @psalm-return list<T>
*/
public function mapClasses(array $classes): array {
return array_map(fn(string $className): object => new $className(), array_keys($classes));
}
}
/**
* 高階関数、型エイリアス、インデックスアクセス型の例
*
* @template T
* @psalm-type Predicate = callable(T): bool
*/
class FunctionalExamples {
/**
* @param list<T> $items
* @param Predicate<T> $predicate
* @return list<T>
*/
public function filter(array $items, callable $predicate): array {
return array_filter($items, $predicate);
}
/**
* @param array<string, T> $map
* @param key-of $map $key
* @return T|null
*/
public function getValue(array $map, string $key): mixed {
return $map[$key] ?? null;
}
}
/**
* セキュリティ注釈、型制約、インデックスアクセス型、プロパティ取得型、キー取得型、値取得型の例
*
* @template T
*/
class SecureAccess {
/**
* @psalm-type UserProfile = array{
* id: int,
* name: non-empty-string,
* email: non-empty-string,
* roles: list<non-empty-string>
* }
* @psalm-param UserProfile $profile
* @psalm-param key-of<UserProfile> $property
* @return value-of<UserProfile>
* @psalm-taint-escape system
*/
public function getUserProperty(array $profile, string $property): mixed {
return $profile[$property];
}
}
/**
* 非常に複雑な構造の型やセキュリティ・注釈、純粋関数の実装例
*
* @template T of object
* @template-covariant U of array-key
* @psalm-type ErrorResponse = array{error: non-empty-string, code: positive-int}
*/
class ComplexExample {
/** @var array<U, T> */
private array $registry = [];
/**
* @param U $key
* @param T $value
*/
public function register(mixed $key, object $value): void {
$this->registry[$key] = $value;
}
/**
* @param U $key
* @return T|null
* @psalm-pure
* @psalm-assert-if-true ErrorResponse $this->registry[$key]
*/
public function getRegistered(mixed $key): ?object {
return $this->registry[$key] ?? null;
}
}
<?php
namespace App\Additional\Types;
/**
* テンプレート型の制約とcontravariantの例
*
* @template-contravariant T of \Throwable
*/
interface ErrorHandlerInterface {
/**
* @param T $error
* @return void
*/
public function handle(\Throwable $error): void;
}
/**
* より具体的な型への実装例
*
* @implements ErrorHandlerInterface<\RuntimeException>
*/
class RuntimeErrorHandler implements ErrorHandlerInterface {
public function handle(\Throwable $error): void {
// RuntimeExceptionの処理
}
}
/**
* 複雑な型の組み合わせと条件分岐の例
*
* @psalm-type JsonPrimitive = string|int|float|bool|null
* @psalm-type JsonArray = array<array-key, JsonValue>
* @psalm-type JsonObject = array<string, JsonValue>
* @psalm-type JsonValue = JsonPrimitive|JsonArray|JsonObject
*/
class JsonProcessor {
/**
* @param JsonValue $value
* @return (JsonValue is JsonObject ? array<string, mixed> : (JsonValue is JsonArray ? list<mixed> : scalar|null))
*/
public function process(mixed $value): mixed {
if (is_array($value)) {
return array_keys($value) === range(0, count($value) - 1)
? array_values($value)
: $value;
}
return $value;
}
}
/**
* より高度なタプル型とレコード型の例
*/
class AdvancedTypes {
/**
* @return array{0: int, 1: string, 2: bool}
*/
public function getTuple(): array {
return [42, "hello", true];
}
/**
* @param array{id: int, name: string, meta: array{created: string, modified?: string}} $record
* @return void
*/
public function processRecord(array $record): void {
// レコード型の処理
}
/**
* @template T of object
* @param class-string<T> $className
* @param array<string, mixed> $properties
* @return T
*/
public function createInstance(string $className, array $properties): object {
$instance = new $className();
foreach ($properties as $key => $value) {
$instance->$key = $value;
}
return $instance;
}
}
/**
* カスタム型ガードとアサーションの例
*/
class TypeGuards {
/**
* @psalm-assert-if-true non-empty-string $value
*/
public function isNonEmptyString(mixed $value): bool {
return is_string($value) && $value !== '';
}
/**
* @template T of object
* @param mixed $value
* @param class-string<T> $className
* @psalm-assert-if-true T $value
*/
public function isInstanceOf(mixed $value, string $className): bool {
return $value instanceof $className;
}
}
/**
* PHPUnit用のテスト関連の型アノテーションの例
*/
class TestTypes {
/**
* @param class-string<\Exception> $expectedClass
* @param callable(): mixed $callback
*/
public function expectException(string $expectedClass, callable $callback): void {
try {
$callback();
$this->fail('Exception was not thrown');
} catch (\Exception $e) {
$this->assertInstanceOf($expectedClass, $e);
}
}
/**
* @template T
* @param T $expected
* @param T $actual
* @param non-empty-string $message
*/
public function assertEquals(mixed $expected, mixed $actual, string $message = ''): void {
// 型安全な比較ロジック
}
}
/**
* コレクション型とイテレータの高度な例
*
* @template-covariant TKey of array-key
* @template-covariant TValue
* @template-implements \IteratorAggregate<TKey, TValue>
*/
class TypedCollection implements \IteratorAggregate {
/** @var array<TKey, TValue> */
private array $items = [];
/**
* @return \Traversable<TKey, TValue>
*/
public function getIterator(): \Traversable {
yield from $this->items;
}
/**
* @param TValue $item
* @return void
*/
public function add(mixed $item): void {
$this->items[] = $item;
}
/**
* @template TCallback
* @param callable(TValue): TCallback $callback
* @return TypedCollection<TKey, TCallback>
*/
public function map(callable $callback): self {
$result = new self();
foreach ($this->items as $key => $value) {
$result->items[$key] = $callback($value);
}
return $result;
}
}
/**
* 条件付きメソッドの例
*/
interface ConditionalInterface {
/**
* @template T
* @param T $value
* @return (T is numeric ? float : string)
*/
public function process(mixed $value): mixed;
}
リファレンス
PHPDoc型を最大限に活用するためには、PsalmやPHPStanといった静的解析ツールが必要です。詳細については、以下のリソースを参照してください:
PHPDoc ユーティリティ型
ユーティリティ型は、既存の型を操作したり、動的に新しい型を生成するために使用される型です。これらの型を使用することで、より柔軟で表現力豊かな型定義が可能になります。
目次
- [key-of
](#key-oft) - [value-of
](#value-oft) - [properties-of
](#properties-oft) - class-string-map<T of Foo, T>
- T[K]
- Type aliases
- Variable templates
key-of
key-of<T>
は、型 T
のすべての可能なキーの型を表します。
/**
* @template T of array
* @param T $data
* @param key-of<T> $key
* @return mixed
*/
function getValueByKey(array $data, $key) {
return $data[$key];
}
// 使用例
$userData = ['id' => 1, 'name' => 'John'];
$name = getValueByKey($userData, 'name'); // OK
$age = getValueByKey($userData, 'age'); // Psalmは警告を出します
value-of
value-of<T>
は、型 T
のすべての可能な値の型を表します。
/**
* @template T of array
* @param T $data
* @return value-of<T>
*/
function getRandomValue(array $data) {
return $data[array_rand($data)];
}
// 使用例
$numbers = [1, 2, 3, 4, 5];
$randomNumber = getRandomValue($numbers); // int型
properties-of
properties-of<T>
は、型 T
のすべてのプロパティの型を表します。
class User {
public int $id;
public string $name;
public ?string $email;
}
/**
* @param User $user
* @param key-of<properties-of<User>> $property
* @return value-of<properties-of<User>>
*/
function getUserProperty(User $user, string $property) {
return $user->$property;
}
// 使用例
$user = new User();
$name = getUserProperty($user, 'name'); // string型
$id = getUserProperty($user, 'id'); // int型
$unknown = getUserProperty($user, 'unknown'); // Psalmは警告を出します
class-string-map<T of Foo, T>
class-string-map
は、クラス名をキーとし、そのインスタンスを値とする配列を表します。
interface Repository {}
class UserRepository implements Repository {}
class ProductRepository implements Repository {}
/**
* @template T of Repository
* @param class-string-map<T, T> $repositories
* @param class-string<T> $className
* @return T
*/
function getRepository(array $repositories, string $className): Repository {
return $repositories[$className];
}
// 使用例
$repositories = [
UserRepository::class => new UserRepository(),
ProductRepository::class => new ProductRepository(),
];
$userRepo = getRepository($repositories, UserRepository::class);
T[K]
T[K]
は、型 T
のインデックス K
の要素を表します。
/**
* @template T of array
* @template K of array-key
* @param T $data
* @param K $key
* @return T[K]
*/
function getArrayElement(array $data, $key) {
return $data[$key];
}
// 使用例
$config = ['debug' => true, 'version' => '1.0.0'];
$debugMode = getArrayElement($config, 'debug'); // bool型
テスト
適切なテストは、ソフトウェアを継続性のある、より良いものにします。全ての依存がインジェクトされ、横断的関心事がAOPで提供されるBEAR.Sundayのクリーンなアプリケーションはテストフレンドリーです。
テスト実行
composerコマンドが用意されています。
composer test // phpunitテスト
composer tests // test + sa + cs
composer coverage // テストカバレッジ
composer pcov // テストカバレッジ (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 __construct(
private FooInterface $foo
){}
protected function configure(): void
{
$this->bind(FooInterface::class)->toInstance($this->foo);
}
};
$injector = Injector::getOverrideInstance('hal-app', $module);
}
スパイ
スパイ対象のインターフェイスまたはクラス名を指定してSpyModule
をインストールします。16
スパイ対象が含まれる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::class, '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とテスト、それにフォームのバリデーションテストも含まれます。
- ray/aura-sql-module - Extended PDO (Aura.Sql)
- ray/web-form-module - Web form (Aura.Input)
- madapaja/twig-module - Twig template engine
- koriym/now - Current datetime
- koriym/query-locator - SQL locator
- koriym/http-constants - Contains the values HTTP
MyVendor.ContactForm
https://github.com/bearsunday/MyVendor.ContactForm
各種のフォームページのサンプルです。
- 最小限のフォーム
- 複数のフォーム
- INPUTエレメントをループで生成したフォーム
- チェックボックス、ラジオボタンを含んだプレビュー付きのフォーム
コマンドラインインターフェイス (CLI)
BEAR.Sundayのリソース指向アーキテクチャ(ROA)は、アプリケーションのあらゆる機能をURIでアドレス可能なリソースとして表現します。このアプローチにより、Webに限らず様々な方法でリソースにアクセスできます。
$ php bin/page.php '/greeting?name=World&lang=ja'
{
"greeting": "こんにちは, World",
"lang": "ja"
}
BEAR.Cliは、このようなリソースをネイティブなCLIコマンドに変換し、Homebrewで配布可能にするツールです:
$ greet -n "World" -l ja
こんにちは, World
追加のコードを書くことなく、既存のアプリケーションリソースを標準的なCLIツールとして再利用できます。Homebrewを通じた配布により、PHPやBEAR.Sundayで動作していることを知ることなく、一般的なコマンドラインツールと同じように利用できます。
インストール
Composerでインストールします。
composer require bear/cli
基本的な使い方
リソースへのCLI属性の追加
リソースクラスにCLI属性を追加して、コマンドラインインターフェースを定義します。
use BEAR\Cli\Attribute\Cli;
use BEAR\Cli\Attribute\Option;
class Greeting extends ResourceObject
{
#[Cli(
name: 'greet',
description: 'Say hello in multiple languages',
output: 'greeting'
)]
public function onGet(
#[Option(shortName: 'n', description: 'Name to greet')]
string $name,
#[Option(shortName: 'l', description: 'Language (en, ja, fr, es)')]
string $lang = 'en'
): static {
$greeting = match ($lang) {
'ja' => 'こんにちは',
'fr' => 'Bonjour',
'es' => '¡Hola',
default => 'Hello',
};
$this->body = [
'greeting' => "{$greeting}, {$name}",
'lang' => $lang
];
return $this;
}
}
CLIコマンドとフォーミュラの生成
リソースをコマンドにするには、以下のようにアプリケーション名(ベンダー名とプロジェクト名)を指定してコマンドを実行します:
$ vendor/bin/bear-cli-gen 'MyVendor\MyProject'
# 生成されたファイル:
# bin/cli/greet # CLIコマンド
# var/homebrew/greet.rb # Homebrewフォーミュラ
Homebrewフォーミュラが生成されるのはGitHubでリポジトリが設定されている場合のみです。
コマンドの使用方法
生成されたコマンドは以下のような標準的なCLI機能を提供します:
ヘルプの表示
$ greet --help
Say hello in multiple languages
Usage: greet [options]
Options:
--name, -n Name to greet (required)
--lang, -l Language (en, ja, fr, es) (default: en)
--help, -h Show this help message
--version, -v Show version information
--format Output format (text|json) (default: text)
バージョン情報の表示
$ greet --version
greet version 0.1.0
基本的な使用例
# 基本的な挨拶
$ greet -n "World"
Hello, World
# 言語を指定
$ greet -n "World" -l ja
こんにちは, World
# 短いオプション
$ greet -n "World" -l fr
Bonjour, World
# 長いオプション
$ greet --name "World" --lang es
¡Hola, World
JSON出力
$ greet -n "World" -l ja --format json
{
"greeting": "こんにちは, World",
"lang": "ja"
}
出力の挙動
CLIコマンドの出力は以下の仕様に基づきます:
- デフォルト出力: 指定されたフィールドの値のみを表示
--format=json
オプション: APIエンドポイントと同様に、フルJSONレスポンスを表示- エラーメッセージ: 標準エラー出力(stderr)に表示
- HTTPステータスコードのマッピング: 終了コードにHTTPステータスコードをマップ(0: 成功、1: クライアントエラー、2: サーバーエラー)
配布方法
BEAR.Cliで作成したコマンドは、Homebrewを通じて配布できます。 フォーミュラの生成にはアプリケーションがGitHubで公開されていることが必要です。
フォーミュラのファイル名および中のクラス名はリポジトリの名前に基づいています。例えばGHリポジトリがkoriym/greet
の場合、Greet
クラスを含むvar/homebrew/greet.rb
が生成されます。この時greet
が公開するタップ名になりますが変更したい場合はフォーミュラのクラス名とファイル名を変更してください。
1. ローカルフォーミュラによる配布
開発版をテストする場合:
$ brew install --formula ./var/homebrew/greet.rb
2. Homebrewタップによる配布
公開リポジトリを使用して広く配布する方法です:
$ brew tap your-vendor/greet
$ brew install greet
この方法は特に以下の場合に適しています:
- オープンソースプロジェクト
- 継続的なアップデートの提供
開発版のテスト
$ brew install --HEAD ./var/homebrew/greet.rb
$ greet --version
greet version 0.1.0
安定版のリリース
- タグを作成:
$ git tag -a v0.1.0 -m "Initial stable release" $ git push origin v0.1.0
- フォーミュラを更新:
class Greet < Formula + desc "Your CLI tool description" + homepage "https://github.com/your-vendor/greet" + url "https://github.com/your-vendor/greet/archive/refs/tags/v0.1.0.tar.gz" + sha256 "..." # 以下のコマンドで取得したハッシュ値を記述 + version "0.1.0" head "https://github.com/your-vendor/greet.git", branch: "main" depends_on "php@8.1" depends_on "composer" end
フォーミュラには必要に応じてデータベースなどの依存関係を追加できます。ただし、データベースのセットアップなどの環境構築は
bin/setup
スクリプトで行うことを推奨します。 - SHA256ハッシュの取得:
# GitHubからtarballをダウンロードしてハッシュを計算 $ curl -sL https://github.com/your-vendor/greet/archive/refs/tags/v0.1.0.tar.gz | shasum -a 256
- Homebrewタップの作成:
GitHub CLI(gh)またはgithub.com/newでリポジトリを作成してください。公開リポジトリ名は
homebrew-
で始める必要があります。たとえばhomebrew-greet
です:
$ gh auth login
$ gh repo create your-vendor/homebrew-greet --public --clone
# または、Webインターフェースを使用してリポジトリを作成してcloneしてください
$ cd homebrew-greet
- フォーミュラの配置と公開:
$ cp /path/to/project/var/homebrew/greet.rb . $ git add greet.rb $ git commit -m "Add formula for greet command" $ git push
- インストールと配布:
エンドユーザーは以下のコマンドだけでツールを使い始めることができます。PHP環境や依存パッケージのインストールは自動的に行われるため、ユーザーが環境構築について心配する必要はありません:
$ brew tap your-vendor/greet # homebrew-プレフィックスは省略可能 $ brew install your-vendor/greet # すぐに使用可能 $ greet --version greet version 0.1.0
フォーミュラのカスタマイズ
必要に応じて、brew edit
コマンドでフォーミュラを編集できます:
$ brew edit your-vendor/greet
class Greet < Formula
desc "Your CLI tool description"
homepage "https://github.com/your-vendor/greet"
url "https://github.com/your-vendor/greet/archive/refs/tags/v0.1.0.tar.gz"
sha256 "..." # tgzのSHA256
version "0.1.0"
depends_on "php@8.4" # PHPバージョンの指定
depends_on "composer"
# アプリケーションが必要とする場合は追加
# depends_on "mysql"
# depends_on "redis"
end
クリーンアーキテクチャ
BEAR.Cliは、リソース指向アーキテクチャ(ROA)とクリーンアーキテクチャの強みを実証しています。クリーンアーキテクチャが目指す「UIは詳細である」という原則に従い、同じリソースに対してWebインターフェースだけでなく、CLIという新しいアダプターを追加できます。
さらに、BEAR.Cliはコマンドの作成だけでなく、Homebrewによる配布や更新もサポートしています。これにより、エンドユーザーはコマンド一つでツールを使い始めることができ、PHPやBEAR.Sundayの存在を意識せず、ネイティブなUNIXコマンドのように扱えます。
また、CLIツールはアプリケーションリポジトリから独立してバージョン管理および更新が可能です。そのため、APIの進化に影響されず、コマンドラインツールとしての安定性と継続的なアップデートを保つことができます。これは、リソース指向アーキテクチャとクリーンアーキテクチャの組み合わせにより実現した、APIの新しい提供形態です。
アトリビュート
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つのプロジェクトに混在することもできます。10 このマニュアルに表記されている全てのアノテーションはアトリビュートに変更しても動作します。
パフォーマンス
最適化されるため、プロダクション用にアノテーション/アトリビュート読み込みコストがかかることはほとんどありませんが、 以下のようにアトリビュートリーダーしか使用しないと宣言すると開発時の速度が向上します。
// 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 (Markdown)
オプション属性
title
APIタイトル
<title>MyBlog API</title>
description
APIディスクリプション
<description>MyBlog API description</description>
links
リンク。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
というディスクリプタをそれぞれtitle
、def
で定義した例です。title
は言葉を記述して意味を明らかにしますが、def
はSchema.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の基本機能である依存性注入(DI)、アスペクト指向プログラミング(AOP)、REST APIを紹介します。 tutorial1のコミットを参考にして進めましょう。
プロジェクト作成
年月日を入力すると対応する曜日を返すWebサービスを作成します。 まずプロジェクトを作成しましょう。
composer create-project bear/skeleton MyVendor.Weekday
vendor名をMyVendor
に、project名を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はリクエストに問題があることを示すエラーコードです。エラーにはlogref
IDが付与され、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
リクエストを実行する前に、public/index.php
を以下のように書き換えます。
<?php
declare(strict_types=1);
use MyVendor\Weekday\Bootstrap;
require dirname(__DIR__) . '/autoload.php';
- exit((new Bootstrap())(PHP_SAPI === 'cli-server' ? 'hal-app' : 'prod-hal-app', $GLOBALS, $_SERVER));
+ exit((new Bootstrap())(PHP_SAPI === 'cli-server' ? 'hal-api-app' : 'prod-hal-api-app', $GLOBALS, $_SERVER));
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
より高速なカバレッジ計測を行うpcovを使用する場合:
composer pcov
カバレッジレポートはbuild/coverage/index.html
をWebブラウザで開いて確認できます。
コーディング規約への準拠を確認:
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(ハイパーメディア)アプリケーション開発を学びます。
* JSONのスキーマを定義し、バリデーションやドキュメンテーションに利用する [JSON Schema](https://json-schema.org/)
* ハイパーメディアタイプ [HAL (Hypertext Application Language)](https://stateless.group/hal_specification.html)
* CakePHPが開発しているDBマイグレーションツール [Phinx](https://book.cakephp.org/phinx/0/en/index.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
に保存します。17
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で直接実行できます。1118
JSON Schema
Ticket
(チケットアイテム)、Tickets
(チケットアイテムリスト)のリソース表現をJSON Schemaで定義し、それぞれ保存します。
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 title of the ticket.",
"type": "string",
"maxLength": 255
},
"date_created": {
"description": "The date and time that the ticket was created.",
"type": "string",
"format": "date-time"
}
}
}
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のクエリを行うオブジェクトが自動生成されます。
インターフェイスを副作用が発生するコマンドまたは値を返すクエリーという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エンティティの配列が得られる
各行の値は名前引数でコンストラクタに渡されます。19
<?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
) {}
}
リソース
リソースクラスはクエリーインターフェイスに依存します。
ticketリソース
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によってリクエスト毎にバリデートされます。
シードを入力してリソースをリクエストしてみましょう。20
% 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:00",
"_links": {
"self": {
"href": "/ticket?id=1"
}
}
}
Ray.MediaQuery
Ray.MediaQueryを使えば、ボイラープレートとなりやすい実装クラスをコーディングすることなく、インターフェイスから自動生成されたSQL実行オブジェクトがインジェクトされます。21
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テンプレートを使って渡すこともできますし、インジェクトされたリクエストのパラメータを修正、追加することもできます。 詳しくはリソースをご覧ください。
もう一度リクエストすると_embedded
というプロパティにprojectリソースの状態が追加されているのが分かります。
% php bin/app.php get '/ticket?id=1'
{
"id": "1",
"title": "foo",
"date_created": "1970-01-01 00:00:00",
+ "_embedded": {
+ "project": {
+ "title": "Project A"
+ }
}
}
埋め込みリソースはREST APIの重要な機能です。コンテンツにツリー構造を与えHTTPリクエストコストを削減します。 情報が他の何の情報を含んでいるかはドメインの関心事です。クライアントで都度取得するのではなく、その関心事はサーバーサイドのLE(埋め込みリンク)でうまく表すことができます。22
ticketsリソース
POST
で作成、GET
でチケットリストが取得できるtickets
リソースを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などを束縛してアプリケーションで横断的に利用できます。23
ハイパーメディアAPIテスト
REST(Representational State Transfer)という用語は、2000年にRoy Fieldingが博士論文の中で紹介、定義したもので「適切に設計されたWebアプリケーションの動作」をイメージさせることを目的としています。 それはWebリソースのネットワーク(仮想ステートマシン)であり、ユーザーはリソース識別子(URL)と、GETやPOSTなどのリソース操作(アプリケーションステートの遷移)を選択することで、アプリケーションを進行させ、その結果、次のリソースの表現(次のアプリケーションステート)がエンドユーザーに転送されて使用されるというものです。
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);
}
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
を取得しています。 - リソースボディのテストを記述する必要はありません。レスポンスのJSON Schemaバリデーションが通ったという保証がされているので、ステータスコードの確認だけでOKです。
- RESTの統一インターフェイスに従い、次にアクセスするリクエストURLは常にレスポンスに含まれます。それを次々に検査します。
RESTの統一インターフェイス
1) リソースの識別、2) 表現によるリソースの操作、3) 自己記述メッセージ、 4) アプリケーション状態のエンジンとしてのハイパーメディア(HATEOAS)の4つのインターフェイス制約です。24
実行してみましょう。
./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
されると2番目の引数で指定されたブートストラップスクリプトでビルトインサーバーが起動します。
テストサーバー用のブートストラップスクリプトも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リクエスト/レスポンスログが3番目の引数のリソースログに記録されます。
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.MediaQueryの
entity
オプション 29f0a1f - 静的なSQLと動的なSQLを合成したクエリービルダー 9d095ac
RESTフレームワーク
Web APIには以下の3つのスタイルがあります。
- トンネル(SOAP、GraphQL)
- URI(オブジェクト、CRUD)
- ハイパーメディア(REST)
リソースを単なるRPCとして扱うURIスタイル25に対して、このチュートリアルで学んだのはリソースがリンクされているRESTです。26
リソースは#[Link]
のLO(アウトバウンドリンク)で結ばれワークフローを表し、#[Embed]
のLE(埋め込みリンク)でツリー構造を表しています。
BEAR.Sundayは標準に基づいたクリーンなコードであることを重視します。
フレームワーク固有のバリデータよりJSON Schema。独自ORMより標準SQL。独自構造JSONよりIANA標準メディアタイプ27JSON。
アプリケーション設計は「実装が自由である」ことではなく「制約の選択が自由である」ということが重要です。 アプリケーションはその制約に基づき、開発効率やパフォーマンス、後方互換性を壊さない進化可能性を目指すと良いでしょう。
コメントは説明になるだけでなく、スロークエリーログ等からもSQLを特定しやすくなります。
※ 以前のPHP 7対応のチュートリアルは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/
アプリケーション固有のクラスファイルを設置します。
public/
Web公開フォルダです。
var/
log
、tmp
フォルダは書き込み可能にします。var/www
はWebドキュメントの公開エリアです。
conf
など可変のファイルを設置します。
実行シーケンス
- コンソール入力(
bin/app.php
、page.php
)またはWebサーバーのエントリーファイル(public/index.php
)がbootstrap.php
を実行します。 bootstrap.php
では実行コンテキストに応じたルートオブジェクト$app
を作成します。$app
に含まれるルーターは外部のHTTPまたはCLIリクエストをアプリケーション内部のリソースリクエストに変換します。- リソースリクエストが実行され、結果がクライアントに転送されます。
フレームワーク・パッケージ
フレームワークは以下のパッケージから構成されます。
ray/aop
Javeの AOPアライアンス に準拠したAOPフレームワークです。
ray/di
google/guice スタイルのDIフレームワークです。ray/aop
を含みます。
bear/resource
bear/sunday
フレームワークのインターフェイスパッケージです。bear/resource
を含みます。
bear/package
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 |
ベンダー・パッケージ
特定のパッケージやツールの組み合わせをモジュールだけのパッケージにして再利用し、同様のプロジェクトのモジュールを共通化することができます。10
Semver
すべてのパッケージはセマンティックバージョニングに従います。マイナーバージョンアップでは後方互換性が破壊されることはありません。
アプリケーション
実行シーケンス
アプリケーションは、コンパイル、リクエスト、レスポンスの順で実行されます。
0. コンパイル
コンテキストに応じたDIとAOPの設定により、アプリケーションの実行に必要なルートオブジェクト$app
が生成されます。$appはrouter
やtransfer
などの、アプリケーション実行に必要なサービスオブジェクトで構成されます28。$appはシリアライズされ、各リクエストで再利用されます。
- router - 外部入力をリソースリクエストに変換
- resource - リソースクライアント
- transfer - 出力
1. リクエスト
リクエストに基づき、リソースオブジェクトが作成されます。
リクエストに応じてonGet
やonPost
などに応答するメソッドを持つリソースオブジェクトは、自身のリソースの状態として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コンテキストを呼び出すことで、一部の束縛を上書きすることができます。
コンテキスト無知
コンテキストの値は、ルートオブジェクトの作成のみに使用され、その後に消滅します。アプリケーションから参照可能なグローバルな”モード”は存在せず、アプリケーションは現在実行されているコンテキストを知ることはできません。外部の値を参照して振る舞いを変えるのではなく、インターフェイスのみに依存29し、コンテキストによる束縛の変更で振る舞いを変更します。
モジュール
モジュールは、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
で”検索”し、マッチするメソッドにインターセプターを束縛します。
// 例1:メソッド名による束縛
$this->bindInterceptor(
$this->matcher->any(), // どのクラスの
$this->matcher->startsWith('delete'), // "delete"で始まるメソッド名のメソッドには
[Logger::class] // Loggerインターセプターを束縛
);
// 例2:クラスとアノテーションによる束縛
$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のマニュアルをご覧ください。
-
例えばECサイトであれば、商品一覧、カートに入れる、注文、支払いなど、それぞれのアプリケーションステートの遷移をテストで表します。 ↩ ↩2 ↩3 ↩4
-
APIリクエストをJSONで送信する場合には
content-type
ヘッダーにapplication/json
をセットしてください。 ↩ ↩2 -
out-bound links 例)HTMLは関連した他のHTMLにリンクを張ることができます。 ↩ ↩2
-
embedded links 例)HTMLは独立した画像リソースを埋め込むことができます。 ↩ ↩2
-
query-locaterはSQLをファイルとして扱うライブラリです。Aura.Sqlと共に使うと便利です。 ↩ ↩2
-
参考モジュール Koriym.DbAppPackage ↩ ↩2 ↩3 ↩4
-
Web APIなど外部のシステムの値を利用する時には、クライアントクラスやWeb APIアクセスリソースなど1つにの場所に集中させDIやAOPでモッキングが容易にするようにします。 ↩
-
ResourceInject
などのインジェクション用トレイトはインジェクションのボイラープレートコードを削減するために存在しましたが、PHP8で追加されたコンストラクタの引数をプロパティへ昇格させる機能により意味を失いました。コンストラクタインジェクションを使いましょう。 ↩ -
SpyModuleの利用にはray/test-doubleのインストールが必要です。 ↩
-
このSQLはSQLスタイルガイドに準拠しています。PhpStormからはJoe Celkoとして設定できます。 ↩
-
PHP 8.0+ 名前付き引数 ¶。PHP 7.xの場合にはカラムの順番になります。 ↩
-
ここでは例としてMySQLから直接実行していますが、マイグレーションツールでシードを入力したりIDEのDBツールの利用方法も学びましょう。 ↩
-
Ray.MediaQueryはHTTP APIリクエストにも対応しています。 ↩
-
このようなコンテンツの階層構造のことを、IA(インフォメーションアーキテクチャ)ではタクソノミーと呼びます。Understanding Information Architecture参照 ↩
-
広く誤解されていますが、統一インターフェイスはHTTPメソッドのことではありません。Uniform Interface参照 ↩
-
いわゆる”Restish API”。REST APIと紹介されている多くのAPIはこのURI/オブジェクトスタイルで、RESTが誤用されています。 ↩
-
チュートリアルからリンクを取り除けばURIスタイルになります。 ↩
-
https://www.iana.org/assignments/media-types/media-types.xhtml ↩
-
オブジェクトは、他のオブジェクトを保持しているか、保持されているかによって繋がっています。これをObject Graphといい、$appはそのルートオブジェクトとなります。 ↩