@blog.justoneplanet.info

日々勉強

CakePHP 2でRedisにSessionを保持する

Cake2系用のが無かったのでCake1系用のこれをforkして作ってみた。

■導入

iRedis

Vendorに配置する

cakephp-redis-session

app/Model/DataSource/Session/に配置する

core.php

core.phpに以下のように記述する。

Configure::write('Session.handler', array('engine' => 'RedisSession'));
Configure::write('RedisSession.hostname', 'some.host.name');
Configure::write('RedisSession.port', 1337);//port
Configure::write('RedisSession.password', 'your password');// password
Configure::write('RedisSession.database', 0);// database number

■コード

オリジナルのコードはパスワードやDB番号が設定できなかったので改良してある。

App::import('Vendor', 'iRedis/iredis');
App::uses('DatabaseSession', 'Model/Datasource/Session');
/**
 * Redis Session Store Class
 */
class RedisSession extends DatabaseSession implements CakeSessionHandlerInterface {
    private static $store;
    private static $timeout;
    
    public function __construct() {
        parent::__construct();
        $timeout = Configure::read('Session.timeout');
        if (empty($timeout)) {
            $timeout = 60 * 24 * 90;
        }
        self::$timeout = $timeout;
    }

    /**
     * open
     * connect to Redis
     * authorize
     * select database
     */
    public function open() {
        $host = Configure::read('RedisSession.hostname');
        $port = Configure::read('RedisSession.port');
        $password = Configure::read('RedisSession.password');
        $database = Configure::read('RedisSession.database');

        if ($host !== null && $port !== null) {
            $redis = new iRedisForRedisSession(array('hostname' => $host, 'port' => $port));
        }
        else {
            $redis = new iRedisForRedisSession();
        }
        if (!empty($password)) {
            $redis->auth($password);
        }
        if (!empty($database)) {
            $redis->select($database);
        }

        self::$store = $redis;
    }

    /**
     * close
     * disconnect from Redis
     * @return type 
     */
    public function close() {
        self::$store->disconnect();
        return true;
    }

    /**
     * read
     * @param type $id
     * @return type 
     * - Return whatever is stored in key
     */
    public function read($id) {
        return self::$store->get($id);
    }

    /**
     * write
     * @param type $id
     * @param type $data
     * @return type 
     * - SETEX data with timeout calculated in open()
     */
    public function write($id, $data) {
        self::$store->setex($id, self::$timeout, $data);
        return true;
    }

    /**
     * destroy
     * @param type $id
     * @return type 
     * - DEL the key from store
     */
    public function destroy($id) {
        self::$store->del($id);
        return true;
    }

    /**
     * gc
     * @param type $expires
     * @return type 
     * not needed as SETEX automatically removes itself after timeout
     */
    public function gc($expires = null) {
        return true;
    }
}
if (!class_exists('iRedisForRedisSession')) {
    class iRedisForRedisSession extends iRedis {
        function __destruct() {
            // don't disconnect yet
        }
        function disconnect() {
            parent::__destruct();
        }
    }
}

iRedisのコードを読んでみると面白いのだがマジックメソッド__callを使って各コマンドをcallしている。

PHPで文字列の類似度を求める

レーベンシュタイン距離を使う。

function mb_str_split($str, $split_len = 1) {
    mb_internal_encoding('UTF-8');
    mb_regex_encoding('UTF-8');
    if ($split_len <= 0) {
        $split_len = 1;
    }

    $strlen = mb_strlen($str, 'UTF-8');
    $ret    = array();

    for ($i = 0; $i < $strlen; $i += $split_len) {
        $ret[] = mb_substr($str, $i, $split_len);
    }
    return $ret;
}

