チュートリアル2

このチュートリアルでは以下のツールを用いてタスク管理のチケット作成・取得用REST APIを作成し、疎結合で高品質なREST APIアプリケーションの開発をテスト駆動で学びます。1

  • CakePHPが開発してるフレームワーク非依存のPhinx DBマイグレーションツール
  • JSONのデータ構造を定義しバリデーションやドキュメンテーションに利用する Json Schema
  • SQL文をSQL実行オブジェクトに変換しアプリケーションレイヤーとデータアクセスレイヤーを疎にする ray/query-module
  • UUIDや現在時刻をインジェクトする IdentityValueModule

作成するAPIはスキーマ定義され、自己記述(self-descriptive)性に優れた高品質なものです。

チュートリアルと被る箇所もありますがおさらいのつもりでトライしてみましょう。 レポジトリは bearsunday/tutorial2 にあります。うまくいかないときは見比べてみましょう。

プロジェクト作成

プロジェクトスケルトンを作成します。

composer create-project bear/skeleton MyVendor.Ticket

vendor名をMyVendorproject名をTicketとして入力します。2

composerインストール

次に依存するパッケージを一度にインストールします。

composer require bear/aura-router-module ray/identity-value-module ray/query-module
composer require --dev robmorgan/phinx bear/api-doc 1.x-dev

モジュールインストール

src/Module/AppModule.phpを編集してcomposerでインストールしたパッケージをモジュールインストールします。

<?php
namespace MyVendor\Ticket\Module;

use BEAR\Package\AbstractAppModule;
use BEAR\Package\PackageModule;
use BEAR\Package\Provide\Router\AuraRouterModule;
use BEAR\Resource\Module\JsonSchemaLinkHeaderModule;
use BEAR\Resource\Module\JsonSchemaModule;
use Ray\AuraSqlModule\AuraSqlModule;
use Ray\IdentityValueModule\IdentityValueModule;
use Ray\Query\SqlQueryModule;

class AppModule extends AbstractAppModule
{
    /**
     * {@inheritdoc}
     */
    protected function configure()
    {
        $appDir = $this->appMeta->appDir;
        require_once $appDir . '/env.php';
        $this->install(
            new AuraSqlModule(
                getenv('TKT_DB_DSN'),
                getenv('TKT_DB_USER'),
                getenv('TKT_DB_PASS'),
                getenv('TKT_DB_SLAVE')
            )
        );
        $this->install(new SqlQueryModule($appDir . '/var/sql'));
        $this->install(new IdentityValueModule);
        $this->install(
            new JsonSchemaModule(
                $appDir . '/var/json_schema',
                $appDir . '/var/json_validate'
            )
        );
        $this->install(new JsonSchemaLinkHeaderModule('http://www.example.com/'));
        $this->install(new AuraRouterModule($appDir . '/var/conf/aura.route.php'));
        $this->install(new PackageModule);
    }
}

テスト用のデータベースのためにsrc/Module/TestModule.phpも作成します。

<?php
namespace MyVendor\Ticket\Module;

use BEAR\Package\AbstractAppModule;
use Ray\AuraSqlModule\AuraSqlModule;

class TestModule extends AbstractAppModule
{
    /**
     * {@inheritdoc}
     */
    protected function configure()
    {
        $this->install(
            new AuraSqlModule(
                getenv('TKT_DB_DSN') . '_test',
                getenv('TKT_DB_USER'),
                getenv('TKT_DB_PASS'),
                getenv('TKT_DB_SLAVE')
            )
        );
    }
}

モジュールが必要とするフォルダを作成します。

mkdir var/sql
mkdir var/json_schema
mkdir var/json_validate

ルーターファイル

tickets/{id}のアクセスをTicketクラスにルートするためにルーターファイルをvar/conf/aura.route.phpに設置します。

<?php
/* @var \Aura\Router\Map $map */
$map->route('/ticket', '/tickets/{id}');

データベース

プロジェクトルートフォルダの.envファイルに接続情報を記述します。3

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

.envはリポジトリにはコミットされません。.env.distに記述例を残して置きましょう。

cp .env .env.dist
// remove password, etc..
git add .env.dist

マイグレーション

phinxの実行環境を整えます。

まずはphinxが利用するフォルダを作成します。

mkdir -p var/phinx/migrations
mkdir var/phinx/seeds

次に.envの接続情報をphinxで利用するためにvar/phinx/phinx.phpを設置します。

<?php
require_once dirname(__DIR__, 2) . '/env.php';
$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
require dirname(__DIR__) . '/autoload.php';
require_once dirname(__DIR__) . '/env.php';
// 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('CREATE DATABASE IF NOT EXISTS ' . 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');

