OTOBANK Engineering Blog

オトバンクはコンテンツが大好きなエンジニアを募集しています!

BEAR.Sundayをコードリーディングしたのでメモ程度にアウトプットする

この記事には @koriym さんによるフォローアップ記事: Re: BEAR.Sundayをコードリーディングしたのでメモ程度にアウトプットする - BEAR Blog があります。併せてご覧ください。


お久しぶりです。 @kalibora です。

題名が若干のルー大柴感ありますが、以前から気にはなっていたものの手を出していなかった BEAR.Sunday を最近触りまして、

理解を深めるためにもコードを多少読んだので、ここにそのメモを藪からスティックに垂れ流したいと思います。

誰かのお役に立てれば幸いです。

(多分に間違えている可能性があるので、その際はマサカリを投げてください。)

前提条件

以下のバージョンのコードを読みました。

$ composer show | grep 'bear/\(app-meta\|package\|resource\|sunday\)'
bear/app-meta                      1.2.4              BEAR.Sunday application meta information
bear/package                       1.7.0              BEAR.Sunday framework package
bear/resource                      1.9.0              Hypermedia framework for object as a service
bear/sunday                        1.2.1              A resource-oriented application framework

また、下記のように bear/skeleton を使ってプロジェクトを開始しています。

$ composer create-project bear/skeleton MyVendor.MyPackage

github.com

読み進める前提としては、BEAR.Sunday の チュートリアル をやって、なんとなく理解していた方が分かりやすいかと思います。

3つのエントリポイント

何はともあれ開始地点であるエントリポイントを見てみます。 webからのアクセス用と、CLI用とで3つありました。

<?php
// public/index.php

$context = PHP_SAPI === 'cli-server' ? 'hal-app' : 'prod-hal-app';
require dirname(__DIR__) . '/bootstrap/bootstrap.php';
<?php 
// bootstrap/web.php

$context = PHP_SAPI === 'cli' ? 'cli-hal-app' : 'hal-app';
require __DIR__ . '/bootstrap.php';
<?php
// bootstrap/api.php

$context = PHP_SAPI === 'cli' ? 'cli-hal-api-app' : 'hal-api-app';
require __DIR__ . '/bootstrap.php';

それぞれの違いは単純にコンテキストを変えているだけ。 そして bootstrap.php を呼んでいるのみ。

bootstrap.phpでは何をしているのか?

<?php
// bootstrap/bootstrap.php

use BEAR\Package\Bootstrap;
use BEAR\Resource\ResourceObject;

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

/* @global string $context */
$app = (new Bootstrap)->getApp('MyVendor\MyPackage', $context, dirname(__DIR__));
$request = $app->router->match($GLOBALS, $_SERVER);

try {
    $page = $app->resource->{$request->method}->uri($request->path)($request->query);
    /* @var $page ResourceObject */
    $page->transfer($app->responder, $_SERVER);
    exit(0);
} catch (\Exception $e) {
    $app->error->handle($e, $request)->transfer();
    exit(1);
}

このように非常に短いスクリプトで全体の流れを記述しているのみ。

次節からは $app, $request, $page, $page->transfer(), $app->error->handle() について深掘りしていく。

$app とは?

<?php
$app = (new Bootstrap)->getApp('MyVendor\MyPackage', $context, dirname(__DIR__));

上記の $app とは何者なのか?

<?php
// `BEAR\Package\Bootstrap` より抜粋
use BEAR\Sunday\Extension\Application\AppInterface;

final class Bootstrap
{
    public function getApp(string $name, string $contexts, string $appDir = null) : AbstractApp
    {
        return $this->newApp(new AppMeta($name, $contexts, $appDir), $contexts);
    }

    public function newApp(AbstractAppMeta $appMeta, string $contexts, Cache $cache = null) : AbstractApp
    {
        // snip: $contexts が prod や stage だったらキャッシュから取得する処理

        $app = (new AppInjector($appMeta->name, $contexts))->getInstance(AppInterface::class);

        // snip: キャッシュに保存する処理

        return $app;
    }
}

BEAR\Sunday\Extension\Application\AppInterface をDI(Dependency Injection. 後述の AppInjector がその役割を担っている)で解決したインスタンスが $app となる。

