Open7

datastore と仲良くなります

satoyasatoya

datastore を docker でローカルに立てる

↓↓を使う
https://github.com/remko/dsadmin

適当に exporse する PORT は変えます。

version: "3.9"
services:
  # DSAdmin container
  dsadmin:
    image: "ghcr.io/remko/dsadmin:latest"
    depends_on:
      - datastore
    ports:
      - "30303:8080"
    environment:
      DATASTORE_PROJECT_ID: my-datastore-project
      DATASTORE_EMULATOR_HOST: "datastore:8081"

  # Datastore Emulator container
  datastore:
    image: "gcr.io/google.com/cloudsdktool/cloud-sdk:latest"
    volumes:
      - datastore_data:/opt/datastore/data
    ports:
      - "30300:8081"
    command: [
      "gcloud", "--quiet", "beta", "emulators" ,"datastore", "start", 
      "--host-port=0.0.0.0:8081", "--data-dir=/opt/datastore/data"
    ]
    environment:
      CLOUDSDK_CORE_PROJECT: my-datastore-project

volumes:
  datastore_data:

サンプル通りで繋がります。

適当に追加したデータを golang で参照する

適当に入れます。

SDK が emulator を参照するように適当な環境変数を export します。

export DATASTORE_EMULATOR_HOST=localhost:30300

テスト書きます。通ります。

package main

import (
	"context"
	"testing"

	"cloud.google.com/go/datastore"
	"github.com/google/go-cmp/cmp"
)

type User struct {
	ID   string `datastore:"id"`
	Name string `datastore:"name"`
}

func TestGet(t *testing.T) {
	ctx := context.Background()
	projectID := "my-datastore-project"

	client, err := datastore.NewClient(ctx, projectID)
	if err != nil {
		t.Fatal(err)
	}

	var users []User
	if _, err := client.GetAll(ctx, datastore.NewQuery("Users"), &users); err != nil {
		t.Fatal(err)
	}

	if len(users) != 3 {
		t.Fatalf("want=3, act=%d", len(users))
	}
	expected := []User{
		{
			"ce6b04f2-c6bd-44fb-b9dd-25ff1ccdaacd",
			"ほげたろう",
		},
		{
			"e4f65fe6-1d70-48cf-86cc-e33c9b34d6a5",
			"ばーじろう",
		},
		{
			"854cb6db-9e32-48a1-a388-774cd814ea3e",
			"ふーたろう",
		},
	}
	if d := cmp.Diff(expected, users); d != "" {
		t.Errorf("got = %v, want %v, diff %s", users, expected, d)
	}
}
❯ go test -v ./...
=== RUN   TestGet
--- PASS: TestGet (0.24s)
PASS
ok      github.com/somen440/dsample     1.107s

golang でデータを追加する

Put します。

	ctx := context.Background()

	user := &User{
		ID:   uuid.New().String(),
		Name: "テストマン",
	}

	projectID := "my-datastore-project"

	client, err := datastore.NewClient(ctx, projectID)
	if err != nil {
		t.Fatal(err)
	}

	k := datastore.NameKey("Users", uuid.New().String(), nil)
	if _, err := client.Put(ctx, k, user); err != nil {
		t.Fatal(err)
	}

GUI から追加が確認できました。

todo: namespace, index ... etc

satoyasatoya

namespace をつけてデータ追加する

IncompleteKey を始めとして各種 Key は Namespace 空文字で生成されます。

// IncompleteKey creates a new incomplete key.
// The supplied kind cannot be empty.
// The namespace of the new key is empty.
func IncompleteKey(kind string, parent *Key) *Key {
	return &Key{
		Kind:   kind,
		Parent: parent,
	}
}

つまりこうです。

		k := datastore.IncompleteKey("Users", nil)
		if k.Namespace != "" {
			t.Fatal("namespace is not empty", k.Namespace)
		}

