datastore

mercari/datastore の知見を語っておく

技術書典WebのAPI実装を全部mercari/datastoreに置き換えたので知見を語っておくことにする。

boomを使う

goon互換のAPIのboomを使います。
AECompatibleな互換モードが用意されているので、とりあえず動く状態で移植するのは比較的容易でした。
goonよりAPI的に改善されている点も複数あります(AllocateIDやDeleteの振る舞い)。
goonを使っていなかった人は使わなくてもいいと思います。

Tx中でPutした時にその場でIDが設定されないことに注意する

Cloud Datastoreの仕様に沿っているため、単にPutしただけではIDがセットされません。
TxがCommitされたタイミングでIDがセットされます。
この差分を埋めるため、自力でAllocateIDするコードに置き換える必要がある点に注意が必要。
この仕様の差を埋めるためにToAECompatibleTransactionが用意されているので、移行のタイミングではこれを使ってしまうのがよいでしょう。

※この辺の挙動の違いについては一回どこかで解説記事書いたほうがよさそう。
※※10モナコインくらい叩きつけられたら即座に書きます。

Context次第でTxか非Txか挙動が変わるのは非人道的行いだった

AppEngine Datastoreの仕様なので特に気にしていませんでしたがこれは非人道的行いでした。
型で区別できない上にTxなContextか否か調べる手段がデフォルトでは存在しないので人間がこれを管理し運用する必要がありました。
人間はこれを上手に管理できず、どこかで破綻したり不当に強い制約を自分で作るしかありませんでした。

Cloud Datastore準拠のAPIだと、Txと非Txな操作は型レベルで異なるため管理が容易です。
最初はこれはめんどくさいだけの制約ではないか?と思っていましたが人間の脳をいたわってくれることがわかりました。

boomには過渡期を過ごすための AECompatible* 的なインタフェースが用意してありますが、これは非人道的苦しみを再生産するだけなので最終的にはリファクタリングして全て消し去るほうが幸せになれるでしょう。

Batchモードを使う

*datastore.Batchとか*boom.Batchとかを使います。
Datastoreは御存知の通り、外部のDBサーバと通信を行う必要があります。
なので、やり取りする回数を減らせば性能を稼げる!という発想です。
Batchモードを使うと、"やりたいRPCを予約する"ことができます。
これを任意のタイミングでドン!できるわけです。

これを使いこなすと、forループ内で愚直に1件ずつGetする(かのような)コードを書いてしまって問題ありません。
弊害として、"またGetが完了していない空のオブジェクト"が存在してしまうタイミングがあることです。
これはトレードオフなので、テスト時にフィールドに値がしっかりセットされているかを確かめるようなコードが必要です。

ネーミングルール

BatchモードやTxなど、考慮すべき要素が増えているのでプロジェクト内のネーミングルールを整理しました。
概ね、次のような基準で運用することにしました。

  • Batchの確定(Exec)を呼び出し側でやる必要がある場合、メソッド名のPrefixを Batch にする
    • 逆に、Batchプリフィクスが付かない場合、Batchモードの処理を完了させるのはそのメソッドの責任
  • Txを引数に取る場合、Postfixを WithTx にする
    • 単に Get と GetWithTx の2つが必要になってしまい名前がだるかったのでそうした
    • Get と GetWithoutTx 派がいてもいいと思う

イディオム

goonのFromContext相当の処理を自プロダクト内で準備し統一的に利用する

技術書典Webで使ってるのはこんな感じ。

type contextBoom struct{}
type contextBoomBatch struct{}

func FromContext(ctx context.Context) (context.Context, *boom.Boom) {
    ds, err := aedatastore.FromContext(ctx)
    if err != nil {
        panic(err)
    }

    ch := aememcache.New()
    ds.AppendMiddleware(ch)

    rr := rpcretry.New(
        rpcretry.WithRetryLimit(5),
        rpcretry.WithMinBackoffDuration(5*time.Millisecond),
        rpcretry.WithLogf(func(c context.Context, format string, args ...interface{}) {
            log.Warningf(c, format, args...)
            message := fmt.Sprintf(format, args...)
            logURL := buildRequestLogURL(c)
            err := SlackSend(c, os.Getenv("SLACK_ENDPOINT_URL"), "web-report", ":wakaranai:", "RPC自動リトライ君", appengine.AppID(c)+": "+message+" "+logURL.String())
            if err != nil {
                log.Warningf(c, "on SlackSend err=%s", err.Error())
            }
        }),
    )
    ds.AppendMiddleware(rr)

    bm := boom.FromClient(ctx, ds)

    // FIXME 現状のAPIだとbmを渡すのが厳しい場合があるのでhackとしてctxに持たせる…
    ctx = context.WithValue(ctx, contextBoom{}, bm)
    bm.Context = ctx

    // PropertyLoadSaver系で使うために共通のbatchを1つ持たせておく
    bt := bm.Batch()
    ctx = context.WithValue(ctx, contextBoomBatch{}, bt)
    bm.Context = ctx

    return ctx, bm
}