後述するが、結局の所これは MyVendor\MyPackage\Module\App のインスタンスである。

AppInjector

AppInjector::getInstance() メソッドでは、指定したinterfaceに束縛されたインスタンスを、依存解決済みで返してくれる。
(最初の1回目の場合は、依存解決したものをすべてフラットなPHPファイルとしてダンプする。これをコンパイル処理と呼んでいるみたい)

では、膨大なクラス同士の依存関係はどのように定義されているのか?

<?php
// `BEAR\Package\AppInjector`から抜粋
use Ray\Di\AbstractModule;

final class AppInjector implements InjectorInterface
{
    /**
     * Return configured module
     */
    private function newModule(AbstractAppMeta $appMeta, string $contexts) : AbstractModule
    {
        $contextsArray = array_reverse(explode('-', $contexts));
        $module = null;
        foreach ($contextsArray as $context) {
            $class = $appMeta->name . '\Module\\' . ucwords($context) . 'Module';
            if (! class_exists($class)) {
                $class = 'BEAR\Package\Context\\' . ucwords($context) . 'Module';
            }
            if (! is_a($class, AbstractModule::class, true)) {
                throw new InvalidContextException($class);
            }
            /* @var $module AbstractModule */
            $module = new $class($module);
        }
        $module->install(new ResourceObjectModule($appMeta));
        $module->override(new AppMetaModule($appMeta));

        return $module;
    }
}

このように Ray\Di\AbstractModule を継承したモジュールがその役割を担っている。

MyVendor\MyPackage\Module\{Context}Module もしくは BEAR\Package\Context\{Context}Module$contexts の逆順で読み込まれる。

例えば、 $contexts = cli-hal-api-app であれば、

  • MyVendor\MyPackage\Module\AppModule
  • BEAR\Package\Context\ApiModule
  • BEAR\Package\Context\HalModule
  • BEAR\Package\Context\CliModule

の順番で読み込まれる。しかし優先順位はその逆である。($module = new $class($module); だと、引数で渡されたモジュールの方が優先度が低いので)

その後に ResourceObjectModule を install し、 AppMetaModule を override しているので、最終的な優先順位は

  1. BEAR\Package\AppMetaModule
  2. BEAR\Package\Context\CliModule
  3. BEAR\Package\Context\HalModule
  4. BEAR\Package\Context\ApiModule
  5. MyVendor\MyPackage\Module\AppModule
  6. BEAR\Package\Provide\Resource\ResourceObjectModule

となる。

モジュールが依存関係(AOPの設定もしているけど)を定義していると述べたが、具体的にはどのように行っているのか?

AppMetaModule を例に取ると、

<?php
// `BEAR\Package\AppMetaModule`から抜粋
    protected function configure()
    {
        // (snip)
        $this->bind(AppInterface::class)->to($this->appMeta->name . '\Module\App');
        // (snip)
    }

このように AppInterface$this->appMeta->name . '\Module\App' に束縛(bind)している事がわかる。

この設定から、先に述べたように最終的に $appMyVendor\MyPackage\Module\App のインスタンスとなるのだ!(ドヤァ)

$requestとは?

<?php
$request = $app->router->match($GLOBALS, $_SERVER);

上記の $request とは何者なのか?

前節で $appMyVendor\MyPackage\Module\App のインスタンスであると述べた。

また、これは BEAR\Sunday\Extension\Application\AbstractApp を継承している。

そしてデフォルトでは AbstractApp::$router : RouterInterrfaceBEAR\Package\Provide\Router\WebRouter に束縛されている。

この設定は下記のモジュールを見ると分かる。

  • BEAR\Package\PackageModule
    • BEAR\Package\Provide\Router\WebRouterModule

そういうわけで、実態である WebRouter を見ていく。

// `BEAR\Package\Provide\Router\WebRouter` から抜粋
<?php
    public function match(array $globals, array $server)
    {
        $request = new RouterMatch;
        list($request->method, $request->query) = $this->httpMethodParams->get($server, $globals['_GET'], $globals['_POST']);
        $request->path = $this->schemeHost . parse_url($server['REQUEST_URI'], PHP_URL_PATH);

        return $request;
    }

