Yiiを使ってMySQLで設計したActiveRecordを最速でMongoDBに移行する

##前提

いまどきのWebアプリケーションは、いつアクセスがスパークするかわからないので初期段階から負荷分散を意識して設計する必要がありますね。アプリケーションサーバはスケールアウトして負荷分散、データベースはリードレプリカパターン(書き込みはマスター1台、読み取りはそのコピーを持つ複数のスレーブから)で読み込みを負荷分散とするのが一般的です。

しかしそれでも、マスターDBへの書き込みトランザクションがどうしてもボトルネックになるという悩みは残ります。そこで、ACIDな特性が重要でなく書き込みが非常に多いデータは、メインのデータベースとは別立てにして、NoSQL系のデータベースを使うことになります。MongoDBであれば、シャーディングという機能を使い、書き込みの負荷を分散処理することができます。

ところが、まだ本格的な環境構築ができていないプロトタイピングの段階では、素朴な設計アイデアを、簡単な環境でできるだけ早く実装したいところでしょう。最初からパフォーマンスチューニングを意識しながらモデリングすることは、そう簡単ではありません。いちどパフォーマンスのことをいっさい忘れて、できるだけ早くプロトタイプを作ったほうが早い。...のですが、そうは言っても、その技術的負債(スピードスタートのための借金)が後からなんとかなる保証がないと、安心して作業できませんね。

この悩みが、Yiiフレームワークではどのように解決されているかを見ます。

## プロトタイプという名の負債をつくる

わかりやすいように、ちょっと極端な例を挙げます。

投稿記事 `Post` にはちょっと変わったページビューカウントが必要でした。過去の任意の日付から数えたページビュー数を、動的に求めることができるというものです。

そこで、すべてのページビューを記録するために has-many 関係の `AccessLog` を設けることになったとします。 `AccessLog` は、Webページを閲覧するたびにレコードを1つ作ります。これを MySQL だけで実装してみようと考えました。

```php
    public function up()
    {
        $this->createTable('{{%access_log}}', [
            'id' => Schema::TYPE_BIGPK,
            'post_id' => Schema::TYPE_INTEGER . ' NOT NULL',
            'recorded_at' => Schema::TYPE_INTEGER . ' NOT NULL',
        ]);
        $this->addForeignKey('fk_access_log_post_id', '{{%access_log}}', 'post_id', '{{%post}}', 'id', 'CASCADE');
    }
```

Gii で生成してメソッドを足します。

```php
<?php
namespace app\models;

use Yii;

/**
 * This is the model class for table "{{%post}}".
 *
 * @property integer $id
 * @property string $title
 * @property string $body
 *
 * @property AccessLog[] $accessLogs
 */
class Post extends \yii\db\ActiveRecord
{
    public static function tableName()
    {
        return '{{%post}}';
    }

// 中略

    /**
     * @return \yii\db\ActiveQuery
     */
    public function getAccessLogs()
    {
        return $this->hasMany(AccessLog::className(), ['post_id' => 'id']);
    }

    /**
     * 投稿記事のページが表示されたときにレコードを作成する
     */
    public function recordPageViewed()
    {
        $log = new AccessLog();
        $log->post_id = $this->id;
        $log->recorded_at = time();
        $log->save(false);
        // $this->refresh();
    }

    /**
     * ある日時以降のページビュー数を得る
     * 
     * @param integer $timestamp
     * @return integer
     */
    public function getPageViewAfter($timestamp=null)
    {
        $query = $this->getrelation('accessLogs');
        if ($timestamp) {
            $query->where(['>=', 'recorded_at', $timestamp]);
        }
        return $query->count();
    }
}
```

```php
<?php
namespace app\models;

use Yii;

/**
 * This is the model class for table "{{%access_log}}".
 *
 * @property integer $id
 * @property integer $post_id
 * @property integer $recorded_at
 *
 * @property Post $post
 */
class AccessLog extends \yii\db\ActiveRecord
{
    /**
     * @inheritdoc
     */
    public static function tableName()
    {
        return '{{%access_log}}';
    }

// 中略

    /**
     * @return \yii\db\ActiveQuery
     */
    public function getPost()
    {
        return $this->hasOne(Post::className(), ['id' => 'post_id']);
    }
}
```

ビュー処理の終了時に `Post::recordPageViewed()` メソッドを呼んで `access_log` テーブルにデータを投入して、目的の機能を達成しました。


開発時は問題なく動きますが、これ、このまま公開するわけにはいきませんね。開発環境に対してであれば、テストするのは1人ですが、Webには何万人という潜在ユーザーがいます。ページ表示だけでマスターDBに書き込みが起こって、それで負荷が集中するのは大問題です。この技術的負債を返済するのが本題です。

## 本題: MySQLのActiveRecordをMongoDBにする

Yii フレームワーク 2.0 は、RDBMS 用と NoSQL 用の ActiveRecord を、同時に、しかも抽象クラスやトレイトの大部分を共有して開発を進めました。そのおかげで、タイプの異なる ActiveRecord でも、かなりの互換性を持っています。

MongoDB を使うには、Composer でライブラリを追加インストールします。

```
$ composer require yiisoft/yii2-mongodb
```

アプリケーションの設定ファイルに `mongodb` コンポーネントを追加します。

```php
$config = [
    'components' => [
        // ...
        'mongodb' => [
            'class' => 'yii\mongodb\Connection',
            'dsn' => 'mongodb://localhost:27017/mydb',
        ],
    ],
```

そうしたうえで、 `AccessLog` を MongoDB の ActiveRecord に書き換えると、先に挙げた2クラスの実装はこういうふうになります。

