並列リソース実行 Alpha

BEAR.Asyncは#[Embed]リソースの透過的な並列実行を可能にします。アプリケーションコードを変更することなく、埋め込みリソースを並列に取得します。10年前に書かれたリソースクラスも、Moduleを追加するだけで並列実行の恩恵を受けられます。

概要

標準のBEAR.Sundayでは#[Embed]リソースは順次取得されますが、BEAR.Asyncを使用すると並列に取得されます。

[順次実行]                       [並列実行]
Request                          Request
    │                                │
    ├── Embed 1 ──── 50ms            ├── Embed 1 ──┬── 50ms
    ├── Embed 2 ──── 50ms            ├── Embed 2 ──┤
    ├── Embed 3 ──── 50ms            ├── Embed 3 ──┤
    └── Embed 4 ──── 50ms            └── Embed 4 ──┘
    │                                │
Response (200ms)                 Response (50ms)

設計思想

URLは意図である

BEAR.Sundayにおいて、URIは単なる場所ではなく意図を表現します。

#[Embed(rel: 'profile', src: 'query://self/user_profile{?id}')]

このquery://self/user_profileは「ユーザーのプロファイル情報が欲しい」という意図だけを示しています。この「What(何を)」と「How(どう)」の分離により、同じコードが同期実行でも並列実行でも動作します。開発時は通常のPHPとしてXdebugでデバッグし、本番ではModuleを切り替えるだけで並列実行を有効化できます。

関数の色問題の解決

非同期プログラミングには「関数の色」問題があります。非同期関数を呼ぶ関数は自身も非同期でなければならず、コード全体が「非同期に汚染」されていきます。

BEAR.Sundayでは「リソース」という境界がこの問題を断ち切ります。非同期のためのコード記述は一切不要で、リソースクラスは自分がどう呼び出されたかを知る必要がありません。

インストール

composer require bear/async

設定

サーバー環境に応じて適切なモジュールを選択します。

環境 モジュール 特徴
PHP-FPM / Apache AsyncParallelModule ext-parallel使用、ZTS PHP必要
Swoole HTTPサーバー AsyncSwooleModule コルーチン使用、接続プール必要

AsyncParallelModule

use BEAR\Async\Module\AsyncParallelModule;

class AppModule extends AbstractModule
{
    protected function configure(): void
    {
        $this->install(new AsyncParallelModule(
            namespace: 'MyVendor\MyApp',
            context: 'prod-app',
            appDir: dirname(__DIR__),
        ));
    }
}

AsyncSwooleModule

use BEAR\Async\Module\AsyncSwooleModule;
use BEAR\Async\Module\PdoPoolEnvModule;

class AppModule extends AbstractModule
{
    protected function configure(): void
    {
        $this->install(new AsyncSwooleModule());
        $this->install(new PdoPoolEnvModule('PDO_DSN', 'PDO_USER', 'PDO_PASSWORD'));
    }
}

Swooleではコルーチンがメモリを共有するため、PdoPoolEnvModuleによる接続プールが必要です。

使用方法

モジュールをインストールすると、既存の#[Embed]リソースは自動的に並列実行されます。

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;
    }
}

開発環境では非同期モジュールをインストールせず、本番環境でのみ有効にすることで、同期モードでのデバッグと本番での並列実行を使い分けられます。

BEAR.Projectionとの連携

BEAR.Projectionは、SQLクエリ結果を型付きのProjectionオブジェクトに変換し、query://スキームでリソースとして公開します。#[Embed]と組み合わせることで、複数のSQLクエリが並列実行されます。

Projectionクラスはイミュータブルな値オブジェクトとして定義します。

final class UserProfile
{
    public function __construct(
        public readonly string $id,
        public readonly string $name,
        public readonly int $age,
        public readonly string $avatarUrl,
    ) {}
}

Factoryクラスでは、SQLの生データをProjectionに変換します。DIで依存を注入できるため、年齢計算やURL解決などのビジネスロジックを適用できます。

final class UserProfileFactory
{
    public function __construct(
        private readonly AgeCalculator $ageCalculator,
        private readonly ImageUrlResolver $imageResolver,
    ) {}

    public function __invoke(
        string $id,
        string $name,
        string $birthDate,
        string $avatarPath,
    ): UserProfile {
        return new UserProfile(
            id: $id,
            name: $name,
            age: $this->ageCalculator->fromBirthDate($birthDate),
            avatarUrl: $this->imageResolver->resolve($avatarPath),
        );
    }
}

SQLファイルはFactoryのパラメータ名に対応するカラムを返します。

-- var/sql/query/user_profile.sql
SELECT id, name, birth_date, avatar_path FROM users WHERE id = :id

これらを#[Embed]で利用すると、複数のProjectionが並列実行されます。

class User extends ResourceObject
{
    #[Embed(rel: 'profile', src: 'query://self/user_profile{?id}')]
    #[Embed(rel: 'orders', src: 'query://self/user_orders{?id}')]
    public function onGet(string $id): static
    {
        return $this;
    }
}

SQLバッチ実行

mysqliのネイティブ非同期サポートを使用した並列SQLクエリ実行も提供します。

use BEAR\Async\Module\MysqliBatchEnvModule;

$this->install(new MysqliBatchEnvModule('MYSQL_HOST', 'MYSQL_USER', 'MYSQL_PASS', 'MYSQL_DB'));
use BEAR\Async\SqlBatch;
use BEAR\Async\SqlBatchExecutorInterface;

class MyService
{
    public function __construct(
        private SqlBatchExecutorInterface $executor,
    ) {}

    public function getData(int $userId): array
    {
        return (new SqlBatch($this->executor, [
            'user' => ['SELECT * FROM users WHERE id = ?', [$userId]],
            'posts' => ['SELECT * FROM posts WHERE user_id = ?', [$userId]],
        ]))();
    }
}

参考リンク