GoとテストとSDKとGCP

SREチーム(新卒)の市川恭佑です。これはKAYAC Advent Calendar 2022の9日目の記事です。

今年の弊社アドベントカレンダーは、筋肉やランニング、さらにはサウナなど、多様性に富んだ面白いエントリが出揃っています。 自分も好きなファッションについて書きたくなってしまったのですが、ここはグッと気持ちを抑えて、仕事で触った技術について書きます。

※ この記事のタイトルは、酒とゲームとインフラとGCPというイベントのオマージュです。

仕事の近況報告

まず、最近どんな仕事をしているのかについて報告させてください。恐らく誰も興味がないと思いますが、年末のアドベントカレンダー企画ということもあるので......

Amazon Web Services(AWS)を用いた自社サービス

今年の4月に新卒入社してから、技術ブログを2本執筆しました。下記がそのリンクです。

techblog.kayac.com

techblog.kayac.com

どちらの記事もTonamelという自社サービス(Web)に関して、Site Reliability Engineer (SRE)としての取り組みの紹介です。 実は短期間サーバーエンジニアとして関わっていた時期もあったのですが、どちらにせよ9月まではTonamelチームで働いていました。

Tonamelは、ほとんど全ての他のカヤックの自社サービスと同様に、サーバーの実行基盤としてAWSを採用しています。 筆者自身もAWSの経験は個人開発を含めて入社時で3年以上あったので、基本的な概念理解や開発テクニックで詰まることなく仕事ができました。

Google Cloudを用いた受託開発案件

今年の10月からは面白法人グループカヤックアキバスタジオで受託開発の案件に参加しています。

SREというポジションは変わらないのですが、以下の点でTonamelと大きく異なりました。

  • サーバーの実行基盤としてGoogle Cloudを採用している
  • 事業領域がWebサービスではなくモバイルアプリのゲーム
  • 事業形態が自社サービスではなく受託開発

これらの変化のうち、現時点までの業務に対する影響は、先頭に挙げた「クラウドの違い」が最も顕著でした。

そこで今回は、Google Cloudを用いた開発において筆者が感じた技術的なギャップについて紹介します。

google-cloud-goとの対峙

カヤックの最近のプロジェクトでは、プログラミング言語としてGoを採用することが多いです。 実際に、自分が入社以降関わった全てのプロジェクトではサーバーでGoが使われていました。

Goのプロジェクトでパブリッククラウドなどの外部APIにアクセスする場合、Go製のSDKが用意されていれば、原則それを採用します。

AWSにおいてはAWS SDK for Go(aws-sdk-go-v2)、Google Cloudの場合はGoogle Cloud Client Libraries for Go(google-cloud-go)がこれに相当します。

そして筆者は、google-cloud-goおよび周辺のプログラミングにおいて、AWSとのギャップを最も顕著に受け取りました。

なぜgoogle-cloud-goが壁になったか

もちろん、概念理解や設計のアンチパターンなど、google-cloud-go以外の壁も確かに存在しました。 しかし、Google Cloudの経験があるメンバーの手助けや、過去に行われた議論の履歴のおかげで、業務計画における不確実性という意味では大きな障害になりませんでした。

自分で「できる!」と思って着手したタスクについて、後から問題点が発覚すること(=不確実性)は本当に厄介ですが、完全に無くすことは不可能です。 しかし、それを効果的に抑えて業務計画の見通しを確保することはエンジニアにとって重要な責務です。

だからこそ、AWSに慣れ親しんだ開発者にとってのGoogle Cloud入門は、google-cloud-goとの向き合いと言っても過言ではないです。 くれぐれも筆者のように「GoのSDKなら作業はだいたい同じでしょ?」という姿勢で見積もらないように気をつけてください。

以下は、それを説明するための一部の例です。

BigQueryの構造体マッピングの挙動を確信しづらい

これについてはPR TIMESさんとの合同技術勉強会でも取り上げました。

AWSのData Warehouse(DWH)サービスであるRedshiftにGoのプログラムから接続する場合は、通常のPostgreSQLサーバーと同じような手法を取るのが一般的です。 具体的には、 postgres:// から始まるエンドポイントに対してSQLドライバを用いてクエリを発行します。つまり、aws-sdk-go-v2に出番はないので、構造体へのマッピングもSDKの責務を外れます。

これに対して、Google CloudのBigQueryでは公式サンプルなどに紹介されているとおり、SDKであるgoogle-cloud-goを使ってクエリを発行するのが一般的です。Amazon RedshiftのようにSQLドライバを用いて接続している事例は筆者が探した範囲では見当たりませんでした。

