スケルトン・エピ

letsspeakのブログです。

ビジュアルプログラミング

ビジュアルプログラミングがいま熱い!

enchantMOONの登場でさらに加熱して、自分ならタブレット端末でどんな風にプログラミングできる環境を作るかを妄想しながら寝た。

まずもって時が小さかったり情報が詰まってるのはアウト。
よく使う制御構造や機能は簡単な記号の手書き認識化。
挿入文字列はすべて別の辞書化。
制御構造の逐次記述は多くても1画面4行程度。

関数型言語をまじめに勉強していないので、
逐次記述がほんとうに必要なのかどうなのかをもう少し煮詰めて、
モジュール化とその関係性のビジュアル化の可能性を突き詰める必要あり。

起きてから、MOON block 見てみたけど...
ごちゃごちゃしてる作業場にするならタッチペン使う必要ないじゃん...。
ほんとうにさっと端末を取り出して、さっと書けるようにするのが理想。

宇宙の外側

宇宙の外側がどうなってるのかってのは、人間として生きてる以上、気になって夜も眠れないレベルの問題なんだけれども、

地球上のもの < 地球
地球 < 太陽系
太陽系 < 銀河系
銀河系 < 銀河団

みたいな感じでスケールして、宇宙すげー!
ってことは

宇宙 < ???(宇宙がたくさんある?)

っていう考えはまず間違いであることに気づいた。
というのも次元で考えると全部同じ範囲に収まるからで、

地球上のもの < 地球 < ... < 宇宙 < ????

という比較は、

もの < 宇宙 < ????

と変わらず「もの」の中の規則をそのまま外にあてがうのは間違っている。
あまりにも宇宙ヤバイインパクトが強すぎて目の前が見えなくなってしまっていた。
比べることで規則性を見いだすのであれば、

素粒子のふるまい < 宇宙 < ????

の方がよっぽど妥当じゃないか、と風呂に入りながら考えた。

CentOSにGrowthForecastをいれて色々表示してみた

最近CentOSでFuel先生とごにょごにょしているのですが、データがあまりにも大量なため基本的には静的コンテンツをタスク生成してリクエスト発生時はSELECTかmemcachedからフェッチするだけーみたいな感じで動かしていました。

そうなるとfuel/app/tasksにタスクが増える増える。crontab先生大活躍な訳なんですが、はたから見てるとちゃんと動いているのかどうかまったく分からないので、統計をとってみようとfluentdとか調べ始めたところでGrowthForecastというグラフ生成Webツールを発見して、さっそく入れてみました!

ついでに、先日の作業中0時頃にとつぜんフリーズでBrokenPipeした件も調べたいと思い、CPU使用率、メモリ使用率も集計してみることにしました。

GrowthForecastのインストール

ここが一番大変でした。
最初はこちらのgist installing_growth_forecast の通り進めたのですが、root権限でインストールしてこれで良かったのか..。と思っていた所に良い記事を発見したので下記記事を参考に進めました。

GrowthForecast を CentOS 6.3 にインストールして Supervisor で管理してみた

主にcpanmとGrowthForecastのインストールに結構時間がかかりました。

GrowthForecastのアクセス制限

動作確認後、新しいホスト名を追加してnginxでBASIC認証かけてポートを閉じます。

nginx.conf
  upstream growthforecast {
    server 127.0.0.1:5125;
  }

    server {
    listen 80;
    server_name growthforecast.yourdomain.com;

    proxy_connect_timeout 60;
    proxy_read_timeout    60;
    proxy_send_timeout    60;


    auth_basic "Secret Area";
    auth_basic_user_file "/home/growthforecast/.htpasswd";

    location / {
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header Host $http_host;
      proxy_pass http://growthforecast;
      proxy_redirect off;
    }
  }
.htpasswdの作成
htpasswd -c /home/growthforecast/.htpasswd username

CPU使用率をロギングしてみる

GrowthForecastはintegral (整数) でなければ受け付けてくれないので、小数点以下を切り捨てた値を取得するワンライナーを書いて、crontabで1分毎に報告させます。

