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>&nbsp;
<?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 ?>&nbsp;
<?php echo $m['time'] ?><br />
<?php echo $m['message'] ?>
<hr />
<?php endforeach ?>
</ul>
<?php endif ?>