func extractBoomFromContext(ctx context.Context) (*boom.Boom, error) {
    bm, ok := ctx.Value(contextBoom{}).(*boom.Boom)
    if !ok {
        return nil, errors.New("ctx doesn't have *boom.Boom")
    }

    return bm, nil
}

func extractBatchFromContext(ctx context.Context) (*boom.Batch, error) {
    bt, ok := ctx.Value(contextBoomBatch{}).(*boom.Batch)
    if !ok {
        return nil, errors.New("ctx doesn't have *boom.Batch")
    }

    return bt, nil
}

SingleGetもBatchモードで行う

後述するが、PropertyLoadSaverでBatchモードに積み上げを行う都合上、BatchのExecを忘れないためにSingleGetもBatchモードで行うようにするとミスが減らせるという話。
BatchのExecは積み残しが0になるまで再帰的に実行されるからです。

GoLandのFind Usagesで初期化従ってる箇所が一括で取れて便利というのもある。

func (img *Image) BatchFill(c context.Context, bt *boom.Batch, id int64, h datastore.BatchErrHandler) error {
    img.ID = id
    bt.Get(img, h)
    return nil
}

呼び出し側はこんな感じ

img := &Image{}
err := img.BatchFill(c, bt, circle.CircleCutImageID, nil)
if err != nil {
    return err
}
err = bt.Exec()
if err != nil {
    return err
}

BatchモードでErrNoSuchEntityが発生した時にこれを無視したい場合

ErrNoSuchEntityが返ってくるのが期待値、という場合がある。
この時、ErrNoSuchEntityをnilに置き換えないとExecの返り値で datastore.MultiError が返ってきてめんどくさい。

if circle.CircleCutImageID != 0 {
    img := &Image{}
    err := img.BatchFill(c, bt, circle.CircleCutImageID, func(err error) error {
        if err == nil {
            // 値が取れた時に初めてあるべき場所に代入する
            circle.CircleCutImage = img
        } else if err == datastore.ErrNoSuchEntity {
            log.Warningf(c, "on CircleExhibitInfo.Load, missing CircleCutImageID=%d", circle.CircleCutImageID)
            circle.CircleCutImageID = 0
            return nil
        }

        return err
    })
    if err != nil {
        return err
    }
}

ちなみに、BatchのExec後の処理はGet, Put, Deleteの処理それぞれに対して処理を積んだ順に行われます。
なので、listに対してappendする操作をコールバック中に書いても意図した順番でlistにappendされていきます。
直感的です。

Batchモード中の特定の1操作のエラーを関数の返り値にしたい

BatchErrHandler でerrを保存しておいてBatchをExecした後に保存しておいたものを優先的に返すだけ。

product := &ProductInfo{}
var rErr error
product.BatchFill(bm.Context, bt, id, func(err error) error {
    rErr = err
    return err
})
err = bt.Exec()
// BatchをExecした後、取っておいたerrを優先的に返す
if rErr != nil {
    return nil, rErr
}
if err != nil {
    return nil, err
}

PropertyLoadSaver内部でBatchへの積み上げを行う

こんな感じ。
つまり、Get したら付属品のGetMultiがBatchモードに積み立てられている状態。
Tx下でGetした場合でもBatchの実行は非Tx下で行われるように作れば、XGTxの制約に引っかからなくて済むのが利点。

func (event *Event) Load(c context.Context, ps []datastore.Property) error {
    if err := datastore.LoadStruct(c, event, ps); err != nil {
        return err
    }

    // ctxにBatchを持たせるのでちょっとお行儀が悪いけど妥協する
    bt, err := extractBatchFromContext(c)
    if err != nil {
        return err
    }

    event.EventExhibitCourses = make([]*EventExhibitCourse, 0, len(event.EventExhibitCourseIDs))
    for _, eventExhibitCourseID := range event.EventExhibitCourseIDs {
        eventExhibitCourse := &EventExhibitCourse{}
        eventExhibitCourse.BatchFill(c, bt, event.ID, eventExhibitCourseID, nil)

        event.EventExhibitCourses = append(event.EventExhibitCourses, eventExhibitCourse)
    }

    return nil
}

終わりに

今回のリファクタリングによりBatchモードが理解できている僕にはかなりスッキリとしたわかりやすいコードになりました。
処理も手続き的でわかりやすく、整頓されています。
一方、エラーハンドリング的なボイラープレートのコードを書く場面が少し増えたかのように思います。
まぁでもGo言語だとよくある話すぎて今更だな!って感じです。

何か思い出したら書き足します。