function getLevenshteinFactor($string1, $string2, $insert = 1, $delete = 1, $replace = 1) {
    $string1 = mb_str_split($string1);
    $length1 = count($string1);
    $string2 = mb_str_split($string2);
    $length2 = count($string2);

    if ($length1 < $length2) {
        $c = $string1;
        $string1 = $string2;
        $string2 = $c;
        $o = $length1;
        $length1 = $length2;
        $length2 = $o;
    }

    $d = array();
    $d[0] = array();
    for ($i = 0; $i < $length2 + 1; $i++) {
        $d[0][$i] = $i;
    }

    for ($i = 1; $i < $length1 + 1; $i ++) {
        $d[$i] = array();
        $d[$i][0] = $i;
        for ($j = 1; $j < $length2 + 1; $j ++) {
            $cost = ($string1[$i - 1] === $string2[$j - 1]) ? 0 : 1;
            $d[$i][$j] = min(
                $d[$i - 1][$j] + $insert,
                $d[$i][$j - 1] + $delete,
                $d[$i - 1][$j - 1] + ($replace * $cost)
            );print($d[$i][$j]);
        }
    }
    $distance = $d[$length1][$length2];
    $rate = $distance / $length1;
    return $rate;
}

他のサイトから持ってきたけど期待した値と少し違ったので調整した。

CakePHPでOAuth Service Providerを実装する

■プロジェクト

以下のコマンドでCakePHPをダウンロードして展開する。

wget https://github.com/cakephp/cakephp/tarball/master
tar xvzf cakephp-cakephp-2.1.3-1-g5270721.tar.gz
cd cakephp-cakephp-5270721

■Pluginの設置

面倒なのでプラグインを使う。

cd app/Plugin
git clone https://github.com/seddonmedia/cakephp-oauth-server.git OAuth
mv OAuth/Controller/OAuthController.php OAuth/Controller/OauthController.php #多分必要

oauth2-phpが必要になるので以下のコマンドを実行する。

cd app/Vendor
git clone https://github.com/quizlet/oauth2-php.git

■Schema