また、google-cloud-goを用いたクエリでは、構造体へのマッピングはイテレータのNextメソッドが引き受けます。1 以下はドキュメント(Godoc)のサンプルを一部改変したものです。

q := client.Query(`
    SELECT year, SUM(number) as num
    FROM ` + "`bigquery-public-data.usa_names.usa_1910_2013`" + `
    WHERE ...
`)
it, err := q.Read(ctx)
if err != nil {
    // TODO: Handle error.
}
// マッピングする型
type Count struct {
    Year int
    Num  int
}
for {
    var c Count
    err := it.Next(&c) // ここでマッピングされる
    if err == iterator.Done {
        break
    }
    if err != nil {
        // TODO: Handle error.
    }
    fmt.Println(c)
}

同じくドキュメントには、構造体にbigqueryタグを適用して構造体マッピングの動作を変更できるという旨の記述がありますが、以下の引用文およびサンプルコードの限りです。

Struct inference supports tags like those of the encoding/json package, so you can change names, ignore fields, or mark a field as nullable (non-required). Fields declared as one of the Null types (NullInt64, NullFloat64, NullString, NullBool, NullTimestamp, NullDate, NullTime, NullDateTime, and NullGeography) are automatically inferred as nullable, so the "nullable" tag is only needed for []byte, *big.Rat and pointer-to-struct fields.

type student2 struct {
    Name     string `bigquery:"full_name"`
    Grades   []int
    Secret   string `bigquery:"-"`
    Optional []byte `bigquery:",nullable"`
}
schema3, err := bigquery.InferSchema(student2{})
if err != nil {
    // TODO: Handle error.
}
// schema3 has required fields "full_name" and "Grade", and nullable BYTES field "Optional".

これに対して、BigQueryはネストされた列をサポートしています。ドキュメントでは「パフォーマンスの向上するために、リレーションの代わりに使うと良い」と言った記述があります。

これを使うのが一般的であるかどうかには議論の余地がありますが、実際に(マネージドサービス間の統合で)Google Cloud側が自動で生成したBigQueryスキーマには以下のような部分がありました。

ネストされた列を含むスキーマの例
ネストされた列を含むスキーマの例

この例では、resource.typeなどが「ネストされた列」に該当しており、これらに対しても同様にクエリを実行できるのはドキュメントにあるとおりです。

しかし、ネストされた列のクエリの結果をGoの構造体にマッピングする方法に関しては記述がありません。 マッピングを受け持つNextメソッドのソースコードを読んでみても「マッピングのロジックだけ分離してテストする」といったことも出来そうにありません。

このような事情から、ネストされた列を含むスキーマを利用している場合、BigQueryにクエリを吐いてデータを取得するようなコードを書いているときに、ロジックの正しさを確信できません。自動化テストをするには特別な工夫やコストが必要です。

Cloud SpannerやDatastoreなど、他のデータベース系のサービスには公式エミュレータがありましたが、BigQueryについては見当たりませんでした。 こうした想定外の落とし穴により、筆者が担当していたBigQueryに関するタスクが見積もりから大きく延びてしまうことがありました。

開発中の品質保証については「ローカルから本物のBigQueryに接続する」などの方策についても検討しましたが、紆余曲折を経てgoccy/bigquery-emulatorという非公式のエミュレータを導入してインテグレーションテストを導入することで解決しました。2

完全に蛇足ですがbigquery-emulatorを作った方の解説スライドが大変面白いです。

speakerdeck.com

イテレータを返すメソッドのモックが煩雑

これについては、以下の記事に具体的な説明がありますが、それなりに分量がある上に英語で書かれているので筆者の言葉で要約します。

www.jackpines.info

まず、あるサービスに、Fooというリソースを列挙するためのAPIがあったとします。

google-cloud-goの慣例では、これに対応するメソッドの定義は以下のようになります。

package fooservice

type Client struct { ... }

func (c *Client) ListFoos(ctx context.Context, req *fooservicepb.ListFoosRequest, opts ...gax.CallOption) *FooIterator {
    // ここにライブラリの実装がある
}

type FooIterator struct { ... }

func (it *FooIterator) Next() (*fooservicepb.Foo, error) {
    // ここにライブラリの実装がある
}

これに対して、テスト等でDependency Injectionが必要になり、以下のようなインタフェースを定義したとします。

package mypkg

type ListFoosAPI interface {
    io.Closer // 忘れないでね
    ListFoos(ctx context.Context, req *fooservicerpb.ListFoosRequest, opts ...gax.CallOption) FooIterator
}

type FooIterator interface {
    Next() (*fooservicerpb.Foo, error)
}

しかし、これでは不十分で、たとえば以下のコードは型エラーでビルドできません。

