GAE/Goのハマったところ(´・ω・`)

  • 156
    いいね
  • 2
    コメント

2016.12 追記:
本記事はGAEのGo SDKに特化した内容です。
GAE全般については↓に記事を書きました。
GAEでハマったこと(´・ω・`)

2014年は半年くらいGoogle App Engine(GAE)/Goのアプリを書く事に費やすことが出来て、とても幸せな一年でした(^^)

GAE/Goはご存知の通りずっとbeta(or experimental) (2015/7に正式リリースされました!\(^o^)/)ですが、個人的にはGAEランタイムの中で最強だと思っています。
何よりspin-upが早い!リクエストの処理もgoroutineでシンプルに高速化出来る!GAEアプリ書くならGoを選ばない理由はない!と思っています。

とは言え書籍や情報の少ないGAE/Goの開発、少なからずハマった箇所もありましたのでそれを技術不足露呈も覚悟の上で共有したいと思います。

あ、GAEに興味のない方には全く役に立たない記事です、悪しからずm(_ _)m

GOPATH設定でハマった(´・ω・`)

当初アプリケーションディレクトリが$GOPATH/srcになるように設定して開発していました。3rd partyのライブラリをgoapp getすると$GOPATH/src以下に配置されます。そして以下の様な問題にハマりました。

  • 不要なパッケージまで大量に取ってきてしまってビルドが遅くなる
  • syscallやunsafeなどGAEで使えないパッケージを使用しているコードまで取ってきてしまい、ビルドエラーになる

解決策

GOPATHをアプリケーションディレクトリとは別に設定しましょう。goapp getで取得したコードはそちらにインストールされます。ビルド時にはアプリケーションから参照されているパッケージのみリンクしてくれます。
ビルド・デプロイするだけならアプリケーションディレクトリをGOPATHに設定する必要はありません。何らかの都合で設定したい場合はコロン(unix系の場合。Windowsではセミコロン)で区切って複数設定できます。goapp getは先頭のGOPATHにインストールされるのでライブラリ用GOPATHを先頭に設定しましょう。

この問題については、下記にもう少し詳細書いてます↓
http://knightso.hateblo.jp/entry/2014/11/26/103637

ファイル更新時間でハマった(´・ω・`)

GAEはアプリケーションに含まれるファイルであればGo標準ライブラリで読み込むことが出来ます。
ただ、更新時間に罠があります。


    file, err := os.OpenFile("test.txt", os.O_RDONLY, 0666)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    defer file.Close()

    fi, err := file.Stat()
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    c.Infof("modtime=%v", fi.ModTime())

ローカルサーバーでの結果↓

modtime=2014-12-21 00:14:15 +0000 UTC

プロダクションサーバーでの結果↓

modtime=1970-01-01 00:00:00 +0000 UTC

プロダクションではUNIX timeの始点になりますね。

Goのhttpパッケージにhttp.ServeContentという関数がありIf-Modified-Since、Last-Modifiedヘッダの処理を適切に行って必要なら304(Not Modified)返してくれるのですが、それに1970/1/1のファイル更新時間を渡していた為クライアントでキャッシュがずっと更新されないという現象が置きてハマりました。

