Phalconでソシャゲを作る[Redis + MySQL-Sharding編]

# 目的

PhalconとRedisとMySQLを上手く組み合わせて使いたい。


# 完成品 GitHub
[ienaga/RedisPlugin](https://github.com/ienaga/RedisPlugin)


# 開発環境
WEBサーバー：Nginx [EC2]
APPサーバー：php-fpm [EC2 + Auto Scaling]
Cacheサーバー：redis [ElastiCache]
DBサーバー：MySQL [RDS]
　マスタDB：Master, Slava
　ユーザDB：[Master, Slave], [Master, Slave]...

Framework：Phalcon 2.x


# 準備するもの
* phpredis
* YAML


## phpredis
これがないとそもそもphpでredisが使えない。

~~~linux
sudo yum install php-pecl-redis
~~~

## YAML
開発環境、STG環境、本番環境と環境の管理が楽。

~~~linux
sudo yum install libyaml libyaml-devel
sudo pecl install YAML
sudo vim /etc/php.d/yaml.ini
extension=yaml.so
~~~

## app/config/config.php

app/config内のymlファイルを自動的に読み込む実装。

```php:app/config/config.php

<?php

$dir = __DIR__ .'/../../app/';
$env = getenv('ENV'); // [Nginx] fastcgi_param ENV XXXX;
$ignore_file = array('routing'); // 無視したいymlがあれば設定


$configYml = array();
if ($configDir = opendir($dir.'config')) {

    while (($file = readdir($configDir)) !== false) {

        $exts = explode('.', $file);

        if ($exts[1] !== 'yml')
            continue;

        $file_name = $exts[0];
        if ($ignore_file && in_array($file_name, $ignore_file))
            continue;

        $yml = yaml_parse_file($dir . "config/{$file_name}.yml");
        $configYml = array_merge($configYml, $yml[$env]);

        if (isset($yml['all'])) {
            $configYml = array_merge($configYml, $yml['all']);
        }

    }

    closedir($configDir);
}

$application = array(
    'application' => array(
        'controllersDir' => $dir . 'controllers/',
        'modelsDir'      => $dir . 'models/',
        'viewsDir'       => $dir . 'views/',
        'pluginsDir'     => $dir . 'plugins/',
        'libraryDir'     => $dir . 'library/',
        'cacheDir'       => $dir . 'cache/',
    )
);

return new \Phalcon\Config(array_merge($application, $configYml));

```

これでapp/config内のymlは自動的に読み込まれるようになった。


準備完了☆彡




# 課題
* MySQLのINDEXにあわせてクエリを発行
* PhalconでSharding対応する為にコネクションの切り替えが必要
* DBから一回取得したデータは更新があるまでキャッシュしておきたい



# [課題] MySQLのINDEXにあわせてクエリを発行

発行されるクエリがINDEXにマッチしない事があるので
INDEXにあわせてクエリを並び換える。

metadataを拡張。

ついでにSCHEMAもキャッシュしてしまう。
さらに、そのついでにINDEXを取得してキャッシュ。


```php:app/config/services.php

...省略

$di->set('modelsMetadata', function () { return new \RedisPlugin\MetaData(); });

```


```php:\RedisPlugin\MetaData.php

namespace RedisPlugin;

class MetaData extends \Phalcon\Mvc\Model\MetaData
{
    ...省略

    public function write($key, $data)
    {
        // ここでSCHEMAをキャッシュ
        $this->getRedis()->hSet(self::CACHE_KEY, $key, $data);

        if (!$this->getRedis()->isTimeout(self::CACHE_KEY)) {
            $options = $this->getOptions();
            $this->getRedis()->setTimeout(self::CACHE_KEY, $options['lifetime']);
        }

        $this->_cache[$key] = $data;

        // ついでにINDEXを取得してキャッシュ
        $this->writeIndexes($key);
    }

    /**
     * @param string $key
     */
    public function writeIndexes($key)
    {
        if (!$key)
            return;

        $keys = explode('-', $key);
        if (3 > count($keys))
            return;

        $source = array_pop($keys);

        $class = '';
        foreach (explode("_", $source) as $value) {
            $class .= ucfirst($value);
        }

        /** @var \Phalcon\Mvc\Model $model */
        $model = new $class;
        $indexes = $model->getReadConnection()->describeIndexes($source);

        $cacheKey = $this->getIndexesKey($source);

        $this->getRedis()->hSet(self::CACHE_KEY, $cacheKey, $indexes);

        $this->_cache[$cacheKey] = $indexes;
    }

    /**
     * @param  string $source
     * @return null
     */
    public function readIndexes($source)
    {
        ...省略
    }
}

~~~

INDEXの一覧はreadIndexesから取得が可能

~~~php
$indexes = $model->getReadConnection()->describeIndexes($source);
~~~

ここの取得の方法、もっといい方法ないかなぁ・・・

後はわたってきたクエリをINDEXにあわせて加工


```php:\RedisPlugin\RedisDb.php


...省略


/** @var \Phalcon\Db\Index[] $indexes */
$indexes = $model->getModelsMetaData()->readIndexes($model->getSource());

$indexQuery = array();
if ($indexes) {

    foreach ($indexes as $key => $index) {

        $columns = $index->getColumns();

        if (!isset($query[$columns[0]]))
            continue;

        $chkQuery = array();
        foreach ($columns as $column) {
            if (!isset($query[$column]))
                break;

            $chkQuery[$column] = $query[$column];
        }

        if (count($chkQuery) > count($indexQuery)) {
            $indexQuery = $chkQuery;
        }

        // PRIMARY優先
        if ($key === 0)
            break;
    }
}

$query = array_merge($indexQuery, $query);

```

# [課題] PhalconでSharding対応する為にコネクションの切り替えが必要

まずは使うDBの情報をymlに設定

~~~php:app/config/database.yml
prd:
stg:
dev:
  database:
    dbMaster:
      adapter:  Mysql
      host:     127.0.0.1
      port:     3301
      username: root
      password: XXXXX
      dbname:   XXXXX
      charset:  utf8
      options:
        20: false
      transaction: true
      transaction_name: XXXXX # master
    dbSlave:
      adapter:  Mysql
      host:     127.0.0.1
      port:     3311
      username: root
      password: XXXXX
      dbname:   XXXXX
      charset:  utf8
      options:
        20: false
      transaction: false
    dbCommonMaster:
      adapter:  Mysql
      host:     127.0.0.1
      port:     3301
      username: root
      password: XXXXX
      dbname:   XXXXX
      charset:  utf8
      options:
        20: false
      transaction: false
    dbCommonSlave:
      adapter:  Mysql
      host:     127.0.0.1
      port:     3311
      username: root
      password: XXXXX
      dbname:   XXXXX
      charset:  utf8
      options:
        20: false
      transaction: false
    dbMember1Master:
      adapter:  Mysql
      host:     127.0.0.1
      port:     3306
      username: root
      password: XXXXX
      dbname:   XXXXX
      charset:  utf8
      options:
        20: false
      transaction: true
      transaction_name: XXXXX # member1
    dbMember1Slave:
      adapter:  Mysql
      host:     127.0.0.1
      port:     3316
      username: root
      password: XXXXX
      dbname:   XXXXX
      charset:  utf8
      options:
        20: false
      transaction: false
    dbMember2Master:
      adapter:  Mysql
      host:     127.0.0.1
      port:     3307
      username: root
      password: XXXXX
      dbname:   XXXXX
      charset:  utf8
      options:
        20: false
      transaction: true
      transaction_name: XXXXX # member2
    dbMember2Slave:
      adapter:  Mysql
      host:     127.0.0.1
      port:     3317
      username: root
      password: XXXXX
      dbname:   XXXXX
      charset:  utf8
      options:
        20: false
      transaction: false
~~~

redis.ymlもDBの数だけ設定
参照先は可変可能。

```php:app/config/redis.yml

prd:
stg:
dev:
  redis:
    enabled: true # false => cache off
    default:
      name: db
      expire: 3600
      autoIndex: true
    prefix: # 対象のカラムがModelに存在したら使用。左から順に優先。存在が確認できた時点でbreak
      columns: column, column, column # e.g. user_id, id, social_id


    # 共通のマスタがあれば登録「table_」と共有部分だけの記載はtable_*と同義
    # common
    common:
      dbs: table, table, table... # e.g.  master_, access_log


    admin:
      # ユーザマスタ
      # e.g.
      #    CREATE TABLE IF NOT EXISTS `admin_user` (
      #      `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
      #      `social_id` varchar(255) NOT NULL COMMENT 'ソーシャルID',
      #      `admin_config_db_id` tinyint(3) unsigned NOT NULL COMMENT 'AdminConfigDb.ID',
      #      `admin_flag` tinyint(3) unsigned NOT NULL DEFAULT '0' COMMENT '0=一般、1=管理者',
      #      `status_number` tinyint(3) unsigned NOT NULL DEFAULT '0',
      #      `created_at` datetime NOT NULL,
      #      `updated_at` datetime NOT NULL,
      #      PRIMARY KEY (`id`),
      #      UNIQUE KEY `social_id` (`social_id`)
      #    ) ENGINE=InnoDB  DEFAULT CHARSET=utf8;
      model:  XXXXX # AdminUser
      column: XXXXX # admin_config_db_id

      # ユーザマスタの登録「table_」と共有部分だけの記載はtable_*と同義
      dbs: table, table, table... # e.g. admin_, user_ranking


    shard:
      enabled: true # Shardingを使用しないばあいはfalse

      # Shardingをコントロールするテーブルとカラム
      #
      # e.g.
      #    CREATE TABLE IF NOT EXISTS `admin_config_db` (
      #      `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
      #      `name` varchar(50) NOT NULL COMMENT 'DBコンフィグ名',
      #      `gravity` tinyint(3) unsigned NOT NULL DEFAULT '0' COMMENT '重み(振り分け用)',
      #      `status_number` tinyint(3) unsigned NOT NULL DEFAULT '0',
      #      PRIMARY KEY (`id`)
      #    ) ENGINE=InnoDB  DEFAULT CHARSET=utf8;
      #    INSERT INTO `admin_config_db` (`id`, `name`, `gravity`, `status_number`) VALUES
      #    (1, 'dbMember1', 50, 0),
      #    (2, 'dbMember2', 50, 0);
      # shard config master
      control:
        model:  XXXXX # AdminConfigDb
        column: XXXXX # name

    metadata:
      host: XXXXX
      port: 6379
      select: 0


    server:
      dbMaster:
        host: XXXXX
        port: 6379
        select: 1 # redis select [データベースインデックス]
      dbSlave:
        host: XXXXX
        port: 6379
        select: 1
      dbCommonMaster:
        host: XXXXX
        port: 6379
        select: 0
      dbCommonSlave:
        host: XXXXX
        port: 6379
        select: 0
      dbMember1Master:
        host: XXXXX
        port: 6379
        select: 2
      dbMember1Slave:
        host: XXXXX
        port: 6379
        select: 2
      dbMember2Master:
        host: XXXXX
        port: 6379
        select: 3
      dbMember2Slave:
        host: XXXXX
        port: 6379
        select: 3


```


次にservices.phpに登録。


~~~php:app/config/services.php
<?php

use Phalcon\Db\Adapter\Pdo\Mysql as DbAdapter;
use Phalcon\Mvc\Model\Transaction\Manager;

...省略

foreach ($config->get('database') as $db => $arguments)
{

    $di->setShared($db, function () use ($arguments)
    {

        // 存在するDBすべて登録
        return new DbAdapter($arguments->toArray());

    });

    // Master分トランザクションを登録
    if (isset($arguments['transaction']) && $arguments['transaction']) {

        $di->setShared($arguments['transaction_name'], function() use ($db)
        {
            $manager = new Manager();

            if ($db !== null)
                $manager->setDbService($db);

            return $manager;
        });

    }
}

~~~


## find | findFirst 簡易版

~~~php

use \RedisPlugin\RedisDb;

class Robot extends \Phalcon\Mvc\Model
{

    /**
     * @param  int    $id
     * @param  string $type
     * @return Robot
     */
    public static function findFirst($id, $type)
    {
        return RedisDb::findFirst(array(
            'query' => array(
                'id' => $id,
                'type' => $type
            )
        ), new self);
    }

    /**
     * @param  int    $id
     * @param  string $type
     * @return Robot[]
     */
    public static function find($id, $type)
    {
        return RedisDb::find(array(
            'query' => array(
                'id' => $id,
                'type' => $type
            )
        ), new self);
    }

    // cache => falseで個別にキャッシュをコントロール
    public static function no_cache($id, $type)
    {
        return RedisDb::find(array(
            'query' => array(
                'id' => $id,
                'type' => $type
            ),
            'cache' => false
        ), new self);
    }

    // autoIndex => falseで個別にキャッシュをコントロール
    public static function no_autoIndex($id, $type)
    {
        return RedisDb::find(array(
            'query' => array(
                'id' => array(
                    'operator' => Criteria::IN,
                    'value' => array(1,6,10)
                ),
                'type' => $type
            ),
            'autoIndex' => false
        ), new self);
    }

    public static function order($id, $type)
    {
        return RedisDb::find(array(
            'query' => array(
                'id' => $id,
                'type' => $type
            ),
            'order' => 'id DESC'
        ), new self);
    }

    public static function group($id, $type)
    {
        return RedisDb::find(array(
            'query' => array(
                'id' => $id,
                'type' => $type
            ),
            'group' => 'id'
        ), new self);
    }

    public static function limit($id, $type)
    {
        return RedisDb::find(array(
            'query' => array(
                'id' => $id,
                'type' => $type
            ),
            'limit' => 10 // array('number' => 10, 'offset' => 5)
        ), new self);
    }
}

~~~


## find | findFirst 比較演算子

~~~php

use \RedisPlugin\RedisDb;
use \RedisPlugin\Criteria;

class Robot extends \Phalcon\Mvc\Model
{
    // LIST
    // Criteria::EQUAL = '=';
    // Criteria::NOT_EQUAL = '<>';
    // Criteria::GREATER_THAN = '>';
    // Criteria::LESS_THAN = '<';
    // Criteria::GREATER_EQUAL = '>=';
    // Criteria::LESS_EQUAL = '<=';
    // Criteria::IS_NULL = 'IS NULL';
    // Criteria::IS_NOT_NULL = 'IS NOT NULL';
    // Criteria::LIKE = 'LIKE';
    // Criteria::I_LIKE = 'ILIKE';
    // Criteria::IN = 'IN';
    // Criteria::NOT_IN = 'NOT IN';
    // Criteria::BETWEEN = 'BETWEEN';


    public static function not_equal($id, $type)
    {
        return RedisDb::findFirst(array(
            'query' => array(
                'id' => array(
                    'operator' => Criteria::NOT_EQUAL,
                    'value' => $id
                ),
                'type' => $type
            )
        ), new self);
    }


    public static function in($id, $type)
    {
        return RedisDb::findFirst(array(
            'query' => array(
                'id' => array(
                    'operator' => Criteria::IN,
                    'value' => array(1,6,10)
                ),
                'type' => $type
            )
        ), new self);
    }


    public static function not_in($id, $type)
    {
        return RedisDb::find(array(
            'query' => array(
                'id' => array(
                    'operator' => Criteria::NOT_IN,
                    'value' => array(1,6,10)
                ),
                'type' => $type
            )
        ), new self);
    }


    public static function between($start, $end)
    {
        return RedisDb::findFirst(array(
            'query' => array(
                'id' => array(
                    'operator' => Criteria::BETWEEN,
                    'value' => array($start, $end)
                ),
                'type' => $type
            )
        ), new self);
    }
}

~~~

## Criteria

~~~php

use \RedisPlugin\RedisDb;
use \RedisPlugin\Criteria;

class Robot extends \Phalcon\Mvc\Model
{

    public static function findFirst($id, $type)
    {
        $criteria = new Criteria(new self);
        return $criteria
            ->add('id', $id)
            ->add('type', $type, Criteria::NOT_EQUAL)
            ->group('type')
            ->findFirst();
    }

    public static function find($id, $start, $end)
    {
        $criteria = new Criteria(new self);
         return $criteria
            ->add('id', array($id), Criteria::IN)
            ->add('type', array($start, $end), Criteria::BETWEEN)
            ->limit(10, 30)
            ->order('type DESC')
            ->find();
    }

    // ->cache($boolean)でキャッシュをコントロール
    public static function no_cache($id, $start, $end)
    {
        $criteria = new Criteria(new self);
         return $criteria
            ->add('id', array($id), Criteria::IN)
            ->add('type', array($start, $end), Criteria::BETWEEN)
            ->limit(10, 30)
            ->order('type DESC')
            ->cache(false)
            ->find();
    }

    // ->autoIndex($boolean)でautoIndexをコントロール
    public static function no_autoIndex($id, $start, $end)
    {
        $criteria = new Criteria(new self);
         return $criteria
            ->add('id', array($id), Criteria::IN)
            ->add('type', array($start, $end), Criteria::BETWEEN)
            ->limit(10, 30)
            ->order('type DESC')
            ->autoIndex(false)
            ->find();
    }
}
~~~

## transaction, save
~~~php

▼transaction

try {

    RedisDb::beginTransaction();

    $robot= new Robot();
    $robot->setId($id);
    $robot->setType($type);
    RedisDb::save($robot);

    $user= new User();
    $user->setId($id);
    $user->setName($name);
    RedisDb::save($user);

    RedisDb::commit();

} catch (Exception $e) {

    RedisDb::rollback($e);

}

~~~


# [課題] 取得したデータは更新があるまでキャッシュしておきたい

find|findFirstで取得したデータをprefixで設定されたcolumnでキーを生成してキャッシュ。

更新があるまでredisにデータをキャッシュしてDBの負荷を減らしたい。

saveを経由したモデルを格納しておき、commit後にキャッシュを消す


```php:\RedisPlugin\RedisDb.php

<?php

namespace RedisPlugin;

class RedisDb
{


...省略


    /**
     * autoClear
     */
    public static function autoClear()
    {

        foreach (self::getModels() as $model) {

            self::setPrefix($model);

            self::connect($model, self::getPrefix());

            $redis = self::getRedis($model);
            $redis->delete(self::getHashKey($model));
        }

        self::$models = array();
    }


```

こんな感じで実装のベースは完成。

まだまだ改善の余地ありだな。。。

いけてない所があれば、どしどしご指摘ください。


## 完成品 GitHub
[ienaga/RedisPlugin](https://github.com/ienaga/RedisPlugin)


View関連もソシャゲ用に拡張だな。