意外に情報が少なくて、何がベストかはよく分からないですが、メモがてら残しておきます。
ソースコードは以下で公開してるので、詳細を見たい場合は参照ください。
https://github.com/rinoguchi/microblog

利用している技術

  • 言語: golang
  • web framework: chi
  • ORM: bun

トランザクションの開始/終了

通常の Web APIでは1リクエスト1トランザクションが一番シンプルで分かりやすいので、リクエストとトランザクションのライフサイクルは同じにしたいです。
一つのAPIコールの途中で何かエラーが発生したら、全部ロールバックする形です。

この場合、controllers 層でトランザクションを開始/終了するのが良さそうです。

controllers 層の各ハンドラーにて、以下のような感じで実装するのが一番シンプルですが、同じ実装が何度も出てくるのがとても煩雑です。

func (s *Server) DoSomething(w http.ResponseWriter, r *http.Request) {
    s.db.RunInTx(r.Context(), nil, func(ctx context.Context, tx bun.Tx) error {
        err := doSomething()
        if err != nil {
            return err // rollbackされる
        }
        return nil // commit される
    })
}

重複コードを減らすには、middleware を使うのが良さそうです。
chi と bun を使ってるケースですが、以下のような middleware を作成しました。

func (s *Server) SetTxMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        s.db.RunInTx(r.Context(), nil, func(ctx context.Context, tx bun.Tx) error { // start transaction
            new_ctx := context.WithValue(r.Context(), repositories.TX_KEY, &tx)
            ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
            next.ServeHTTP(ww, r.WithContext(new_ctx))

            if ww.Status() != http.StatusOK {
                return errors.New("rollbacked needed") // rollback
            }
            return nil // commit
        })
    })
}

ここでは以下のようなことを実装しています。

  • ハンドラーが呼び出されるたびに、トランザクションをスタートして context に設定する
    • repositories 層では、context からトランザクションを取得して利用する
  • status を取得して、OK(200)以外の場合は、ロールバックする
    • status を取得できるようにするために、NewWrapResponseWriterを利用している
  • s.dbはwireでDIして渡してる
    • 詳細はgithubのソースコードを参照ください

後は、作成した middleware を設定するだけです。

func main() {
    server := InitializeServer()
    router := chi.NewRouter()
    router.Use(server.SetTxMiddleware)
    controllers.HandlerFromMux(server, router)
    http.ListenAndServe(port, router)
}

これで、ハンドラーが呼び出されるたびに、トランザクションが開始して、status がOK(200)ならコミット、OK(200)以外ならロールバックされるようになりました。

トランザクションの利用

contorllers層で開始したトランザクションは、以下のような感じでrepositories層で利用できます。

func (c CommentRepositoryImpl) Add(ctx context.Context, commentEntity entities.CommentEntity) (entities.CommentEntity, error) {
    dbComment := repositories.FromCommentEntity(commentEntity)
    tx := ctx.Value(TX_KEY).(*bun.Tx)
    _, err := tx.NewInsert().Model(&dbComment).Exec(ctx)
    if err != nil {
        return entities.CommentEntity{}, err
    }
    //  return entities.CommentEntity{}, errors.New("dummy error")
    return dbComment.ToCommentEntity(), nil
}

試しに、上記のコメントアウトしている部分をコメントインして実行してみると、ちゃんとロールバックされていることをログおよびDBから確認することができました。

最後に

今回のケースでは、chi+bunの組み合わせですが、他のルータやORMを使うケースでも同じ考え方でいけると思います。
初めてmiddlewareを使ってみたのですが、定型のコードを一箇所にまとめて、transactionに関する処理を一元化できたのがよかったです。
また、リクエストとトランザクションのライフサイクルを合わせたのですが、ライフサイクルを意識して実装するのも大事だなぁと改めて思いました。

Posted in: go