datastore.Keyをmapのキーに使おうとしてハマった(´・ω・`)

GAE/Javaのcom.google.appengine.api.datastore.Keyはhashcodeとequalsメソッド実装してたのでHashMapのキーとして使えました。そのノリでdatastore.Keyをmapのキーに使おうとするとハマります。

datastore.Keyは、datastore.NewKey関数がポインタを返しその他のデータストアアクセス関数がポインタを要求する為、基本的にポインタで持ち回されます。そのままポインタをmapのキーにしようとしてはいけません。Goのmapはポインタをキーにした場合、その参照アドレスでルックアップしますので、参照先のdatastore.Keyが同じ値を持っていてもヒットしません。

    key := datastore.NewKey(c, "Test", "", 1, nil)
    key2 := datastore.NewKey(c, "Test", "", 1, nil)

    pmap := make(map[*datastore.Key]bool)
    pmap[key] = true

    c.Debugf("pmap[key]=%t, pmap[key2]=%t", pmap[key], pmap[key2])

結果↓

pmap[key]=true, pmap[key2]=false

ここまではGAE/Go特有の問題ではなくGoの話ですが、さらに「じゃあポインタじゃなくてdatastore.Key実体を使えばキーに出来るのでは」と考えてまたハマります。試してみると期待通り動いてしまったりするので質が悪いです。

    vmap := make(map[datastore.Key]bool)
    vmap[*key] = true

    c.Debugf("vmap[key]=%t, vmap[key2]=%t", vmap[*key], vmap[*key2])

結果↓

vmap[key]=true, vmap[key2]=true

実はdatastore.Keyの構造体は以下の様になっています。

type Key struct {
    kind      string
    stringID  string
    intID     int64
    parent    *Key
    appID     string
    namespace string
}

parentキーがポインタになっている為parentキーがnilの場合は正しく動きますが、parentがnot nilの場合は期待通り動きません。

    parent := datastore.NewKey(c, "Parent", "", 1, nil)
    parent2 := datastore.NewKey(c, "Parent", "", 1, nil)
    key := datastore.NewKey(c, "Child", "", 1, parent)
    key2 := datastore.NewKey(c, "Child", "", 1, parent2)

    vmap := make(map[datastore.Key]bool)
    vmap[*key] = true
    c.Debugf("vmap[key]=%t, vmap[key2]=%t", vmap[*key], vmap[*key2])

結果↓

vmap[key]=true, vmap[key2]=false

回避策

Key.Encode関数でstringにしてmapのキーにしましょう。

    smap := make(map[string]bool)
    smap[key.Encode()] = true
    c.Debugf("smap[key]=%t, smap[key2]=%t", smap[key.Encode()], smap[key2.Encode()])

結果↓

smap[key]=true, smap[key2]=true

RunInTransactionを冪等にせずハマった(´・ω・`)

実はdatastore.RunInTransactionはcommitに失敗すると3回までリトライします。つまりコールバック関数は冪等(繰り返し実行されても結果が変わらないこと)にしておく必要があります。

悪い例↓

    if err := datastore.RunInTransaction(c, func(c appengine.Context) error {

        account.Balance += 1000

        if _, err := datastore.Put(c, key, &account); err != nil {
            return err
        }

        return nil
    }, nil); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

上記の様なコードを書くと、リトライが起きた時に残高が不正に増加してしまいます。

commit失敗は同じkeyに対する更新頻度が多いとよく起きる現象ですので、必ず冪等に書く様にしましょう。上記の例ならばaccountはトランザクションの中でGetしてから残高加算してPutすればOKです。

    if err := datastore.RunInTransaction(c, func(c appengine.Context) error {

        var account Account
        if err := datastore.Get(c, key, &account); err != nil {
            return err
        }

        account.Balance += 1000

        if _, err := datastore.Put(c, key, &account); err != nil {
            return err
        }

        return nil
    }, nil); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

Datastore - フィールド削除してハマった(´・ω・`)

Datastoreエンティティ用構造体から不要になったプロパティフィールドを削除すると、そのプロパティが保存されているエンティティを取得した際にエラーになります。

datastore: cannot load field "Fuga" into a "adcal.Hoge": no such struct field

このケースはGAE/Java&Slim3だとエラーにならないので、少しハマりました。

解決策

不要になってもフィールドは削除せず残しておくのが一番楽ですね。。
どうしても削除したければ予め保存済エンティティをコンバートするか、またはPropertyLoadSaverインターフェースを実装してプロパティのロードを自分で書く方法もあります(結構面倒ですが)。

PropertyLoadSaverについては↓
https://cloud.google.com/appengine/docs/go/datastore/reference#hdr-The_PropertyLoadSaver_Interface

ちなみに、フィールドの追加は問題ありません。プロパティがmissingのエンティティをGetするとフィールドには何も値が設定されません。

追記:
by vvakameさん
http://qiita.com/vvakame/items/e017e7d955f82ddd8af1
ErrFieldMismatchが返るのでそれを無視すればよい、とのこと\(^o^)/

datastore.NewKeyでハマった(´・ω・`)

データストアのキーは、任意のキーならdatastore.NewKeyで、自動採番でPutするならdatastore. NewIncompleteKeyで生成します。

