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 ?>