CPU使用率(パーセント)を整数で表示する
# cpu usage (integral)
cat /proc/loadavg | cut -d ' ' -f 1 | cut -d '.' -f 1
CPU使用率をGrowthForecastに報告するcrontab
# send your cpu usage (integral) to GrowthForecast every 5 minutes via crontab
*/1 * * * * curl -F number=`cat /proc/loadavg | cut -d ' ' -f 1 | cut -d '.' -f 1` http://localhost:5125/api/yourmachine/system/cpu 2>&1

gistにも纏めておきました。
centos_cpu_usage_one_liner.sh

メモリ使用率をロギングしてみる

ワンライナーで書けさえすればGrowthForecastに報告できる!と知ってawkを調べながら書いてみました。実際の使用率については、freeコマンドの見方を参考に計算しています。

メモリ使用率(パーセント)を整数で表示する
// actual memory usage (integral)
// http://open-groove.net/linux-command/free/
free | awk 'NR==2' | awk '{print int(($3-($6+$7))/$2*100)}'

こちらもCPU使用率と同じようにnumber=``でくくってcrontabに登録すれば完了です。
メモリ使用量についてもgistに纏めておいてます。

centos_memory_usage_one_liner.sh

グラフ化結果

f:id:letsspeak:20130330222650p:plain

データを取ってみると、CPU使用率が全然低いのとメモリ使用量がおよそ70%と高めなのが気になります。
もしかすると取得方法が間違っているのかもしれないので、これから他のVPSや新しい環境構築時にログをとって比べてみたいと思います。
もし間違いなどお気づきになりましたら、ご指摘頂けると幸いです。

FuelPHPのタスクと連携

FuelPHPのタスクと連携するためにGrowthForecastにPOSTするための簡単なモデルを作成しました。

fuel/app/classes/model/growthforecast.php
<?php

Class Model_GrowthForecast extends Model
{
  static public function post($section_name = null, $graph_name = null, $number = null, $mode = 'gauge')
  {
    // validation
    if (is_null($section_name)) return;
    if (is_null($graph_name)) return;

    if (is_int($number) === false)
    {   
      if (is_numeric($number) === true)
      {   
        $number = (int)$number;
      }   
      else
      {   
        return;
      }   
    }   

    $url = 'http://localhost:5125/api/yourapplication/'.$section_name.'/'.$graph_name.PHP_EOL;
    $data = array(
      'number' => (string)$number,
      'mode' => $mode,
    );  

    $headers = array(
      "Content-Type: application/x-www-form-urlencoded",
      "Content-Length: ".strlen(http_build_query($data)),
    );  

    $options = array('http' => array(
      'method' => 'POST',
      'content' => http_build_query($data),
      'header' => implode("\r\n", $headers),
    )); 

    $contents = file_get_contents($url, false, stream_context_create($options));
  }
}

これを、こんな感じでTaskから呼び出すだけでどんどんグラフ化してくれます。

    \Model_GrowthForecast::post('crawler', 'source_count', count($sources));

f:id:letsspeak:20130330223350p:plain

やっぱり視覚化されるのは純粋に楽しい!わくわくします。

問題点など

今回ポートを閉じてしまいましたが、特定のサーバーから報告をPOSTする場合、IPアドレス指定のオプションをつけて起動して、ポート自体は開けておいたほうが良さそうです。

また今回作成したモデルではyourapplicationの部分を固定化してしまっていますが、開発環境、テスト環境、プロダクション環境に応じて値が変わるようにmodifyする必要がありそうです!

FuelPHPでopauthを使って色んなログインに対応してみた

FuelPHPでopauthを使って色んなログインに対応してみました。
ログインのパターンは

1.通常のusernameとpasswordのログイン
2.Twitterのoauthログイン
3.Facebookのoauthログイン

です。
TwitterFacebookのログインについてはfuel-opauthを使っていますが、こちらの参考ブログ記事にも記載されている通りnamespaceの設定に不具合があるためgithubでforkしたものをsubmodule化しています。

本家 https://github.com/andreoav/fuel-opauth本家
修正版 https://github.com/letsspeak/fuel-opauth

