FuelPHPのテンプレートコントローラでPC/スマホの出しわけ
FuelPHPでテンプレートコントローラを使用している場合に、PCとスマホでビューを出し分ける方法を検討。
ベースとなるテンプレートは
スマホからアクセスされた場合、parent::before()を実行する前に、$templateを上書きする。
<?php class Controller_Blog extends Controller_Template { public function before() { if (Agent::is_smartphone()) { $this->template = 'template_sp'; } parent::before(); } public function action_index() { $view = View::forge('pc/blog/index'); if (Agent::is_smartphone()) { $view = View::forge('mb/blog/index'); } $this->template->title = 'Blog » index'; $this->template->content = $view; } }
上記の場合、ファイル名やパスの修正が入った場合、コツコツ修正する必要がある為、無理があるか。。。
FuelPHP + RedisでTwitterもどき
元ネタはこちら。
ケーススタディ — redis 2.0.3 documentation
上記ページの内容をFuelPHPで実装しました。
※一部実装を変えている部分があります。
ソースはこちら。
https://github.com/mmat/fuelphp-redis-tweet
ユーザー登録ページ
入力チェックを通過したユーザー名/passwordを文字列型にセット後、
ログイン中を表す期限付きのキーを発行し、Cookieにセットします。
Controller
// app/classes/controller/user.php <?php class Controller_User extends Controller { public function before() { parent::before(); $this->redis = Redis::instance(); } public function action_add() { $view = View::forge('user/add'); $form = Fieldset::forge(); $form->validation()->add_callable(new MyValidation()); $form->add('username', 'ユーザー名', array('max_length' => 16)) ->add_rule('required') ->add_rule('max_length', 16) ->add_rule('duplicate_user'); $form->add('password', 'パスワード', array('type' => 'password', 'maxlength' => 16)) ->add_rule('required') ->add_rule('min_length', 8) ->add_rule('max_length', 16); $form->add('submit', '', array('type' => 'submit', 'value' => '作成')); $form->repopulate(); if ($form->validation()->run()) { $input = $form->validation()->validated(); // ユーザー登録 $uid = $this->redis->incr('global:nextUserId'); $this->redis->set(sprintf('uid:%d:username', $uid), $input['username']); $this->redis->set(sprintf('uid:%d:password', $uid), md5($input['password'])); $this->redis->set(sprintf('username:%s:uid', $input['username']), $uid); // Cookieをセット $authkey = md5(uniqid(rand(), true)); $this->redis->setex(sprintf('auth:%s', $authkey), 3600, $uid); Cookie::set('authkey', $authkey); Response::redirect('user/add_comp'); } else { $view->set_safe('errors', $form->validation()->show_errors()); } $view->set_safe('html_form', $form->build(Uri::create('user/add'))); return $view; } public function action_add_comp() { // ログインチェック $authkey = Cookie::get('authkey'); $uid = $authkey ? $this->redis->get(sprintf('auth:%s', $authkey)) : null; if (!$uid) { Response::redirect('user/add'); } return View::forge('user/add_comp'); } }
Custom Validator
ユーザー名の重複チェック用のバリデーションです。
// app/classes/myvalidation.php <?php class MyValidation { public static function _validation_duplicate_user($username) { $redis = \Redis::instance(); if ($redis->get(sprintf('username:%s:*', $username))) { return false; } return true; } }
Template
// app/views/user/add.php <?php if (isset($errors)): ?> <?php echo $errors ?> <?php endif ?> <?php echo $html_form ?> // app/views/user/add_comp.php ユーザー登録完了<br /> <a href="<?php echo Uri::create('top') ?>">TOPへ</a>
ログインページ
ログインを通ったユーザーに対し、ログイン中を表す期限付きのキーをセットし、Cookieを発行します。
(元のページでは、もう少しだけ込み入った事をしています)
Controller
app/classes/controller/login.php <?php class Controller_Login extends Controller { public function before() { parent::before(); $this->redis = Redis::instance(); } public function action_index() { $view = View::forge('login/index'); // フォーム $form = Fieldset::forge(); $form->add('username', 'ユーザー名', array('maxlength' => 20)) ->add_rule('required'); $form->add('password', 'パスワード', array('type' => 'password', 'maxlength' => 20)) ->add_rule('required'); $form->add('submit', '', array('type' => 'submit', 'value' => 'ログイン')); $form->repopulate(); if (Input::post()) { if ($form->validation()->run()) { $input = $form->validation()->validated(); $uid = $this->redis->get(sprintf('username:%s:uid', $input['username'])); if ($uid && md5($input['password']) == $this->redis->get(sprintf('uid:%d:password', $uid))) { $authkey = md5(uniqid(rand(), true)); $this->redis->setex(sprintf('auth:%s', $authkey), 3600, $uid); Cookie::set('authkey', $authkey); Response::redirect('top'); } else { $view->error_flg = true; } } else { $view->error_flg = true; } } $view->set_safe('html_form', $form->build(Uri::create('login'))); return $view; } }
Template
// views/login/index.php <?php if (isset($error_flg)): ?>ログインに失敗しました<br /><?php endif ?> <?php echo $html_form ?> <a href="<?php echo Uri::create('user/add') ?>">ユーザー登録はこちら</a>
ログアウト処理
ログアウトは単純に、ログイン中を表すキーを削除し処理します。
(削除後、ログインページへリダイレクト)
Controller
// app/classes/controller/logout.php <?php class Controller_Logout extends Controller { public function before() { parent::before(); $this->redis = Redis::instance(); } public function action_index() { $authkey = Cookie::get('authkey'); if ($authkey) { $this->redis->del(sprintf('auth:%s', $authkey)); } Response::redirect('login'); } }
TOPページ
ログイン後のページでは『タイムライン』『全体のタイムライン』『フォロー一覧』『フォロワー一覧』を表示します。
ポスト
メッセージをポストした場合、文字列型にpost_idをキーにメッセージをセットし、その時点のフォロワーに対し、post_idを紐付けます。
紐付けにリスト型を使用し、毎回先頭に追加する事により、時間でソートする必要が無くなっています。
リスト型に格納されたメッセージの取得は、レンジを指定し取得します。
フォロー
フォロー情報は、1つのキーに対し同じ値が入らないようになっているセット型を使用します。
下のサンプルでは、手抜きをしてトップページのコントローラにフォロー/リムーブ処理を入れ、GETで処理を行なっていますが、非同期処理にする事により、よりそれっぽくなるんではないでしょうか。
Controller
// app/classes/controller/top.php <?php class Controller_Top extends Controller { public function before() { parent::before(); $this->redis = Redis::instance(); // ログインチェック $authkey = Cookie::get('authkey'); $this->uid = $authkey ? $this->redis->get(sprintf('auth:%s', $authkey)) : null; if ($this->uid) { $this->redis->expire(sprintf('auth:%s', $authkey), 3600); } else { Response::redirect('login'); } } public function action_index() { $view = View::forge('top/index'); // フォーム $form = Fieldset::forge(); $form->add('message', 'メッセージ', array('type' => 'textarea', 'rows' => 5)) ->add_rule('required') ->add_rule('max_length', 140); $form->add('submit', '', array('type' => 'submit', 'value' => '送信')); if ($form->validation()->run()) { // ポストされたメッセージをセット $postid = $this->redis->incr('global:nextPostId'); $message = preg_replace('/\n|\t/s', ' ', Input::post('message')); $post = sprintf("%s\t%d\t%s", $this->uid, time(), $message); $this->redis->set(sprintf('post:%d', $postid), $post); $this->redis->lpush(sprintf('uid:%d:posts', $this->uid), $postid); $followers = $this->redis->smembers(sprintf('uid:%d:followers', $this->uid)); foreach ($followers as $fid) { $this->redis->lpush(sprintf('uid:%d:posts', $fid), $postid); } $this->redis->lpush('global:timeline', $postid); $this->redis->ltrim('global:timeline', 0, 1000); } else { $form->repopulate(); $view->set_safe('errors', $form->validation()->show_errors()); } $view->set_safe('html_form', $form->build(Uri::create('top'))); // フォローしている $following = array(); $members = $this->redis->smembers(sprintf('uid:%d:following', $this->uid)); if ($members) { foreach ($members as $member_uid) { $following[$member_uid] = $this->redis->get(sprintf('uid:%d:username', $member_uid)); } } $view->following = $following; // フォローされている $followers = array(); $members = $this->redis->smembers(sprintf('uid:%d:followers', $this->uid)); if ($members) { foreach ($members as $member_uid) { $followers[$member_uid] = $this->redis->get(sprintf('uid:%d:username', $member_uid)); } } $view->followers = $followers; // ツイート取得 $messages = array(); $timeline = $this->redis->lrange(sprintf('uid:%d:posts', $this->uid), 0, 100); if ($timeline) { foreach ($timeline as $postid) { $messages[] = $this->_get_post($postid); } } $view->messages = $messages; // 全体のツイート取得 $gmessages = array(); $global_timeline = $this->redis->lrange('global:timeline', 0, 100); if ($global_timeline) { foreach ($global_timeline as $postid) { $gmessages[] = $this->_get_post($postid); } } $view->gmessages = $gmessages; return $view; } public function action_follow($follow_uid = null) { if ($follow_uid && $this->redis->get(sprintf('uid:%d:username', $follow_uid))) { $this->redis->sadd(sprintf('uid:%d:following', $this->uid), $follow_uid); $this->redis->sadd(sprintf('uid:%d:followers', $follow_uid), $this->uid); } Response::redirect('top'); } public function action_remove($remove_uid = null) { if ($remove_uid && $this->redis->get(sprintf('uid:%d:username', $remove_uid))) { $this->redis->srem(sprintf('uid:%d:following', $this->uid), $remove_uid); $this->redis->srem(sprintf('uid:%d:followers', $remove_uid), $this->uid); } Response::redirect('top'); } private function _get_post($postid) { list($uid, $unixtime, $message) = explode("\t", $this->redis->get(sprintf('post:%d', $postid))); $username = $this->redis->get(sprintf('uid:%d:username', $uid)); $post_info = array( 'uid' => $uid, 'username' => $this->redis->get(sprintf('uid:%d:username', $uid)), 'time' => date('Y-m-d H:i:s', $unixtime), 'message' => $message, 'following_flg' => $uid != $this->uid && $this->redis->sismember(sprintf('uid:%d:following', $this->uid), $uid) ? true : false ); return $post_info; } }
Template
// views/top/index.php <a href="<?php echo Uri::create('logout') ?>">ログアウト</a><br /> <?php if (isset($errors)): ?> <?php echo $errors ?> <?php endif ?> <?php echo $html_form ?> <?php if ($messages): ?> <h2>タイムライン</h2> <hr /> <?php foreach ($messages as $m): ?> <?php echo $m['username'] ?><a href="<?php echo Uri::create('top/remove/'.$m['uid']) ?>">[フォロー解除]</a> <?php echo $m['time'] ?><br /> <?php echo $m['message'] ?> <hr /> <?php endforeach ?> <?php endif ?> <?php if ($followers): ?> <h2>フォローされている</h2> <ul> <?php foreach ($followers as $uid => $username): ?> <li><?php echo $username ?></li> <?php endforeach ?> </ul> <?php endif ?> <?php if ($following): ?> <h2>フォローしている</h2> <ul> <?php foreach ($following as $uid => $username): ?> <li><?php echo $username ?><a href="<?php echo Uri::create('top/remove/'.$uid) ?>">[フォロー解除]</a></li> <?php endforeach ?> </ul> <?php endif ?> <?php if ($gmessages): ?> <h2>全体のタイムライン</h2> <?php foreach ($gmessages as $m): ?> <?php echo $m['username'] ?> <?php if ($m['following_flg']): ?> <a href="<?php echo Uri::create('top/remove/'.$m['uid']) ?>">[フォロー解除]</a> <?php else: ?> <a href="<?php echo Uri::create('top/follow/'.$m['uid']) ?>">[フォロー]</a> <?php endif ?> <?php echo $m['time'] ?><br /> <?php echo $m['message'] ?> <hr /> <?php endforeach ?> </ul> <?php endif ?>
FuelPHPのクエリビルダを表にまとめた
※Ver1.2の情報なので最新バージョンと合わない部分があるかもしれません...
タイトルそのまま。FuelPHP1.2のクエリビルダ関連を表にまとめました。
SELECT | // SELECT * FROM... \DB::select() |
// SELECT `hoge`, `fuga` FROM... \DB::select(column1, column2...) \DB::select_array(array(column1, column2...)) | |
// SELECT `hoge` AS `h`, `fuga` AS `f` FROM... \DB::select(array(column1, alias), array(column2, alias)...) \DB::select_array(array(column1, alias), array(column2, alias)...) | |
DISTINCT | // SELECT DISTINCT `hoge` FROM... distinct(true) |
FROM | // SELECT * FROM `hoge` from(table) |
// SELECT * FROM `hoge` AS `h` from(array(table, alias)) | |
JOIN | // SELECT * FROM `hoge` INNER JOIN `fuga` ON (`hoge.id` = `fuga.hoge_id`) join(table)->on(column1, '=', column2) |
// SELECT * FROM `hoge` LEFT JOIN `fuga` ON (`hoge.id` = `fuga.hoge_id`) join(table, type)->on(column1, '=', column2) ※ type : 『INNER』 『LEFT』 『RIGHT』 ... | |
// SELECT * FROM `hoge` AS `h` INNER JOIN `fuga` AS `f` ON (`h.id` = `f.hoge_id`) join(array(column, alias))->on(column1, '=', column2) | |
WHERE | // WHERE `foo` = `bar` where(column, value) |
// WHERE `foo` != `bar` where(column, op, value) ※ op : 『=』『!=』『>』『<』 ... ※opが!=でvalueがNULLの場合、クエリは自動的にIS NOT NULLに | |
// WHERE `hoge` IN (`foo`, `bar`) where(column, 'in', array(value1, value2...)) where(column, 'not in', array(value1, value2...)) | |
// WHERE `hoge` LIKE `%fuga%` where(column, 'like', value) | |
// WHERE `hoge` BETWEEN `1` AND `10` where(column, 'between', array(value1, value2)) | |
// AND `hoge` = `fuga` and_where(column, value) | |
// OR `hoge` = `fuga` or_where(column, value) | |
// (`hoge` = `fuga` OR `foo` = `bar`) where_open() ->where(column, value) ->or_where(column, value) ->where_close() | |
// OR (`hoge` = `fuga` AND `foo` = `bar`) or_where_open() ->where(column, value) ->and_where(column, value) ->or_where_close() | |
HAVING | // HAVING `hoge` = `fuga` having(column, op, value) ※ op : 『=』『!=』『>』『<』 ... |
// AND HAVING `hoge` = `fuga` and_having(column, op, value) ※ op : 『=』『!=』『>』『<』 ... | |
// OR HAVING `hoge` = `fuga` or_having(column, op, value) ※ op : 『=』『!=』『>』『<』 ... | |
// HAVING (`hoge` = `fuga` AND `foo` = `bar`) having_open() ->having(column, op, value)) ->and_having(column, op, value)) ->having_close() | |
// AND (HAVING (`hoge` = `fuga` OR `foo` = `bar`)) and_having_open() ->having(column, op, value)) ->or_having(column, op, value)) ->and_having_close() | |
// OR (HAVING (`hoge` = `fuga` AND `foo` = `bar`)) or_having_open() ->having(column, op, value)) ->and_having(column, op, value)) ->or_having_close() | |
ORDER BY | // ORDER BY `hoge` order_by(column) |
// ORDER BY `hoge` ASC order_by(column, 'asc') | |
// ORDER BY `hoge` DESC order_by(column, 'desc') | |
GROUP BY | // GROUP BY `hoge` group_by(column) |
LIMIT | // LIMIT 10 limit(value) |
OFFSET | // OFFSET 10 offset(value) |
INSERT | // INSERT INTO `hoge` (`foo`, `fuga`) VALUES (`bar`, `buz`); \DB::insert(table)->set(array( column1 => value1, column2 => value2... ))->execute(); |
\DB::insert(table)->columns(array( column1, column2... ))->values(array( value1, value2... ))->execute(); | |
UPDATE | // UPDATE `hoge` SET `foo` = `bar` WHERE `fuga` = `buz`; \DB::update(table) ->value(column, value) ->where(WHERE参照) ->execute(); |
\DB::update(table)->set(array( column1 => value1, column2 => value2... )) ->where(WHERE参照) ->execute(); | |
DELETE | // DELETE FROM `hoge` WHERE `foo` = `bar`; \DB::delete(table) ->where(WHERE参照) ->execute(); |
クエリビルダでCOUNTを取りたい場合はこんな感じ
$result = DB::select(DB::expr('COUNT(*) as count'))->from('users')->execute(); $result_arr = $result->current(); $count = $result_arr['count'];
FuelPHPのSimpleAuthでACL(Access Control List)
FuelPHPのSimpleAuthのACLの概要は、以下のようになっています。
- ロールを設定ファイルで管理
- ユーザーではなく、グループに対しロールがひもづく
以下、設定例になります。
config
$ vi app/config/simpleauth.php
設定対象は『groups』と『roles』になります。
<?php ... // グループの設定 'groups' => array( /** * グループID => array( // グループIDはusersテーブルのgroupカラム * 'name' => '識別名', * 'role' => 'ロール名' * ) */ 1 => array( 'name' => 'Users', 'roles' => array('user') ) ), // 権限設定 'roles' => array( /** * ロール名 => array( * 'アクセス先(任意の文字列)' => '権限(任意の文字列)' * ) */ 'user' => array( 'blog' => array('read') ), /** * ワイルドカード * * ワイルドカードは未ログインユーザーもあてはまる為、 * 必要であれば、ログインチェックを別途行う必要があります */ '#' => array( 'comments' => array('read') ), /** * ロール名での指定 */ 'user' => false, // 指定のロール名は全てアクセス不可 'admin' => true, // 指定のロール名は全てアクセス許可 ),
Controller
controllerでは『has_access』を使用し、権限チェックを行います。
<?php ... // blogのread権限があるかどうか if (Auth::has_access('blog.read')) { // OK } else { // NG } // まとめて記述したい場合 // 下記の場合、read/write/delete全ての権限があるユーザーがOKになります if (Auth::has_access('blog.[read,write,delete]')) { // OK } else { // NG }
SimpleAuthを使ったログイン機能はこちらを参照
FuelPHPのテーマクラスのサンプル
Fuelphpのテーマクラスのサンプルです。
『active』を変更する事により、テーマの切り替えが可能です。
サンプル
Config
Controller
app/classes/controller/sample.php
<?php class Controller_Sample extends Controller { public function before() { $this->theme = \Theme::instance(); } public function action_index() { $this->theme->set_template('homepage')->set('title', 'sample'); $this->theme->set_partial('header', 'header')->set('name', 'hoge'); $this->theme->set_partial('footer', 'footer')->set(array( 'foo' => 'val1', 'bar' => 'val2' )); } public function after($response) { if (empty($response) or ! $response instanceof Response) { $response = \Response::forge($this->theme->render()); } return parent::after($response); } }
Template
app/views/mytheme1/homepage.php
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title><?php echo $title ?></title> </head> <body> <?php echo $partials['header'] ?> ... <?php echo \Theme::instance()->asset->img('myicon.png') ?> ... <?php echo $partials['footer'] ?> </body> </html>
app/views/mytheme1/header.php
... <?php echo $name ?> ...
諸々の説明
Config
active | アクティブなテーマ名 |
fallback | アクティブなテーマが存在しない場合のテーマ |
paths | テーマ検索パス |
assets_folder | assetフォルダ名 |
view_ext | テンプレートの拡張子 |
info_file_name | ? |
require_info_file | ? |
info_file_type | ? |
テンプレートのベースパスは、pathsで指定したディレクトリ内のactive名、fallback名のディレクトリになり、まずactive内でテンプレートを探し、ない場合にfallback内のテンプレートを表示します。
デフォルトでは『DOCROOT/themes/default』を参照するようになっている為、サンプルではapp/viewsに変更しています。
また、テーマクラスに関する設定は、設定ファイル(theme.php)以外に、instance()実行時またforge()実行時に指定が可能です。
例)
<?php .. function before() { ... $this->theme = \Theme::instance( 'custom', array( 'active' => 'mytheme1', 'fallback' => 'default', 'paths' => array(APPPATH.'views'), 'assets_folder' => 'themes', 'view_ext' => '.php' ) ); ... }
テンプレート/パーツの読み込み
『set_template('テンプレート')』を使用し、ベースとなるテンプレートを読み込みます。
テンプレートで使用する変数は『set』で渡します。
$this->theme->set_template('homepage')->set('title', 'sample');
ヘッダーやフッター等の共通パーツは『set_partial('セクション名', 'テンプレート')』を使用します。
$this->theme->set_partial('footer', 'footer')->set(array( 'foo' => 'val1', 'bar' => 'val2' ));
『set_partial()』で読み込んだパーツは、テンプレート側で以下のように出力が可能です。
<?php echo $partials['footer'] ?>
Asset
テーマクラスを使用した場合、Assetの呼び出しは以下のように行います。
<?php echo \Theme::instance()->asset->img('myicon.png') ?>
サンプルでは『'assets_folder' => 'themes'』を設定している為、『DOCROOT/themes/mytheme1』がベースになり、サンプルは以下のようなタグが出力されます。
<img src="http://hoge.com/themes/mytheme1/img/myicon.png" alt="" />
※通常のAsset同様、『img』『css』『js』の各ディレクトリ配下のファイルを呼び出し
よくわからない点
- set_chrome()
- info_file_name/require_info_file
FuelPHPのAgentクラスと拡張
FuelPHPのAgentクラスは、下記URLからブラウザ情報を取得し、これを元にプラットフォーム等の判定を行っています。
http://browsers.garykeith.com/stream.asp?Lite_PHP_BrowsCapINI
上記URLへアクセスは最初に1度のみで、それ以降はキャッシュとして保持されます。
(アクセス先URL、ファイル保存先はhttp://docs.fuelphp.com/classes/agent/config.html:設定ファイルで変更可能)
これを元に判定されたユーザーエージェント情報は、以下の関数で取得する事になります。(諸々の使い方は公式ドキュメント参照)
accepts_charset($charset = 'utf-8')
指定charsetがHTTP_ACCEPT_CHARSETに含まれるか
accepts_language($language = 'en')
指定languageがHTTP_ACCEPT_LANGUAGEに含まれるか
platform()
プラットフォーム(Win95,Win98,WinNT...)
version()
ブラウザのバージョン
charsets()
HTTP_ACCEPT_CHARSET一覧
languages()
HTTP_ACCEPT_LANGUAGE一覧
properties()
プロパティ一覧
property($property = null)
指定プロパティの値
is_mobiledevice()
モバイル判定(スマホ含む)
is_robot()
ロボット判定
上記の関数を見てもらうと分かりますが、現状ではスマホの判定を行う関数がないようですので、Agentクラスを拡張し、スマホ判定の関数を追加してみたいと思います。
まずは『fuel/app/classes/agent.php』を用意します。
<?php class Agent extends Fuel\Core\Agent { public static function _init() { parent::_init(); $sp_list = array( 'iPhone', 'iPod', 'Android', 'IEMobile', 'dream', 'CUPCAKE', 'blackberry9500', 'blackberry9530', 'blackberry9520', 'blackberry9550', 'blackberry9800', 'webOS', 'incognito', 'webmate' ); $pattern = '/'.implode('|', $sp_list).'/i'; static::$properties['x_issmartphone'] = preg_match($pattern, static::$user_agent) ? true : false; } public static function is_smartphone() { return static::$properties['x_issmartphone']; } }
次に『fuel/app/bootstrap.php』に対し、Agentを追加します。
Autoloader::add_classes(array( // Add classes you want to override here // Example: 'View' => APPPATH.'classes/view.php', 'Agent' => APPPATH.'classes/agent.php' ));
上記の設定が完了すれば、以下の呼び出しが可能になります。
if (Agent::is_smartphone()) { // スマホ用処理 }
Redisのバックアップ
Redisのバックアップ(スナップショット?)の取得方法のメモ。
bgsaveでデータベースを保存、lastsaveで最終保存日時を確認後、データベースファイルをコピー。
$ redis-cli bgsave $ redis-cli lastsave
lastsaveはUNIXTIMEで返ってくる為、要変換。
例) $ date --date "@`redis-cli lastsave`" +"%Y/%m/%d %H:%M"
lastsaveで、データベースがファイルに保存されている事を確認し、データベースファイルをコピー。
# cd /var/lib/predis/ # cp dump.rdb dump.rdb.YYYYMMDD
※データベースファイルのファイル名と配置パスは、redis.confの『dbfilename』と『dir』を参照。
参考)
http://my.safaribooksonline.com/book/databases/9781449311353/4dot-redis-administration-and-maintenance/id3064347
http://redis.shibu.jp/commandreference/control.html#command-LASTSAVE