```php
<?php
namespace app\models;

use Yii;

/**
 * This is the model class for table "{{%post}}".
 *
 * @property integer $id
 * @property string $title
 * @property string $body
 *
 * @property AccessLog[] $accessLogs
 */
class Post extends \yii\db\ActiveRecord
{
    /**
     * @inheritdoc
     */
    public static function tableName()
    {
        return '{{%post}}';
    }

// 中略

    /**
     * @return \yii\mongodb\ActiveQuery
     */
    public function getAccessLogs()
    {
        return $this->hasMany(AccessLog::className(), ['post_id' => 'id']);
    }

    /**
     * 投稿記事のページが表示されたときにレコードを作成する
     */
    public function recordPageViewed()
    {
        $log = new AccessLog();
        $log->post_id = $this->id;
        $log->recorded_at = time();
        $log->save(false);
        // $this->refresh();
    }

    /**
     * ある日時以降のページビュー数を得る
     * 
     * @param integer $timestamp
     * @return integer
     */
    public function getPageViewAfter($timestamp=null)
    {
        $query = $this->getrelation('accessLogs');
        if ($timestamp) {
            $query->where(['gte' => ['recorded_at' => $timestamp]]);
        }
        return $query->count();
    }
}
```

```php
<?php
namespace app\models;

/**
 * This is the model class for collection "access_log".
 *
 * @property integer $_id
 * @property integer $post_id
 * @property integer $recorded_at
 *
 * @property Post $post
 */
class AccessLog extends \yii\mongodb\ActiveRecord
{
    /**
     * @inheritdoc
     */
    public static function collectionName()
    {
        return 'access_log';
    }

    /**
     * @inheritdoc
     */
    public function attributes()
    {
        return ['_id', 'post_id', 'recorded_at'];
    }

// 中略、RDBのときと全く同じ

    /**
     * @return \yii\db\ActiveQuery
     */
    public function getPost()
    {
        return $this->hasOne(Post::className(), ['id' => 'post_id']);
    }
}
```

変わっていない箇所も省略せずに書きました。独自機能の部分、難しそうな箇所ほど、変更が少ないのがおわかりいただけるでしょうか。

具体的に変更した点は次のとおりです:

- 名前空間の違う `yii\mongodb\ActiveRecord` と `yii\mongodb\ActiveQuery` が登場
- `tableName()` は `collectionName()` になった
- MongoDB にはスキーマがないので `attributes()` でフィールドを明示
- MongoDB の習慣に合わせて `id` を `_id` に変更
- `>=` を使った条件式は MongoDB の大小比較 `gte` を使った構文に

これらは機械的な作業でできるものばかりです。

```diff:Post
     /**
-     * @return \yii\db\ActiveQuery
+     * @return \yii\mongodb\ActiveQuery
      */
     public function getAccessLogs()

     public function getPageViewAfter($timestamp=null)
     {
         $query = $this->getrelation('accessLogs');
         if ($timestamp) {
-            $query->where(['>=', 'recorded_at', $timestamp]);
+            $query->where(['gte' => ['recorded_at' => $timestamp]]);
         }
```

```diff:AccessLog
- * @property integer $id
+ * @property integer $_id

  */
-class AccessLog extends \yii\db\ActiveRecord
+class AccessLog extends \yii\mongodb\ActiveRecord
 {
     /**
      * @inheritdoc
      */
-    public static function tableName()
+    public static function collectionName()
     {
-        return '{{%access_log}}';
+        return 'access_log';
     }

+    /**
+     * @inheritdoc
+     */
+    public function attributes()
+    {
+        return ['_id', 'post_id', 'recorded_at'];
+    }
```

なぜこんなに変更点が少ないのでしょう。Yii のクエリビルダでは、あるフィールドの値がこうで、かつ、別のフィールドの値のはこう、という条件を指定して検索したいとき、次のように書きます。

```php
$query->where([
    'field_a' => $value1,
    'field_b' => $value2,
]);
```

少し複雑な and/or になってきたときは `and` や `or` をキーにした連想配列をネストします。このスタイルは MongoDB のクエリと (ElasticSearchとも) 非常に似ています。どんな問い合わせでも完全に同じように書く、とまでは行きませんが、それでも、かなり NoSQL との一貫性を意識したクエリビルダを搭載していることがわかります。

もっとシンプルな and/or のない KVS だと、それは擬似的にいえば `ActiveRecord::find()->where(['id' => $key])->one();` です。このパターンは`ActiveRecord::findOne($key);` に置き換えできます。

Yii は ActiveRecord のタイプを変更したときの影響が非常に少なく、どのタイプでも一貫した考え方ができるよううまく抽象化されています。

さらに特筆すべきは、MongoDB と RDB のリレーション関係を維持している点です。変更後でもまだ `$post->accessLogs` や `$accessLog->post` のようなコードが成り立ちます。

Yii は関係レコードをイーガーロードする場合、 [JOIN を使わず、相手テーブルの関係キーを使った別のクエリで実現](http://qiita.com/crifff/items/a9bf1971df233b70b56c) しています。そのため、異なるストレージ間でのリレーションでもちゃんと成立するのです。(関係キーに矛盾が起きないプログラムをちゃんと書けていさえすれば) 

このように、Yii では格納実体がどうであろうとインターフェースが変わらないモデルを作ることができます。そのため、アプリケーションの他の部分に突然大きな変更が発生してクラッシュする、というようなことが起こりません。プロトタイプがずっと動く状態を維持しながら、少しずつ最適化していくことができます。