CREATE TABLE IF NOT EXISTS `access_tokens` (
  `oauth_token` varchar(40) NOT NULL,
  `client_id` char(36) NOT NULL,
  `user_id` int(11) unsigned NOT NULL,
  `expires` int(11) NOT NULL,
  `scope` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`oauth_token`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
CREATE TABLE IF NOT EXISTS `auth_codes` (
  `code` varchar(40) NOT NULL,
  `client_id` char(36) NOT NULL,
  `user_id` int(11) unsigned NOT NULL,
  `redirect_uri` varchar(200) NOT NULL,
  `expires` int(11) NOT NULL,
  `scope` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`code`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
CREATE TABLE IF NOT EXISTS `clients` (
  `client_id` char(20) NOT NULL,
  `client_secret` char(40) NOT NULL,
  `redirect_uri` varchar(255) NOT NULL,
  `user_id` int(11) DEFAULT NULL,
  PRIMARY KEY (`client_id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
CREATE TABLE IF NOT EXISTS `refresh_tokens` (
  `refresh_token` varchar(40) NOT NULL,
  `client_id` char(36) NOT NULL,
  `user_id` int(11) unsigned NOT NULL,
  `expires` int(11) NOT NULL,
  `scope` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`refresh_token`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;

■ログイン

アプリケーションによって実装は様々だが、以下の変数にuniqueなユーザidを保持している必要がある。

$this->Auth->user('id');

以降はドキュメントに書いてあるので必要ないが一応。

■ルーティング

app/Config/bootstrap.phpの最終行に以下を追記する。

CakePlugin::loadAll(array(
    'OAuth' => array('routes' => true)
));

■Client IDとClient Secretの取得

var_dump($this->OAuth->Client->add('http://sample.org'));

ここで取得できるのはクライアントID(a)とSecret(b)である。表示されるSecretは実際の値で、DB側に保存されている値はhash化されている。

■Authorization Codeの取得

/oauth/authorize?response_type=code&client_id=xxxx&redirect_url=http%3a%2f%2fsample.org

Authorization Codeが取得できる。(c)

■Access Tokenの取得

/oauth/token?grant_type=authorization_code&code=(c)&client_id=(a)&client_secret=(b)

Access Tokenの取得時に(b)は以下の部分でhash化してfindされる。

    public function checkClientCredentials($client_id, $client_secret = NULL) {
        $conditions = array('client_id' => $client_id);
        if ($client_secret) {
            $conditions = array(
                'client_secret' => self::hash($client_secret),
            );
        }
        $client = $this->Client->find('first', array(
            'conditions' => $conditions,
            'recursive'  => -1
        ));
        if ($client){
            return $client['Client'];
        };
        return false;
    }

CakePHP2系で認証のユーザーモデルを変更する

ログアウト後のログインが上手くできなくてハマったのでメモしておく。

class AppController extends Controller {
    public $components = array(
        'Auth' => array(
            'loginRedirect' => array('controller' => 'hoge', 'action' => 'index'),
            'loginAction'   => array('controller' => 'hoge', 'action' => 'login'),
            'authenticate'  => array(
                'Form' => array(
                    'userModel' => 'Hoge',
                    'fields' => array(
                        'username' => 'email',
                    ),
                    'scope' => array(
                        'Hoge.is_public' => 1,
                    ),
                ),
            ),
        ),
        'Session'
    );

    public function beforeFilter() {
        parent::beforeFilter();
    }
}
class HogeController extends AppController {
    public function beforeFilter() {
        parent::beforeFilter();
        $this->Auth->allow(array('login', 'logout', 'fuga'));// loginとlogoutも記述しておく
    }
    public function login() {
        if ($this->Auth->login()) {
            $this->redirect($this->Auth->redirect());
        }
        else {
            $this->Session->setFlash(__('Invalid username or password, try again'));
        }
    }
    public function logout() {
        $this->redirect($this->Auth->logout());
    }
}

HMAC-MD5を計算する

■PHP

hash_hmac('md5', $str, 'key');

■Python

import hmac
from hashlib import sha1
from hashlib import md5
hmac.new("key", "value", md5).hexdigest()
hmac.new("key", "value", sha1).hexdigest()

■Android

public class HmacMD5 {
private static final String ALGORISM = “HmacMD5”;
private static final String S = “key”;
public static String get(String str) {
SecretKeySpec secretKeySpec = new SecretKeySpec(S.getBytes(), ALGORISM);
try {
Mac mac = Mac.getInstance(ALGORISM);
mac.init(secretKeySpec);
byte[] result = mac.doFinal(str.getBytes());
return byteToString(result);
}
catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
catch (InvalidKeyException e) {
e.printStackTrace();
}
return “”;
}

private static String byteToString(byte [] b) {
StringBuffer buffer = new StringBuffer();
for (int i = 0; i < b.length; i++) { int d = b[i]; d += (d < 0)? 256 : 0; if (d < 16) { buffer.append("0"); } buffer.append(Integer.toString(d, 16)); } return buffer.toString(); } } [/sourcecode]

■iOS

日本語入力に対応するためにstackoverflowから持ってきたコードに少々手を入れた。

+ (NSString *)HMACMD5WithKey:(NSString *)data
{
const char *cKey = [@”key” cStringUsingEncoding:NSUTF8StringEncoding];
const char *cData = [data cStringUsingEncoding:NSUTF8StringEncoding];
const unsigned int blockSize = 64;
char ipad[blockSize];
char opad[blockSize];
char keypad[blockSize];

unsigned int keyLen = strlen(cKey);
CC_MD5_CTX ctxt;
if (keyLen > blockSize) {
CC_MD5_Init(&ctxt);
CC_MD5_Update(&ctxt, cKey, keyLen);
CC_MD5_Final((unsigned char *)keypad, &ctxt);
keyLen = CC_MD5_DIGEST_LENGTH;
}
else {
memcpy(keypad, cKey, keyLen);
}

memset(ipad, 0x36, blockSize);
memset(opad, 0x5c, blockSize);

int i;
for (i = 0; i < keyLen; i++) { ipad[i] ^= keypad[i]; opad[i] ^= keypad[i]; } CC_MD5_Init(&ctxt); CC_MD5_Update(&ctxt, ipad, blockSize); CC_MD5_Update(&ctxt, cData, strlen(cData)); unsigned char md5[CC_MD5_DIGEST_LENGTH]; CC_MD5_Final(md5, &ctxt); CC_MD5_Init(&ctxt); CC_MD5_Update(&ctxt, opad, blockSize); CC_MD5_Update(&ctxt, md5, CC_MD5_DIGEST_LENGTH); CC_MD5_Final(md5, &ctxt); const unsigned int hex_len = CC_MD5_DIGEST_LENGTH*2+2; char hex[hex_len]; for(i = 0; i < CC_MD5_DIGEST_LENGTH; i++) { snprintf(&hex[i*2], hex_len-i*2, "%02x", md5[i]); } NSData *HMAC = [[NSData alloc] initWithBytes:hex length:strlen(hex)]; NSString *hash = [[[NSString alloc] initWithData:HMAC encoding:NSUTF8StringEncoding] autorelease]; [HMAC release]; return hash; } [/sourcecode]

PHPで不正な閉じタグを見つける

ループは1回にできると思うけどまぁとりあえず。

<?php
$str = <<<EOD
<html>
<body>
<center>
<form action="confirm.php" method="get">
<input type="text" name="name"><br>
<input type="submit" value="submit">
</center>
</form>
</body>
</html>
EOD;

preg_match_all("/<.*?>/", $str, $matches);
//var_dump($matches);
$ary = array();
$stack = array();

foreach ($matches[0] as $key => $value) {
    array_push($ary, $value);
}

foreach ($ary as $key => $value) {
    if (preg_match("/<\/([a-zA-Z]+)/", $value, $tag)) {
#        echo "$value\n";
#        var_dump($tag[1]);
        $isFoundClose = false;
        foreach ($ary as $k => $v) {
            if (preg_match("/<{$tag[1]}/", $v)) {
#                echo "match\n";
                $isFoundClose = true;
                array_splice($ary, $k, $key - $k);
                break;
            }   
        }   
        if (!$isFoundClose) {
            echo "error:$value\n";
        }   
    }   
    else {

    }   
}

CakePHP 2系でjsonを出力する

1系よりも簡単に以下のようにして、jsonを出力することができる。

<?php
App::uses('AppController', 'Controller');
class SampleController extends AppController {
    public function index() {
        return new CakeResponse(array('body' => json_encode(array('key' => 'value'))));
    }
}
// {"key" : "value"}

CakePHP 2.0.4で多言語対応する

■手順

アプリケーションのrootディレクトリで以下のコマンドを実行する。

php lib/Cake/Console/cake.php i18n

以下のように表示される。

Welcome to CakePHP v2.0.4 Console
---------------------------------------------------------------
App : app
Path: /var/www/domain/app/
---------------------------------------------------------------
I18n Shell
---------------------------------------------------------------
[E]xtract POT file from sources
[I]nitialize i18n database table
[H]elp
[Q]uit
What would you like to do? (E/I/H/Q) 

Eを選択する。

What is the path you would like to extract?
[Q]uit [D]one  
[/var/www/domain/app/] > 

Dを選択する。

What is the path you would like to output?
[Q]uit  
[/var/www/domain/app//Locale] > 
Would you like to merge all domains strings into the default.pot file? (y/n) 

yを選択すると、ファイルを読み込んでデフォルトの言語ファイルが生成される。

  • /var/www/domain/app/Locale/default.pot
  • /var/www/domain/app/Locale/eng

/var/www/domain/app/Locale/engをコピーして/var/www/domain/app/Locale/jpnを作り、/var/www/domain/app/Locale/default.potをコピーして、/var/www/domain/app/Locale/jpn/LC_MESSAGES/default.poを作る。この時、拡張子を変更しないと上手く動作しない。

翻訳ファイル

翻訳ファイルは以下のように記述する。

#: Controller/HogeController.php:65;166;188;214
msgid "Hello world"
msgstr "世界こんにちわ"

CakePHP 2.0でログインフォームを作る

■実装

app/Controller/UsersController.php

App::uses('AppController', 'Controller');
/**
 * Users Controller
 * @property User $User
 */
class UsersController extends AppController {

    public $uses = array('User');
    
    public $components = array(
        'Session',
        'Auth' => array(
            'loginRedirect' => array('controller'  => 'users', 'action' => 'index'),// ログイン後のリダイレクト先
            'logoutRedirect' => array('controller' => 'pages', 'action' => 'display', 'home')// ログアウト後のリダイレクト先
        )
    );
    
    public function beforeFilter() {
        //$this->Auth->allow('index'/*, 'add'*/);// 認証を除外するアクションを指定する
    }

    /**
     * ユーザを追加する画面
     * @return void
     */
    public function add() {
        /*if ($this->request->is('post')) {
            $this->User->create();
            if ($this->User->save($this->request->data)) {
                $this->Session->setFlash(__('The user has been saved'));
                $this->redirect(array('action' => 'index'));
            }
            else {
                $this->Session->setFlash(__('The user could not be saved. Please, try again.'));
            }
        }*/
    }

    /**
     * ログイン画面
     * @return void
     */
    public function admin_login() {
        if ($this->Auth->login()) {
            $this->redirect($this->Auth->redirect());
        }
        else {
            $this->Session->setFlash(__('Invalid username or password, try again'));
        }
    }

    /**
     * ログアウト画面
     * @return void
     */
    public function admin_logout() {
        $this->redirect($this->Auth->logout());
    }
}

app/Model/User.php

App::uses('AppModel', 'Model');

/**
 * User Model
 * ユーザのモデル
 */
class User extends AppModel {
    public function __construct($id = false, $table = null, $ds = null) {
        $this->useDbConfig = Configure::read('Config.environment');
        parent::__construct($id, $table, $ds);
    }

    /**
     * 保存前に実行される
     * @return void
     */
    public function beforeSave() {
        if (isset($this->data[$this->alias]['password'])) {
            $this->data[$this->alias]['password'] = AuthComponent::password($this->data[$this->alias]['password']);
        }
        return true;
    }

}

Cake1系ではpasswordというカラム名が自動的にハッシュ化されていた。Cake2系では上述のように実装する必要がある。

app/View/Users/admin_login.ctp

以下のようにログインフォームを記述する。

<?php echo $this->Session->flash('auth'); ?>
<?php echo $this->Form->create('User');?>
<fieldset>
<legend><?php echo __('Please enter your username and password'); ?></legend>
<?php
echo $this->Form->input('username');
echo $this->Form->input('password');
?>
</fieldset>
<?php echo $this->Form->end(__('Login'));?>

$thisを介してSessionとFormにアクセスするところがCake2系では異なる。

app/View/Users/admin_logout.ctp

<h3>ログアウトしました。</h3>

■参考

殆ど参考文献の簡略化日本語版だな。

CakePHP2.0でPHPUnitを使う

めも。

■PHPUnitのインストール

以下のコマンドでXAMPP for MacでPHPUnitが使えるようになる。

sudo /Applications/XAMPP/xamppfiles/bin/pear channel-update pear.php.net
sudo /Applications/XAMPP/xamppfiles/bin/pear upgrade pear
sudo /Applications/XAMPP/xamppfiles/bin/pear channel-discover pear.phpunit.de
sudo /Applications/XAMPP/xamppfiles/bin/pear channel-discover pear.symfony-project.com
sudo /Applications/XAMPP/xamppfiles/bin/pear channel-discover components.ez.no
sudo /Applications/XAMPP/xamppfiles/bin/pear install phpunit/PHPUnit

詳しくは以前の記事で。

■Bake

PHPのエラー

お決まりのtimezoneを設定してくれ的なのが出るので以下のようにする。

vim ./lib/Cake/Console/cake.php

require_onceの前に以下の文を一応記述しておく。

ini_set('date.timezone', 'Asia/Tokyo');

データベースの初期設定

以下のコマンドを実行してDBの設定をする。

./lib/Cake/Console/cake.php bake

対話式ですすめる。以下の順序で設定する。

Welcome to CakePHP v2.0.0 Console
---------------------------------------------------------------
App : app
Path: /Applications/XAMPP/xamppfiles/htdocs/cake.sample.justoneplanet.info/app/
---------------------------------------------------------------
Your database configuration was not found. Take a moment to create one.
---------------------------------------------------------------
Database Configuration:
---------------------------------------------------------------
Name:  
[default] > test
Driver: (Mysql/Postgres/Sqlite/Sqlserver) 
[Mysql] > 
Persistent Connection? (y/n) 
[n] > 
Database Host:  
[localhost] > 
Port?  
[n] > 
User:  
[root] > 
Password:  
> hogehoge
Database Name:  
[cake] > dbname
Table Prefix?  
[n] > 
Table encoding?  
[n] > 

モデル(クラス)の作成

以下のコマンドを再度実行する。

./lib/Cake/Console/cake.php bake

以下のように進めていく。テーブル設計などは適当なので適宜読み替える。

---------------------------------------------------------------
App : app
Path: /Applications/XAMPP/xamppfiles/htdocs/cake.sample.justoneplanet.info/app/
---------------------------------------------------------------
Interactive Bake Shell
---------------------------------------------------------------
[D]atabase Configuration
[M]odel
[V]iew
[C]ontroller
[P]roject
[F]ixture
[T]est case
[Q]uit
What would you like to Bake? (D/M/V/C/P/F/T/Q) 
> Model
What would you like to Bake? (D/M/V/C/P/F/T/Q) 
> M
---------------------------------------------------------------
Bake Model
Path: /Applications/XAMPP/xamppfiles/htdocs/cake.sample.justoneplanet.info/app/Model/
---------------------------------------------------------------
Possible Models based on your current database:
1. Move
Enter a number from the list above,
type in the name of another model, or 'q' to exit  
[q] > 1
A displayField could not be automatically detected
would you like to choose one? (y/n) 
> y
1. id
2. game
3. x
4. y
5. color
Choose a field from the options above:  
> 1
Would you like to supply validation criteria 
for the fields in your model? (y/n) 
[y] > y

バリデーションの設定をしていくと、そのうち以下のようにユニットテスト用ファイルを生成するか聞いてくるので[y]を選択する。

Do you want to bake unit test files anyway? (y/n) 
[y] > y
Creating file /Applications/XAMPP/xamppfiles/htdocs/cake.sample.justoneplanet.info/app/Test/Case/Model/MoveTest.php
Wrote `/Applications/XAMPP/xamppfiles/htdocs/cake.sample.justoneplanet.info/app/Test/Case/Model/MoveTest.php`

■生成されたファイル

app/Test/Case/Model/MoveTestCase.php

<?php
/* Move Test cases generated on: 2011-10-21 00:40:13 : 1319125213*/
App::uses('Move', 'Model');

/**
 * Move Test Case
 *
 */
class MoveTestCase extends CakeTestCase {
/**
 * Fixtures
 *
 * @var array
 */
    public $fixtures = array('app.move');

/**
 * setUp method
 *
 * @return void
 */
    public function setUp() {
        parent::setUp();

        $this->Move = ClassRegistry::init('Move');
    }

/**
 * tearDown method
 *
 * @return void
 */
    public function tearDown() {
        unset($this->Move);

        parent::tearDown();
    }

/**
 * こんな感じで書いていけばいいよ的なmethod
 */
    public function testGetById() {
        $this->assertEqual($this->Move->getById(1), 1);
    }
}

app/Model/MoveTestCase.php

<?php
App::uses('AppModel', 'Model');
/**
 * Move Model
 *
 */
class Move extends AppModel {
/**
 * Display field
 *
 * @var string
 */
    public $displayField = 'id';

/**
 * つじつま合わせのメソッド
 */
    public function getById($id) {
        return 1;
    }
}

最後にちゃちゃっとhttp://cake.sample.justoneplanet.info/test.phpにアクセスすればテストが書けているのがわかる。