ぼくが かんがえた さいきょうの でーたすとあ らっぱー

ソウゾウ社の社内勉強会Go Friday 第60回用の資料です。
本来Go Fridayでは資料作ったりとかの事前準備はせんでええわいということになってるんですが素手で「ええやんこれ〜〜」という感想を引き出せる気がしなかったので作りました。
go.mercari.io/datastoreの話です。

今日話すこと

なぜ最強なのか。いかにして最強なのか。これからの最強。

  • ほしい理由
  • 解決方法
  • 実装方法(めんどいのでGo Friday中で口頭で説明)
  • 設計上の判断と移行の注意点
  • これから実装する機能

Datastoreって何?

Googleのやつ
appengineユーザなら誰しもお世話になってるはず。

ラッパがほしい理由

つらいこととかめんどくさいこととかが色々ありそれを解消したい。
→よろしい!ならば自分でラッパーを作るしかない!

つらポイント1 :scream_cat:

type PropertyLoadSaver interface {
    Load([]Property) error
    Save() ([]Property, error)
}

LoadとSaveでcontextがもらえない

  1. logが出せない!
  2. http.Clientも作れない!
  3. datastore.Getとかもできない!

つらポイント1を解決 :smiley_cat:

type PropertyLoadSaver interface {
    Load(ctx context.Context, ps []Property) error
    Save(ctx context.Context) ([]Property, error)
}

datastore.SaveStructdatastore.LoadStruct も再実装しました。

つらポイント2 :scream_cat:

// 独自の型はPutできない…!
type Data struct {
  ChildID ChildID
  CreatedAt UnixTime
}

独自の型を要素に持つStructをPutできない

JSONに出すために仕方なく別のstructに中身を詰め替える処理が必要→だるい
structに要素が増えたら詰め替えコードも書き換えないとダメ→バグる
好きな型をPutしてjson.Marshalerとか好き勝手に実装したいんじゃ〜〜〜!→それな

つらポイント2を解決 :smiley_cat:

type PropertyTranslator interface {
    ToPropertyValue(ctx context.Context) (interface{}, error)
    FromPropertyValue(ctx context.Context, p Property) (dst interface{}, err error)
}

PropertyTranslatorを実装していればSave&Load時に自分でDatastore validな型とのマッピング処理を書けます。
実装例
個人的にはJSONではnumber、DatastoreではKeyにできるとDatastore Viewerでもfrontend実装でもわかりやすく・扱いやすくてGOOD!
(BigQueryにimportした時辛そう説)

ちなみにユーザの権限に応じた出力項目の制御はjwgが便利です。

つらポイント3 :scream_cat:

*datastore.ErrFieldMismatch が邪魔!
Goのstructがschema代わりなんだから無いフィールドは黙って無視してほしい。
明示的に無視するコード書くのは面倒!

つらポイント3を解決 :smiley_cat:

datastore.SuppressErrFieldMismatch を実装。
デフォルトはtrue。falseを設定すると旧来の動作になる。

つらポイント4 :scream_cat:

関係するEntityを持ってくる操作を効率よく書くのがつらい…

よくある辛さの例

  1. Circleのリストを表示するぞ!
  2. CircleはProductを複数(0以上)持ってるぞ!
  3. CircleはどのEventに出展するかも持ってるぞ!

愚直コードのRPCの回数=Circleリスト取得+Circleの数×(Event1回+Product×数)

RPCの回数?

Datastoreはappengineのインスタンスとは別のマシンで動いてるのでRPCが必要になる。
細切れにデータを取ってくると時間がかかる。

技術書典のMemcacheを飛ばした後のトレース

この例だとMemcacheに乗ってないと1100msくらいかかってます。
Memcacheに乗ってても450msくらいはかかります。
RPCの回数が減れば処理時間が大幅に改善されることがわかります。

手動で改善する

  1. Circleのリストを取得する
  2. 全Circleを舐めて取得したいProductのリストを作る
  3. 全Circleを舐めて取得したいEventのリストを作る
  4. DatastoreからGetMultiでドン!
  5. Circleの各要素に取得したProductやEventを配る…本当にだるい…

これでRPCが2回になる。
速度面では改善されるがコードを書く手間と可読性は壊滅する。

つらポイント4を解決 :smiley_cat:

datastore.Client#Batch() を作りました

  1. Circleのリストを取得する
  2. 全Circleを舐めてProductを個別にGetする(遅延評価)
  3. 全Circleを舐めてEventを個別にGetする(遅延評価)
  4. *Batch#Exec() ドン!

終わり :clap: これもRPCは2回
実装例1 実装例2

つらポイント5 :scream_cat:

AppEngine DatastoreとCloud DatastoreのAPI違う…
Cloud Datastoreに統一するとappengineではSocket API経由になるので遅いし…

つらポイント5を解決 :smiley_cat:

go.mercari.io/datastore に共通interfaceを置いて、実装は go.mercari.op/datastore/aedatastorego.mercari.op/datastore/clouddatastore があります。
datastore.Client の生成方法は違いますが、ソレ以外のAPIは同一なので任意のタイミングで差し替え可能です。
AppEngineとGKEの移行も多少はマシに…?
AppEngineで他ProjectのDatastoreをCloud Datastoreで触る時の苦痛も改善。

設計上の判断

  • やること
    • DatastoreのAPIを便利でさいきょうにする
  • やらないこと
    • Datastoreのテストや環境を改善する
    • (でもCloud Datastore Emulatorでテスト流すと早い…)
  • AEDatastoreとCloudDatastoreのAPIが異なる
    • Cloud Datastore側のAPIに寄せる よりメンテされてて実装もマトモなため
    • なので KeyLoader とか datastore:",flatten" もあるよ
  • goon使ってるんだけど移行は…?
    • boom作りました
    • Memcacheを使ったりするレイヤは今後Datastoreラッパ側に実装予定
  • Goのバージョンは?
    • context パッケージを使ってるのでgo1.8が必要です
      • go1.6なら gofmt -w -r '"context" -> "golang.org/x/net/context"' . 的な対応で
  • semverでやります
    • depはsemver対応!

設計上のアドバイス

  • 自分のProject用の FromContext を自分で実装して、それ経由で datastore.Client を取得するようにしておく
    • 今後キャッシュ用オプションの提供は datastore.FromContext(ctx, options.WithAEMemcache()) 的APIにすると思うので全ての箇所にそういう変更後からいれるの大変なので
    • Cloud Datastoreの場合プロジェクト名を指定したりする必要などがあり、プロジェクトでそれを行う場所が散らばるのはまずい
  • Batchは個別に作るより1リクエストの間は使いまわしたほうがよい
    • *Batch.Exec() 中に追加の予約があった場合、それがなくなるまで再帰的にExecし続ける実装です

移行の注意点

  • Cloud Datastoreに準拠しているのでAEDatastoreとの違いに注意
    • Entityのfield要素にstructとかを使っている場合、 datastore:",flatten" が必要
    • TransactionでPutした時KeyではなくPendingKeyが返ってくる
      • CommitするまでKeyがわからない…!
      • KeyのIDが途中の処理で必要ならAllocateIDsに移行する必要あり
  • Cloud DatastoreのClientとAEDatastoreのClientの作り方に差がある
    • AEDatastoreはリクエスト間のClient共有はできない
    • Cloud DatastoreはClient作るたびに新規にgRPCの接続を作る…?
      • あまり詳しくは追ってないけど
    • 将来的にAEDatastore→Cloud Datastoreへの移行があるかもしれないなら初期化処理はちゃんとやったほうがよい

これからの話

  • キャッシュレイヤーを入れる
    • プラグイン形式
    • appengineのMemcacheを使うプラグイン
    • 同一EntityのGetが複数あった場合1つに圧縮するプラグイン
    • マシンローカルのキャッシュを使うプラグイン
    • QueryをKeysOnlyに変換してEntityは個別にGetするプラグイン
      • small op + Memcache = 安い!
    • その他好きなのあったら自分で書いて
  • 自動移行ツール作る…?
    • astutil.Apply しゅごい
    • RunInTransactionとかの書き換えが高難易度…
      • 1回あたりに書き換えるコードブロックの範囲が広い
        • = 見て書き換えないといけないASTのサイズがデカい
  • ↑がだいたいできたらv1.0.0にする

キャッシュレイヤの話

type CacheOperation int
const (
  InvalidCacheOperation CacheOperation = iota
  PutCacheOperation
  GetCacheOperation
  DeleteCacheOperation
)
type CacheFunc func(
    ctx context.Context,
    op CacheOperation,
    keys []datastore.Key,
    next CacheFunc
) ([]datastore.PropertyList, error)

自分で処理できなかったkeyをnextに渡して、nextから返ってきたpropertyListを次回の自分のためにキャッシュする。
渡されたkeysと同じ長さの[]datastore.PropertyListを、渡された時の順番を守って返せばOK。
という処理をPutとGetとDeleteで挟ませるようにして後は好きにしてくれでいいんじゃないの。
という気持ちです(実装時に変える可能性は高いです)。

要望とかバグ報告とか

なんだこの文章のクソな構造化は!

最初スライドモードで作ってたんだけど1スライドに収める調整がだるくて諦めたんだよゆるして!