実行してデータベースを作成します。

composer setup
Phinx by CakePHP - https://phinx.org. 0.10.6

...
using database ticket_test

次にticketテーブルを作成するためにマイグレーションクラスを作成します。

./vendor/bin/phinx create Ticket -c var/phinx/phinx.php
Phinx by CakePHP - https://phinx.org. 0.10.6

...
created var/phinx/migrations/20180920054037_ticket.php

var/phinx/migrations/{current_date}_ticket.phpを編集してchange()メソッドを実装します。

<?php
use Phinx\Migration\AbstractMigration;

class Ticket extends AbstractMigration
{
    public function change()
    {
         $table = $this->table('ticket', ['id' => false, 'primary_key' => ['id']]);
         $table->addColumn('id', 'uuid')
            ->addColumn('title', 'string')
            ->addColumn('description', 'string')
            ->addColumn('status', 'string')
            ->addColumn('assignee', 'string')
            ->addColumn('created_at', 'datetime')
            ->addColumn('updated_at', 'datetime')
            ->create();
    }
}

もう一度セットアップコマンドを実行してテーブルを作成します。

composer setup
> php bin/setup.php
Phinx by CakePHP - https://phinx.org. 0.10.6

...
All Done. Took 0.0248s

これでテーブルが作成されました。次回からこのプロジェクトのデータベース環境を整えるにはcomposer setupを実行するだけで行えます。4 マイグレーションクラスの記述について詳しくはPhixのマニュアル:マイグレーションを書くをご覧ください。

SQL

チケットをデータベースに保存、読み込むために次の3つのSQLをvar/sqlに保存します。

var/sql/ticket_insert.sql

/* create ticket */
INSERT INTO ticket (id, title, description, status, assignee, created_at, updated_at)
VALUES (:id, :title, :description, :status, :assignee, :created_at, :updated_at)

var/sql/ticket_list.sql

SELECT id, title, description, status, assignee, created_at, updated_at
  FROM ticket

var/sql/ticket_item_by_id.sql

SELECT id, title, description, status, assignee, created_at, updated_at
  FROM ticket
 WHERE id = :id

上記のSQLの記述はSQLスタイルガイドに従ったものです。以下の事柄が推奨されています。

  • スペースとインデントを慎重に使用しコードを読みやすくする。
  • ISO-8601に準拠した日付時間フォーマット(YYYY-MM-DD HH:MM:SS.SSSSS)で格納する。
  • 移植性のためベンダー固有の関数の代わりに標準のSQL関数のみを使用する。
  • 必要に応じてSQLコードにコメントを挿入する。可能なら /* で始まり */ で終わるC言語スタイルのコメントを使用し、その他の場合、– で始まり改行で終わる行コメントを使用する。
  • スペースを活用し、基底のキーワードがすべて同じ位置で終わるようにコードを整列させる。これは途中で「リバー」を形作り、コードの見通しを良くし、実装の詳細からキーワードを分離することを容易にする。

PHPStormでDatabase Navigatorを使うと、SQLのコード補完や実行が行えます。 PHPでSQLを実行する前に、データベースツールでSQLを単体で実行して正しく記述できているかを確かめると開発も容易で確実です。

JetBrain DataGripSequel ProMySQL Workbenchなどの単体のデータベースツールがあります。

JsonSchema

Ticketチケットアイテム)、Ticketsチケットアイテムの集合)の2つのリソースを作成するために、まずこれらのリソースの定義をJsonSchemaで定義します。JsonSchemaについて日本語での解説もご覧ください。

それぞれのスキーマファイルをvar/json_schemaフォルダに保存します。

var/json_schema/ticket.json


{
  "$id": "ticket.json",
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Ticket",
  "type": "object",
  "properties": {
    "id": {
      "type": "string",
      "description": "The unique identifier for a ticket."
    },
    "title": {
      "type": "string",
      "description": "The title of the ticket",
      "minLength": 3,
      "maxLength": 255
    },
    "description": {
      "type": "string",
      "description": "The description of the ticket",
      "maxLength": 255
    },
    "assignee": {
      "type": "string",
      "description": "The assignee of the ticket",
      "maxLength": 255
    },
    "status": {
      "description": "The name of the status",
      "type": "string",
      "maxLength": 255
    },
    "created_at": {
      "description": "The date and time that the ticket was created",
      "type": "string",
      "format": "datetime"
    },
    "updated_at": {
      "description": "The date and time that the ticket was last modified",
      "type": "string",
      "format": "datetime"
    }
  },
  "required": ["title", "description", "status", "created_at", "updated_at"]
}