100件固定IDでPutしたくて下記の様なコードを書きました。

    var i int64
    for i = 0; i < 100; i++ {
        key := datastore.NewKey(c, "Book", "", i, nil)
        _, err := datastore.Put(c, key, &book)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
    }

そしたら何故か1件だけ予期しないIDのエンティティが。

strangeid.png

ループインデックスを0から回してるのが原因です。
よく考えると当たり前な気もしますが、datastore.NewKeyも0渡すと自動採番になるのですね。
ちなみにNewIncompleteKeyの実装は以下の様になってます。

key.go
func NewIncompleteKey(c appengine.Context, kind string, parent *Key) *Key {
        return NewKey(c, kind, "", 0, parent)
}

time.TimeのLocationでハマった(´・ω・`)

まあ、実を言うとそんなにハマった訳ではないですが、ちょっと気になったので書いときます。

GAE/Go Datastoreはtime.Time型のプロパティを保存できます。time.TimeはLocationを保持していますが、DatastoreにはLocation情報は保存されません。
つまりJSTのtime.TimeをPutしてもGetするとUTCのtime.Timeを取得することになります。

    book := Book{
        "Perfect Go",
        "Some Gopher",
        1000,
        time.Now().In(time.FixedZone("Asia/Tokyo", 9*60*60)),
    }

    c.Infof("%v", book.CreatedAt)

    key := datastore.NewKey(c, "Book", "book1", 0, nil)
    key, err := datastore.Put(c, key, &book)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    book = Book{}
    err = datastore.Get(c, key, &book)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    c.Infof("%v", book.CreatedAt)

結果↓

2014-12-21 09:01:38.477454164 +0900 Asia/Tokyo
2014-12-21 00:01:38.477454 +0000 UTC

時刻自体がズレている訳ではないですのでそんなにハマるポイントでもないとは思いますが、「JSTで保存したのだから、そのままFormatしたらJSTで出力されるだろ」というコードを書くとUTCで出力されるので気をつけて下さい。

delay関数の生成タイミング間違えてハマった(´・ω・`)

GAE/Go特有の機能として、Task Queueのdelayパッケージというものがあります。
FYI:http://knightso.hateblo.jp/entry/2014/06/13/073355

これは、普通に書くと結構面倒なTask Queueの非同期処理を関数呼び出しの様に書けるというとても便利な機能なのですが、結構ハマりポイントがあります。

まずdelay関数の生成タイミング。例えばリクエストハンドラの中で生成して呼び出してはいけません。

    f := delay.Func("key", func(c appengine.Context, s string) {
        c.Infof("executed delay! %s", s)
    })

    f.Call(c, "hello!!")

これは送信側は期待通りに動作し、Task Queueにタスクが積まれます。受信側も条件によっては正しく動作しタスクを実行しますが、エラーになる場合もあります。

Task Queueは送信側と受信側のインスタンスが同じものとは限りません。受信側インスタンスはTask Queueからのリクエストを受けて初めてspin-upするかもしれません。その場合でもdelay関数は正しく初期化されている必要があります。

解決策

delay.Func関数での初期化はグローバル変数宣言かinit関数で行いましょう。

var delayF = delay.Func("key", func(c appengine.Context, s string) {
    c.Infof("executed delay! %s", s)
})
var delayF *delay.Function
func init() {
    delayF = delay.Func("key", func(c appengine.Context, s string) {
        c.Infof("executed delay! %s", s)
    })
}

delay関数使ったコードのファイル名変えてハマった(´・ω・`)

delayタスク受信側インスタンスは、初期化関数(delay.Func)で指定したキーと記述されているソースコードファイル名のペアで呼び出すdelay関数を探します。つまりキューにタスクが残った状態でファイル名を変更してデプロイするとタスク実行がエラーになります。

エラーメッセージ↓

delay: no func with key "adcal.go:key" found
delay: dropping task

(((( ;゚д゚)))しかもタスクが勝手にドロップされちゃった!
できればエラーのままリトライし続けてもらって、修正&デプロイしたら実行される方が望ましいのですが・・・

解決策

ファイル名変えなくてもいいように、最初からちゃんと設計しましょう:p
どうしても変えたい場合は一旦キューへのタスク追加を止めて、全てのタスクが空になってから変更・デプロイしましょう。
少しの間delay関数を重複して持つ期間を設けるのもよいかもしれません。古い関数に向いたタスクが捌けたことを確認するのは難しいですが。。

delay.Callがerrorを握りつぶしている!(´・ω・`)