今回作成したり変更したファイルは一通りgistにもアップロードしています。
不具合はいつも通り触りながら修正していこうかと思っていますが、問題点などありましたらお気軽にご連絡ください。

gist
https://gist.github.com/letsspeak/5229245

Twitter/Facebookのアプリケーション登録

参考ブログの通り進める。

fuel-opauthのsubmodule化

git submodule add https://github.com/letsspeak/fuel-opauth.git fuel/app/packages/opauth/
TIPS

githubからhttps://でsubmodule化した場合、内容の変更が発生したときのpush時に下記修正が必要なので注意。

fuel/packages/opauth/.git/config
- url = https://github.com/letsspeak/fuel-opauth
+ url = ssh://git@github.com/letsspeak/fuel-opauth.git

opauth.phpの設定

ソルトとTwitter/Facebookの開発者用のキーを登録する。

fuel/app/config/opauth.php
<?php
namespace Opauth;
return array(
    'path' => '/auth/login/',
    'callback_url'  => '/auth/callback/',
    'security_salt' => 'ランダムな文字列を生成して登録する',
    'Strategy' => array(
      'Facebook' => array(
        'app_id' => 'Facebookで取得したアプリID / APIキー',
        'app_secret' => 'Facebookで取得したアプリのシークレットキー'
      ),

      'Twitter' => array(
        'key' => 'Consumer key',
        'secret' => 'Consumer secret'
      ),  
    ),     
);

config.php

fuel/app/config/config.php
  'always_load' = array(
    'packages' => array(
      'opauth',
    ),
  ),

User/TwitterUser/FacebookUserを作成

oil g model user name:string password:string nickname:string email:string last_login:int
oil g model twitteruser uid:string token:string secret:string user_id:int
oil g model facebookuser uid:string token:string expires:int user_id:int
マイグレーションの変更点

ストレージエンジンは全てInnoDBに変更。
Userで一元管理することを想定してUser->last_login以外はすべて'null'=>trueに設定。
モデルクラス名をModel_TwitterUserに変更したいのでテーブル名をtwitter_usersに変更。
モデルクラス名をModel_FacebookUserに変更したいのでテーブル名をfacebook_usersに変更。

モデルの変更点

モデルクラス名をそれぞれModel_TwitterUser、Model_FacebookUserに変更。
Model_User、Model_TwitterUser、Model_FacebookUserのバリデーション追加。

authコントローラーを作成

通常ログイン時は引数無しで /auth/login/にPOSTする。
Twitterログイン時は/auth/login/twitter/を叩く。
Facebookログイン時は/auth/login/facebook/を叩く。

fuel/app/classes/controller/auth.php
<?php
class Controller_Auth extends Controller
{
    private $_config = null;
    private $_salt_length = null;
    private $_iteration_count = null;

    public function before()
    {
      if(!isset($this->_config))
      {
          $this->_config = Config::load('opauth', 'opauth');
      }

      $this->_salt_length = 32;
      $this->_iteration_count = 10;
    }

    // auth/login/*
    public function action_login($_provider = null, $method = null)
    {
      // 引数無し時は通常ログイン
      if (is_null($_provider)) return $this->normal_login();

      // http://domainname/auth/login/twitter/oauth_callback?denied=signature
      if ($method === 'oauth_callback') {
        if (Input::get('denied')){
          return $this->login_failed();
        }
      }

      if(array_key_exists(Inflector::humanize($_provider), Arr::get($this->_config, 'Strategy')))
      {
        $_oauth = new Opauth($this->_config, true);
      }
      else
      {
        return $this->login_failed();
      }
    }

    // 通常ログイン
    public function normal_login()
    {
      $username = Input::post('username');
      $password = Input::post('password');

     // ユーザー名とパスワード空欄時はログインフォームを表示する
      if (is_null($username) and is_null($password)) {
        return Response::forge(View::forge('auth/form'));
      }
      
      // 認証
      $query = Model_User::query()->where('name', $username);
      if ($query->count() === 0){
        // 認証エラー
        $this->login_failed();
      }

      // パスワードのハッシュ化
      $user = $query->get_one();
      $salt = substr($user->password, 0, $this->_salt_length);
      $enc_password = $salt.$password;
      for ($i = 0; $i < $this->_iteration_count; $i++)
      {
        $enc_password = sha1($enc_password);
      }
      
      if ($user->password === $salt.$enc_password){
        // 認証成功
        return $this->login_succeeded($user->id);
      }else{
        // 認証エラー
        $this->login_failed();
      }
    }

