元々、1つの処理内でfirestoreに複数件の書き込みをしている箇所は、バッチ書き込みで同時に書き込み処理を実行していた。
GoのFirestore SDKのv1.9.0から、バッチ書き込みでclient.Batchを使用するのは非推奨になった。
そこで以下のPRでclient.Batch→client.BulkWriterへ移行してみた。
ただ、移行した後に気づいたが、BulkWriterは大量のドキュメントを処理するための機能で用途に合わなくなっていたので、最終的にバッチ書き込み→トランザクション更新に移行に変更。
処理が諸々変わる関係で、安全に移行するために対象箇所のe2eテストを追加してから移行してみた。
PR
実装
既存でバッチ書き込み処理を使用している箇所を検索して影響範囲を確認。 VSCodeで調べた感じ使用箇所は以下の6箇所。
以下のPRで上記の6箇所を通るe2eのテストケースを追加
使用している箇所は以下のシナリオテストでカバーできることが分かったので追加。
- 招待から共有メンバーの認証→共有メンバーの解除まで
- アカウント削除
共有メンバーは各ユーザー同士で招待→認証の操作を行う必要があるので2ユーザー必要。またアカウント削除も専用のアカウントが必要だったので、スクリプトでe2eテスト用のユーザーを3つ作成するように修正。
package main import ( "bytes" "context" "encoding/json" "fmt" "io" "log" "net/http" "os" firebase "firebase.google.com/go" "firebase.google.com/go/auth" "github.com/joho/godotenv" "google.golang.org/api/option" "gopkg.in/yaml.v2" ) const verifyCustomTokenURL = "https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyCustomToken?key=%s" type User struct { UserToken1 string `yaml:"userToken1"` UserToken2 string `yaml:"userToken2"` UserToken3 string `yaml:"userToken3"` } type LoginYaml struct { Vars User } func main() { err := godotenv.Load(".env") if err != nil { log.Fatalf("読み込み出来ませんでした: %v", err) } ctx := context.Background() opt := option.WithCredentialsFile("../serviceAccount.json") config := &firebase.Config{ProjectID: os.Getenv("FIREBASE_PROJECT_ID")} app, err := firebase.NewApp(ctx, config, opt) if err != nil { log.Fatalf("error initializing app: %v\n", err) } client, err := app.Auth(ctx) if err != nil { log.Fatalf("error getting Auth client: %v\n", err) } idToken1, err := makeUser(client, ctx, "uid1") if err != nil { log.Fatalf("error minting custom token: %v\n", err) } idToken2, err := makeUser(client, ctx, "uid2") if err != nil { log.Fatalf("error minting custom token: %v\n", err) } idToken3, err := makeUser(client, ctx, "e2e-delete-user") if err != nil { log.Fatalf("error minting custom token: %v\n", err) } ly := LoginYaml{} ly.Vars.UserToken1 = idToken1 ly.Vars.UserToken2 = idToken2 ly.Vars.UserToken3 = idToken3 if err := WriteOnFile("./login.yaml", ly); err != nil { log.Fatalf("WriteOnFile: %v\n", err) } if err = os.Chmod("./login.yaml", 0600); err != nil { log.Fatalf("OSコマンドで失敗: %v", err) } } func makeUser(client *auth.Client, ctx context.Context, uid string) (string, error) { token, err := client.CustomToken(ctx, uid) if err != nil { return "", err } req, err := json.Marshal(map[string]interface{}{ "token": token, "returnSecureToken": true, }) if err != nil { return "", err } apiKey := os.Getenv("FIREBASE_API_KEY") resp, err := postRequest(fmt.Sprintf(verifyCustomTokenURL, apiKey), req) if err != nil { return "", err } var respBody struct { IDToken string `json:"idToken"` } if err := json.Unmarshal(resp, &respBody); err != nil { return "", err } return respBody.IDToken, nil } func postRequest(url string, req []byte) ([]byte, error) { resp, err := http.Post(url, "application/json", bytes.NewBuffer(req)) //nolint:gosec if err != nil { return nil, err } defer func() { if err := resp.Body.Close(); err != nil { log.Fatal(err) } }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected http status code: %d", resp.StatusCode) } return io.ReadAll(resp.Body) } func WriteOnFile(fileName string, data interface{}) error { // ここでデータを []byte に変換しています。 buf, err := yaml.Marshal(data) if err != nil { return err } // []byte をファイルに上書きしています。 err = os.WriteFile(fileName, buf, os.ModeExclusive) if err != nil { return err } return nil }
e2eテストは、以前記事で紹介したscenarigoを使用。
以下のテストケースを追加。
title: 招待 steps: - title: login include: "./login.yaml" bind: vars: userToken1: "{{vars.userToken1}}" userToken2: "{{vars.userToken2}}" - title: 招待コード作成 bind: vars: inviteCode: "{{response.data.createInvite.code}}" protocol: http request: method: POST url: "http://{{env.HOST}}/query" header: Authorization: "Bearer {{vars.userToken1}}" Content-Type: application/json body: query: |- mutation CreateInvite { createInvite { code } } expect: code: 200 - title: 招待をリクエスト bind: vars: followerId: "{{response.data.createRelationshipRequest.followerId}}" followedId: "{{response.data.createRelationshipRequest.followedId}}" protocol: http request: method: POST url: "http://{{env.HOST}}/query" header: Authorization: "Bearer {{vars.userToken2}}" Content-Type: application/json body: query: |- mutation CreateRelationshipRequest($input: NewRelationshipRequest!) { createRelationshipRequest(input: $input) { id followerId followedId status createdAt updatedAt user { id displayName } } } variables: input: code: "{{vars.inviteCode}}" - title: 招待をリクエスト一覧を取得 protocol: http request: method: POST url: "http://{{env.HOST}}/query" header: Authorization: "Bearer {{vars.userToken1}}" Content-Type: application/json body: query: |- query RelationshipRequests($input: InputRelationshipRequests!, $skip: Boolean) { relationshipRequests(input: $input) { edges { node { id followerId followedId user(skip: $skip) { id displayName image } } } } } variables: input: after: "" first: 1 skip: true expect: code: 200 body: data: relationshipRequests: edges: - node: followerId: "test_id2" followedId: "test_id1" - title: 招待リクエストを承諾 protocol: http request: method: POST url: "http://{{env.HOST}}/query" header: Authorization: "Bearer {{vars.userToken1}}" Content-Type: application/json body: query: |- mutation AcceptRelationshipRequest($followedID: String!) { acceptRelationshipRequest(followedID: $followedID) { id followerId followedId } } variables: followedID: "{{vars.followerId}}" expect: code: 200 - title: 共有ユーザーを取得① protocol: http request: method: POST url: "http://{{env.HOST}}/query" header: Authorization: "Bearer {{vars.userToken2}}" Content-Type: application/json body: query: |- query Relationships($input: InputRelationships!, $skip: Boolean) { relationships(input: $input) { edges { node { id followerId followedId user(skip: $skip) { id displayName image } } } } } variables: input: after: "" first: 1 skip: true expect: body: data: relationships: edges: - node: followerId: "test_id1" followedId: "test_id2" - title: 共有ユーザーを取得② protocol: http request: method: POST url: "http://{{env.HOST}}/query" header: Authorization: "Bearer {{vars.userToken1}}" Content-Type: application/json body: query: |- query Relationships($input: InputRelationships!, $skip: Boolean) { relationships(input: $input) { edges { node { id followerId followedId user(skip: $skip) { id displayName image } } } } } variables: input: after: "" first: 1 skip: true expect: code: 200 body: data: relationships: edges: - node: followerId: "test_id2" followedId: "test_id1" - title: 共有メンバーを解除する protocol: http request: method: POST url: "http://{{env.HOST}}/query" header: Authorization: "Bearer {{vars.userToken1}}" Content-Type: application/json body: query: |- mutation DeleteRelationship($followedID: String!) { deleteRelationship(followedID: $followedID) { id followerId followedId } } variables: followedID: "test_id2" expect: code: 200
title: ユーザー退会 steps: - title: login include: "./login.yaml" bind: vars: userToken: "{{vars.userToken3}}" - title: ユーザーを退会する protocol: http request: method: POST url: "http://{{env.HOST}}/query" header: Authorization: "Bearer {{vars.userToken}}" Content-Type: application/json body: query: |- mutation DeleteUser { deleteUser { id } } expect: code: 200
これで、まずバッチ書き込みの状態で正常に動作しているかの担保ができるようになった。 次に、この状態でバッチ書き込み→トランザクション更新に移行して、今まで通りe2eテストが通れば問題無しということで進める。
以下のPRでバッチ書き込み→トランザクション更新に移行を対応。
トランザクション更新では、複数件書き込みを行う処理を以下のように、RunTransactionでラップすればOK。
■ graph/relationship_request.go
err := g.FirestoreClient.RunTransaction(ctx, func(ctx context.Context, tx *firestore.Transaction) error { if err := g.App.RelationshipRequestRepository.Update(ctx, g.FirestoreClient, tx, rr1); err != nil { return err } if isFollowedRequest { // 相手側もリクエストしていた場合はstatusを更新 if err := g.App.RelationshipRequestRepository.Update(ctx, g.FirestoreClient, tx, rr2); err != nil { return err } } r1 := &model.Relationship{ ID: g.Client.UUID.Get(), FollowerID: g.UserID, FollowedID: followedID, CreatedAt: g.Client.Time.Now(), UpdatedAt: g.Client.Time.Now(), } r2 := &model.Relationship{ ID: g.Client.UUID.Get(), FollowerID: followedID, FollowedID: g.UserID, CreatedAt: g.Client.Time.Now(), UpdatedAt: g.Client.Time.Now(), } if err := g.App.RelationshipRepository.Create(ctx, g.FirestoreClient, tx, r1); err != nil { return err } if err := g.App.RelationshipRepository.Create(ctx, g.FirestoreClient, tx, r2); err != nil { return err } return nil }) if err != nil { return nil, ce.CustomError(err) }
RunTransactionの中のfunction内でFirestoreの書き込み処理を行い戻り値がnillの場合は、そのまま書き込み、errorの場合は、書き込みはロールバックされるようになっている。
コードを移行してe2eを実行して、問題ないことを確認。
https://github.com/wheatandcat/memoir-backend/actions/runs/3860166574/jobs/6580318638
これで一通りの作業が完了。手動でテストするとチェックが大変だったので、e2eテストでカバーできて楽ができた。