チュートリアル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名をMyVendor
にproject名を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 DataGrip、Sequel Pro、MySQL 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->resource
はMyVendor\Ticket
アプリケーションをtest-app
コンテキストで動作させた時のリソースクライアントです。
AppModule
、TestModule
の順のモジュールで上書きされるのでデータベースはテスト用の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.json
JSONスキーマをご覧になってください。ticket.json
スキーマの集合(array)と定義されています。
このようにJSONスキーマはスキーマの構造を表すことができます。メソッドは/ticket
リソース同様にticket_list.sql
のSQL実行を結果として返します。
tickets - POSTリクエスト
コンストラクタでインジェクトされた$this->createTicket
はticket_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
と宣言されたリソースはETag
とLast-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をお願いします!
-
チュートリアルを終えた方を対象としています。被る箇所もありますがおさらいのつもりでトライしてみましょう。レポジトリはbearsaunday/Tutorial2にあります。うまくいかないときは見比べてみましょう。 ↩
-
通常はvendor名は個人またはチーム(組織)の名前を入力します。githubのアカウント名やチーム名が適当でしょう。projectにはアプリケーション名を入力します。 ↩
-
BEAR.Sundayフレームワークが依存する環境変数は1つもありません。 ↩
-
mysqlコマンドの操作などをREADMEで説明する必要もないので便利です。 ↩
-
2018年9月現在php7.3だと実行できますが
PHP Deprecated
が表示されます。 ↩ -
http://json-schema.org/latest/json-schema-core.html#rfc.section.10.1 ↩
-
/ticket
でPOSTされると/tickets
リソースのキャッシュを破壊しています。@Refresh
とすると破壊のタイミングでキャッシュを再生成します。 ↩ -
コミットフックを設定するのも良い方法です。 ↩
-
キャッシュを”温める”ために2度行うと確実です。 ↩
-
コンテキストの変更は
composer.json
のcompile
スクリプトコマンドを編集します。 ↩ -
Publishing your GitHub Pages site from a /docs folder on your master branch ↩