BEAR\Package\Provide\Router\HttpMethodParams も併せて読むと分かるが、

$_GET$_POSTphp://input などの差異をうまく吸収して、 クエリ文字列だろうがPOSTされたボディであろうが、それらを気にせず、 $request->query に入れてくれている。

また、 _methodHTTP_X_HTTP_METHOD_OVERRIDE を見てよしなに $request->method に入れている。

例として、 コンテキストが api で、 GET /path/to?key=value をリクエストすると、 下記のような値が入る。

  • $request->method: get
  • $request->query:['key'=> 'value', 'hoge' => 'fuga']
  • $request->path: app://self/path/to

HTTPリクエストから、BEARで扱う形式への変換、マッピングというのがこの処理の肝なのではないだろうか。

$pageとは?

<?php
$page = $app->resource->{$request->method}->uri($request->path)($request->query);

上記の $page とは何者なのか?

と、その前に分かりやすくするために、 GET /path/to?key=value というリクエストが来たことにしておく。

するとこうなる。

<?php
$page = $app->resource->get->uri('app://self/path/to')(['key'=> 'value', 'hoge' => 'fuga']);

デフォルトでは AbstractApp::$resource : ResourceInterfaceBEAR\Resource\Resource に束縛されている。

この設定は下記のモジュールを見ると分かる。

  • BEAR\Package\PackageModule
    • BEAR\Sunday\Module\SundayModule
      • BEAR\Sunday\Module\Resource\ResourceModule
        • BEAR\Resource\Module\ResourceClientModule

では実態である Resource を見ていく。

<?php
// `BEAR\Resource\Resource` から抜粋
    // (snip)
    public function __get($name)
    {
        $this->method = $name;

        return $this;
    }
    // (snip)
    public function uri($uri)
    {
        if (is_string($uri)) {
            $uri = new Uri($uri);
        }
        $uri->method = $this->method;
        $resourceObject = $this->newInstance($uri);
        $resourceObject->uri = $uri;
        $this->request = new Request(
            $this->invoker,
            $resourceObject,
            $uri->method,
            $uri->query,
            [],
            $this->linker
        );
        $this->method = 'get';

        return $this->request;
    }
    // (snip)
    public function newInstance($uri)
    {
        return $this->factory->newInstance($uri);
    }

oops. まずは factory が何か知らないといけない。 詳細は割愛するが、 BEAR\Resource\Factory がそれだ。

というわけでさらに BEAR\Resource\Factory を読むと、

<?php
// `BEAR\Resource\Factory` から抜粋
    // (snip)
    /**
     * Resource adapter biding config
     *
     * @var SchemeCollectionInterface
     */
    private $scheme;
    // (snip)
    public function newInstance($uri)
    {
        if (! $uri instanceof Uri) {
            $uri = new Uri($uri);
        }
        $adapter = $this->scheme->getAdapter($uri);

        return $adapter->get($uri);
    }
    // (snip)

というように、今度は SchemeCollectionInterface にたどり着く。

これは BEAR\Resource\Module\SchemeCollectionProvider によってインスタンスが提供されている。

<?php
// `BEAR\Resource\Module\SchemeCollectionProvider` から抜粋
    // (snip)
    public function get()
    {
        $schemeCollection = new SchemeCollection;
        $pageAdapter = new AppAdapter($this->injector, $this->appName);
        $appAdapter = new AppAdapter($this->injector, $this->appName);
        $schemeCollection->scheme('page')->host('self')->toAdapter($pageAdapter);
        $schemeCollection->scheme('app')->host('self')->toAdapter($appAdapter);

        return $schemeCollection;
    }
    // (snip)

とまぁこんな感じで、 BEAR\Resource\AppAdapter を見ればいいんだなってことが何となく分かる。

<?php
// `BEAR\Resource\AppAdapter`から抜粋
    // (snip)
    public function get(AbstractUri $uri)
    {
        if (substr($uri->path, -1) === '/') {
            $uri->path .= 'index';
        }
        $path = str_replace('-', '', ucwords($uri->path, '/-'));
        $class = sprintf('%s%s\Resource\%s', $this->namespace, $this->path, str_replace('/', '\\', ucwords($uri->scheme) . $path));

        try {
            $instance = $this->injector->getInstance($class);
        } catch (Unbound $e) {
            throw $this->getNotFound($uri, $e, $class);
        }

        return $instance;
    }
    // (snip)

