フューチャー技術ブログ

Go Cloud#3 Go CloudのDocStoreを使う

TIG DX Unitの渋川です。今回はGo Cloudの紹介の連載の第3弾です。


Go Cloudにはいろいろ便利な機能がありますが、ほとんどの機能は既存のAPIへの薄いラッパーだったりします。そんな中、よくぞ実装したな、と思われるのがDocStoreです。

DocStoreはFireStoreやDynamoDB、MongoDBへの透過的なアクセスを提供するパッケージ群です。NoSQLをどのように使うのか、RDBの代わりになるのかどうかみたいなのは定期的に炎上するネタですが、これらが対象としているドキュメントストア(NoSQLの一部の分野)の場合、ちょっと高度なクエリー言語やら少し高度なアトミックな操作をサポートしていたりすることがあります。DocStoreもこのようなクエリーやアトミックに行える操作などをいくつか提供しています。

  • アップデート: 数値をインクリメントするなどが一発で実行できる
  • アクションリスト: 取得、追加、更新、削除などの複数の操作をまとめて実行。RedisでいいうところのMULTI。読み込んだ値を元に加工して更新、まではできないが、追加と削除を同時に行うぐらいはできる。トランザクションのようでトランザクションではない、ちょびっとトランザクションな機能。
  • クエリー: SQLのように、Where/Limit/Offsetなどの条件式を使ってカラムを取得してくる

オンメモリ版のmemdocstoreでもこのような高度な機能が利用できますし、バックエンドでサポートしていない機能はGo Cloud上でエミュレーションしていたりするらしいです。

基本的な使い方

DocStoreは、O/Rマッパー内臓のストレージAPIのように使えます。Goの構造体に直接マッピングして読み書きできますので、O/Rマッパーを別途用意する必要はありません。

package main

import (
// 使いたいバックエンドをこのようにimportしておく
_ "gocloud.dev/docstore/memdocstore"
)

type CounterEntity struct {
ID string `docstore:"id"`
Count int `docstore:"count"`
}

func main() {
coll, err := docstore.OpenCollection(context.Background(), "mem://counter/id")
// これでレコードの作成
row := CounterEntity{
ID: "1",
Count: 1,
}
coll.Create(context.Background(), &row)

// 読み込みたい時もエンティティの読み書きのオブジェクトを作り、主キーだけ設定しておく
rowToRead := CounterEntity{
ID: "1",
}
// 主キー以外の要素(ここではCount)に値が設定される
coll.Get(context.Background(), &rowToRead)
}

簡単ですね。ここではオンメモリで動作するmemdocstoreを使いましたが、awsdynamodb、gcpfirestore、mongodbdocstoreがあります。テーブルを作成する機能はないので、memdocstore以外はawscliやgcloudコマンドなどを利用するなどして事前にテーブルは作っておく必要はあります。

メソッドとしては次の6つがあります。基本的なCRUD + UPSERT + 更新用の特別なメソッドになります。使い方で迷うことはあまりないでしょう。

  • Create: 新しいレコードを追加する(すでに存在するとエラー)
  • Replace: 既存のレコードを置換する(存在していなければエラー)
  • Put: 既存のレコードがあれば置換し、なければ新規で作成
  • Get: レコードの取得(なければエラー)
  • Delete: レコードの削除
  • Update: 特定のカラムだけ更新(後述)

上級な使い方

単なる読み書き以外にさまざまな機能が提供されています。

アップデート

RDBの強力なトランザクションがない代わりに、トランザクションが実現していたユースケースのごく一部(レコードを取ってきて、変更してセーブ)をカバーするのがこのアップデート機能です。

coll.Update(ctx, &record, docstore.Mods{"count": docstore.Increment(1)})

Modsはmapのエイリアスになっており、特定のフィールドにのみ変更を加えることができます。Modsの値によって結果が変わりますが、現状サポートされている操作は次の3つです。

  • nil: フィールドを削除する
  • docstore.Increment: 値をインクリメントする
  • その他の値: フィールドの値を変更する

クエリー

docstoreの中で個人的に一番便利で、クラウドに乗らないサービスでもmemdocstoreを使ってしまおうと思う動機づけになっているのがこのクエリー機能です。公式ドキュメントのサンプルが一通りイテレータの使い方・エラー処理も含めて触れているため、これが一番参考になるかと思います。

