wheatandcatの開発ブログ

React Nativeで開発しているペペロミア & memoirの技術系記事を投稿してます

Firestoreのバッチ書き込み→トランザクション更新に移行 & 安全に移行するためにe2eテストケースを追加

元々、1つの処理内でfirestoreに複数件の書き込みをしている箇所は、バッチ書き込みで同時に書き込み処理を実行していた。

firebase.google.com

GoのFirestore SDKのv1.9.0から、バッチ書き込みでclient.Batchを使用するのは非推奨になった。

pkg.go.dev

そこで以下のPRでclient.Batchclient.BulkWriterへ移行してみた。

github.com

ただ、移行した後に気づいたが、BulkWriterは大量のドキュメントを処理するための機能で用途に合わなくなっていたので、最終的にバッチ書き込み→トランザクション更新に移行に変更。

処理が諸々変わる関係で、安全に移行するために対象箇所のe2eテストを追加してから移行してみた。

PR

github.com

github.com

実装

既存でバッチ書き込み処理を使用している箇所を検索して影響範囲を確認。 VSCodeで調べた感じ使用箇所は以下の6箇所。

以下のPRで上記の6箇所を通るe2eのテストケースを追加

github.com

使用している箇所は以下のシナリオテストでカバーできることが分かったので追加。

  • 招待から共有メンバーの認証→共有メンバーの解除まで
  • アカウント削除

共有メンバーは各ユーザー同士で招待→認証の操作を行う必要があるので2ユーザー必要。またアカウント削除も専用のアカウントが必要だったので、スクリプトでe2eテスト用のユーザーを3つ作成するように修正

e2e/script/main.go

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を使用。

www.wheatandcat.me

以下のテストケースを追加。

e2e/invite.yaml

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

e2e/script/main.go

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でバッチ書き込み→トランザクション更新に移行を対応。

github.com

トランザクション更新では、複数件書き込みを行う処理を以下のように、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テストでカバーできて楽ができた。