namespace が空文字である場合の生成は前試した通り (Default Namespace) にデータが追加されます。
namespace つけます。

		k := datastore.IncompleteKey("Users", nil)
		k.Namespace = "test"

		user := &User{
			ID:   uuid.New().String(),
			Name: "namespace test テストマン",
		}

		if _, err := client.Put(ctx, k, user); err != nil {
			t.Fatal(err)
		}

namespace 増えてます。

増えた namespace にデータ追加されてます。

satoyasatoya

allocate

Incomplete → allocate でデータストア上有効であることが保証されている安全な ID が取れます。

	ctx := context.Background()

	k := datastore.IncompleteKey("Users", nil)
	k.Namespace = "test_allocate"

	keys, err := client.AllocateIDs(ctx, []*datastore.Key{k})
	if err != nil {
		t.Fatal(err)
	}

	log.Println(keys[0].ID) // e.g. 4644337115725824

allocateIDs の是非についてはここの回答の中の
https://stackoverflow.com/questions/60516959/how-does-allocateids-work-in-cloud-datastore-mode

Yes, allocateIds() should work for the case where you want to get an ID from Datastore mode and use it as both an ID and property

がしっくりきました。

公式リンクです。
https://cloud.google.com/datastore/docs/concepts/entities#assigning_your_own_numeric_id

競合を防ぐ目的などでも(特に大規模アプリケーションを想定している場合)使っておいた方が無難そうです。

データ追加も問題なかったです。

satoyasatoya

index

エミュレーターにおける index 定義ファイルのありかは公式より
https://cloud.google.com/datastore/docs/tools/indexconfig?hl=ja

index.yaml は <project-directory>/WEB-INF/ フォルダにあります。

とのこと。分かりやすく data-dir で出力先にしてるディレクトリを tmp に持ってきます。

-      - datastore_data:/opt/datastore/data
+      - ./tmp/datastore:/opt/datastore/data
...
...
      "--host-port=0.0.0.0:8081", "--data-dir=/opt/datastore/data"

公式には、エミュレーターのデータ出力先のことや諸々書かれています。
要はこういうことです。

❯ tree tmp
tmp
└── datastore
    ├── WEB-INF
    │   ├── appengine-generated
    │   │   └── local_db.bin
    │   └── index.yaml
    └── env.yaml

3 directories, 3 files

index 定義ファイルは空です。

cat tmp/datastore/WEB-INF/index.yaml 
indexes:
# AUTOGENERATED

index 拡張確認に datastore Entity を拡張します。

-       ID   string `datastore:"id"`
-       Name string `datastore:"name"`
+       ID      string    `datastore:"id"`
+       Name    string    `datastore:"name"`
+       Number  int       `datastore:"number"`
+       Created time.Time `datastore:"created"`

ここで適当にデータを投入してクエリを投げると結果として取れます。

特に index.yml を弄ってないですが AUTOGENERATED とある通り index は自動生成してくるようです。
以下のクエリを叩いているときに出ました。

SELECT* FROM Users
WHERE number <= 1010
ORDER BY number DESC, created DESC
indexes:
# AUTOGENERATED

# This index.yaml is automatically updated whenever the Cloud Datastore
# emulator detects that a new type of query is run. If you want to manage the
# index.yaml file manually, remove the "# AUTOGENERATED" marker line above.
# If you want to manage some indexes manually, move them above the marker line.

- kind: "Users"
  properties:
  - name: "created"
    direction: desc
  - name: "number"
- kind: "Users"
  properties:
  - name: "created"
    direction: desc
  - name: "number"
    direction: desc
- kind: "Users"
  properties:
  - name: "number"
  - name: "created"
    direction: desc
- kind: "Users"
  properties:
  - name: "number"
  - name: "created"
    direction: desc
  - name: "id"
- kind: "Users"
  properties:
  - name: "number"
  - name: "created"
    direction: desc
  - name: "name"
- kind: "Users"
  properties:
  - name: "number"
    direction: desc
  - name: "created"
    direction: desc

