Laravel で Amazon DynamoDB を利用するための実装 tips 集

ウィルゲートで開発を行っている岡田 (okashoi) です。

私の所属するソリューションチームでは web コンサルティングのためのシステムの開発を行っています。

現在開発中のプロダクトにおいて Amazon DynamoDB (以下、DynamoDB)を利用しており、 Laravel で DynamoDB を利用する際の実装上の tips が貯まってきたので紹介していきたいと思います。

DynamoDB 概要

DynamoDB は KVS と ドキュメント指向データベースの両方の特徴を併せ持ったデータベースサービスです。

概要は以下の通りです。

用途

  • 基本的には KVS のような使い方
  • 加えて、あらかじめ決めておいたカラムでの範囲検索ができる

メリット

  • レスポンスが安定して速い(KVS の利点)
  • 柔軟・スキーマレス(ドキュメント指向データベースの利点)
  • アプリケーション側だけ考えれば良い(フルマネージド)
  • ストレージに上限がない
  • 自動的にスケールさせることができる

デメリット

  • 決められた方法での検索しか出来ない
  • 特性を把握しないとうまくスケールしてくれない
  • トランザクションはない

より詳しい話については他の多くの方が資料や記事を公開しているのでそちらに譲ります。

以下、 tips を紹介していきます。

ORM には laravel-dynamodb を利用

Laravel といえば Active Record パターンの実装である Eloquent ORM を特徴として挙げる方も多いでしょう。

この Eloquent ORM とほぼ同様のインターフェースを、DynamoDB 用に提供しているのが baopham/laravel-dynamodb です。

github.com

packagist での人気(Installs, Stars)などから、私たちのプロダクトではこの laravel-dynamodb を利用することにしました。

例として DynamoDB 上に Movies というテーブルがある場合、以下のようなモデルを定義しておくことで App\Models\Movie::create() でレコードを作成したり、 App\Models\Movie::find()App\Models\Movie::get() を使ってデータの取得が行うことが出来ます。

<?php

namespace App\Models;

use Baopham\DynamoDb\DynamoDbModel as Model;

class Movie extends Model
{
    protected $table = 'Movies';

    protected $primaryKey = 'year';

    protected $compositeKey = ['year', 'title'];

    public $timestamps = false;

    protected $fillable = [
        'year',
        'title',
        'info',
    ];
}

Model の使い方としては以下を押さえておくと良いと思います。

  • 基本的に Eloquent Model と互換がある(Eloquent Model を継承しているため)
    • できるからと言って何でもかんでもやるのはよくない
  • $fillable なども記述する必要がある
    • デフォルトで $incrementing = false の上書きだけされている
  • テーブルの Key を明記する必要がある(Hash Key 名前が id の場合は $primaryKey は省略可)
<?php

    // Hash Key のみの場合
    protected $primaryKey = 'hash_key'

    // Hash Key + Range Key の場合
    protected $primaryKey = 'hash_key';
    protected $compositeKey = ['hashKey', 'rangeKey'];
  • Index(GSI, LSI)についても明記する必要がある
<?php

    // GSI , LSI ともに $dynamoDbIndexKeys に書く
    protected $dynamoDbIndexKeys = [
        'index_1_name' => [
            'hash' => 'index_1_hash_key_name',
        ],
        'index_2_name' => [
            'hash' => 'index_2_hash_key_name',
            'range' => 'index_2_range_key_name',
        ],
    ];

より詳しい使い方は README を読みましょう。

ちなみに、Model 内部の実装はこちらです。

laravel-dynamodb/DynamoDbModel.php at master · baopham/laravel-dynamodb · GitHub

使っているときに迷ったり、疑問に思ったりしたら実装を読んでみるのも良いでしょう。

マイグレーションRDBMS と区別しない

以前 Qiita にも投稿したのですが、RDBMS 用のマイグレーションと同じ場所に DynamoDB のマイグレーションも書いています。

qiita.com

理由も Qiita に書いた通りで「DynamoDB のテーブル管理のために特別なオペレーションを増やしたくない」に尽きます。

具体的には以下のようなコードになります。

<?php

use Illuminate\Database\Migrations\Migration;
use BaoPham\DynamoDb\DynamoDbClientService;

class CreateDynamodbGaTrafficRecordsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        // テスト時には DynamoDB を利用しない
        if (app()->environment('testing')) {
            return;
        }

        $params = [
            'TableName' => $this->getTableName('Movies'),
            'KeySchema' => [
                [
                    'AttributeName' => 'year',
                    'KeyType' => 'HASH',
                ],
                [
                    'AttributeName' => 'title',
                    'KeyType' => 'RANGE',
                ],
            ],
            'AttributeDefinitions' => [
                [
                    'AttributeName' => 'year',
                    'AttributeType' => 'N'
                ],
                [
                    'AttributeName' => 'title',
                    'AttributeType' => 'S'
                ],
            ],
            // キャパシティユニットは仮の値を入れておくだけ
            'ProvisionedThroughput' => [
                'ReadCapacityUnits' => 5,
                'WriteCapacityUnits' => 5,
            ],
        ];

        resolve(DynamoDbClientService::class)->getClient()->createTable($params);
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        // テスト時には DynamoDB を利用しない
        if (app()->environment('testing')) {
            return;
        }

        $params = [
            'TableName' => $this->getTableName('Movies'),
        ];

        resolve(DynamoDbClientService::class)->getClient()->deleteTable($params);
    }

    /**
     * 環境ごとのテーブル名を取得
     *
     * @param string $tableBaseName
     * @return string テーブル名
     */
    protected function getTableName(string $tableBaseName): string
    {
        // 環境名を Suffix として追加する(理由は後述)
        return studly_case($tableBaseName) . studly_case(app()->environment());
    }
}

コード中のコメントにも記載したとおり、キャパシティユニット(以下、CU)に関しては、テーブル作成時は仮の値(最小の 5 とか)を入れておき、あとから管理コンソール上で値を更新するようにしています。

CU は環境(本番/ステージングなど)によって変わるうえに、運用の中で変化していく値でもあるため、マイグレーションで管理する必要性が薄いことが理由です。

また、マイグレーションに関しては他のパッケージの利用も検討しましたが、それぞれ以下の理由で採用を見送っています。

環境をまたいでテーブル名を一意にする方法

運用に際して「テーブル名は本番環境・ステージング環境をまたいでを一意にしなければならない」という問題に後から気が付きました*1

これについてはテーブル名の suffix として環境名(APP_ENV の値)を付与することで解決しました。

今まで Movies という名前のテーブルだったのであれば、本番環境なら MoviesProduction に、ステージング環境なら MoviesStaging に、といった具合です。

先述のマイグレーションgetTableName() メソッドが登場したのはこのためです。

Model が参照するテーブルも環境ごとに変える必要もあるわけですが、これは getTable() メソッドをオーバーライドすることで解決しました。

<?php

namespace App\Models;

use Baopham\DynamoDb\DynamoDbModel as Model;

class Movie extends Model
{
    // (略)

    /**
     * テーブル名を取得
     * 環境ごとの suffix を付ける
     *
     * @return string
     */
    public function getTable(): string
    {
        $baseTableName = parent::getTable();
        return $baseTableName . studly_case(app()->environment());
    }
}

複数の Model で DynamoDB を使うのであればこの getTable() は Trait に切り出してしまうのが良いでしょう。

APP_ENV が衝突する複数の環境が存在する場合は、各々の事情に合わせて一意になる suffix を付けてあげましょう。

その他細かな tips など

find() は強い整合性での Read しかできない

Qiita に書いてあります。

qiita.com

Range Key での BETWEEN 条件を指定する際は where($rangeKeyName, 'range', [$min, $max]) を使う

こちらも Qiita に書いてあります。

qiita.com

バッチ処理などのリアルタイム性を求められない Read/Write を大量に行うときには retry() と併用する

Laravel のグローバルヘルパ retry() と併せて使うことで「CU 超過していたら 1 秒待ってもう一回リクエスト」というロジックをシンプルに書くことができます。

<?php

$movies = collect([]);
foreach ($conditions as $condition) {
    // キャパシティユニット超過の場合、再リクエスト(ウェイト1秒)
    $tmpMovies = retry(2, function () use ($condition) {
        return App\Models\Movies::where('year', $condition['year'])
            ->where('title', $condition['title'])
            ->get();
    }, 1000);

    $movies = $movies->concat($tmpMovies);
}

// $movies を用いた処理
// :

このような書き方は、大量に読み書きをする必要がある代わりに、リアルタイム性が求められないバッチ処理において活用する方法があります。

CU を低めに設定してあると、そのCUをいっぱい使い切る速さで読み書きの処理が進みます*2

実際に試した場合の様子がこちらです。

f:id:okashoi:20180227154331p:plain

21:00~00:00 の間、消費 CU がプロビジョニング済み CU 付近に張り付いているのがわかります。

なお、一時的にプロビジョニング済みの CU を越えているのは、過去300秒分は未使用の CU を持ち越せる(バーストキャパシティ)ためです。

この方法は、同じテーブル(または GSI)に対して並列で読み書きが動いているとうまくいかないかもしれません。

おわりに

以上、 Laravel で DynamoDB を利用する際の実装上の tips でした。

今後もこのような tips ・ノウハウは貯まっていくと思うので、DynamoDB に限らず紹介していこうと思います!

*1:本番環境とステージング環境でリージョンが異っていれば問題ない

*2:ただし、1回の読み書きで超過してしまうくらい低い CU では動きませんが