var/json_schema/tickets.json

{
  "$id": "tickets.json",
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Collection of Tickets",
  "type": "array",
  "items": {
    "$ref": "ticket.json"
  }
}

作成したJSONをvalidate-jsonを使ってバリデートすることができます。5

./vendor/bin/validate-json var/json_schema/ticket.json
./vendor/bin/validate-json var/json_schema/tickets.json

これでリソースを定義をすることが出来ました。このスキーマは実際にバリデーションで使うことが出来ます。また独立したJSONファイルはフロントエンドのバリデーションでも使うことができるでしょう。

テスト

次に今から作ろうとする/ticketリソースのテストをtests/Resource/App/TicketsTest.phpに用意します。

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

use BEAR\Package\AppInjector;
use BEAR\Resource\ResourceInterface;
use BEAR\Resource\ResourceObject;
use Koriym\HttpConstants\ResponseHeader;
use Koriym\HttpConstants\StatusCode;
use PHPUnit\Framework\TestCase;

class TicketsTest extends TestCase
{
    /**
     * @var ResourceInterface
     */
    private $resource;

    protected function setUp() : void
    {
        $this->resource = (new AppInjector('MyVendor\Ticket', 'test-app'))->getInstance(ResourceInterface::class);
    }

    public function testOnPost()
    {
        $ro = $this->resource->post('app://self/tickets', [
            'title' => 'title1',
            'status' => 'status1',
            'description' => 'description1',
            'assignee' => 'assignee1'
        ]);
        $this->assertSame(StatusCode::CREATED, $ro->code);
        $this->assertStringContainsString('/ticket?id=', $ro->headers[ResponseHeader::LOCATION]);

        return $ro;
    }

    /**
     * @depends testOnPost
     */
    public function testOnGet(ResourceObject $ro)
    {
        $location = $ro->headers[ResponseHeader::LOCATION];
        $ro = $this->resource->get('app://self' . $location);
        $this->assertSame('title1', $ro->body['title']);
        $this->assertSame('description1', $ro->body['description']);
        $this->assertSame('assignee1', $ro->body['assignee']);
    }
}

$this->resourceMyVendor\Ticketアプリケーションをtest-appコンテキストで動作させた時のリソースクライアントです。 AppModuleTestModuleの順のモジュールで上書きされるのでデータベースはテスト用のticket_testデータベースが使われます。

testOnPostでリソースをPOSTリクエストで作成して、testOnGetではそのレスポンスのLocationヘッダーに表されているリソースのURIをGETリクエストして、作成したリソースが正しいものかをテストしています。

まだ実装してないのでエラーが出ますが、テスト実行を試してみましょう。

composer test

当面の目標はこのテストがパスするようになる事です。テストを先に用意する事で、作ったリソースの実行やデバックトレースが簡単になり、着手した作業のゴールが明確になります。

リソース

リソースのロジックはSQLとして、そのバリデーションはJSONファイルで表すことが出来ています。リソースクラスではそれらのファイルを利用します。

ticketリソース

ticketリソースをsrc/Resource/App/Ticket.phpに作成します。

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

use BEAR\RepositoryModule\Annotation\Cacheable;
use BEAR\Resource\Annotation\JsonSchema;
use BEAR\Resource\ResourceObject;
use Ray\Query\Annotation\Query;

/**
 * @Cacheable
 */
class Ticket extends ResourceObject
{
    /**
     * @JsonSchema(schema="ticket.json")
     * @Query("ticket_item_by_id", type="row")
     */
    public function onGet(string $id): static
    {
        unset($id);

        return $this;
    }
}

ticket - GETリクエスト

GETのためのonGetメソッドを見てみましょう。メソッドシグネチャーを見れば、リクエストに必要な入力は$_GET['id']のみで、それは省略ができないという事が分かります。

@JsonSchemaアノテーションはこのクラスのbodyプロパティのticketキー配列がticket.jsonで定義されたスキーマであるということを宣言しつつリアルタイムのバリデーションをAOPで毎回行う事で保証しています。

@Query("ticket_item_by_id", type="row")と指定されているので、このメソッドはSQL実行と置き換わります。var/sql/ticket_item_by_id.sqlファイルのSQLが実行され、その結果が単一行(type=”row”)で返ります。このようにロジックが単純にSQLで置換えられる場合は@Queryを使ってPHPの記述を省略することができます。

ticketsリソース

次はticketリソースの集合のticketsリソースをsrc/resource/App/Tickets.phpに作成します。

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

