[php] フロントエンドな人だって開発用のREST APIサーバーをPHP+Slimで作ってみたい

2015/06/1

2016/08/20 Node.js版も作りました
>> [JavaScript] フロントエンドな人だって開発用のREST APIサーバーをNode.jsのExpressで作ってみたい

=============

こんにちは。きんくまです。

SPA(Single Page Application)の開発だったり、JavaScriptのフレームワークを学習するときに、サーバーとの通信のやりとりをチェックをしたいなと思うことがあります。
だけどなんだかサーバー側の人は忙しそうだし、もし自前で簡単な開発用のサーバーを作ってしまえれば、いろいろと実験できて便利そうです。
なので今回は本番用ではなく、動作チェックするための開発用REST APIサーバーをどうやって作るのか調べて、作ってみたメモです。

今回の参考サイトです。ありがとうございます。
>> Creating a RESTful API using Slim php framework
>> Create REST applications with the Slim micro-framework

REST APIの仕様を考える

今回はblog用のデータベースを作り、記事(article)テーブルを管理したいと思います。

エンドポイント メソッド 内容
http://ドメイン/api/v1/articles GET 記事一覧の取得
http://ドメイン/api/v1/articles/:id GET 単一記事の取得
http://ドメイン/api/v1/articles POST 記事の作成
http://ドメイン/api/v1/articles/:id PUT 記事の更新
http://ドメイン/api/v1/articles/:id DELETE 記事の削除

前に買ったこの本を参考にしています。

SQLiteデータベースを設定

データベースはSQLiteを使います。特に準備も必要なく、気楽に使えるところがいいと思います。
いらなくなったらDBファイルを捨てちゃって下さい。

初期化用のSQLファイルを用意します。

setup.sql

CREATE TABLE IF NOT EXISTS articles (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  date_created INTEGER,
  title TEXT,
  body TEXT
);
PRAGMA page_size = 16384;
VACUUM;

最後のpage_sizeとVACUUMは必要なければやらなくても大丈夫だと思います。

ここでターミナルを開いて

sqlite3 blog.db
.read setup.sql

とやると、さきほどのSQLファイルが実行されます。 readコマンドの前は . (ドット)が必要です。

さてこれで大丈夫かGUIのアプリから確認してみます。私は下のアプリが便利なので使っております。

>> DB Browser for SQLite

sqlite_snapshot1

sqlite_snapshot2

テーブルがちゃんと作成されているみたいです。

ちなみにSQLiteはiOSやAndroidアプリの中でDBとして使われている様に、実際にはけっこうできるヤツだと思います。

前に開発用にCSVとかXMLからパースしたデータをSQLiteに突っ込んで、読み込み専用で10万件ぐらいのデータを処理していたのですが、そこまで気になることなく動作してました。そのときにプライマリーキーとインデックスをちゃんと設定して、あと上にも書いたpage_sizeの上限 = 32768してみたらすごく速くなったので、上のところではpage_sizeを変えています。

ただSQLiteは以下のものあたりを注意した方が良いと思われます。(DBはあんまり詳しくないんで、だいたいこんな感じだと思っていただけると)

・複数人で使われることを想定していない(あくまで個人用。管理者側だったり利用ユーザー側の意味でも)
・トランザクションが本格的なDBと比べると簡易的なもの(ロック機構とか)
・何百万件というオーダーは難しいそう
・接続にid、passwordが必要なかったりして、セキュリティ的な面で気をつける必要がある(逆に言うとSQLiteファイルそのものや、SQLiteファイルが置かれたディレクトリのパーミッションに気をつける)

これらの機能が省かれた分、すごく手軽に扱えるDBなので、使いどころを合わせれば便利だと思います。

Slimをインストール

今回はサーバー側はphpを使います。REST APIを一から書くのはそれなりに大変そうだったので、小さなフレームワークを使います。
調べてみたらPhalconというフレームワークもあり、良いみたいなんですが、今回は記事も多くて枯れてそうなSlimを使いました。実際すごく簡単に導入できました。

>> Slim a micro framework for PHP

phpにはcomposerというパッケージ管理ツール?があるみたいでこれでインストールするみたいです。

>> Composer

ターミナルで

curl -sS https://getcomposer.org/installer | php

composerをインストールした後に、下のjsonファイルを作ります。

composer.json

{
    "require": {
        "slim/slim": "2.*"
    }
}

ターミナルで

php composer.phar install

これでvendorというディレクトリが出来てそこにいろいろと入ってきます。

PHPでSQLiteの読み書き

SQLiteを読み書き(CRUD)するクラスを作ります。

BlogMapper.class.php

<?php

class BlogMapper {
    const DB_TYPE = 'sqlite:';
    const DB_PATH = '../db/blog.db';
    const ARTICLE_TABLE = 'articles';
    const COLUMNS = 'id, date_created, title, body';
    public $db;

    public function __construct(){
        $this->db = new PDO(static::DB_TYPE.static::DB_PATH);
    }