コメントで書かれている通り、手動でインデックスを管理したい場合、 AUTOGENERATED の上に記述すれば良いようです。
概ね自動で生成してくれるのは便利です。

index クエリを golang で

先程のクエリを golang で書きます。

SELECT* FROM Users
WHERE number <= 1010
ORDER BY number DESC, created DESC

ASC はそのまま DESC は "-{property}" となるようです。

	ctx := context.Background()

	var users []User
	q := datastore.NewQuery("Users").Filter("number <=", 1010).Order("-number").Order("-created")
	if _, err := client.GetAll(ctx, q, &users); err != nil {
		t.Fatal(err)
	}

	if len(users) != 2 {
		t.Fatalf("want = 2, len(users) = %d", len(users))
	}

	expected := []int{1010, 1000}
	actual := []int{users[0].Number, users[1].Number}
	if !reflect.DeepEqual(expected, actual) {
		t.Fatalf("want = %+v, act = %+v", expected, actual)
	}

無事取れました。

satoyasatoya

gui 色々

先に紹介した dsadmin はネスト構造をまだサポートしてません。

🚧 Not yet implemented
Editing nested entities
UI for filtering & projecting

https://github.com/remko/dsadmin

シンプルな viewer であれば以下がネスト構造も JSON っぽく見れるようなものをサポートしていて簡単です。

https://github.com/GabiAxel/google-cloud-gui

ちなみにいずれも datastore の REST API を用いて実装されています。
https://cloud.google.com/datastore/docs/reference/data/rest

datastore emulator も同様に起動すると REST API を提供してくれていて、API を通して import / export などができます。

https://cloud.google.com/datastore/docs/tools/emulator-export-import

satoyasatoya

emulator における projectID の罠

例えば以下のように hoge-project で emulator を起動します。

services:
  datastore:
    image: google/cloud-sdk:latest
    command: ["gcloud", "--project=hoge-project", "beta", "emulators", "datastore", "start", "--consistency=1", "--host-port=0.0.0.0:8812", "--data-dir=/opt/datastore/data"]

すると emulator 環境変数セットは以下のようになります。

---
DATASTORE_DATASET: hoge-project
DATASTORE_EMULATOR_HOST: 0.0.0.0:8812
DATASTORE_EMULATOR_HOST_PATH: 0.0.0.0:8812/datastore
DATASTORE_HOST: http://0.0.0.0:8812
DATASTORE_PROJECT_ID: hoge-project

しかし、起動時に設定した projectID と実行時の projectID が一致せずともエラーとはならず、DATASTORE_EMULATOR_HOST で接続先さえ一致していれば projectID はなんでも受け入れてくれます。

...
client, err := datastore.NewClient(ctx, "hoge-project") // ok
...
client, err := datastore.NewClient(ctx, "foo-project") // ok
...
client, err := datastore.NewClient(ctx, "bar-project") // ok

また、このときちゃんと指定した projectID としてデータを参照/永続化してくれるため、 環境間で projectID を切り替えるような仕組みを用意しているとき に projectID ズレでデータがあると思っていたのにないみたいなことになりかねないので気をつけます。(自戒です)

satoyasatoya

Tips

  • 裏側は Spanner
  • 同一の Ancestor を持つ entity は物理的に同一の箇所に置かれる特性をもつ
  • Spanner の論理的な分割単位 split を考えると物理的に同一箇所が同一として扱われる
  • split が同一の方が Spanner 上の効率が高い
    • 例えば、ユーザーを ancestor として子データを持たせるような構造が効率がいい
  • ただし、全てのクエリが同じ split に集まると機能を最大限有効化できない
    • ユーザーデータが public として他の人も参照出来る場合、ホットスポットになりうる
    • db にいかせないためのキャッシュとか split 分けるような工夫が必要になる

参考リンク
https://dev.classmethod.jp/articles/reinvent-2019-dat209-l-jp/