更新: 最新のSDKでは修正されました! \(^o^)/
https://cloud.google.com/appengine/docs/go/taskqueue/delay#Function.Call

ハマり所というより愚痴ですが、残念な問題です。
delayパッケージのCallメソッドはerrorを握りつぶしています。

func (f *Function) Call(c appengine.Context, args ...interface{}) {
    t, err := f.Task(args...)
    if err != nil {
        c.Errorf("%v", err)
        return
    }
    if _, err := taskqueueAdder(c, t, queue); err != nil {
        c.Errorf("delay: taskqueue.Add failed: %v", err)
        return
    }
}

Task Queueとデータストア・トランザクションと併用して結果整合性を得るのが定石ですが、ここでエラーが出た場合整合性が保証されません。とても残念。

解決策

更新: Callがerrorを返す様に修正されてこの問題は解決されましたが、Task関数の使用例として、下記残しておまます(^^)

delay.Callではなくdelay.Task関数を使えば、少し手数が増えますがerrorが拾えます。
delay.Task関数は受信側のdelay関数を呼び出す為のtaskqueue.Taskを返します。それを通常のTaskと同様にQueueにAddすればOKです。

    // delay.Funcの呼び出しは同じ

    t, err := delayF.Task("hello!!")
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    if _, err := taskqueue.Add(c, t, "queue1"); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

defaultキュー以外を使用したい場合もこちらの手法を使う必要がありますね。

ちなみにGoogle Groupsでこの問題を指摘している人がいて、以下の様な答えをもらってました(^^;
「互換性と利便性考えてシグニチャの変更は出来ない。Managed VMsだったらappengineパッケージをforkして自分で修正して使えるよ!」
https://groups.google.com/forum/?hl=ja#!searchin/google-appengine-go/delay$20error/google-appengine-go/vbEBg5R_pY0/OXNX-4eO8msJ

appengine.Contextがないと何も出来ない(´・ω・`)

次は完全な愚痴ですが・・・
GAE/Goはほぼ全ての機能にappengine.Contextが必要となります。プロダクション環境でのログ出力も同様です(ローカルサーバーではGo標準のlogパッケージで出力可能)。
ログ出力はコードのあらゆる場所で書きたいと思うのですが、必要なところ全てにappengine.Contextを引数で受け渡していく必要があります。

解決策

私もベストプラクティスは見出せていないです(´・ω・`)
まあ、Go言語と特性とAppengineの仕様を考えると仕方ないのかな、と諦めてます。
とりあえずグローバル関数でログをheapに保存し、各リクエストの最後にTask Queue経由でBigQueryへ飛ばす仕組みを作って使ってますが、ログをリクエストで集約出来ないのがイマイチです。
だれかいい案あったら教えて下さい

Search API - SearchOptionにOffsetがない(´・ω・`)

GAE/GoのSearch APIは長い間Offset、Limit、Sortの機能がなくかなり不便な状態でした。でも今年LimitとSortOptionが順次追加されて、かなり使える状態になりました\(^o^)/
あとはOffsetだけ。。

解決策

待ちましょう^^;

Sort対象フィールドがユニークなら、それをクエリ文字列で条件指定することでカーソル的に回せる・・・かな?(未検証です。スミマセン)
Offset的に使いたいのならとりあえずIteratorを最初から回してお茶を濁しましょう。。

追記(2015/08/14)
MVMs用パッケージにCursorとOffsetが追加されていました!
https://godoc.org/google.golang.org/appengine/search#SearchOptions
近いうちにclassicパッケージにも追加されるだろう、とのこと。

つづく

バッドノウハウとグチばかり書いてしまいGAE/Goに不満たっぷりなカンジになってしまいましたが、それでもやっぱりGAE/Goは最強!と思っています(^^)

今後も使い続けて、またハマったらこの記事に追記していきます!

この投稿は Go Advent Calendar 201422日目の記事です。