    public function __destruct(){
        $this->db = null;
    }

    public function selectArticles($limit, $offset){
        $sql = "SELECT ".static::COLUMNS." FROM ".static::ARTICLE_TABLE.
            " LIMIT ".$limit." OFFSET ".$offset;
        try{
            $stmt = $this->db->prepare($sql);
            $stmt->execute();
            return $stmt->fetchAll(PDO::FETCH_ASSOC);
        }catch(PDOException $e){
            return $e->getMessage();
        }
    }

    public function findArticle($id){
        $sql = "SELECT ".static::COLUMNS." FROM ".static::ARTICLE_TABLE.
            " WHERE (id = :id)";
        try{
            $stmt = $this->db->prepare($sql);
            $stmt->bindValue(':id', $id, PDO::PARAM_STR);
            $stmt->execute();
            return $stmt->fetchAll(PDO::FETCH_ASSOC);
        }catch(PDOException $e){
            return $e->getMessage();
        }
    }

    public function insertArticle($title, $body){
        $sql = "INSERT INTO ".static::ARTICLE_TABLE." (date_created, title, body)".
            " VALUES (:date_created, :title, :body)";
        try{
            $stmt = $this->db->prepare($sql);
            $stmt->bindValue(':date_created', time(), PDO::PARAM_INT);
            $stmt->bindValue(':title', $title, PDO::PARAM_STR);
            $stmt->bindValue(':body', $body, PDO::PARAM_STR);
            $stmt->execute();
            $id = $this->db->lastInsertId();
            return $id;
        }catch(PDOException $e){
            return $e->getMessage();
        }
    }

    public function updateArticle($id, $title, $body){
        $sql = "UPDATE ".static::ARTICLE_TABLE." SET title = :title, body = :body".
            " WHERE id = :id";
        try{
            $stmt = $this->db->prepare($sql);
            $stmt->bindValue(':id', $id, PDO::PARAM_INT);
            $stmt->bindValue(':title', $title, PDO::PARAM_STR);
            $stmt->bindValue(':body', $body, PDO::PARAM_STR);
            $stmt->execute();
            return $this->findArticle($id);
        }catch(PDOException $e){
            return $e->getMessage();
        }
    }

    public function deleteArticle($id){
        $sql = "DELETE FROM ".static::ARTICLE_TABLE.
            " WHERE id = :id";
        try{
            $stmt = $this->db->prepare($sql);
            $stmt->bindValue(':id', $id, PDO::PARAM_INT);
            $stmt->execute();
            return $id;
        }catch(PDOException $e){
            return $e->getMessage();
        }
    }
}

そうです。そうです。作成中にうまくいかなくてすごく困ったのがパーミッションでした。
あとで、ディレクトリ構成を載せますが、phpの入ったapiフォルダと、sqliteファイルの入ったdbフォルダというのを作ったのですが、dbフォルダのパーミッションの書き込み設定をきちんとつけてあげないと、別フォルダのapiから書き込みができませんでした。
「パーミッション疑うのはサーバー側の基本だろう!」というお怒りの声も聞こえてきそうなのですが、解決に時間がかかったところなので、一応メモです。

いよいよSlimを使ってルーティング

.htaccessを設定しておきます。

<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [QSA,L]
</IfModule>

補足
RESTサーバーとフロントエンドのファイルたち(html/css/js)が格納するフォルダも一緒のとき(同じドメインのとき)はRewriteRuleを以下のようにもう少し詳しく設定してあげるといいかもしれません。

RewriteRule "^api/(.*)/?$" "index.php" [QSA,L]

つづき
さきほどのSQLiteを使うphpのクラスとSlimを使ったルーティングを扱うメインのファイルを作ります。

index.php

<?php

require '../vendor/autoload.php';
require_once './BlogMapper.class.php';

class BlogAPI {
    public $slim;

    public function __construct(){
        $this->slim = new \Slim\Slim();
        $this->setupRouter();
    }

    public function __destruct(){
        $this->slim = null;
    }

    private function setupRouter(){
        $this->slim->get('/api/v1/articles', array($this, 'getArticles'));
        $this->slim->get('/api/v1/articles/:id', array($this, 'findArticle'))   ;
        $this->slim->post('/api/v1/articles', array($this, 'insertArticle'));
        $this->slim->put('/api/v1/articles/:id', array($this, 'updateArticle'));
        $this->slim->delete('/api/v1/articles/:id', array($this, 'deleteArticle'));
    }

    public function getArticles(){
        $request = $this->slim->request();
        $mapper = new BlogMapper();
        $limit = $request->get('limit');
        if($limit == null){
            $limit = 5;
        }
        $offset = $request->get('offset');
        if($offset == null){
            $offset = 0;
        }
        $result = $mapper->selectArticles($limit, $offset);
        $result = array(
            'articles' => $result
        );
        $this->echoText($result);
    }

    public function findArticle($id){
        $mapper = new BlogMapper();
        $result = $mapper->findArticle($id);
        $result = array(
            'articles' => $result
        );
        $this->echoText($result);
    }