package mypkg

func init() {
    client, err := foopb.NewClient(context.Background())
    if err != nil {
        // TODO: Handle error.
    }
    var _ ListFoosAPI = client
}

おそらく受け取るエラーは以下のようなものです。

Cannot use 'client' (type Client) as the type ListFoosAPI Type does not implement 'ListFoosAPI' need the method: ListFoos(ctx context.Context, req fooservicerpb.ListFoosRequest, opts ...gax.CallOption) FooIterator have the method: ListFoos(ctx context.Context, req fooservicerpb.ListFoosRequest, opts ...gax.CallOption) FooIterator

これが意味するところは「ListFoos返り値の型が(mypkgから見て)、代入する側は*fooservice.FooIteratorなのに、代入される側はFooIteratorだからダメだよ」ということです。もちろん*fooservice.FooIteratorFooIteratorに代入可能なのですが、Goの言語仕様においてはメソッドのシグニチャが完全に一致していないとインタフェースには代入できないのです。

TypeScriptなどの言語では、関数型やメソッドにも構造的部分型が適用されます3が、Goではシグニチャの完全一致が必要です。

そのため、結局以下のようなラッパーを用意してあげる必要性が生じます。

package mypkg

type FooWrapper struct {
    client *fooservice.Client
}

func (f *FooWrapper) ListFoos(ctx context.Context, req *fooservicepb.ListFoosRequest, opts ...gax.CallOption) FooIterator {
    // ↓が返す *fooservice.FooIterator は↑が要求する FooIterator に代入可能だから問題は発生しない
    return f.client.ListFoos(ctx, req, opts...)
}

これでようやく、以下のような代入が可能になります。

package mypkg

func init() {
    client, err := foopb.NewClient(context.Background())
    if err != nil {
        // TODO: Handle error.
    }
    // ラップしてあげる
    var _ ListFoosAPI = &FooWrapper{client: client}
}

なお、最初に貼った出典の英語ブログでは、Goの「利用者がインタフェースを用意する」という理念に対して一石を投じておりますが、筆者はそれには同調しかねます。なぜなら、Go 1.18で導入されたジェネリクスを活用することで無視できる程度に緩和可能な手間であるからです。4

とはいえ「aws-sdk-go-v2と似たようなものだ」と思って取り掛かると、やはりこれは想定外の手数が必要になるので、特に最初は注意が必要です。

これからGoogle Cloudに入門する人はどうするべきか

結局のところ、AWSに慣れている開発者がGoogle Cloudに入門する際はどうしたら良いのでしょうか。 多少なりとも役に立つ注意点を前章で列挙しましたが、もちろん網羅的なものではありません。

今回、焦点を当てた問題は不確実性ですが、その源流は本当にSDK(google-cloud-go)にあるのでしょうか。 もう少しだけ一緒に考えてみましょう。

最初にBigQueryの構造体マッピングの挙動について取り上げましたが、結局のところ本物を動かして確認することが一番の近道です。 bigquery-emulatorは大変クオリティの高いエミュレータなので、開発上の大きな助けになります。 しかし、公式・非公式問わずクラウドリソースのエミュレータの再現性には限界があります。これはAWSなり他のクラウドに慣れている方であれば心当たりがあるはずです。 だからこそ「とりあえずエミュレータを用いて開発して、テストを書いて、最後に本物のリソースを動かして動作確認をする」という順番で進めてしまうと、最後の最後に問題が発生することを防げません。5 エミュレータに頼り切るのではなく、あくまでも開発の補助ツールとして使うことを忘れないでください。

次に、テストのためのモック記述に追加の手間が掛かることを挙げましたが、そもそもモックを書くためにはAPIの挙動を確信している必要があります。 たとえば、冪等にFooというリソースを削除できるメソッドを作りたいとします。 もしFooBarというリソースを親子関係の"子"として持っているとき、Barが残っている状態でFooを削除するAPIを叩くと、どのようなレスポンスが返ってきますか? また、Fooが既に削除されていた場合は、どのようなレスポンスが返ってきますか? あなたが作っているメソッドは、これらのレスポンスを適切に処理するように書かれているはずですし、その処理こそがテストの関心になるはずです。 このとき、あなたが定義するモックが実際のAPIの挙動に即していない場合、それを使用したテストは何のためにあるのでしょう?

要するに、APIを実際に動かしてみることが不確実性を効率的に削るための必要条件だと分かります。 身も蓋もないことですが、不確実性の源流は「SDKに慣れていないこと」である以上に「APIの挙動を確信していないこと」だったのです。

なので、結論としては自由に動かせる環境を確保するのが一番です。(いや、マジで)6