    public function action_signup()
    {
      $username = Input::post('username');
      $password = Input::post('password');

      // ユーザー名とパスワード空欄時はサインアップフォームを表示する
      if (is_null($username) and is_null($password)) {
        return Response::forge(View::forge('auth/signup'));
      }

      // サインアップ処理

      // パスワードのハッシュ化
      $salt = substr(md5(uniqid(rand(), true)), 0, $this->_salt_length);
      $enc_password = $salt.$password;
      for ($i = 0; $i < $this->_iteration_count; $i++)
      {
        $enc_password = sha1($enc_password);
      }

      // バリデーション
      $val = Model_User::validate('create');
      $input = array(
        'name' => $username,
        'password' => $salt.$enc_password,
        'last_login' => \Date::time()->get_timestamp(),
      );
    
      if ($val->run($input))
      {
        // バリデーション成功時
        $user = Model_User::forge($input);
        if ($user and $user->save())
        {
          // サインアップ成功時
          return $this->login_succeeded($user->id);
        }
        else
        {
          // サインアップ失敗時
          return $this->login_failed();
        }
      }
      else
      {
        // バリデーション失敗時
        $data['errors'] = $val->error();
        return Response::forge(View::forge('auth/signup', $data));
      }

    }

    public function action_test1()
    {
      return Response::forge(View::forge('auth/test'));
    }

    public function action_test2()
    {
      return Response::forge(View::forge('auth/test'));
    }

    public function action_test3()
    {
      return Response::forge(View::forge('auth/test'));
    }

    // Twitter / Facebook ログイン成功/失敗時に呼ばれる
    public function action_callback()
    {
      $_opauth = new Opauth($this->_config, false);

      switch($_opauth->env['callback_transport'])
      {
        case 'session':
          session_start();
          $response = $_SESSION['opauth'];
          unset($_SESSION['opauth']);
        break;            
      }

      if (array_key_exists('error', $response))
      {
        return $this->login_failed();
//            echo '<strong style="color: red;">Authentication error: </strong> Opauth returns error auth response.'."<br>\n";
      }
      else
      {
        if (empty($response['auth']) || empty($response['timestamp']) || empty($response['signature']) || empty($response['auth']['provider']) || empty($response['auth']['uid']))
        {
          return $this->login_failed();
//          echo '<strong style="color: red;">Invalid auth response: </strong>Missing key auth response components.'."<br>\n";
        }
        elseif (!$_opauth->validate(sha1(print_r($response['auth'], true)), $response['timestamp'], $response['signature'], $reason))
        {
          return $this->login_failed();
//          echo '<strong style="color: red;">Invalid auth response: </strong>'.$reason.".<br>\n";
        }
        else
        {
          // Twitter / Facebook ログイン成功
          return $this->opauth_login($response);
        }
      }
    }

    public function opauth_login($response = null)
    {
       $provider = $response['auth']['provider'];
       if ($provider === 'Twitter') return $this->twitter_login($response);
       if ($provider === 'Facebook') return $this->facebook_login($response);
    }

   public function twitter_login($response = null)
    {
      $uid = (string) $response['auth']['uid'];
      $query = Model_TwitterUser::query()->where('uid', $uid);
      if ($query->count() == 0)
      {
        // TwitterUser未登録の場合はサインアップ
        return $this->twitter_signup($response);
      }

      // TwitterUser登録済みの場合はログイン
      $twitter_user = $query->get_one();
      return $this->login_succeeded($twitter_user->user_id);
    }

    public function facebook_login($response = null)
    {
      $uid = $response['auth']['uid'];
      $query = Model_FacebookUser::query()->where('uid', $uid);
      if ($query->count() == 0)
      {
        // FacebookUser未登録の場合はサインアップ
        return $this->facebook_signup($response);
      }

      // FacebookUser登録済みの場合はログイン
      $facebook_user = $query->get_one();
      return $this->login_succeeded($facebook_user->user_id);
   }

