Plan 9とGo言語のブログ

主にPlan 9やGo言語の日々気づいたことを書きます。

GAE第2世代で実装方法はどう変わったか

久しぶりにGAE/Goで自分用サービス作ったとき、第1世代(Go 1.9まで)と全く違っていて混乱したので自分用メモ。

DatastoreがCloud Firestoreになった

以前はAppEngine専用のDatastore APIを使っていたが、Cloud Datastoreを経て現在はCloud Firestoreを使うようになっているようだった。

2020年1月現在、新しくGCPプロジェクトを作成するとDatastoreモードのCloud FirestoreまたはネイティブモードのCloud Firestoreどちらかを選ぶ必要がある。既存のプロジェクトでCloud Datastoreを利用している場合は将来的に自動でDatastoreモードのCloud Firestoreにマイグレーションされるらしい。

一度選択すると、同じプロジェクトでは変更ができない*1ので、よほどの理由がない限りはネイティブモードを選べば良いと思う。ネイティブモードを選択しても、FirestoreのためにFirebaseコンソールとGCPコンソールを使い分ける必要はなく、GCPコンソールからFirestoreにアクセスできるし、今回は使わなかったがgocloud.dev/docstore(pkg.go.dev)はcloud.google.com/go/firestoreを使わずFirestore v1 APIを叩いているのでネイティブモードで慣れておいて損はない。

firestore.Client.Docnil DocumentRefエラーになる

以下のコードでnilが返ってきてしまう。

doc := firestore.Client.Doc("a/b/c")

docnilなので、このメソッドを呼び出すと以下のエラーが発生する。

firestore: nil DocumentRef

原因はパスの要素数で、ドキュメントとして参照する場合のパスは偶数個の要素でなければ扱えない。なのでDoc("a/b/c/d")なら偶数個なので正しいDocumentRefを取得できる。

コレクションとドキュメントが交互になるよう注意してください。コレクションとドキュメントは常にこのパターンに従う必要があります。コレクション内のコレクションや、ドキュメント内のドキュメントは参照できません。

同じ名前でCollectionとDocumentが存在できるのか

c, _ := firestore.NewClient(ctx, projectID)
articleRef := c.Doc("Articles/<id>")
commentRef := c.Doc("Articles/<id>/Comments/<n>")

のように、ドキュメントと名前が重複するコレクションは作れるのか?という話。Cloud Firestore データモデルに同じような構造のサンプルコードが書かれているので奇妙な設計というわけではなさそうだった。

カーソルはどうするの

ドキュメントのIDを使って、firestore.Query.StartAtまたはfirestore.Query.StartAfterを使うと途中から読める。

q := c.Collection("a/b/c").Where("is_draft", "==", false)
q = q.OrderBy(firestore.DocumentID, firestore.Asc)
q = q.StartAfter("<id>") // 最後に読んだID
iter := q.Documents(ctx)
defer iter.Stop()

保存したDocumentのIDを調べたい

ドキュメントへのパスを指定してDocumentRefを生成する場合は、パスに使った値を使えば良いが、保存されているドキュメントをイテレータで読み出す場合に困った。結局はfirestore.DocumentRef型にIDがあるのでそれを使うと良い。firestore.DocumentSnapshot型はRefフィールドにDocumentRefを持っている。

iter := c.Collection("Articles").Documents(ctx)
defer iter.Stop()

for {
    doc, err := iter.Next()
    if err == iterator.Done {
        return m, nil
    }
    fmt.Println(doc.Ref.ID)
}

まとめて書き込みしたい

firestore.Client.Batchを使うと良いが、これは1回のコミットで最大500件までの制限がある。制限を超えると、以下のエラーが発生する。

maximum 500 writes allowed per request

この場合は単純に、500件ごとにfirestore.WriteBatchを作り直せば良い。

for _, a := range requests { // 500件ごとに分割してある
    b := c.Batch()
    for _, p := range a {
        b.Create(c.Doc(p.Key), p)
    }
    if err := b.Commit(); err != nil {
        return err
    }
}

ただし、firestore.WriteBatchを作り直さずにそのまま使いまわすと、同じエラーが発生する。

