Home

技術日記Laravel4
Laravel 4でキューを使ってみるEdit

キューエンジンとして、beanstalkdを使うんで、

yum install beanstalkd
service beanstalkd start

とかやってbeanstalkdをデフォルト設定で動かしておく。

Laravel 4のapp/config/queue.phpで、

'default' => 'beanstalkd',

に変更する。beanstalkdを使うには追加でライブラリをインストールしておく必要があるんで、composer.jsonのrequireに、

"pda/pheanstalk": "2.0.*"

を追加して、composer updateしてインストールしておく。

これでbeanstalkdコネクタを使ってキューを使う準備OK。

キューを使うサンプルとしては、単純なDBカウンタを作ってみる。

事前準備として、app/config/database.phpを書き換えて、mysqlデータベースが動くように適当にしておく。続いて、

php artisan migrate:make create_counter_table

で、カウンター用のテーブルを作る。app/database/migrations/*_create_counter_table.phpに、

  public function up()
  {
    Schema::create('counters', function ($table)
    {
        $table->increments('id');
        $table->integer('value');
        $table->integer('total');
        $table->timestamps();
    });
  }

としておく。要は、

create table counters (
id integer primary key,
value integer,
total integer,
created_at datetime,
updated_at datetime
);

で、valueに加算する整数値、totalにそこまでのvalueの合計が入るイメージ。

これを操作するモデルも作っておく。app/models/Counter.phpとして、

<?php
class Counter extends Eloquent
{
}

を作っておく。

で、まずはcounterにキューを使わずに(同期処理で)カウントするAPIを用意する。app/routes.phpに以下のルートを追加する。

Route::get('count/{value?}', function ($value = null) {
    $value = is_null($value) ? rand(1, 5) : $value;
    $total = Counter::sum('value') + $value;
    sleep(rand(0, 2));
    $counter = Counter::create(array('value' => $value, 'total' => $total));
    $counter->save();
    return $total;
})->where(array('id' => '\d+'));

http://[sample_app_host]/countにアクセスすると、現在DBに登録されているvalueの合計に、1~5までのランダムな整数が加算され、その結果の数値が表示される。

ちなみに、4行目に0~2秒のランダムなsleepが入っているのは、DBから合計値を取得するタイミングと、新しい計算結果をDBに登録するまでの間に強制的にタイムラグを発生させる(処理に時間がかかった場合に、不整合が起こるようなシチュエーションを再現させる)ためだ。

カウントするAPIだけだと状況がわかりにくいんで、現在DBに登録されている内容を表示するAPIも追加しておこう。

Route::get('list', function () {
   $list = Counter::orderBy('id')->get();
   $result = '';
   $total = 0;
   foreach ($list as $row) {
       $result .= $total . '+' . $row->value . '=' . $row->total . "<br />\n";
       $total = $row->total;
   }
   return $result;
});

これでhttp://[sample_app_host]/listにアクセスすると、現在DBに登録されている内容が以下のように表示される。

0+4=4
4+2=6
6+1=7
7+5=12
12+2=14
14+5=19

ちなみに上記の内容は、カウントAPIをブラウザごしに何回かリロードした結果となっている。しかし、実際にはWeb APIは一つのブラウザから順序よくアクセスされるとは限らない。そこで、いったんcountersテーブルをリセットし、apache benchを使って、以下のような複数同時アクセスを発生させてみよう。

ab.exe -c 5 -n 20 http://[sample_app_host]/count

その結果は以下のようになった。

0+1=1
1+1=2
2+3=4
4+2=3
3+5=6
6+1=13
13+5=18
18+3=4
4+2=20
20+3=26
26+5=26
26+4=25
25+3=38
38+3=24
24+2=23
23+3=46
46+2=28
28+3=38
38+2=43
43+2=43

集計処理とその結果をDBに登録する処理の間にランダムなタイムラグ(sleep)があるため、正しい結果になっていない。

そこで、キューを使って、呼び出し&登録処理が非同期でも、実際の計算処理は必ず順番通りに行われるようにしてみる。

Route::get('qcount/{value?}', function ($value = null) {
    Queue::push(function ($job) use ($value) {
        $value = is_null($value) ? rand(1, 5) : $value;
        $total = Counter::sum('value') + $value;
        sleep(rand(0, 2));
        $counter = Counter::create(array('value' => $value, 'total' => $total));
        $counter->save();
        $job->delete();
    });
    return 'Queued.';
})->where(array('id' => '\d+'));

http://[sample_app_host]/qcountにアクセスすると、先ほどcountで実行した内容と同じ処理をクロージャとしてキューに登録するように書き直した。キューに登録した時点では処理は行われないので、この時点ではDBに登録処理は行われない。

php artisan queue:listen

を実行すると、キューに登録された処理を順次実行していくことになる。

はずだったのだが、実際には上記コードはうまく動かなかった。listenでキューの内容を順次実行するのではなく、

php artisan queue:work

を実行して、最初の一件のジョブだけを実行してみると、以下のようなエラーが発生していた。

{"error":{"type":"Symfony\\Component\\Debug\\Exception\\FatalErrorException","message":"syntax error, unexpected ';'","file":"\/path\/to\/app\/vendor\/laravel\/framework\/src\/Illuminate\/Support\/SerializableClosure.php(173) : eval()'d code","line":12}}

クロージャをevalする際に構文エラーが発生しているようだが、深追いする気になれなかったので、クロージャを使う方法はあきらめた。

クロージャを使わない方法としては、キュージョブを実行するクラスを用意し、そのクラス名を登録する方法がある。app/libs/QueueTest.phpとして、

<?php

class QueueTest
{
    public function fire($job, $data)
    {
        $value = is_null($data['value']) ? rand(1, 5) : $data['value'];
        $total = Counter::sum('value') + $value;
        sleep(rand(0, 2));
        $counter = Counter::create(array('value' => $value, 'total' => $total));
        $counter->save();
        $job->delete();
    }
}

を用意し、QueueTestクラスをオートロードするために、app/start/global.phpのClassLoader::addDirectoriesに、「app_path().'/libs'」ディレクトリを追加しておく。

さらにキューを使ったルートの内容を以下のように変更する。

Route::get('qcount/{value?}', function ($value = null) {
    Queue::push('QueueTest', array('value' => $value));
    return 'Queued.';
})->where(array('id' => '\d+'));

これで、キューに登録されたジョブを処理する際には、QueueTest::fire()メソッドが呼ばれるようになる。apache benchを使ってこちらの実行結果も試してみると、

ab.exe -c 5 -n 20 http://[sample_app_host]/qcount
0+1=1
1+4=5
5+5=10
10+2=12
12+5=17
17+3=20
20+2=22
22+2=24
24+3=27
27+2=29
29+2=31
31+1=32
32+4=36
36+2=38
38+4=42
42+5=47
47+3=50
50+4=54
54+2=56
56+2=58

こちらはAPIは同時アクセスで呼び出されても、処理自体は順次(シリアルに)実行されているのがわかる。

というわけで、Laravel 4のキューを実際に使ってみたら、なんかうまく動かない部分もあったけれども、一応回避して使えることはちゃんと使えました、よかったよかった、という話でした。

※すでにクロージャを使ったキュージョブが登録されている場合は、クラスを使ったキュー登録処理を実行する前に、beanstalkdを再起動するなどして、クロージャを使った(=実行できない)ジョブをクリアしておく必要がある。

※キューをリッスンしておくために、あらかじめsupervisordなどを使って、php artisan queue:listenを自動起動&自動再起動するように設定しておくと便利。

追記)クロージャでジョブを登録するとSyntax Errorが出る件は、

    Queue::push(function ($job) use ($value) {

    Queue::push(function($job) use ($value) {

のように、クロージャの「function」と「(」の間の半角スペースを除去すれば動くようになった。see: SerializableClosure::getCodeFromFile()。

現状のLaravel 4の仕様を厳密に捉えると、バグといえばバグなんだろうけど、修正するよりもPSR-2に準拠して書こうってことで、使う側が対処する方が真っ当な気もする。strposの代わりにpreg_matchを使ってクロージャの開始位置を探すようにすれば修正は可能だけど。

Published At2013-07-05 20:01Updated At2020-01-01 12:58

技術日記Laravel4PHP
Laravel 4でTwitterログイン機能を作ってみるEdit

Laravel 4で、標準のAuth関連機能とTwitterのOAuth認証パッケージとを連動させて、Twitterでログイン&ユーザー登録機能を作ってみる。

Twitterのユーザー情報をそのまま自前のユーザーテーブルに保存して利用するんで、

php artisan migrate:make create_users_table

をしてmigrationファイルを生成し、以下のようなテーブルスキーマを定義する。

    public function up()
    {
        Schema::create('users', function($table) {
            $table->integer('id')->unsigned();
            $table->primary('id');
            $table->string('screen_name');
            $table->string('oauth_token');
            $table->string('oauth_token_secret');
            $table->timestamps();
        });
    }

    public function down()
    {
        Schema::drop('users');
    }

プライマリーキーのusers.idに、Twitterのuser_idを直接保存して利用するので、$table->increments('id')にはせず(auto incrementにはせず)、unsigned intなプライマリーキーとして定義する。あと、Twitterの認証情報であるoauth_tokenとoauth_token_secretを保存するカラムも用意しておく。

Userモデルは以下のような感じ。

use Illuminate\Auth\UserInterface;

class User extends Eloquent implements UserInterface {

    public function getAuthIdentifier()
    {
        return $this->getKey();
    }

    public function getAuthPassword()
    {
        return null;
    }

}

Authで認証に利用するユーザークラスにはimplements UserInterfaceが必要なので、getAuthIdentifier()とgetAuthPassword()の二つを追加しておく。Twitter認証ではシステム独自のパスワードは必要ないのでgetAuthPassword()ではNULLを返している。

続いて、Twitter認証パッケージのTwitterOAuth Service Provider for Laravel 4をインストールする。インストール手順はドキュメント通り。composer.jsonに依存設定を追加して、composer updateしたり、config:publishしたり。

あと、dev.twitter.comでアプリケーション登録して認証キーの発行をおこない、app/config/packages/philo/twitter/config.phpにその値を登録しておく。

さてここからが実際のコーディング。Twitterログイン用のルーティング定義は以下のような感じになる。ほぼドキュメントに書かれている通り。

Route::get('login', function() {
    if (Auth::check()) {
        return Redirect::to('/')->with('message', 'ログイン済みです。');
    }
    $tokens = Twitter::oAuthRequestToken();
    Twitter::oAuthAuthorize(array_get($tokens, 'oauth_token'));
    die;
});

これで/loginにアクセスすると、Twitterの認証ページにリダイレクトする。Twitter側で認証されると呼ばれるコールバックURLは、dev.twitter.comで設定できるので、サンプルアプリの「/login/callback」に設定しておく。コールバック時の処理は以下のようになる。

Route::get('login/callback', function() {
    $token = Input::get('oauth_token');
    $verifier = Input::get('oauth_verifier');
    $accessToken = Twitter::oAuthAccessToken($token, $verifier);

    if (isset($accessToken['user_id'])) {
        $user_id = $accessToken['user_id'];
        $user = User::find($user_id);
        if (empty($user)) {
            $user = new User;
            $user->id = $user_id;
        }
        $user->screen_name = $accessToken['screen_name'];
        $user->oauth_token = $accessToken['oauth_token'];
        $user->oauth_token_secret = $accessToken['oauth_token_secret'];
        $user->save();

        Auth::login($user);

        return Redirect::to('/');
    } else {
        return Redirect::to('login')->with('message', 'Twitter認証できませんでした。');
    }
});

コールバックされたパラメータからアクセストークンを取得し、TwitterユーザーID($accessToken['user_id'])が取れていればログイン成功。そのTwitterユーザーIDがusersテーブルに登録されていない場合は、新しいレコードとして登録する。

screen_name、oauth_token、oauth_token_secretに関しては、ログインするたびに新しい情報に更新する。

で、Auth::login($user)がポイントで、これによってそのユーザーでログインしている状態をシステム(セッション)に記憶させる。

今回はusersテーブルに直接Twitterのログイン情報を持たせたんで、Twitter認証情報から直接Userクラスのインスタンスを取得できた。

もしも、Twitter認証情報をusersテーブル以外に持たせたい場合(通常は自前のID、パスワードでログインしつつ、必要ならばTwitterでもログインできるようにする、など)でも、最終的にUserクラス(というかUserIntafaceが実装されたクラス)のインスタンスを取得してしまえば同様にログイン処理が行える。

擬似コードとしては、以下のような感じ。

// Twitter認証テーブルから該当行を取得
$twitterUser = TwitterUser::find($accessToken['user_id']);

// Twitter認証テーブルとリンクしているUserテーブルの該当行を取得
$user = User::find($twitterUser['user_id']); 

Auth::login($user); // 該当ユーザーをログインさせる

一応ログアウト処理のルーティングも書いておく。これはTwitter認証とは関係なく、Laravel 4のログアウト処理のまま。

Route::get('logout', function() {
   Auth::logout();
   return Redirect::to('/')->with('message', 'ログアウトしました。');
});

あと、ホーム画面でログイン状況がわかるように書いておく。

Route::get('/', function()
{
    return View::make('home');
});
// app/views/home.blade.php
@if (Auth::check())
    {{{ Auth::user()->screen_name }}}ログイン中
    <a href="/logout">ログアウト</a>
@else
        未ログイン
    <a href="/login">ログイン</a>
@endif

未ログイン状態の場合は、ログインへのリンクを表示。ログイン中の場合は、ログインユーザーのscreen_nameを表示し、ログアウトリンクを表示している。

ちなみにこのTwitter認証パッケージは、Twitter APIパッケージを利用しているので、このままTwitter APIの機能を利用できる。たとえば、

$user = Auth::user();
Twitter::setOAuthToken($user->oauth_token);
Twitter::setOAuthTokenSecret($user->oauth_token_secret);
$timeline = Twitter::statusesUserTimeline($user->id);

なんて感じで、ユーザーのタイムラインを取得できる。

Laravel 4の認証処理周りはシンプルで、自前でいろいろ処理したい場合でも、なかなか使いやすそうだね。

Published At2013-07-11 17:34Updated At2020-01-01 15:44

技術日記Laravel4PHP
Laravel 4でfacebookログイン機能も作ってみるEdit

Twitterログイン機能を作ってみたついでに、facebookログイン機能も作ってみる。ただfacebookはあんまり好きじゃないんで、APIのドキュメントを眺めてみたことがある程度しか知識がないから、Laravel 4以前に手探りで実装することになるんだけど。

まず、facebookのPHP SDKをインストールする。composer.jsonのrequireに

    "facebook/php-sdk": "dev-master"

を追加して、composer updateするとfacebook PHP SDKがインストールされる。

続いて、facebookアプリケーションの登録。facebook developersで新しいアプリを作成し、アプリのIDとシークレットキーを取得しておく。その設定は、app/config/facebook.phpとして、

<?php

return array(
    'appId' => '[アプリID]',
    'secret' => '[シークレットキー]',
);

って感じで保存しておくと、Laravel 4アプリ内ではConfig::get('facebook')で取得できる。このあたりの設定ファイルの扱いもLaravel 4はシンプルでいいね。環境(本番/開発など)ごとにオーバーライドさせたければ、環境名のディレクトリを掘ってその中に入れればいいだけだし。

続いてDBの準備。Twitterログインと同じように、usersテーブルに直接facebookのユーザー情報を保持する設計にする。

        Schema::create('users', function($table) {
            $table->biginteger('id')->unsigned();
            $table->primary('id');
            $table->string('name');
            $table->string('access_token');
            $table->timestamps();
        });

facebookのユーザーIDは桁数が多いっぽいんでbigintegerにしておいた。あとは名前とアクセストークンを保存するようにしておく。

ログインルーティングは以下のような感じ。

Route::get('login', function() {
    $facebook = new Facebook(Config::get('facebook'));
    $config = array(
        'redirect_uri' => url('/login/callback'),
    );
    return Redirect::to($facebook->getLoginUrl($config));
});

facebook側の認証ページにリダイレクトさせ、その結果をコールバックURLに返してもらう。続いてコールバックのルーティング。

Route::get('login/callback', function() {
    $code = Input::get('code');
    if (strlen($code) == 0) {
        return Redirect::to('/')->with('message', 'ログインできませんでした。');
    }

    $facebook = new Facebook(Config::get('facebook'));
    $user_id = $facebook->getUser();

    if ($user_id == 0) {
        return Redirect::to('/')->with('message', 'ログインできませんでした。');
    }

    $user = User::find($user_id);
    if (empty($user)) {
        $user = new User;
        $user->id = $user_id;
    }

    $me = $facebook->api('/me');
    $user->name = $me['name'];
    $user->access_token = $facebook->getAccessToken();
    $user->save();

    Auth::login($user);

    return Redirect::to('/')->with('message', 'ログインしました。');
});

facebookログインの仕組みの全貌が把握できていないので、本当にこれでいいのかいまいち確信が持てていないが、少なくとも認証自体はできているはず。

Twitterの場合と同様に、最終的にはAuth::login()でLaravel側での認証情報をセットしている。

ログアウトは、Laravel側のログアウト処理だけ。

Route::get('logout', function() {
    Auth::logout();
    return Redirect::to('/');
});

ホーム画面にfacebookに登録されている名前くらいは表示するようにしておく。

Route::get('/', function()
{
    $data = array();

    if (Auth::check()) {
        $facebook = new Facebook(Config::get('facebook'));
        $me = $facebook->api('/me');
        $data['me'] = $me;
    }

    return View::make('home', $data);
});
// app/views/home.blade.php
@if (!empty($me))
    Hello, {{{ $me['name'] }}}
    <a href="/logout">ログアウト</a>
@else
    <a href="/login">ログイン</a>
@endif

今回はLaravel 4側の問題ではなくfacebook APIの仕組みを調べるのに手間取ってしまった。ちゃんと使うんだったら、もうちょっとfacebook APIのドキュメントを読み込まないとだめだなー。

Published At2013-07-12 15:45Updated At2020-01-01 15:42

技術日記Laravel4PHP
Laravel 4のエラーメッセージとフォームエラーEdit

Laravel 4では、

Redirect::to()->withErrors()

をすると、自動的にリダイレクト後のビュー内で$errorsに展開されるなど、エラーメッセージは特別な扱いになっている。このエラーメッセージは共通レイアウトなんかで、

@if ($errors->count() > 0)
<ul class="error">
@foreach ($errors as $error)
<li>{{{ $error }}}</li>
@endforeach
@endif

なんて感じに表示するのが普通だろうけど、フォームエラーなんかで、入力欄のそばにエラーメッセージを表示したい場合は、どういうふうに使い分けるといいんだろう。

(エラー表示コードを含む)共通レイアウトは使わず、フォーム表示ビューの方で、

{{ Form::open() }}
{{ Form::label('name') }}
{{ Form::text('name') }}
{{ $errors->first('name', '<p class="error">:message</p>') }}
{{ Form::close() }}

みたいな感じに書くのがいいのか。でもそうなると、(エラーメッセージ表示をしたい)フォームを含むかどうかで、レイアウトファイルを(エラー表示コードを含む物と含まない物で)切り替えることになるのが、あんまり気持ちよくない。

あるいは

[source language="php"] Redirect::to()->with('formErrors', $validator->messages) [/source]

なんて感じで、標準エラーメッセージを使わずに、独自のフラッシュセッションに保存して、

[source language="php"] if (Session::has('formErrors')) { View::share('formErrors', Session::get('formErrors'); } [/source]

とかやる方がいいんだろうか。これはこれでありだろうけど、せっかく標準エラーメッセージ機能があるのに、別でハンドリングするのも今ひとつのような気もする。

標準エラーメッセージを使いつつ、フォームビューの方でエラーメッセージを表示した場合は、何らかの方法でフラグをセットして、レイアウトの方のエラーメッセージを抑止するという方法も考えたんだけど、(コンテンツ)ビューから(レイアウト)ビューにデータを渡す、ほどよい方法が見当たらないんで、ペンディング中。

$errorsの実装クラスであるMessageBagにMessageBag::clear()とかが実装されていれば話が早かったんだけど、MessageBagは値を追加したりマージしたりはできても、削除とかリセットとかはできない模様。

今のところ、独自フラッシュセッションを使う方法が一番ましかなーとは思っているんだけど、もっときれいな方法があるという方、情報をお待ちしております。

追記@2013/07/18)

って話は、Laravel 4のwithErrors()は、レイアウトファイルなどを使って、サイト全体で共通表示するフラッシュメッセージ的なものと捉えた上で書いていたんだけど、なんか違うような気がしてきた。

withErrors()で渡すエラーは、共通フラッシュメッセージ的なものではなく、あくまでも特定機能(ページ)でのエラーを手軽にハンドリングするための入れ物なのか? 共通フラッシュメッセージは、with()とかを使った独自処理として書くべき?

たとえば、Laravel-4-Bootstrap-Starter-Site/app/views/notifications.blade.php at master · andrew13/Laravel-4-Bootstrap-Starter-Siteなんかを見ると、共通フラッシュメッセージ的な物としては、success、warning、error、infoを直接Session::flash()(=with())経由でやりとりして表示し、withErrors()(=$errors)はフォームエラー表示専用として扱っているようだ。

withErrors()はあくまでフォームエラー専用の入れ物であって、フラッシュメッセージとは別物、というのが妥当なアプローチなのかな。なんかフォームエラー専用の代物を、フレームワーク標準で自動的にビュー変数にまで代入しちゃうのは、やり過ぎ感が否めないんだけどなー。

Published At2013-07-17 19:07Updated At2020-01-01 15:40