    public function twitter_signup($response = null)
    {
      // バリデーション
      $val = Model_TwitterUser::validate('create');
      $input = array(
        'uid' => (string) $response['auth']['uid'],
        'token' => $response['auth']['credentials']['token'],
        'secret' => $response['auth']['credentials']['secret'],
      );
    
      if ($val->run($input))
      {
        // バリデーション成功時
        $user = Model_User::forge(array(
          'nickname' => $response['auth']['info']['nickname'],
          'last_login' => \Date::time()->get_timestamp(),
        ));
        $twitter_user = Model_TwitterUser::forge($input);

        if ($user and $twitter_user)
        {
          // ユーザー生成成功
          try
          {
            \DB::start_transaction();
            if ($user->save() === false)
            {
              // User保存失敗
              throw new \Exception('user save failed.');
            }
              
            $twitter_user->user_id = $user->id;
            if ($twitter_user->save() === false)
            {
              // TwitterUser保存失敗
              throw new \Exception('twitter_user save failed.');
            }

            // UserとTwitterUserの保存成功
            \DB::commit_transaction();
            return $this->login_succeeded($user->id);
          }
          catch (\Exception $e)
          {
            \DB::rollback_transaction();
            return $this->login_failed();
          }

        }
        else
        {
          // ユーザー生成失敗
          return $this->login_failed();
        }

      }
      else
      {
        // バリデーション失敗時
        return $this->login_failed();
      }
    }

    public function facebook_signup($response = null)
    {
      // バリデーション
      $val = Model_FacebookUser::validate('create');
      $expires = strtotime($response['auth']['credentials']['expires']);
      $input = array(
        'uid' => (string) $response['auth']['uid'],
        'token' => $response['auth']['credentials']['token'],
        'expires' => $expires,
      );
    
      if ($val->run($input))
      {
        // バリデーション成功時
        $user = Model_User::forge(array(
          'nickname' => $response['auth']['info']['name'],
          'last_login' => \Date::time()->get_timestamp(),
        ));
        $facebook_user = Model_FacebookUser::forge($input);

        if ($user and $facebook_user)
        {
          // ユーザー生成成功
          try
          {
            \DB::start_transaction();
            if ($user->save() === false)
            {
              // User保存失敗
              throw new \Exception('user save failed.');
            }
              
            $facebook_user->user_id = $user->id;
            if ($facebook_user->save() === false)
            {
              // FacebookUser保存失敗
              throw new \Exception('facebook_user save failed.');
            }

            // UserとFacebookUserの保存成功
            \DB::commit_transaction();
            return $this->login_succeeded($user->id);
          }
          catch (\Exception $e)
          {
            \DB::rollback_transaction();
            return $this->login_failed();
          }

        }
        else
        {
          // ユーザー生成失敗
          return $this->login_failed();
        }

      }
      else
      {
        // バリデーション失敗時
        return $this->login_failed();
      }
    }

    public function login_succeeded($user_id)
    {
      Session::set('user_id', $user_id);
      Response::redirect('auth/test1');
    }

    public function login_failed()
    {
      return Response::redirect('auth/test1');
    }
 
    public function action_logout()
    {
      Session::delete('user_id');
      Response::redirect('auth/test1');
    }
}

懸念点など

・とりあえず作っただけなので全然脆弱な気がする。
XSRF対策をしていない。
・ぜんぶlogin_failed()に飛ばしているけどいいの?
・特定のページでログインを経由して特定のページに復帰するようなことは考慮されていない。
・パスワードはいちおうハッシュ化してみた。
・Usersでの一括管理に意味があるのか...

初心者がFuelPHPのトランザクションに失敗した件

追記

記事を書いた後、
1.DBのクエリビルダーで更新してもダメ。
2.mysqlを直接コマンドから叩いてロールバックしてもダメ。
だったのでデータベースエンジンを確認してみたところ