use BEAR\Package\Annotation\ReturnCreatedResource;
use BEAR\RepositoryModule\Annotation\Cacheable;
use BEAR\RepositoryModule\Annotation\Purge;
use BEAR\Resource\Annotation\JsonSchema;
use BEAR\Resource\ResourceObject;
use Koriym\HttpConstants\ResponseHeader;
use Koriym\HttpConstants\StatusCode;
use Ray\AuraSqlModule\Annotation\Transactional;
use Ray\Di\Di\Named;
use Ray\IdentityValueModule\NowInterface;
use Ray\IdentityValueModule\UuidInterface;
use Ray\Query\Annotation\Query;

/**
 * @Cacheable
 */
class Tickets extends ResourceObject
{
    /**
     * @var callable
     */
    private $createTicket;

    /**
     * @var NowInterface
     */
    private $now;

    /**
     * @var UuidInterface
     */
    private $uuid;

    /**
     * @Named("createTicket=ticket_insert")
     */
    public function __construct(callable $createTicket, NowInterface $now, UuidInterface $uuid)
    {
        $this->createTicket = $createTicket;
        $this->now = $now;
        $this->uuid = $uuid;
    }

    /**
     * @JsonSchema(schema="tickets.json")
     * @Query("ticket_list")
     */
    public function onGet(): static
    {
        return $this;
    }

    /**
     * @ReturnCreatedResource
     * @Transactional
     * @Purge(uri="app://self/tickets")
     */
    public function onPost(
        string $title,
        string $description = '',
        string $assignee = ''
    ): static {
        $id = (string) $this->uuid;
        $time = (string) $this->now;
        ($this->createTicket)([
            'id' => $id,
            'title' => $title,
            'description' => $description,
            'assignee' => $assignee,
            'status' => '',
            'created_at' => $time,
            'updated_at' => $time,
        ]);
        $this->code = StatusCode::CREATED;
        $this->headers[ResponseHeader::LOCATION] = "/ticket?id={$id}";

        return $this;
    }
}

tickets - GETリクエスト

var/json_schema/tickets.jsonJSONスキーマをご覧になってください。ticket.jsonスキーマの集合(array)と定義されています。 このようにJSONスキーマはスキーマの構造を表すことができます。メソッドは/ticketリソース同様にticket_list.sqlのSQL実行を結果として返します。

tickets - POSTリクエスト

コンストラクタでインジェクトされた$this->createTicketticket_insert.sqlの実行オブジェクトです。受け取った連想配列をバインドしてSQL実行します。 リソースを作成する時は必ずLocationヘッダーでリソースのURLを保存するようにします。作成した内容をボディに含みたい時は@ReturnCreatedResourceとアノテートします。

まだ作成していないので見る事は今はできませんが、OPTIONSコマンドで調べることができます。

php bin/app.php options /tickets
200 OK
Content-Type: application/json
Allow: GET, POST

{
    "GET": {
        "schema": {
            "$id": "tickets.json",
            "$schema": "http://json-schema.org/draft-07/schema#",
            "title": "Collection of Tickets",
            "type": "array",
            "items": {
                "$ref": "ticket.json"
            }
        }
    },
    "POST": {
        "request": {
            "parameters": {
                "title": {
                    "type": "string"
                },
                "description": {
                    "type": "string",
                    "default": ""
                },
                "assignee": {
                    "type": "string",
                    "default": ""
                }
            },
            "required": [
                "title"
            ]
        }
    }
}

では実際に/ticketsにアクセスしてみましょう。 POSTリクエストでチケット作成します。

php bin/app.php post '/tickets?title=run'
201 Created
Location: /tickets/b0f9c395-3a3d-48ee-921b-ce45a06eee11
content-type: application/hal+json

{
    "id": "b0f9c395-3a3d-48ee-921b-ce45a06eee11",
    "title": "run",
    "description": "",
    "status": "",
    "assignee": "",
    "created": "2018-09-11 13:15:33",
    "updated": "2018-09-11 13:15:33",
    "_links": {
        "self": {
            "href": "/tickets/b0f9c395-3a3d-48ee-921b-ce45a06eee11"
        }
    }
}

レスポンスにあるLocationヘッダーのURIをGETリクエストします。

php bin/app.php get '/tickets/b0f9c395-3a3d-48ee-921b-ce45a06eee11'
200 OK
Link: <http://www.example.com/ticket.json>; rel="describedby"
content-type: application/hal+json
ETag: 3794765489
Last-Modified: Tue, 11 Sep 2018 11:16:05 GMT