会社によって、またチームによってGoogle Cloudの扱いは様々ですが、ベストプラクティスは以下の2点です。

  • Sandboxと見做せる、まっさらなProjectを用意する
  • 開発環境Projectに検証・E2Eテスト用の名前空間を用意する

前者はもちろん、後者も積極的に検討してください。

もちろん本番環境Projectが分離されていないようなことがあれば真っ先にそれに取り組むべきですし、そのような環境内に追加の名前空間を設けることは混乱を呼びます。しかし、開発環境が本番環境とProjectレベルで分離されているのであれば、名前空間を多少汚染する程度のことを恐れる必要はありません。代わりに、ドキュメントを書けば良いのです。

それに対して、まっさらなSandbox環境を用意せずとも、開発環境を利用するメリットは複数あります。

開発環境Projectを利用する理由1: スピード

新しいGoogle Cloud Projectの発行には稟議が必要で時間が掛かることもあります。

また、予算の都合上、Sandbox環境のアカウントの請求は特定のプロダクトと紐づかないこともあるかもしれません。このような事情は会社によって様々です。 場合によっては開発者が「時間課金リソースを常駐させる」とか「月額課金リソースの一時的作成」といったことに必要以上に神経を尖らせることがあるかもしれません。

もっとも、セキュリティや秘密保持契約との兼ね合いで悩むことがあるかもしれません。 いずれにしても、Sandbox環境を作ることによって悩みや作業が露骨に大きくなるのであれば、それに期待しないことも手です。

我々は、限られた時間の中で目的を達成するためにエンジニアリングをしています。 最終的に行き着くところを「バランス」とするのは心細いですが、開発環境Projectの利用を候補に入れる良い説明にはなっているでしょう。

開発環境Projectを利用する理由2: 統合と結合

Google Cloudの強みはマネージドサービス間の統合と言われています。 たとえば、Cloud LoggingではSinkというリソースを使うことで、BigQueryや他のストレージサービス等にログを自動でルーティングできます。

このような便利な機能を使わない手はありませんが、高度に統合されているということは、本質的に密結合であることを意味します。

上記の例では、Logging由来で発生したBigQueryのレコードに関する挙動を調べたいと思ったとき、Sandbox環境に作るのはBigQueryデータセットだけでは足りません。Loggingに流れるログの形式でさえ、ログが発生するサービスによって変わります。

「郷に入っては郷に従え」と言いますが、Google Cloudの流儀に従ってマネージドサービスの統合を尊重するのは良い選択です。 その場合、アプリケーションの一部のコンポーネントだけを全く別のProjectに切り出して検証するコストは容易に膨らみます。

これも、開発環境Projectの利用を候補に入れる大きな理由と言えるでしょう。

さいごに

筆者は適当な性格なので「これで自分も新卒ながらクラウド二刀流エンジニアになってしまいました笑」とか調子の良いことを言ってみたいものです。

とはいえ現実には、本エントリからも察していただけるとおり、クラウドの違いに面喰らって失敗して、落ち込んだりする日も少なくありません。 会社の帰り道、一人で「くっそ〜〜〜!!」と呟きながら歩いているのを見つけたら、そっとしておいてください。

しかし、本当に大切なことは「習得した技術を増やすこと」それ自体ではなく、自分なりに「商業利用する上での技術習得の骨法を見つけること」だと考えています。 だからこそ失敗する余地を与えられていることに感謝しながら、その間に血となり肉となるような挑戦と考察を繰り返したいです。

年末のせいか、感傷的な文章を書いてしまいました。年明け以降に振り返ったら絶対に恥ずかしいやつですね。

それでは、良いホリデーシーズンを👋

カヤックではソフトウェア開発と"hardcore"に向き合いたいエンジニアも募集しています

hubspot.kayac.com


  1. ドキュメントにあるとおり、構造体以外の型にもマッピングできます。
  2. PR TIMESさんとの勉強会開催時にはエミュレータが上手く動作していませんでしたが、後日アップデートに追従してバージョンを上げたら直りました。メンテナさん、ありがとうございます。
  3. @uhyo_ さんの『プロを目指す人のためのTypeScript入門』という本が詳しいです。
  4. 本エントリ下書き字には「App Engineなどランタイムの制約がある場合を除いて」といった前置きを書いていたのですが、なんと投稿日の日本時間午前0時にGo 1.19のサポート(preview)が発表されました。
  5. 恥ずかしながら、筆者はこれもやりました。反省しています。
  6. これを言うこと自体は簡単ですが、効果的な実践のためには、このエントリに書いてあること以上の探求が必要だと考えています。