はい、ここは結構重要だと思ったところ。

ここで、 Uri('app://self/path/to') を元にしたクラス名に変換され、

それを DI から取得している事がわかる。

この場合は、 MyVendor\MyPackage\Resource\App\Path\To になるので、このインスタンスが取得される。

ちなみに、これらのリソースオブジェクトをDIに束縛しているのは、BEAR\Package\Provide\Resource\ResourceObjectModule みたい。

さぁ、ここまででやっと、BEAR\Resource\Resource::uri($uri) 内の処理である

<?php
$resourceObject = $this->newInstance($uri);

が何をしているか分かった。

URIに基づくリソースオブジェクトクラスのインスタンスをDIから取得したわけだ。

しかしまだ実行はしていない。

で、これを BEAR\Resource\Request に詰め込んで生成して返している。

この BEAR\Resource\Request には実行すべきリソースオブジェクトと BEAR\Resource\Invoker という、リソースオブジェクトを実行するインスタンスを持っているため、 あとはこれを実行すればよい。

実行するメソッドは親クラスである BEAR\Resource\AbstractRequest にある。

<?php
// `BEAR\Resource\AbstractRequest`から抜粋
    // (snip)
    public function __invoke(array $query = null)
    {
        if ($query !== null) {
            $this->query = array_merge($this->query, $query);
        }
        if ($this->links) {
            return $this->linker->invoke($this);
        }

        return $this->invoker->invoke($this);
    }
    // (snip)

引数にクエリパラメーターを取り、Invokerを用いて実行している。

<?php
// `BEAR\Resource\Invoker`から抜粋
    // (snip)
    public function invoke(AbstractRequest $request)
    {
        $onMethod = 'on' . ucfirst($request->method);
        if (method_exists($request->resourceObject, $onMethod) !== true) {
            return $this->invokeOptions($request->resourceObject, $request, $onMethod);
        }
        if ($request->resourceObject->uri instanceof AbstractUri) {
            $request->resourceObject->uri->query = $request->query;
            $request->resourceObject->uri->method = $request->method;
        }
        $params = $this->params->getParameters([$request->resourceObject, $onMethod], $request->query);
        $result = call_user_func_array([$request->resourceObject, $onMethod], $params);

        return $this->postRequest($request, $result);
    }
    // (snip)

なんじゃかんじゃあるが、結局のところ重要なのは下記だけ。

<?php
call_user_func_array([$request->resourceObject, $onMethod], $params)

リソースオブジェクトのonGetなどのメソッドは与えられた引数を元に、 自分自身の内部状態を変えて、自分自身(リソースオブジェクト)を返すことになっているので、 最終的にリソースオブジェクトが返る。

ここまでを振り返ってみると、

<?php
$page = $app->resource->get->uri('app://self/path/to')(['key'=> 'value', 'hoge' => 'fuga']);

これは

<?php
$resource = $app->resource; // BEAR\Resource\Resource
$resource = $resource->get; // BEAR\Resource\Resource
$request = $resource->uri('app://self/path/to'); // BEAR\Resource\Request
$page = $request('key'=> 'value', 'hoge' => 'fuga']); // BEAR\Resource\ResourceObject

このように理解することが出来る。

$page->transfer() は何をしている?

力尽きたのでまた今度・・。

$app->error->handle() は何をしている?

力尽きたのでまた今度・・。

今回のまとめ

今回読んだ範囲での自分の理解をまとめてみます。

変数 インターフェイスとクラス 私の理解
$app BEAR\Sunday\Extension\Application\AppInterface
実態は MyVendor\MyPackage\Module\App
$contexts に応じ、DIが依存解決したオブジェクトグラフを持つアプリケーション
$request BEAR\Sunday\Extension\Router\RouterMatch HTTPリクエストをBEARリソースへのメソッド,URI,パラメータに変換したもの
$page BEAR\Resource\ResourceObject BEARリソースのメソッドとURIから、それに該当するリソースオブジェクトを取得し、それを実行した結果

$app が面白いですね。全部そこにまとまっているっていうのが。

さてさて、それではまた。