{
    "id": "b0f9c395-3a3d-48ee-921b-ce45a06eee11",
    "title": "run",
    "description": "",
    "status": "",
    "assignee": "",
    "created": "2018-09-11 13:15:33",
    "updated": "2018-09-11 13:15:33",
    "_links": {
        "self": {
            "href": "/tickets/b0f9c395-3a3d-48ee-921b-ce45a06eee11"
        }
    }
}

レスポンスは200 OKで帰ってきましたか? このレスポンスの定義はticket.jsonで定義されたものであることがLinkヘッダーのdescribedbyで分かります。6 @Cacheableと宣言されたリソースはETagLast-Modifiedヘッダーが付加されより効率の良いHTTPレベルのキャッシュが有効になります。 @Purgeはキャッシュの破壊です。7

最初に作ったテストも今はうまくパスするはずです。試してみましょう。

composer test

コーディング規約通りに書けているか、またはphpdocがコードと同じように正しく書けているかはツールで調べることができます。 エラーが出ればcs-fixで直すことができます。

composer cs-fix

ユニットテストとコーディング規約、静的解析ツールを同時に行うこともできます。コミットする前に実行しましょう。8

composer tests

compileコマンドで最適化されたautoload.phpを生成してDI/AOPスクリプトを生成することができます。ディプロイ前は実行しましょう。910 全てをDIするBEAR.Sundayアプリケーションはアプリケーション実行前に依存の問題を見つけることができます。ログでDIの束縛の情報を見る事もできるので開発時でも役に立ちます。

composer compile

テストはパスしてコンパイルもうまくできましたか? REST APIの完成です!

APIドキュメント

APIドキュメントを出力するためにphp/bin.phpスクリプトを追加します。

<?php

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

use BEAR\ApiDoc\DocApp;

$docApp = new DocApp('MyVendor\Ticket');
$docApp->dumpHtml(dirname(__DIR__) . '/docs', 'app');```

ドキュメントのためのディレクトリを作成します。

mkdir docs

composer docコマンドでAPIサイトのHTMLとJSONが出力されます。

php bin/doc.php

このサイトをGitHub Pages11などで公開して、APIドキュメントにします。

このようなAPIドキュメントサイトができるはずです。

https://bearsunday.github.io/tutorial2/

もしgithub.comを使っていて、APIドキュメントをプライベートにしたい場合にはmarkdownで出力することもできます。

$docApp->dumpMd(dirname(__DIR__) . '/docs', 'app');```

終わりに

  • phinxマイグレーションツールを使ってアプリケーションのバージョンに従ったデータベースの環境構築ができるようになりました。

  • composer setupコマンドで環境構築ができれば、データベースコマンドを操作する必要がなくディプロイやCIでも便利です。

  • SQLファイルをvar/sqlフォルダに置くことでGUIやCLIのSQLツールで単体実行することができ、開発や運用にも便利でテストも容易になります。スタティックなSQLはPhpStormで補完も効くし、GUIでモデリングできるツールもあります。

  • リソースの引数と出力はメソッドやスキーマで宣言されていて明瞭です。AOPでバリデーションが行わることでドキュメントの正当性が保証され、ドキュメントメンテナンスのの労力を最小化できます。

チュートリアルはうまく行ったでしょうか? もしうまく行ったならチュートリアルbearsunday/tutorial2にスターをして記念に残しましょう。 うまくいかない時はgitterで相談すると解決できるかもしれません。提案や間違いがあればPRをお願いします!


  1. チュートリアルを終えた方を対象としています。被る箇所もありますがおさらいのつもりでトライしてみましょう。レポジトリはbearsaunday/Tutorial2にあります。うまくいかないときは見比べてみましょう。 

  2. 通常はvendor名は個人またはチーム(組織)の名前を入力します。githubのアカウント名やチーム名が適当でしょう。projectにはアプリケーション名を入力します。 

  3. BEAR.Sundayフレームワークが依存する環境変数は1つもありません。 

  4. mysqlコマンドの操作などをREADMEで説明する必要もないので便利です。 

  5. 2018年9月現在php7.3だと実行できますがPHP Deprecatedが表示されます。 

  6. http://json-schema.org/latest/json-schema-core.html#rfc.section.10.1 

  7. /ticketでPOSTされると/ticketsリソースのキャッシュを破壊しています。@Refreshとすると破壊のタイミングでキャッシュを再生成します。 

  8. コミットフックを設定するのも良い方法です。 

  9. キャッシュを”温める”ために2度行うと確実です。 

  10. コンテキストの変更はcomposer.jsoncompileスクリプトコマンドを編集します。 

  11. Publishing your GitHub Pages site from a /docs folder on your master branch