mysql> show table status;
+--------------+--------+---------+------------+------+----------------+-------------+------------------+--------------+-----------+----------------+---------------------+---------------------+------------+-----------------+----------+----------------+---------+
| Name         | Engine | Version | Row_format | Rows | Avg_row_length | Data_length | Max_data_length  | Index_length | Data_free | Auto_increment | Create_time         | Update_time         | Check_time | Collation       | Checksum | Create_options | Comment |
+--------------+--------+---------+------------+------+----------------+-------------+------------------+--------------+-----------+----------------+---------------------+---------------------+------------+-----------------+----------+----------------+---------+
| click_queues | MyISAM |      10 | Fixed      |   14 |             21 |         294 | 5910974510923775 |         2048 |         0 |             15 | 2013-03-17 15:43:13 | 2013-03-18 00:47:41 | NULL       | utf8_general_ci |     NULL |                |         |
| items        | MyISAM |      10 | Dynamic    | 1099 |           2440 |     2682076 |  281474976710655 |        13312 |         0 |           1100 | 2013-03-17 15:43:13 | 2013-03-18 00:45:08 | NULL       | utf8_general_ci |     NULL |                |         |
| migration    | MyISAM |      10 | Dynamic    |    4 |             40 |         160 |  281474976710655 |         1024 |         0 |           NULL | 2013-03-17 00:28:33 | 2013-03-17 14:27:45 | NULL       | utf8_general_ci |     NULL |                |         |
| pick_counts  | MyISAM |      10 | Fixed      | 1099 |             29 |       31871 | 8162774324609023 |        13312 |         0 |           1100 | 2013-03-17 15:43:13 | 2013-03-18 00:54:38 | NULL       | utf8_general_ci |     NULL |                |         |
| sources      | MyISAM |      10 | Dynamic    |   39 |            107 |        4184 |  281474976710655 |         2048 |         0 |             47 | 2013-03-17 00:28:33 | 2013-03-17 00:28:33 | NULL       | utf8_general_ci |     NULL |                |         |
+--------------+--------+---------+------------+------+----------------+-------------+------------------+--------------+-----------+----------------+---------------------+---------------------+------------+-----------------+----------+----------------+---------+
5 rows in set (0.00 sec)

はい、どうみてもMyISAMです。トランザクションロールバックに対応していないのであたりまえの挙動でした。。

追記2

下記のようにmigrationのすべてのcreate_table時に'InnoDB'を指定することで、下記のスクリプトのままトランザクションを実現することができました!

diff --git a/fuel/app/migrations/004_create_clickqueues.php b/fuel/app/migrations/004_create_clickqueues.php
index 20f026b..dff08e5 100644
--- a/fuel/app/migrations/004_create_clickqueues.php
+++ b/fuel/app/migrations/004_create_clickqueues.php
@@ -13,7 +13,9 @@ class Create_clickqueues
      'created_at' => array('constraint' => 11, 'type' => 'int', 'null' => true),
      'updated_at' => array('constraint' => 11, 'type' => 'int', 'null' => true),

-   ), array('id'));
+    ), array('id'),
+    false, 'InnoDB', 'utf8_unicode_ci');
+
  }

  public function down()

はじめに

下記のロジックでクリック数の集計システムを作ってみました。

1.itemの一覧があって各itemのクリック時にidつきでAPIを叩く。
2.APIが叩かれたらClickQueueにitem_id, state=0を詰む。
3.一定周期でタスクclickpicker.phpを動かして集計する。

集計タスクは下記の通りです。
トランザクションを使ってClickQueueとItem(PickCount)の同期が確実の行われるように設定したつもりでした。