    public function insertArticle(){
        $request = $this->slim->request();
        $requestBody = json_decode($request->getBody());
        $title = $requestBody->title;
        $body = $requestBody->body;
        $mapper = new BlogMapper();
        $result = $mapper->insertArticle($title, $body);
        $result = array(
            'id' => $result
        );
        $this->echoText($result);
    }

    public function updateArticle($id){
        $request = $this->slim->request();
        $requestBody = json_decode($request->getBody());
        $title = $requestBody->title;
        $body = $requestBody->body;
        $mapper = new BlogMapper();
        $result = $mapper->updateArticle($id, $title, $body);
        $result = array(
            'id' => $result
        );
        $this->echoText($result);
    }

    public function deleteArticle($id){
        $mapper = new BlogMapper();
        $result = $mapper->deleteArticle($id);
        $result = array(
            'id' => $result
        );
        $this->echoText($result);
    }

    public function run(){
        $this->slim->run();
    }

    private function echoText($result){
        $jsonStr = json_encode(
            $result,
            JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP
        );
        $resHeaders = $this->slim->response->headers;
        if($this->slim->request->get('callback') != null){
            $resHeaders->set("Content-Type", "text/javascript; charset=utf-8");
            $resHeaders->set("X-Content-Type-Options", "nosniff");
            $callbackName = $_GET["callback"];
            $this->slim->response->setBody($callbackName."(".$jsonStr.");");

        }else{
            $resHeaders->set("Content-Type", "application/json; charset=UTF-8");
            $resHeaders->set("X-Content-Type-Options", "nosniff");
            $this->slim->response->setBody($jsonStr);
        }
    }
}

$blogAPI = new BlogAPI();
$blogAPI->run();

setupRouterメソッド部分でルーティングしてます。
あとは、echoTextメソッドで、クライアント側にクロスドメイン越えができるように、callbackの引数が入っている場合はJSONPで返すようにしています。

今回のファイル構成
apiフォルダにAPIサービスとしてのwwwルートを割り当てています。

rest_api_file_list

htdocsってフォルダがあるのですが、これはフロントエンド側(JS)でテストしようと思って作ったフォルダなので気にしないでください。ここにhtml,js,cssを書いていって、いろいろと試してみようという考えです。このとき、htdocsにもapiフォルダとは違うwwwルートを割り当てます。この場合はクロスドメインになります。

もしapiフォルダ以下にサーバー側もクライアント側も両方とも一緒に置く同ドメインの場合は、さきほどの.htaccessのところで書いたように、もう少し詳しくRewriteRule設定を書いてあげるとうまくいきました。

Curlを使ってテストしてみる

ターミナルから各エンドポイントがうまくいっているかテストしてみます。
curlコマンドだと全てテストすることができます。便利なんですね。curl。
そういや、読み方 カールかと思ってたんですけど シーユーアールエル なんでしょうかね、、。どっち?
localhost:12345 っていうのは、自分の端末の中のApacheに作った開発用のapi用のヴァーチャルホスト(ポート?)です

記事一覧取得 GET articles:
curl -i -X GET ‘http://localhost:12345/api/v1/articles?limit=5&offset=0’

単一記事取得 GET articles/:id
curl -i -X GET ‘http://localhost:12345/api/v1/articles/2’

記事作成 POST (create) articles
curl -i -X POST -d ‘{“title”:”hello3″,”body”:”world3″}’ ‘http://localhost:12345/api/v1/articles’

記事更新 PUT (update) articles/:id
curl -i -X PUT -d ‘{“title”:”modified hello”,”body”:”body text.”}’ ‘http://localhost:12345/api/v1/articles/3’

記事削除 DELETE articles/:id
curl -i -X DELETE ‘http://localhost:12345/api/v1/articles/2’

まとめ

ひとまずできたのですが、この時点で課題として3点ほどあるかもしれません。

まず認証です。その場合は上のIBMの記事を参考にしてみてくださいませ。

次にエラー発生時の処理。
例えば今はdeleteで存在しないidを指定してきても、そのままそのidを返しちゃっています。ここはエラーが発生しているメッセージを送った方がいいと思います。上の方に載せたWeb APIの参考書には、「何かエラーが発生したときはレスポンスヘッダーに200(=正常)じゃない番号を返しつつ、レスポンスボディ部分にエラーメッセージを入れた方がいいよ」と書いてありました。

最後に作成、更新、削除の際に何をレスポンスするかですね。このへんはプロジェクトごとに取り決める必要がありますね。

いくつか課題がありますが、まずは開発用のREST APIサーバーが完成しました!
一旦ひながたができてしまえば、あとはテーブルふやしたりカラムふやしたり色々とできますね。


自作iPhoneアプリ 好評発売中!
フォルメモ - シンプルなフォルダつきメモ帳
ジッピー電卓 - 消費税や割引もサクサク計算!

ページトップへ戻る