import (
"context"
"fmt"
"io"

"gocloud.dev/docstore"
)

// Ask for all players with scores at least 20.
iter := coll.Query().Where("Score", ">=", 20).OrderBy("Score", docstore.Descending).Get(ctx)
defer iter.Stop()

// Query.Get returns an iterator. Call Next on it until io.EOF.
for {
var p Player
err := iter.Next(ctx, &p)
if err == io.EOF {
break
} else if err != nil {
return err
} else {
fmt.Printf("%s: %d\n", p.Name, p.Score)
}
}

Query()の返り値に対して、fluentインタフェースで情報を付与し、最後にGet()を呼び出します。次のメソッドがあります。どれもSQLでおなじみですね。

  • Where()
  • OrderBy()
  • Limit()

Whereの演算子としては”=”, “>”, “<”, “>=”, “<=”の5種類が使えます。notはありません。

Goの場合はスライス(と配列)、mapのシンプルなデータ構造でプログラムを構成していく必要があります。当然、インデックスやキーでのアクセス以外に範囲アクセスなどをしようとすると、自分でいろいろ作り込む必要があります。オンメモリのデータでもこのクエリー機能を使うと、少しリッチな検索機能が得られます(もちろん、処理コストが極めて重要な場合には使えませんが)。

もちろん、この機能を使うためにはバックエンドのテーブル作成時にインデックスを設定しておくなどをしないとバックエンドによってはフルスキャンになって嬉しくはないでしょう。

アクションリスト

複数のオペレーションをまとめて実行するのがアクションリストです。ほとんどのストレージAPIはバルクでまとめて処理を渡すことで効率よく処理することが可能だったりしますが、このアクションリストはそれを活用するためのAPIになっています。

Actions()で帰ってきたdocstore.ActionListに対してfluentインタフェースでメソッドを複数呼び、最後にDoを呼び出すことでまとめて実行されます。

err := coll.Actions().
Update(&row, docstore.Mods{"count": docstore.Increment(1)}).
Delete(&rowToDelete).
Do(ctx)

ここで返されるerrorの実体はdocstore.ActionListErrorです。これは、エラー情報の配列になっており、ActionListErrorにタイプアサーションでダウンキャストすることで、各アクションのエラー情報が個別に取れるようになっています。

楽観ロック

docstoreのドキュメントにはリビジョンフィールドが設定できます。デフォルトの名前はDocstoreRevisionですが、コレクションを開くときのオプションで設定できます。Replaceなどの更新メソッド呼び出し時にこのフィールドを設定しておくと、自分よりも先に誰かが書き込んだ時に楽観ロックによりエラーを検知できます。リビジョンフィールドが存在しない or リビジョン情報を渡さなければこの機構は動作しません。

ローカルファイルに保存(memdocstore)

memdocstoreには、ローカルファイルとの読み書きの機能があります。これを使うとオープン時にファイルがあればそれを読み込んで復元しますし、クローズ時に保存します。ユニットテストでちょっとしたマスタデータをロードするには便利でしょう。

// memdocstoreのドライバ側のAPIを使ってオープン時にオプションでファイル指定
coll, err := memdocstore.OpenCollection("id", &memdocstore.Filename{
Filename: "collection.db"
})

// ポータブル版のAPIでURLのクエリーでファイル名指定
coll, err := docstore.OpenCollection(context.Background(), "mem://counters?filename=counter.db")

DocStoreの注意点

便利なDocStoreですが、いくつか注意点があります。

gobのエラー(memdocstore)

memdocstoreではファイルの保存ができることを紹介しましたが、保存時にエラーが発生することがあります。そして、エラーが発生すると、次回オープンするときにEOFエラーが出てきます。

保存というのはClose() 時に行われるので、Close()を忘れると保存されませんし、次のように雑に書いているとエラーに気づけません。

// ダメなコード
defer coll.Close()

Close()を明示的にロジックの一部で書いても良いですが、deferを使いたいところでもあります。deferを使う場合はClose()のエラーをきちんとハンドリングする必要があります。返り値に名前をつけておけば、deferの中のエラーを正しく呼び出し元に返すことができます。