fuel/app/tasks/clickpicker.php
  public function run($args = NULL)
  {
    $query = \Model_ClickQueue::query()->where('state', 0); 

    while($query->count() > 0){ 

      $click_queue = $query->get_one();
      $click_queues = $query->where('item_id', $click_queue->item_id)->get();
      $click_count = count($click_queues);

      $item = \Model_Item::find($click_queue->item_id);
      $pick_count = \Model_PickCount::find($item->pick_count_id);

      $pick_count->click = $pick_count->click + $click_count;
      foreach ($click_queues as $done_click_queue)
      {   
        $done_click_queue->state = 1;
      }   

      try 
      {   
        \DB::start_transaction();
        $pick_count->save();

//        throw new \Exception('hogehoge');


        foreach ($click_queues as $done_click_queue)
        {   
          $done_click_queue->save();
        }   
        \DB::commit_transaction();

      }   
      catch (\Exception $e) 
      {   
        \DB::rollback_transaction();
        echo $e->getMessage().PHP_EOL;
        return;
      }   

      echo 'item_id = '.$item->id.'  +'.$click_count.' clicks ( total '.$pick_count->click.' clicks )'.PHP_EOL;

      $query = \Model_ClickQueue::query()->where('state', 0); 
    }   

    echo 'ClickPicker task successfully completed.'.PHP_EOL;
  }
}

実装した結果

実際に動かしてみた結果は下記の通り。

$ oil r clickpicker
item_id = 1  +1 clicks ( total 17 clicks )
ClickPicker task successfully completed.

とてもよく動いているように見えます。
が、コメントアウトしているデバッグ用の例外投げ処理を解除したうえで、item_id = 1067をクリックしてみたところ...

f:id:letsspeak:20130317235436p:plain

=> ClickQueueは未集計の状態

f:id:letsspeak:20130317235519p:plain

=> PickCountは集計された状態

となり、想定していたロールバックは正しく行われませんでした。

調査

下記内容はまだ調べている途中なので間違いがあるかもしれません!

半日かけてよくよく調べてみると、

DB::start_transaction();
DB::end_transaction();
DB::rollback_transaction();

は上記のような使い方はできないみたいです。
というのもfuel/ormは内部でPDOのインスタンスを生成しているっぽいのですが、他のorm modelとの共有機能はないみたいです。

ただfuel/orm model の機能として、

github orm/classes/mode.php

/**
	 * Save the object and it's relations, create when necessary
	 *
	 * @param  mixed  $cascade
	 *     null = use default config,
	 *     bool = force/prevent cascade,
	 *     array cascades only the relations that are in the array
	 */
	public function save($cascade = null, $use_transaction = false)

というsaveのオプションがあるようなのでcascadeを正しく設定していればオプション付きの呼び出しで自動的にtransactionが行われるように思います。

おわりに

しかし冷静に考えてみると今回のようなキュー方式の場合、ItemとClickQueueはリレーショナルな関係にはならないのでsaveのオプションを使ってトランザクションを実現するのは難しそうです。

itemの更新があまり行われない状況ならキュー方式をやめてクリックごとにPickCountを+1ずつ加算してしまうか、そのままキューを使うのであれば、素直にDB::start_transaction()と一緒にそちらのクエリビルダを利用した方が良いのかも知れません。

初心者がFuelPHPのMySQLバックアップタスクを書いてみた

oil さんでマイグレーション時にデータベースのオプションが足りなくて書き直したりしていたとき、

oil r migrate:down
oil r migrate:down

と戻しすぎてテーブルが吹っ飛んだのでバックアップタスクを書いてみました。
間違いなどありましたら是非是非ご指摘をお願いいたします。

gist letsspeak / mysqlbackup.php

fuel/app/tasks/mysqlbackup.php
<?php
 
namespace Fuel\Tasks;
 
class MySQLBackup 
{
  public static function system_ex($cmd, $stdin = "")
  {
    $descriptorspec = array(
      0 => array("pipe", "r"),
      1 => array("pipe", "w"),
      2 => array("pipe", "w")
    );
 
    $process = proc_open($cmd, $descriptorspec, $pipes);
    $result_message = "";
    $error_message = "";
    $return = null;
 
    if (is_resource($process))
    {
      fputs($pipes[0], $stdin);
      fclose($pipes[0]);
 
      while ($error = fgets($pipes[2])){
        $error_message .= $error;
      }
 
      while ($result = fgets($pipes[1])){
        $result_message .= $result;
      }
      foreach ($pipes as $k=>$_rs){
        if (is_resource($_rs)){
          fclose($_rs);
        }
      }
 
      $return = proc_close($process);
    }
 
    return array(
      'return' => $return,
      'stdout' => $result_message,
      'stderr' => $error_message,
    );
  }
 
