Donut cache
全体の中にキャッシュできない穴がある場合、周辺の変化しない部分を再利用します。
Technology
BEAR.Sundayのキャッシュは、レスポンスを一時保存する仕組みではありません。本質的に静的なリソース表現をRead Modelとして生成し、サーバー、CDN、クライアントの各層で同一性と依存関係を維持するアーキテクチャです。
Central idea
ブログ記事、商品情報、ニュース本文、プロフィールのようなコンテンツは、リクエストのたびに PHPとDBを通るため「動的」に見えます。しかし、同じリソース状態から同じ表現が返るなら、 それは本質的には静的です。変わるのは時刻ではなくイベントです。
BEAR.Sundayはこの性質をアーキテクチャとして扱います。ResourceObjectがHTTP表現を生成し、 依存関係をURIタグとして保持し、ETagで同一性を表します。生成された表現はサーバーキャッシュや CDNに配置され、変更イベントが起きるまで静的コンテンツとして配信されます。
Write model
Database + Commands
Projection
ResourceObject creates representation
Server-side resource cache
Cache body, dependency tags, and ETag identity.
CDN shared cache
Cache body, dependency tags, and ETag identity.
Client HTTP cache
Cache body, dependency tags, and ETag identity.
Event-driven content
キャッシュのTTLを短くして、まだ古いかもしれないと祈る。多くのアプリで、これが「キャッシュ戦略」と呼ばれています。
Event-driven contentの要点は、変化の時刻が予測できないことと、内容が常に揺らぐことを 混同しないところにあります。記事はいつ編集されるか分かりません。コメントはいつ追加されるか 分かりません。それでも、イベントが起きていない間、その表現は静的です。
Fastlyはこの種のコンテンツを、未知の期間だけ静的で、変わるかもしれないコンテンツとして整理しました。 必要なのは短いTTLではなく、アプリケーションが知っている変更イベントから、CDNへ即時かつプログラム可能に purgeを届けることです。
だからTTLを短く刻んで不安を薄めるのではなく、変更を知っているアプリケーションが 依存する表現を破棄します。変更がなければCDNは同じ表現を配信し続け、クライアントは ETagで同一性を確認し、同じなら304 Not Modifiedで済ませます。
Classification
状態が変わらない限り、同じURIは同じ表現になる。イベントでのみ変化する。
リクエストごとに意味が変わる。個人化、乱数、現在時刻、計算過程そのものが表現になる。
An actual exchange
1 GET /article/42 200 ETag:"a1" Surrogate-Key: article-42 profile-7
→ CDNが保存
2 GET /article/42 CDN HIT ← PHPもDBも動かない
3 著者が profile-7 を編集
→ PURGE Surrogate-Key: profile-7 ← 依存する article-42 も連鎖で破棄
4 GET /article/42 200 ETag:"b9" ← この時だけ再生成
5 GET /article/42 If-None-Match:"b9" 304 Not Modified ← 本文を送らないDependency resolution
コンテンツAがBに依存し、BがCに依存するなら、Cの変更はCのキャッシュだけで終わりません。 BとAの表現も、BとAのETagも、古い同一性を示してしまうため破棄される必要があります。
BEAR.Sundayでは、`#[Embed]`されたリソースや明示された依存URIがタグになります。 AOPが変更を検知すると、サーバーサイドのキャッシュとETagが連鎖的に無効化され、可能な場合は CDNのSurrogate-Keyにも同じ依存関係が伝播します。依存解決はサーバーの中だけで閉じません。
Dependency graph
depends on: Profile, Comments, Weather
depends on: Comment items
depends on: Forecast source
Forecast sourceが変わると、WeatherとArticleのキャッシュ、そしてそれぞれのETagがサーバー層とCDN層で無効化されます。
Partial read models
ページ全体をキャッシュできるかどうか、という二択ではありません。BEAR.Sundayはドーナツキャッシュと ドーナツの穴キャッシュを持ち、キャッシュできる部分、できない部分、別の周期で変化する部分を分けて扱います。
重要なのは、部分表現の依存も全体の同一性に反映されることです。穴の中身が変われば、必要な範囲だけを 再生成し、全体のETagも更新されます。静的なWebのキャッシュモデルを、部分表現の合成にまで広げています。
全体の中にキャッシュできない穴がある場合、周辺の変化しない部分を再利用します。
穴そのものもキャッシュ可能な場合、部分リソースの変更が全体のキャッシュとETagへ伝播します。
AがBを含み、BがCを含む場合でも、変更されたC以外を再利用して最小コストで再生成します。
Performance and delivery quality
BEAR.Sundayは、極端なほどパフォーマンスを設計の中に置きます。速くするための後付け最適化ではなく、 SQL、リソースグラフ、DI graph、root objectが明示的な構造として存在すること自体が性能につながります。 だから出荷前に検査でき、実行時にはバッチ化、DI compile、ルートオブジェクトキャッシュ、並列化へ切り替えられます。
SQL fileとparameterが独立しているため、実行計画、フルテーブルスキャン、非効率なJOINをCIで解析できます。本番で遅さとして発見する前に、DBアクセスを品質ゲートにかけられます。
linkCrawlでリソースグラフを構成するとき、子リソースごとのDBアクセスはDataLoaderでまとめられます。複数のリソースリクエストを、1つの効率的なクエリへ変換できます。
依存グラフは実行時に毎回探索する対象ではありません。ScriptInjectorでPHP factory codeへ生成し、本番ではDI containerを解釈せず、生成済みのobject graphで起動できます。
contextに応じて組み立てたアプリケーションのルートオブジェクトをシリアライズし、リクエスト間で再利用できます。DIコンテナとAOP構成を毎回生成せず、通常のリクエスト処理から外せます。
BEAR.Asyncを使うと、逐次取得されていた#[Embed]リソースを、リソースコードを変えずに並列取得へ切り替えられます。HTML表現でもJSON表現でも、埋め込まれたリソースは並列に取得され、Moduleの差し替えだけで実行戦略を変えられます。
SQL as a first-class citizen
BEAR.Sundayは標準技術を好み、SQLをORMの裏に隠しません。Ray.MediaQueryでは、SQLは独立した ファイル、入口は型付きインターフェースです。SQLが第一級市民であることは、性能の検査 (出荷前のEXPLAIN)だけでなく、開発のしかたそのものを変えます。契約で分業でき、SQL特化のIDEも AIも、同じSQL資産を直接扱えます。
Ray.MediaQueryでは、SQLは var/sql の独立したファイル、入口は #[DbQuery] を付けた型付きインターフェースです。ORMの裏に隠さないので、JOIN、CTE、ウィンドウ関数、ベンダー固有のSQLをそのまま書けます。
インターフェース(署名・戻り値型・SQLファイル名)が契約になります。SQL担当とアプリ担当は互いの完成を待たず並行で進み、アプリ側はfakeでDBがなくてもユースケースを先に組めます。
独立した .sql ファイルなので、DataGripのようなSQL特化IDEで、スキーマ補完、実行、EXPLAIN、整形、リファクタが直接使えます。実行計画やインデックスの検討を、PHPランタイムと切り離して回せます。
動的なクエリ生成で隠れないため、AIはインターフェースの契約と実際のSQLを直接読み、書けます。人間の専用ツールとAIが、同じSQL資産に同じようにアクセスできます。
Transparent parallel execution
`#[Embed]` は、リソースの「結果」を埋め込むのではありません。リソースへの「リクエスト」、 つまりリソース間の関係そのものを埋め込みます。だから、逐次に取るか、ext-parallelのスレッドで 並列に取るか、Swooleのコルーチンで取るかを決めるのはLinkerの仕事です。 リソースクラスは、自分が並列に呼ばれたことを知りません。
URIが意図(What)を表し、実行方法(How)をModuleへ隠すからこそ、実行戦略は後から差し替えられます。 10年前に書いたリソースが、Moduleを足すだけで並列実行の恩恵を受けます。標準のPHPで開発・デバッグし、 本番では設定の変更だけで並列へ切り替えられます。
非同期プログラミングでよく語られる「関数の色」問題 ―― 非同期関数を呼ぶ関数自身も非同期になり、 コードベース全体へ伝播する ―― は、リソース境界で断ち切られます。逐次でも並列でもコードは同じ。 変わるのは実行戦略だけです。
sequential vs parallel
Sequential Parallel
Request Request
├ Embed 1 ── 50ms ├ Embed 1 ─┐
├ Embed 2 ── 50ms ├ Embed 2 ─┤
├ Embed 3 ── 50ms ├ Embed 3 ─┤
└ Embed 4 ── 50ms └ Embed 4 ─┘
Response 200ms Response 50msruntimes — application code unchanged
ext-parallel
スレッドプール。PHP-FPM / Apache 向け。bin/async.php を足すだけ。
Swoole
コルーチン。常駐サーバーで高い並行性。AsyncSwooleModuleをinstall。
mysqli
DBクエリのみ並列。最小構成。
class Dashboard extends ResourceObject
{
#[Embed(rel: 'user', src: '/user{?id}')]
#[Embed(rel: 'notifications', src: '/notifications{?user_id}')]
#[Embed(rel: 'stats', src: '/stats{?user_id}')]
public function onGet(string $id): static
{
$this->body['id'] = $id;
return $this;
}
}この埋め込み宣言は、逐次でも並列でも一文字も変わりません。MVCが「どう実行するか」を 手続きで書くのに対し、BEAR.Sundayは「リソース間の関係」を宣言します。宣言が実行戦略から 独立しているため、戦略を入れ替えてもコードに影響しません。
※ BEAR.Async は現在 Alpha。ext-parallel は ZTS版PHPとext-parallel拡張、Swooleはext-swooleが必要です。
Tests that follow links
これは便利機能ではありません。クライアントは次の操作を推測せず、リソースが差し出すリンクを辿る—— そのWebの原則(HATEOAS)を、そのまま実行できるテストにしたものです。ユーザーストーリーを リンクを辿るフローとして書き、トランスポートをDIで差し替えれば、同じ筋書きが実HTTP/JSONを通り、 さらにHTMLのlink/formへ続きます。
in-process — follows the affordances
class PurchaseFlowTest extends AbstractWorkflowTest
{
#[Alps('goProduct')]
public function testProduct(): ResourceObject
{
return $this->resource->get('page://self/product', ['id' => 1]);
}
#[Alps('doAddCartItem')]
#[Depends('testProduct')]
public function testAddToCart(ResourceObject $product): ResourceObject
{
// ベタ書きURIではなく、差し出されたリンクを辿る
$cart = $this->resource->post(
$this->linkHref($product, 'doAddCartItem'),
['qty' => 2],
);
$this->assertSame(Code::CREATED, $cart->code);
return $cart;
}
#[Alps('goCheckout')]
#[Depends('testAddToCart')]
public function testCheckout(ResourceObject $cart): ResourceObject
{
return $this->follow($cart, 'goCheckout');
}
}real HTTP/JSON — swap newResource() only
// 全工程を、実HTTP/JSONで再実行。
// 変えるのは newResource() だけ。筋書きは継承する。
final class HttpPurchaseFlowTest extends PurchaseFlowTest
{
protected function newResource(): ResourceInterface
{
return new HttpResource(
'127.0.0.1:8080',
__DIR__ . '/index.php',
);
}
}同じテストが、in-processのリソースグラフ(ミリ秒・ブラウザ無し)でも、実HTTP境界(cookie・リダイレクト込み)でも走ります。
URIをベタ書きせず、レスポンスが差し出す _links や Location(=affordance)を辿ります。クライアントと同じ歩き方。HATEOASを、実行できるテストにしたものです。
各ステップを #[Alps('goCheckout')] のようにALPSの遷移へ束縛します。テスト手順が、そのまま意味の状態遷移の走査になります。
in-processのリソースでも実HTTPでも、リソースは同じものです。newResource()をHttpResourceに変えるだけで、同じ筋書きが実HTTP/JSONを通ります。
「APIが正しく振る舞うか」はリソース層へ降ろせます。ブラウザのE2Eは、視覚回帰・実ブラウザのJS・認証フローという本来の領分に縮められます。
※ 同じ仕組みで、Fake実装とSQL実装を同じアサーションの“双子”にすれば、移行を「祈り」ではなく等価性の確認にできます——これは「すべてが注入される」ことの一例です。
Context-agnostic DI
BEAR.SundayはDIをアプリケーションの便利機能として使うだけではありません。 Google Guiceのコンセプトを継ぐRay.Diを基盤に、フレームワークそのものがDIPとADPに従います。 実行時にグローバルなモードや設定を参照して振る舞いを変えることを避けます。
contextは `prod-hal-api-app` のように、環境、表現、入出力面、アプリケーション種別を 組み合わせるマトリクスです。ただし、それはオブジェクトグラフを組み立てるためだけに使われます。 生成後のオブジェクトは、自分がproductionなのか、HTMLなのか、APIなのかを参照する必要も、 参照する手段も持ちません。
context string
prod-hal-api-app
prod: production constraints
hal: representation
api: application surface
app: resource namespace
同じinterfaceでもQualifier属性で別の依存として識別します。依存の意味は文字列の実行時分岐ではなく、型付きのKeyになります。
ModuleはGuiceと同じく束縛の集合です。installとoverrideで機能単位の構成を組み替え、contextごとのgraphを作ります。
複雑な生成、遅延生成、singletonやprototypeの寿命はProviderとScopeに閉じ込めます。利用側のオブジェクトは生成事情を知りません。
ScriptInjectorは依存グラフからPHP factory codeを生成します。本番ではコンテナを毎回解釈せず、生成済みのgraphで起動できます。
APP_DEBUGやAPP_MODEのようなグローバル値で分岐しません。振る舞いの差はinterfaceへの束縛として注入されます。
prod-hal-api-appのようなcontextはオブジェクトグラフ生成に使われ、生成後のオブジェクトは自分がどのcontextで作られたかを知りません。
アプリケーションだけでなくフレームワークのpackage構造も依存方向を保ちます。外側の都合を内側が参照しない設計です。
HTMLアプリケーションもAPIアプリケーションも同じResourceObjectを通るため、テストはapp:// URIでリソースを呼び、body、headers、linksを確認できます。レンダリング後のHTMLを解析して業務結果を推測する必要はありません。
Application composition
同じリソース群をHTMLアプリケーションとしてもAPIアプリケーションとしても実行できるのは、 振る舞いがcontext moduleとDI bindingで組み替えられるからです。リソースのコード自身は、 どの表現で呼ばれているかを知る必要がありません。
つまり、HTMLサイトとAPIサイトを別々の実装として作る必要はありません。HTML表現もAPI表現も 同じリソースから生まれるため、画面の機能テストでもHTML文字列を解析せず、APIと同じように リソースの状態、リンク、ヘッダーを確認できます。
さらに、別のアプリケーションをvendor packageとして取り込み、名前空間と依存関係で独立性を保ったまま 統合できます。HTTPでサービス境界を作って分割しなくても、DIPとADPに沿って独立したアプリケーションを 1つのオブジェクトグラフに組み込めます。
この性質は外部からの呼び出しにも効きます。BEAR.Sundayで作ったリソースをpackageとして取り込み、 Resource clientを既存アプリケーションへ注入すれば、他のPHPフレームワーク上のコードからも app:// URIで同じリソースを呼び出せます。
same independence, no network
// マイクロサービスなら:ネットワーク越し
$post = $http->get('https://blog.internal/posts/42');
// → タイムアウト・リトライ・直列化・別デプロイ
// BEAR.Sunday:別アプリを vendor に取り込み、URI で呼ぶだけ
composer require acme/blog
$post = $this->resource->get('app://blog/post', ['id' => 42]);
// → 同一プロセス・ネットワーク無しwithout an HTTP wall
MyVendor\Cms\Resource
MyVendor\Blog\Resource
Acme\Inventory\Resource
独立したアプリケーションを、通信プロトコルではなくpackage、namespace、DI bindingで構成する。
Direction of technology
多くのフレームワークでは、HTMLとは別にAPI controllerを作り、CLIやAI向けにはまた別の実装を作ります。 BEAR.Sundayでは向きが逆です。アプリケーションの意味はResourceObjectにあり、HTTP、HTML、API、 CLI、Homebrewコマンド、Tool Use、多言語連携は、そのリソースへ接続するブリッジになります。
APIサイトやCLIコマンドを追加実装するのではなく、既存のResourceObjectをAPIやコマンドとして転送します。Homebrewで配布するコマンドになっても、アプリケーションの意味は同じリソースに残ります。
AI向けの関数群を別に作るのではなく、リソースをTool Useへブリッジします。MCPのような新しい入口が必要なら、作るべき中心は業務ロジックではなくリソースとのブリッジです。
先にIDLを置き、それに合わせてcontrollerを実装する向きではありません。リソースが先にあり、HTTP、Tool Use、ドキュメント、スキーマが、その意味を外へ運びます。
BEAR.Thriftを使えば、BEAR.Sundayリソースを他言語や異なるPHPバージョンから利用できます。リソースの汎用性が、HTTPの外側にも伸びます。
Portable resources
BEAR.SundayのResourceObjectは、特定のWebフレームワークのcontroller actionではありません。 URIで識別され、Resource clientから呼び出されるアプリケーション部品です。そのため、 BEAR.Sundayで作った機能をvendor配下に取り込み、既存のPHPアプリケーションから利用できます。
これは単なるモジュラモノリスではありません。ネットワーク越しのマイクロサービスを作らずに、 リソース単位の独立性、再利用性、URIによる呼び出し境界を得る設計です。
call flow
BEAR.Sundayのリソースは、HTTP controllerに閉じた処理ではありません。Resource clientを注入できれば、他のPHPアプリケーションからもURIで呼び出せます。
独立したアプリケーションを別プロセスやHTTPサービスに分けなくても、package、namespace、DI bindingで独立性を保ったまま統合できます。
既存アプリケーションの中からBEAR.Sundayの新しいリソースを呼び出し、必要な機能だけを段階的に導入できます。
Architecture
BEAR.Sundayにおけるキャッシュは、レスポンスを速くするための補助機能ではありません。 リソースから本質的に静的なHTTP表現を生成し、その同一性をETagで示し、その依存関係をサーバーとCDNにまたがって 維持し、変更イベントで破棄する。これは、アプリケーションのRead ModelをWebの仕組みとして創り出す設計です。
Start with one resource