// 名前付きの返り値を使って、Close()のエラーを忘れずに上流に流す。
func anyFunc() (err error) {
coll, err := docstore.OpenCollection(context.Background(), "mem://counters?filename=counter.db"
if err != nil {
return
}
defer func() {
// すでにエラーがあったときは上書きしないように
if err != nil {
err = coll.Close()
}
}
// 読み書き
}

クローズ時のエラーの原因は、おそらく、保存時に使っているencoding/gob周りだと思います。errさえきちんと把握できればそのあとの対応は難しくはないでしょう。必要な型の保存ができるようにコレクションの読み込み前にはgob.Registerで型の登録を済ませておきましょう。

func init() {
gob.Register(map[string]interface{}{})
}

スレッドセーフではない(memdocstore)

memdocstoreはソースコード中のコメントにこっそり、「スレッドセーフに修正する」と書かれています。

// TODO(jba): make this package thread-safe.

コレクションそのものは内部にsync.Mutexを持っており、コレクションに対する操作は複数のgoroutineで実行しても問題はないと思います。また、ドライバAPI側のOpenCollection()も、新規のコレクションを作成して返すので問題はありません。

問題はポータブル版のAPIのコレクション作成です。GoのユニットテストはデフォルトでGOMAXPROCSの数だけ平行に実行されます。同じホストを持つURLを渡すと、同じコレクションのインスタンスを返すため、ユニットテストがお互いに影響を与え合うことになります。

解決法としてはドライバ版のAPIを使うか、コレクション名にユニークな識別子を付与して衝突しないようにする、といった対策が必要です。

import (
"github.com/rs/xid"
"github.com/stretchr/testify/assert"
"gocloud.dev/docstore"
_ "gocloud.dev/docstore/memdocstore"
"golang.org/x/sync/errgroup"
"testing"
)

func TestIncrement(t *testing.T) {
coll, err := docstore.OpenCollection(context.Background(), "mem://counter"+xid.New().String()+"/id")
// 以下テストコード
}

2つのコンテキスト

DocStoreはコレクションを開くときにコンテキストを要求します。また、個別の操作のときにもコンテキストが必要です。

memdocstoreの場合、最初のオープン時のコンテキストがキャンセルされると、データが全てリセットされます。ここは議論を呼んだところですが、この実装から推測できるのは、リクエストのたびにコレクションを開いて操作してクローズするというのではなく、コレクションはプロセスの寿命と同じだけ起動しっぱなしにすることを想定して設計されているということです。

つまり2種類は独立したコンテキストとなります。1つめはコレクションとの接続(≒アプリケーションのプロセスの生存期間)のためのもので、2つめは1つの読み書きのアクションに対応するコンテキストです。例えばサーバーアプリケーションであれば、サーバーが起動したときに作成され、サーバーの寿命と同じコンテキストと、フロントエンドからのリクエストを受けてそれを返すまでの寿命しかないコンテキストです。

パーティションキー、ソートキー、Read/Writeキャパシティ(awsdynamodb)

DynamoDBのデータベースがどのようにデータを保持しており、どのようにアクセスすると効率よくデータの取得ができるかについてはドキュメントに詳しく書かれています

DynamoDBでは他のデータベースなどのストレージとくらべて維持費が安くなっています。一方で、送受信に備えて、テーブルごとにキャパシティユニットを設定しておく必要があり、これの維持費がかかるという設計になっています。そのため、主キーで呼び分けが確実にできるのであればテーブルをまとめておくことでキャパシティを効率よくわけことも可能かもしれません。

このように、バックエンドのDBの特性がなくなるわけではなく、それをわかった上で利用する必要があります。docstoreがエミュレーションしてくれるおかげで、なんとなく動作してしまうことも多いと思いますが、実行/コスト効率をあげるには、裏の仕組みの理解は欠かせません。

まとめ

これまで紹介してきたBlob、PubSubと、このDocStore、そしてGCP/AWS/Azure向けに提供されているMySQL/PostgreSQL用のアダプタが、現状Go Cloudでアプリケーションを組み立てるための部品として提供されています(あとは設定ファイル用アダプタと、もろもろ設定済みのウェブサーバー)。もっとも、SQLの方は使い方は普段のものと変わらないので、大きな機能は今回の本連載の中で一通り説明できたと思います。

明日はDocStoreなどを少し便利にするユーティリティについて説明します。