Firestoreのテストはどうするの

gcloud beta emulators firestoreエミュレータが用意されている。

cronとタスクキュー

cronはCloud Schedulerに移行する

これはまだAppEngineのcronが使えるので以前のままcron.yamlを使った。今ならCloud SchedulerとCloud Functionsで作れば良さそうに思う。

AppEngineタスクキューはCloud Tasksに移行

AppEngineのタスクキューとだいたい同じ感覚で使えるが、突然Protocol Buffersの型が出てきてつらみがある。これも今ならCloud Functionsの方が良いかもしれない。

import (
    cloudtasks "cloud.google.com/go/cloudtasks/apiv2"
    taskspb "google.golang.org/genproto/googleapis/cloud/tasks/v2"
)

...

c, err := cloudtasks.NewClient(ctx)
if err != nil {
    return err
}
defer c.Close()

queuePath := path.Join(
    "projects", projectID,
    "locations", locationID,
    "queues", "default",
)
req := &taskspb.CreateTaskRequest{
    Parent: queuePath,
    Task: &taskspb.Task{
        MessageType: &taskspb.Task_AppEngineHttpRequest{
            AppEngineHttpRequest: &taskspb.AppEngineHttpRequest{
                HttpMethod:  taskspb.HttpMethod_POST,
                RelativeUri: "/_ah/tasksadd",
            },
        },
    },
}
for _, task := range tasks {
    body, err := json.Marshal(task)
    if err != nil {
        return err
    }
    req.Task.GetAppEngineHttpRequest().Body = body
    if _, err := c.CreateTask(ctx, req); err != nil {
        return err
    }
}

デプロイ

無視するファイルはgcloudignoreに書く

node_modulesなどデプロイする必要のないファイルは.gcloudignoreに書くと無視できる。

node_modules/

staticでルーティングしたファイルが404 not found

Go 1.11以降(2nd gen以降?)は、go.modファイルのある場所がカレントディレクトリになる。そのため、

app/
  app.yaml
  static/
    bundle.js
    index.html
go.mod
go.sum

このようなファイル階層のとき、app/app.yamlからの相対パスを書いても読めない。

# ダメな例
handlers:
- url: /api/.*
  script: auto

- url: /bundle.js
  static_files: static/bundle.js
  upload: static/bundle.js

- url: /(.*)
  static_files: static/index.html
  upload: static/index.html

これだと、go.modの位置にはstatic/ディレクトリは存在しないので参照できない。

上の記事にもあるが、app.yamlを以下のように変更するか、またはgo.modapp.yamlを同じディレクトリに置くと良い。

handlers:
- url: /api/.*
  script: auto

- url: /bundle.js
  static_files: app/static/bundle.js
  upload: app/static/bundle.js

- url: /(.*)
  static_files: app/static/index.html
  upload: app/static/index.html

Go 1.13を使いたい

app.yamlに設定すれば普通に使える。

runtime: go113

無料枠(Always-Free)はスケーリングとインスタンスクラスによって異なる

GAEの場合、Google Cloudの無料枠では

1 日あたり28時間のフロントエンドインスタンス時間、1日あたり9時間のバックエンドインスタンス時間

としか書かれていないが、実際は割り当てに書かれているように、インスタンスクラスによって

  • Automaticスケーリングの場合はF1インスタンスクラスなら28時間まで無料
  • Basic/Manualの場合はB1インスタンスクラスなら9時間まで無料
  • それ以外は対象外

という制限がある。スケーリングについては以下のリンクが詳しい。

Cloud IAP

app.yamllogin: requiredが使えなくなったので、お手軽に認証したければCloud Identity-Aware Proxyを使うと良い。Googleアカウント以外にも対応する必要があるなら、Identity Platformというサービスが使えるらしい。

面倒なアプリのログイン機能を超簡単に実装する on GCP

Cloud IAPでOwnerを持っているユーザなのにアクセスできない

オーナーは設定変更する権限だけで、アクセス権は持っていない。アクセスするためにIAP-Secured Web App Userの追加が必要だった。セキュリティIdentity-Aware Proxyと進んで、情報パネルにアカウントを追加する。

*1:別のプロジェクトには影響しない