SPA のバックエンドを Symfony2 で開発したい方向けに、Symfony2 で REST API を作る手順についてまとめてみました。
イメージしやすいように、簡単な例で実際に実装する手順をなぞりながら解説していきたいと思います。
1. Symfony をインストール
いつもどおり Symfony プロジェクトを新規インストールしてください。
symfony-installer を使う方法 のほうが composer create-project よりかなり早いのでおすすめです。
$ symfony new rest-sample
$ cd rest-sample
$ php app/console server:run
2. FOSRestBundle をインストール
FOSRestBundle は、その名のとおり REST API の開発に便利な機能を追加してくれるバンドルです。Symfony2 で REST API を開発する場合は通常このバンドルを活用することになります。
インストール方法
インストール方法は ドキュメントのとおり です。
まずは普通に composer でインストールして、AppKernel でバンドルを有効化します。
$ composer require friendsofsymfony/rest-bundle
// in AppKernel::registerBundles()
$bundles = array(
// ...
+ new FOS\RestBundle\FOSRestBundle(),
);
これに加えて、FOSRestBundle は Serializer に依存している ため、以下のいずれかの方法で Serializer を有効化する必要があります。
fos_rest.services.serializer
サービスに独自の Serializer を指定する- JMSSerializerBundle をインストールする
- symfony/serializer を有効にする
JMSSerializerBundle をインストール
今回は 2.
の JMSSerializerBundle を使うことにします。
ドキュメントのとおり に普通にインストールすれば OK です。
$ composer require jms/serializer-bundle
// in AppKernel::registerBundles()
$bundles = array(
// ...
+ new JMS\SerializerBundle\JMSSerializerBundle(),
);
【補足】Serializer について
「シリアライズ」とは、オブジェクトを JSON や XML などのファイル化可能なフォーマットに直列化することです。REST API ではリソースを JSON などにシリアライズして HTTP レスポンスとして返すので、その際に Serializer が必要になります。
組み込みの symfony/serializer を使ってもよいですが、JMS serializer のほうが便利(アノテーションで色々設定できたり、Doctrine ORM や循環参照があるオブジェクトでも扱える)なので、ある程度リッチなアプリケーションを作る場合はこちらを選んでおけばよいのではないかと思います。
JMSSerializerBundle をインストールすれば、自動的に JMS serializer が有効になります。
3. エンティティを作成
通常のアプリ開発と同様の手順で、まずはエンティティを作成しましょう。
今回は例として、ブログチュートリアルっぽく 投稿 とそれに対する コメント という 2 つのエンティティを作ってみたいと思います。
$ php app/console doctrine:generate:entity
:
The Entity shortcut name: AppBundle:Post
:
Configuration format (yml, xml, php, or annotation) [annotation]:
:
New field name (press <return> to stop adding fields): title
Field type [string]:
Field length [255]:
:
New field name (press <return> to stop adding fields): body
Field type [string]: text
:
$ php app/console doctrine:generate:entity
:
The Entity shortcut name: AppBundle:Comment
:
Configuration format (yml, xml, php, or annotation) [annotation]:
:
New field name (press <return> to stop adding fields): body
Field type [string]: text
:
これで以下のように Post
Comment
の 2 つのエンティティが作成されました。
$ tree src/AppBundle/Entity
src/AppBundle/Entity
├── Comment.php
└── Post.php
0 directories, 2 files
エンティティ間のリレーションシップを設定
さらにこの 2 つのエンティティ間のリレーションシップを設定しましょう。Post.php
Comment.php
それぞれに以下のようにプロパティを追加します。
// in Post.php
/**
* @var Comment[]
*
* @ORM\OneToMany(targetEntity="Comment", mappedBy="post", cascade={"all"})
*/
private $comments;
// in Comment.php
/**
* @var Post
*
* @ORM\ManyToOne(targetEntity="Post", inversedBy="comments")
* @ORM\JoinColumn(name="post_id", referencedColumnName="id", nullable=false)
*/
private $post;
手動でプロパティを追加したら、setter/getter/adder/remover の追加はコマンドから実施できます。
$ php app/console doctrine:generate:entities AppBundle:Post
Generating entity "AppBundle\Entity\Post"
> backing up Post.php to Post.php~
> generating AppBundle\Entity\Post
$ php app/console doctrine:generate:entities AppBundle:Comment
Generating entity "AppBundle\Entity\Comment"
> backing up Comment.php to Comment.php~
> generating AppBundle\Entity\Comment
プロパティに必須制約を追加
後ほどバリデーションの動作を確認したいので、例として投稿のタイトルに必須の制約を付けておきましょう。
<?php
namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Validator\Constraints as Assert;
// ...
/**
* @var string
*
+ * @Assert\NotBlank()
+ *
* @ORM\Column(name="title", type="string", length=255)
*/
private $title;
// ...
4. DB を作成
エンティティができたら、忘れないうちに DB を作成しておきましょう。
# 事前に app/config/parameters.yml を設定しておくこと
$ php app/console doctrine:database:create
$ php app/console doctrine:schema:create
5. コントローラを作成
続いて、コントローラを作成しましょう。
REST のコントローラは基本的にはリソースごとに作成することが多いと思いますが、今回は簡略化のために PostController
だけを作成して、コメントに対するアクションもこの中に入れてしまうことにします。
コントローラクラスは、Symfony\Bundle\FrameworkBundle\Controller\Controller
の代わりに FOS\RestBundle\Controller\FOSRestController
を継承して作成します。
<?php
namespace AppBundle\Controller;
use FOS\RestBundle\Controller\FOSRestController;
class PostController extends FOSRestController
{
}
6. ルーティングを設定
作成したコントローラに、ルーティングを設定しましょう。
FOSRestBundle のカスタムルートローダを利用する必要があるので、routing.yml
に設定を追加します。
# in app/config/rouging.yml
app:
resource: "@AppBundle/Controller/"
type: annotation
+app_post:
+ resource: "@AppBundle/Controller/PostController.php"
+ type: rest
これを設定しておくだけで、コントローラのアクションメソッド名を元に自動でいい感じのルーティングを生成してくれるようになります。
例えば今回の例なら、
<?php
public function getPostAction($id)
{
}
こんなメソッドを定義すると、自動で以下のルーティングが生成されます。
$ php app/console debug:router
[router] Current routes
Name Method Scheme Host Path
:
get_post GET ANY ANY /posts/{id}.{_format}
:
リソース名をアクションメソッド名から追い出す
このままでも問題はないのですが、コントローラのクラス名から対象のリソースが Post
だと分かっているのにメソッド名にも Post
と書かないといけないのがちょっと気に入らないので、リソース名をメソッド名から追い出しておくことにします。
やり方は簡単で、以下のように ClassResourceInterface を implements すればよいだけ です。
<?php
namespace AppBundle\Controller;
use FOS\RestBundle\Controller\FOSRestController;
use FOS\RestBundle\Routing\ClassResourceInterface;
class PostController extends FOSRestController implements ClassResourceInterface
{
public function getAction($id)
{
}
}
$ php app/console debug:router
:
get_post GET ANY ANY /posts/{id}.{_format}
:
基本的なアクションメソッドを追加する
getAction
以外のアクションも追加していきましょう。今回の例ではとりあえず以下のようなアクションを実装することにします。
<?php
namespace AppBundle\Controller;
use FOS\RestBundle\Controller\FOSRestController;
use FOS\RestBundle\Routing\ClassResourceInterface;
use Symfony\Component\HttpFoundation\Request;
class PostController extends FOSRestController implements ClassResourceInterface
{
// get collection of posts
public function cgetAction()
{
}
// get the post
public function getAction($id)
{
}
// create new post
public function postAction(Request $request)
{
}
// get collection of comments under the post
public function getCommentsAction($id)
{
}
// create new comment under the post
public function postCommentAction($id, Request $request)
{
}
}
$ php app/console debug:router
[router] Current routes
Name Method Scheme Host Path
:
get_posts GET ANY ANY /posts.{_format}
get_post GET ANY ANY /posts/{id}.{_format}
post_post POST ANY ANY /posts.{_format}
get_post_comments GET ANY ANY /posts/{id}/comments.{_format}
post_post_comment POST ANY ANY /posts/{id}/comments.{_format}
:
FOSRestBundle で用意されているアクションメソッド名の一覧は こちらをご参照ください。
また、用意されているアクションメソッド名を使わない場合には、アクションごとに独自に設定することも可能 です。
7. View を設定
View によるフォーマットの隠蔽
コントローラから見ると、レスポンスのフォーマット(JSON, XML, etc)は FOSRestBundle の View レイヤーによって隠蔽されています。
簡単に言うと、コントローラからは View
クラスのインスタンスを
return $this->handleView($view);
というような形で返しておけば、ViewHandler
がいい感じにしてくれる仕組みになっています。
handleView をイベントリスナーに任せる
さらに View Response Listener を使えばもっと簡単 で、
return $view;
とだけすればイベントリスナーが自動で handleView
してくれるようになります。これを使うための設定は、config.yml
に以下を追記するだけです。
fos_rest:
view:
view_response_listener: force
ちなみに、force
ではなく true
をセットしておいて、リスナーを使いたい対象のアクションメソッドに @View
をアノテートするという方法もあり、こちらのほうが少し柔軟です。
エンティティをそのまま返す
さらに、コントローラから返されたデータが View
オブジェクトではなく生データだった場合は、リスナー側で View オブジェクトにラップしてくれます。
なので、コントローラの実装としては、エンティティやエンティティの配列などの生データを直接 return しておけば、あとは ViewHandler
が自動でいい感じにレスポンスを作ってくれます。超便利ですね。
実際に View を設定する
では、実際に View を設定しましょう。今回は全てのアクションで強制的に View Response Listener を使う方法をとりたいと思います。
# in app/config.yml
# FOSRestBundle
fos_rest:
view:
view_response_listener: force
8. get 系アクションを実装
View Response Listener を設定したので、コントローラからエンティティやエンティティの配列を返せば ViewHandler
が勝手にいい感じのレスポンスにしてくれるようになりました。
というわけで、ひとまず get 系のアクションを実装してみましょう。例えば以下のような感じになるかと思います。
<?php
public function cgetAction()
{
$posts = $this->getRepository()->findAll();
return $posts;
}
public function getAction($id)
{
$post = $this->getRepository()->find($id);
return $post;
}
// ...
public function getCommentsAction($id)
{
$comments = $this->getRepository()->find($id)->getComments();
return $comments;
}
// ...
private function getRepository()
{
return $this->getDoctrine()->getManager()->getRepository('AppBundle:Post');
}
動作確認
ここまでで一度動かしてみましょう。server:run
コマンドを起動してから、
http://localhost:8000/posts.json
にブラウザでアクセスしてみてください。
[]
が返ってきたら成功です。(まだ投稿データが何もないので)
9. post 系アクションを実装
では次に、post 系のアクションを実装していきます。リクエストの内容を元にエンティティを生成して DB に永続化するアクションです。
symfony/form と View レイヤー
FOSRestBundle では、通常のアプリと同じように symfony/form を使ってリクエストをいい感じに処理できるようになっています。(詳細は こちら をご参照ください)
具体的には、コントローラから return されたデータが以下のいずれかに該当する場合に、ViewHandler
において少し特殊な処理が行われるようになっています。
Form
オブジェクト- データとして
Form
オブジェクトだけがセットされたView
オブジェクト ['form' => {Form オブジェクト}]
という形の連想配列- バリデーションエラーを含む
Form
オブジェクト
これらに該当する場合に、以下のような処理が行われます。
Form::createView()
したものを'form'
というキーでView
に渡してくれるForm::getData()
の結果を'data'
とというキーでView
に渡してくれるForm
オブジェクトにバリデーションエラーがあった場合は、ステータスコード 400 で、エラーの詳細を含んだレスポンスを返してくれる
バリデーションエラー時のレスポンスの例
{ "code": 400, "message": "Validation Failed", "errors": { "children": { "username": { "errors": [ "This value should not be blank." ] } } } }
要するに、通常のアプリと同じように、コントローラで handleRequest
した Form
オブジェクトを返しておけば、バリデーション等を含め色々いい感じにやってもらえるということですね。
FormType を作成
というわけで、まずは普通に FormType を作成しましょう。
$ php app/console doctrine:generate:form AppBundle:Post
$ php app/console doctrine:generate:form AppBundle:Comment
これで以下のように PostType
CommentType
の 2 つの FormType が作成されました。
$ tree src/AppBundle/Form
src/AppBundle/Form
├── CommentType.php
└── PostType.php
0 directories, 2 files
CommentType を修正
CommentType
にはデフォルトで body
と post
という 2 つのフィールドができていますが、今回の例では、投稿が特定されているアクションからしかこのフォームは使われないので、post
フィールドは不要です。
このままだと、既に URL で投稿 id を指定しているのに、さらに送信するデータにも投稿 id を含めなければいけなくなります。これは API の設計としておかしいですよね。
というわけで、post
フィールドについては削除しておきましょう。
$builder
->add('body')
- ->add('post')
;
アクションを実装
では、作成した FormType を使ったアクションを実装してみましょう。
<?php
namespace AppBundle\Controller;
use AppBundle\Entity\Comment;
use AppBundle\Entity\Post;
use AppBundle\Form\CommentType;
use AppBundle\Form\PostType;
use FOS\RestBundle\Controller\FOSRestController;
use FOS\RestBundle\Routing\ClassResourceInterface;
use Symfony\Component\HttpFoundation\Request;
class PostController extends FOSRestController implements ClassResourceInterface
{
// ...
public function postAction(Request $request)
{
$form = $this->createForm(new PostType(), $post = new Post(), [
'csrf_protection' => false,
]);
$form->handleRequest($request);
if ($form->isValid()) {
$em = $this->getDoctrine()->getManager();
$em->persist($post);
$em->flush();
return $post;
}
return $form;
}
// ...
public function postCommentAction($id, Request $request)
{
$comment = new Comment();
$comment->setPost($this->getRepository()->find($id));
$form = $this->createForm(new CommentType(), $comment, [
'csrf_protection' => false,
]);
$form->handleRequest($request);
if ($form->isValid()) {
$em = $this->getDoctrine()->getManager();
$em->persist($comment);
$em->flush();
return $comment;
}
return $form;
}
// ...
}
通常のアプリの場合と何ら変わらない実装だと思います。
動作確認(その1)
では試しに投稿の新規作成を行ってみましょう。
http://localhost:8000/posts.json に title=test&body=test
を POST すれば、投稿が新規作成されて、作成された投稿の内容がレスポンスとして返ってくるはずです。
Google Chrome を使っている方なら、Advanced REST client などの REST クライアントアプリをインストールすると、手軽にブラウザから POST リクエストを送信することができます。
Advanced REST client を使う場合は、以下のようにして POST してみてください。(該当しない方は各自で REST クライアントを用意してください)
レスポンスは以下のような結果になりました。
{"children":{"title":[],"body":[]}}
なんかおかしいですね。投稿がちゃんと新規作成されているか確認してみましょう。
GET http://localhost:8000/posts.json (普通にブラウザでアクセスすれば OK です)
[]
やっぱりおかしいですね。投稿が作成されていません。
フォームのデータ構造とリクエストのデータ構造のズレ
この原因は、handleRequest()
のソースを読んでみると分かります。
この部分 を見ると、リクエストからデータを取得する際に、フォーム名をキーにしていることが分かります。
通常のアプリケーションで、Form
オブジェクトを普通に Twig でレンダリングしたときのレンダリング結果を思い出してみると、
<input type="text" id="フォーム名_フィールド名" name="フォーム名[フィールド名]">
というような形になっていました。なるほどこの場合は上記の処理で正常にデータが取得できますね。
しかし今回の例ではリクエストのデータ構造にフォーム名のキーが入っていないため、$data = $request->query->get($name);
の結果は null になります。これでは正常に動作しません。
動作確認(その2)
というわけで、リクエストのデータ構造を変えてもう一度送信してみましょう。
POST http://localhost:8000/posts.json appbundle_post[title]=test&appbundle_post[body]=test
{"id":1,"title":"test","body":"test","comments":[]}
正常に登録できましたね。
無名のフォームを使うようにする
さて、一応動作はしましたが、このままだとフロント側がフォーム名を知っていないと正常にリクエストできないので、PHP 側でちゃんと対処しましょう。
先ほどの handleRequest()
のソースの この部分 を見ると、フォーム名がない場合はリクエストのデータをそのまま取得するようになっています。
であれば、コントローラで Form
オブジェクトを作る際に名前がないものを作っておけばいいということになりますね。
-$form = $this->createForm(new PostType(), $post = new Post(), [
+$form = $this->get('form.factory')->createNamed('', new PostType(), $post = new Post(), [
'method' => 'GET',
'csrf_protection' => false,
]);
-$form = $this->createForm(new CommentType(), $comment, [
+$form = $this->get('form.factory')->createNamed('', new CommentType(), $comment, [
'method' => 'GET',
'csrf_protection' => false,
]);
動作確認(その3)
では、再度元のデータ構造でトライしてみましょう。
POST http://localhost:8000/posts.json title=test&body=test
{"id":2,"title":"test","body":"test","comments":[]}
おお、正常に登録できましたね。
同様にコメントの追加も実行してみましょう。
POST http://localhost:8000/posts/1/comments.json body=test
{"id":1,"body":"test","post":{"id":1,"title":"test","body":"test","comments":[]}}
大丈夫そうですね。
動作確認(その4)
続いてバリデーションエラーの確認もしてみましょう。
投稿のタイトルには必須制約が付いているので、タイトルなしの投稿を作成しようとするとバリデーションエラーになるはずです。
POST http://localhost:8000/posts.json body=test
{"code":400,"message":"Validation Failed","errors":{"children":{"title":{"errors":["This value should not be blank."]},"body":[]}}}
バッチリですね。
10. シリアライズ対象プロパティを設定
現状だと、投稿を取得すると配下のコメントもすべて付随して取得されます。
GET http://localhost:8000/posts/1.json
{"id":1,"title":"test","body":"test","comments":[{"id":1,"body":"test"}]}
一見、特に問題なさそうに思えますが、ここは注意が必要です。
今はエンティティ間のリレーションシップが単純なので問題になりませんが、もっと大量のエンティティが複雑に関係しあっているようなアプリケーションだと、エンティティをシリアライズする処理のネストが極端に深くになってしまう恐れがあります。
そもそも、投稿の配下のコメントを取得する API は別で用意されている (GET http://localhost:8000/posts/1/comments.json) わけですから、投稿を取得したときに配下のコメントまで一緒についてくる必要はないですよね。
というわけで、Post::$comments
はシリアライズの対象外にしてしまうことにしましょう。
<?php
namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
+use JMS\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;
// ...
/**
* @var Comment[]
*
* @ORM\OneToMany(targetEntity="Comment", mappedBy="post", cascade={"all"})
+ *
+ * @Serializer\Exclude()
*/
private $comments;
// ...
これで、投稿取得時のレスポンスから配下のコメントの情報が削除されました。
GET http://localhost:8000/posts/1.json
{"id":1,"title":"test","body":"test"}
サンプルコード
ここまでで実装したサンプルコードは こちらに公開しておきます ので、参考にしてみてください。
おまけ:FOSJsRoutingBundle
最後におまけでもう一つバンドルを紹介しておきます。
同じ Symfony プロジェクト内にフロントエンドの実装も含めてしまいたい場合には、FOSJsRoutingBundle が便利です。FOSJsRoutingBundle は、Symfony のルーティング名を js のファイルから使えるようにしてくれるバンドルです。
インストール
ドキュメント の案内どおりにインストールするだけです。
- composer でインストール
- バンドルを AppKernel に追加
- バンドルのルーティング定義を読み込み
- バンドルのアセットをインストール
使い方
以下のように、バンドルが持っている js ファイルをロードするようにテンプレートに追記します。
<script src="{{ asset('bundles/fosjsrouting/js/router.js') }}"></script>
<script src="{{ path('fos_js_routing_js', {'callback': 'fos.Router.setData'}) }}"></script>
これで、他の js ファイルから以下のように Routing
オブエジェクトを使って Symfony のルーティング名から URL が生成できます。
Routing.generate('route_id')
注意点
ルーティングの設定で、以下のように expose
(露出)しておいたルーティング名しか使えないので注意が必要です。
# routing.yml
some_routing:
options:
expose: true
詳細
詳細についてはドキュメントをご参照ください。
https://github.com/FriendsOfSymfony/FOSJsRoutingBundle/blob/master/Resources/doc/index.md