  public static function run($args = NULL)
  {
    \Config::load('db', true);
    $name = \Config::get('db.active');
 
    $dsn = \Config::get('db.'.$name.'.connection.dsn');
    $username = \Config::get('db.'.$name.'.connection.username');
    $password = \Config::get('db.'.$name.'.connection.password');
 
    @preg_match('/^(.+):host=(.+);dbname=(.+)$/i', $dsn, $matches);
    
    if (count($matches) !== 4){
      $message = "Config dsn doesn't match.".PHP_EOL;
      $message .= 'check fuel/app/config/development/db.php'.PHP_EOL;
      echo $message;
      \Model_AlertMail::send_error('Fuel MySQLBackup Error', $message);
      return;
    }
 
    $dbtype = $matches[1];
    $dbhost = $matches[2];
    $dbname = $matches[3];
 
    if (strtolower($dbtype) !== 'mysql') {
      $message = 'Config database type is not MySQL.'.PHP_EOL;
      $message .= 'check fuel/app/config/development/db.php'.PHP_EOL;
      echo $message;
//      \Model_AlertMail::send_error('Fuel MySQLBackup Error', $message);
      return;
    }
 
 
    $fileDir = '/var/log/fuel/';
    $fileName = date('ymd').'_'.date('His').'.sql';
 
    if ( ! file_exists($fileDir) ) {
 
      try
      {
        if ( ! @mkdir($fileDir) ){
          $message = 'Cannnot create directory '.$fileDir.PHP_EOL;
          echo $message;
          \Model_AlertMail::send_error('Fuel MySQLBackup Error', $message);
          return;
        }
      }
      catch (\Exception $e)
      {
        echo "Cannnot create directory ",$fileDir.PHP_EOL;
        \Model_AlertMail::send_error('Fuel MySQLBackup Error', $message);
        return;
      }
    }
 
    $command = "mysqldump --default-character-set=binary ".$dbname." --host=".$dbhost." --user=".$username." --password=".$password." > ".$fileDir.$fileName;
    $return = MySQLBackup::system_ex($command);
    
    if ($return["stderr"] !== "") {
      $message = 'mysqldump failed for reasion:'.PHP_EOL.$return["stderr"];
      \Model_AlertMail::send_error('Fuel MySQLBackup Error', $message);
      return;
    }
 
    echo 'MySQLBackup successfully completed.'.PHP_EOL;
  
  }
 
}
/* End of file tasks/MySQLBackup.php */

phpのstrftime()で警告が出る件とoil create fuelphpの失敗

前回の記事で

$ php -a
Interactive shell

php > echo strftime('%Y-%m-%d %H:%M:%S', time());
2013-03-09 13:40:52

を実行すると

PHP Warning: strftime(): It is not safe to rely on the system's timezone settings. You are *required* to use the date.timezone setting or the date_default_timezone_set() function. In case you used any of those methods and you are still getting this warning, you most likely misspelled the timezone identifier. We selected 'Asia/Tokyo' for 'JST/9.0/no DST' instead in php shell code on line 1

というエラーが出る件で「宿題(キリッ」なんて言ってましたが、/etc/php.ini の date.timezone が指定されていない事が原因でした。

/etc/php.ini
date.timezone = "Asia/Tokyo"

日本標準時なら上記設定でOK。

ちなみに date.timezone が正しく設定されていない場合、oil create fuelphp も下記エラーで失敗します。

Submodule path 'fuel/packages/parser': checked out '593d93d0e4baf551ea14f59d8ecbba4ba6f28dea'
Entering 'docs'
Entering 'fuel/core'
Entering 'fuel/packages/auth'
Entering 'fuel/packages/email'
Entering 'fuel/packages/log'
Entering 'fuel/packages/oil'
Entering 'fuel/packages/orm'
Entering 'fuel/packages/parser'
PHP Fatal error:  Exception thrown without a stack frame in Unknown on line 0

ググってみたところ、こちらの素敵なブログに事例が書いてあって解決しました。
この場を借りて御礼申し上げます!

いやーとてつもなくダサいッ!