LoginSignup
29
21

More than 3 years have passed since last update.

Cloud Spanner をつかったテストのやり方

Last updated at Posted at 2018-12-05

この記事は Google Cloud Platform その1 Advent Calendar 2018 の6日目の記事です。

GCP には Cloud Spanner というデータベースがあります。分散技術をふんだんに使ったとにかくすごいデータベースなのですが、最低料金がお高いこともあってか、情報がまだ少ない印象です。

実際に Cloud Spanner をつかってアプリを開発する場合、テストはどのように書くとよいのでしょうか。

この記事で解説すること

  • Cloud Spanner 開発用環境の構築方法
  • Cloud Spanner データベースの初期化のやり方
  • 初期データの挿入のやり方
  • Cloud Spanner 特有の引っかかりやすい点と解決策

Cloud Spanner 開発環境の構築

Cloud Spanner は完全なマネージドサービスで、現在は残念ながら Cloud Spanner 自体をローカルで動かすことはできません :cry:

よって、Cloud Spanner を使ったテストを行いたい場合、実際に Cloud Spanner のインスタンスを作っておいて、そこにテストのたびに接続する形となります。

ロジックのテストを行うだけであれば、ノード数 1 で十分です。(1 nodeでもお高いですが・・・ :sweat_smile:

認証情報の作成

Cloud Spanner は GCP のサービスなので、ADC と呼ばれる仕組みで認証を行います。
テスト用の環境では、データベースを作ったり壊したりできたほうがいいので、データベースの削除などを含めた強めの権限 を持っておくとテストしやすいです。(実運用の環境とは別のインスタンスでやりましょう!)

自分は Cloud Spanner の databaseAdmin の権限を持ったサービスアカウントを作成し、その鍵ファイルを環境変数 GOOGLE_APPLICATION_CREDENTIALS に入れることで認証しています。

export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json

ADC の仕組みにより、この環境変数をセットするだけでソースコード側で何も認証を指定せずに Cloud Spanner に接続できます!

データベース テストのやり方

Cloud Spanner はデータベースです。まず最初は一般的なデータベースにおけるテストのやり方を見てみましょう。

テストの初期状態では、常にデータベースの状態が一定になっていることが望ましいです。

  1. データベースをまっさらな状態にする
  2. テストのための初期データ(フィクスチャ) を入れる
  3. テストを実行する
  4. データベースをまっさらな状態にする

データベースをまっさらな状態にする

最も簡単な方法は、データベースごと作り直す方法です。

  1. DROP DATABASE ... データベースを削除する(あれば)
  2. CREATE DATABASE ... データベース自体をつくる
  3. CREATE TABLE ... テーブルやインデックスを作る

Cloud Spanner でこれをやってみましょう。サンプルコードは Go で書いていますが、ベースは gRPC なので他の言語でもやり方は基本同じです。

package main

import (
    "context"
    "log"
    "cloud.google.com/go/spanner/admin/database/apiv1"
    adminpb "google.golang.org/genproto/googleapis/spanner/admin/database/v1"
    "fmt"
    "time"
    "google.golang.org/grpc/status"
    "google.golang.org/grpc/codes"
)

func main() {
    ctx := context.Background()
    client, err := database.NewDatabaseAdminClient(ctx)
    if err != nil {
        log.Fatal(err)
    }

    projectID := "xxx"
    instanceID := "xxx"
    databaseID := "xxx"
    dsn := fmt.Sprintf("projects/%s/instances/%s/databases/%s", projectID, instanceID, databaseID)
    dsnParent := fmt.Sprintf("projects/%s/instances/%s", projectID, instanceID)

    {
        exists := true
        if _, err := client.GetDatabase(ctx, &adminpb.GetDatabaseRequest{Name: dsn}); err != nil {
            st, ok := status.FromError(err)
            if ok && st.Code() == codes.NotFound {
                exists = false
            } else {
                log.Fatal(err)
            }
        }

        if exists {
            fmt.Printf("begin (DROP DATABASE)\n")
            start := time.Now()
            if err := client.DropDatabase(ctx, &adminpb.DropDatabaseRequest{
                Database: dsn,
            }); err != nil {
                log.Fatal(err)
            }
            fmt.Printf("end (DROP DATABASE) (%s)\n", time.Since(start))
        }
    }

    {
        fmt.Printf("begin (CREATE DATABASE)\n")
        start := time.Now()
        op, err := client.CreateDatabase(ctx, &adminpb.CreateDatabaseRequest{
            Parent:          dsnParent,
            CreateStatement: fmt.Sprintf("CREATE DATABASE `%s`", databaseID),
        })
        if err != nil {
            log.Fatal(err)
        }
        if _, err := op.Wait(ctx); err != nil {
            log.Fatal(err)
        }
        fmt.Printf("end (CREATE DATABASE) (%s)\n", time.Since(start))
    }

    {
        fmt.Printf("begin (CREATE TABLE & INDEX)\n")
        start := time.Now()
        op, err := client.UpdateDatabaseDdl(ctx, &adminpb.UpdateDatabaseDdlRequest{
            Database: dsn,
            Statements: []string{
                `CREATE TABLE Singers (
                    SingerId   INT64 NOT NULL,
                    FirstName  STRING(1024),
                    LastName   STRING(1024),
                    SingerInfo BYTES(MAX)
                ) PRIMARY KEY (SingerId)`,
                `CREATE INDEX SingersByFirstName ON Singers(FirstName)`},
        })
        if err != nil {
            log.Fatal(err)
        }
        if err := op.Wait(ctx); err != nil {
            log.Fatal(err)
        }
        fmt.Printf("end (CREATE TABLE & INDEX) (%s)\n", time.Since(start))
    }
}

これを実行してみたところ、次の結果となりました。

begin (DROP DATABASE)
end (DROP DATABASE) (2.471652346s)

begin (CREATE DATABASE)
end (CREATE DATABASE) (7.146112054s)

begin (CREATE TABLE & INDEX)
end (CREATE TABLE & INDEX) (8m1.547491812s)
  • DROP DATABASE 約3秒
  • CREATE DATABASE 約7秒
  • CREATE TABLE & INDEX 約8分

・・・・8分!??

image.png

データベースの作成・削除はともかく、テーブルとインデックスの作成がめちゃ遅いです 😥

その上、作るテーブルやインデックスの数が増えるとさらに遅くなってきて地獄です。
テストのたびにこんなに待たされてるのではさすがに辛いですね。

ExtraStatements を使ってテーブルとインデックスの作成を爆速にする

テーブルやインデックスの作成が遅いのは Cloud Spanner の仕様かと思って諦めかけていたのですが、解決策がひとつ見つかりました。 ExtraStatements というパラメータです。

CREATE TABLECREATE INDEX といったものを、データベース作成時に一緒に送ることができるパラメータです。

使い方は簡単で、 CREATE DATABASE するときに追加で与えるだけでOKです。

        op, err := client.CreateDatabase(ctx, &adminpb.CreateDatabaseRequest{
            Parent:          dsnParent,
            CreateStatement: fmt.Sprintf("CREATE DATABASE `%s`", databaseID),
+           ExtraStatements: []string{
+               `CREATE TABLE Singers (
+                   SingerId   INT64 NOT NULL,
+                   FirstName  STRING(1024),
+                   LastName   STRING(1024),
+                   SingerInfo BYTES(MAX)
+               ) PRIMARY KEY (SingerId)`,
+               `CREATE INDEX SingersByFirstName ON Singers(FirstName)`},
        })

これでもう一度同じ初期化を実行してみます

begin (DROP DATABASE)
end (DROP DATABASE) (2.507284052s)

begin (CREATE DATABASE + ExtraStatements)
end (CREATE DATABASE + ExtraStatements) (8.352879087s)
  • DROP DATABASE 約3秒
  • CREATE DATABASE + ExtraStatements 約8秒

・・・・・8秒!!!

image.png

この速度なら繰り返しテストを実行しても大丈夫そうですね!

初期データを挿入する

初期データを入れておきたい場合は、特別なことをしなくても普通に INSERT を使って入れると良いでしょう!

初期の Cloud Spanner は INSERT UPDATE DELETE などの文が使えなかったのですが、最近のアップデートで使えるようになりました 🎉

client.ReadWriteTransaction(ctx, func(ctx context.Context, tx *spanner.ReadWriteTransaction) error {
    sql := "INSERT INTO User (...) VALUES (...)"
    if _, err := tx.Update(ctx, spanner.NewStatement(sql)); err != nil {
        return err
    }
    return nil
})

しかし、大量のデータを入れたい場合は注意が必要です! Cloud Spanner で INSERT などの更新系の命令に制限があります。

  • 1回のトランザクションにつき、20,000 mutations 以下に収める
  • クエリパラメータの数は 950 個 まで

mutations??? とは何でしょうか? :thinking:

ミューテーションとは挿入、更新、削除といった操作の一つの単位です。変更を加える行数と列数、そして対象の列を含むインデックスの数で決まります。

ミューテーションの数え方については、次の動画の解説がわかりやすいです。

image.png

1つのトランザクションで 20,000 ミューテーションを超えるようなデータを入れる場合は、トランザクションを複数回に分割するなどして工夫する必要があります。

ミューテーション数の制限を受けない Partitioned DML

ミューテーション数の制限を受けない特殊な文 Partitioned DML というものもあります。これは、ひとつの UPDATE DELETE 文を分散して実行することで大規模な更新が可能になるものです。

ただし、INSERT 文では使用できないので、テスト用データの大量挿入には使えなさそうです。。

クエリパラメータの制約

クエリパラメータとは、クエリの一部分をパラメータ化して再利用可能にするものです。 MySQL でいうとプリペアドステートメントという機能に近いです。

SELECT * FROM Singers WHERE ID = @param1

クエリパラメータを使うことで、次のような効果が期待できます。

  • SQLインジェクションの防止
  • 類似クエリを連続して実行する際のパフォーマンス向上

ただし、これにも制約がいくつかあるので注意です。

  • 1つの文にクエリパラメータは 950 個まで
  • LIKE '%test%' のようなワイルドカードを含むクエリは最適化が効かず、インデックスが使用されない場合がある(参考ページ

この制約を解除する方法は現在ではないので、ミューテーション数の制約を Partitioned DML で解決したとしても、こっちのクエリパラメータ数の制約にかかってしまう場合があります。

回避策としては、次のような感じでしょうか。

  • 複数のトランザクションに分割する
  • クエリパラメータを使わない

まとめ

  • Cloud Spanner はローカル環境上に立てることはできないので、テスト用の環境を作ってがんばる
  • データベースの初期化は、毎回データベースごと作り直すと簡単。その際、 ExtraStatements の指定を忘れずに。
  • ミューテーション数やクエリパラメータ数といった Cloud Spanner 特有の制限があるので知っておくとよい

Go + Cloud Spanner によるテストの実例

Cloud Spanner 含む複数データベース間で差分を取るツール tamate というものの開発のお手伝いをしたことがありまして、そこで Cloud Spanner をつかったテストを書いています。

こんな感じになるのか〜って実例として参考になれば幸いです!

以上、Google Cloud Platform その1 Advent Calendar 2018 の6日目の記事でした。次回の担当は @cvusk さんです!

29
21
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
29
21