Google App EngineでGoのバージョンアップを行う #golang #gae

はじめに

メルペイ エキスパートチームの@tenntennです。
この記事は技術書典4で頒布した"こうして僕らは、書籍を売るアプリを作った 2.0.1"の一部を少し改定したものです。

Google App Engine for Go (GAE/Go)で開発する上で付き合っていかないといけない問題として、GAEのSDKのバージョンやそこで使用されるGo自体のバージョンアップが挙げられます。
GAE/Goはクラウドサービスであるため、SDKや言語のバージョンアップは必ず追従して行く必要のある問題です。
バージョンアップしなくても、一定期間はそのまま使えることが多いでしょう。
しかし、場合によっては非推奨になり、その後使えなくなる可能性もあり得ます。
そのため、特に大きな問題がない場合は積極的にバージョンアップをしていくとよいでしょう。

Goのバージョンを上げることによって、次のような恩恵を受けることが期待できます。

  • 使える機能やパッケージが増える
  • ランタイムの改善によりパフォーマンスが上がる
  • ツール群の改善により開発効率が上がる

加えて、Goはバージョン1の間は言語による後方互換性が担保されているため、言語自体の後方互換性は心配する必要はありません。

一方で、言語以外の部分で次のような問題が生じる可能性があります。

  • 使用しているライブラリの互換性
  • golang.org/x以下で管理されている準標準パッケージが標準パッケージに昇格した場合の扱い

この記事では、このようなGAE/Goを用いた開発の中における、Goのバージョンアップにまつわる話をまとめてみようと思います。

Google App EngineのGoのバージョンの怖い話

Google App EngineのGoのバージョン指定

Goのバージョンは現在半年に一度のサイクルでアップデートされています。
Google App EngineもGo1.6まではそのバージョンアップサイクルに多少おくれながらも追いついてきていました。
しかし、Go1.7で標準パッケージに導入されたコンテキストの互換性の問題により、Go1.7以降はなかなかGoogle App EngineにはGoのバージョンアップがやってきませんでした。

Go1.8がリリースされ、Google Cloud PlatformのユーザコミュニティではGoogle App EngineのGo1.8の追従が期待されていました。そしてついに、Go1.8にアップデートされることが決定され、無事リリースに至りました。

Go1.8対応がリリースされた際にGoogle App Engineのアプリケーションの設定を行うapp.yamlファイルの設定方法にもアップデートがありました。

それまでのapp.yamlファイルでは、以下のようにapi_versionには単にgo1とだけ書いていました。
これはGo1の間は後方互換が保たれているので、マイナーバージョンの指定は不要という理由からだと推測できます。

api_version: go1

一方、新しい指定の方法では、以下のように、マイナーバージョンまで指定することができるようになりました。

api_version: go1.8

これにより、Goのバージョンを指定してランタイムを切り替えることが可能になりました。

バージョンを詳細に指定すべき理由

新しいGoogle App EngineのSDKからは、app.yamlファイルのapi_versionにマイナーバージョンを指定することが可能になりました。
しかし、後方互換のためにマイナーバージョンを指定しなくてもapi_version: go1のように指定してもGoogle App EngineのSDKで定められたデフォルトのバージョンが使われるようになっています。

この変更がリリースされた当初では、マイナーバージョンを指定しない場合には、Go1.6としてデプロイされていました。しかし、その後のバージョンアップでデフォルトがGo1.8に変更になったのです。

そのため、プロダクトを開発する上でマイナーバージョンを指定せずにデプロイしていると、ある日突然意図しないマイナーバージョンでアプリケーションが動作することが考えられます。

なぜGoのランタイムのバージョンが突然変わると問題なのでしょうか。
バージョンとbuild constraintsにまつわる、現実に起きうる話をしていきましょう。

バージョンとbuild constraintsにまつわる怖い話

build constraintsとは、ビルドタグとして知られるOSやアーキテクチャ、Goのバージョンによってビルド対象のファイルを切り替えるためのしくみです。
例えば、ファイルの先頭に以下のようにバージョンを指定して書くと、そのバージョン以降のGoのコンパイラでしかビルド対象になりません。つまり以下の例では、Go1.7以上でしかビルドされません。

// +build go1.7

build constraintsは、非常に便利で多くのライブラリなどで活用されています。
しかし、app.yamlファイルでマイナーバージョンを指定していない場合に非常に厄介な問題に直面します。

api_versiongo1を指定していた場合、デフォルトのバージョンのGoのコンパイラでビルドされてしまいます。

そして、使用しているライブラリ内でbuild constraintsでビルドをGoのバージョンによって分けている場合に問題が生じてしまう場合があります。

例えば、以下の2つのコードについて考えてみます。
上のコードでは、build constraintsによってGo1.6以下のコンパイラでしかビルドできないようになっています。一方で、下のコードではGo1.7以上でコンパイルされるように指定されています。
どちらのファイルにもHello関数が定義されており、Goのバージョンによって挙動が異なっています。
これまでGo1.6を使ってビルドされていた場合に、ある日突然Go1.8でデプロイされたとすると、それ以降Hello関数がpanicを起こし始めます。
ビルド時には特にコンパイルエラーになる訳ではないため、デプロイして実際に動かすまで異常に気がつくことはできません。

// +build !go1.7

package hello

func Hello() string {
    return "Hello"
}
// +build go1.7

package hello

func Hello() string {
    panic("error")
    return ""
}

ここで見た例は非常に極端ですが、似たような問題が生じることは十分に考えられます。
実際に、一部のWebフレームワークでコンテキストの取り回しをGoのバージョンによって分けている事例がありました。
安全にライブラリを導入するには、導入の際にbuild constraintsをしているか調べ、現在使用しているバージョン以外でどうのような挙動をするかを十分に検証することです。
しかし、現実的には最低限ランタイムのバージョンを上げる際に検証することをオススメします。
またそれ以前に、api_versionの指定はgo1.8のように、マイナーバージョンも含めてバージョン指定を行うようにするとよいでしょう。

コンテキストの歴史にまつわるインタフェースの互換性と型エイリアス

コンテキストの遍歴とGoのバージョンアップ

Google App Engine上のアプリケーションからGoogle Cloud Platformが提供する機能を用いるには、コンテキストというオブジェクトが必要になります。
コンテキストは多くの場合に関数やメソッドの第1引数に取ります。
例えば、以下コードはCloud Datastoreからデータを取得するGet関数を呼び出しています。そして、第1引数のctxはコンテキストです。

var data Data
if err := datastore.Get(ctx, key, &data); err != nil {
    // エラー処理
}

このようにGoogle App Engineでは、コンテキストを多用するのですが、歴史的経緯から今まで次の3種類のコンテキストが存在しました。

  • appengine.Context
  • golang.org/x/net/context.Context
  • context.Context

appengine.Contextは最も古いコンテキストです。
コンテキストという概念がGoの標準に入る前から存在しています。

golang.org/x/net/context.ContextはGo1.6まで利用されていた準標準パッケージとして提供されていたコンテキストです。Google App EngineのランタイムがGo1.8へバージョンアップされる段階でcontext.Contextが利用されるようになりました。

context.ContextはGo1.7以降に標準パッケージに導入されたコンテキストで、中身はGo1.6以前のgolang.org/x/net/context.Contextと同じものです。

このため、ランタイムがGo1.6からGo1.8に変わる際に、コンテキストの互換性について議論がされるようになりました。そして、Go1.9へアップデートされる際に型エイリアスの登場によって、さらに状況が変わってきました。ここでは、Go1.6からGo1.8、そしてGo1.9にアップデートされていく中で、コンテキストの互換性がどのように変化していったのかという点について説明していきます。そして、その中でインタフェースの互換性と型エイリアスの存在意義について議論していきます。

Goのインタフェース

Goのインタフェースについて詳しい解説は"インタフェースの実装パターン"という記事に書いていますのでそちらを参照して下さい。ここでは簡単にインタフェースの解説を行います。

インタフェースは、メソッドのリストです。ある型がインタフェースを実装するということは、その型がインタフェースで定義しているメソッドをすべて持っているという状態を指します。

例えば、以下ようなインタフェースを考えてみます。

type InterfaceA interface {
  M() string
}

InterfaceAM() stringというメソッドを持ちます。
つまり、このインタフェースを実装するには、以下のようにメソッドMを持つ型を定義してやれば良いでしょう。

type Str string

func (s Str) M() string {
  return string(s)
}

どのような型でも構いませんが、とにかくメソッドMを同じシグニチャで定義していれば問題ありません。
このとき、Str型の定義には、implementsのように明示的にInterfaceAを実装することを表す記述は必要ありません。単にメソッドを用意するだけで、インタフェースを実装したことになります。

さて、このGoのインタフェースの特徴を踏まえてインタフェースと型エイリアスの議論を進めていきましょう。

インタフェース同士の互換

以下のような、同じメソッドを持つ2つのインタフェースを考えてみましょう。

type InterfaceA interface {
  M() string
}

type InterfaceB interface {
  M() string
}

InterfaceAInterfaceBは同じシグニチャのメソッドを持ちます。
そのため、InterfaceAを実装していれば、自ずとInterfaceBも実装していることになります。

つまり、以下のよう明示的に型変換を行わずに、それぞれの型の変数を代入しあうことができます。

type InterfaceA interface {
  M() string
}

type InterfaceB interface {
  M() string
}

var a InterfaceA
var b InterfaceB

a, b = b, a

ここで最初に出てきたコンテキストの話題に戻りましょう。
golang.org/x/net/context.Context型はGo1.8までは以下のように定義されています。

type Context interface {
  Deadline() (deadline time.Time, ok bool)
  Done() <-chan struct{}
  Err() error
  Value(key interface{}) interface{}
}

一方、Go1.7から提供されている標準パッケージのcontext.Context型は以下のように定義されています。

type Context interface {
  Deadline() (deadline time.Time, ok bool)
  Done() <-chan struct{}
  Err() error
  Value(key interface{}) interface{}
}

各インタフェースのメソッドを見ると、すべてのメソッドのシグニチャが一致していることが分かります。
つまり、golang.org/x/net/context.Contextを実装する型は標準パッケージのcontext.Contextも実装していることになります。
そのため、引数でgolang.org/x/net/context.Context型として渡されてきた値は、標準パッケージのcontext.Contextxを引数に取る関数に、特になにもせずそのまま渡すことができます。

このようなGoのインタフェースの特徴からGoogle App Engineにおいても、Go1.6からGo1.8に移行する際に、大きな問題が起きずにimportgolang.org/x/net/contextからcontextに変更するだけで大部分は標準パッケージのcontext.Contextに移行できました。

しかし、いくつか移行の際に問題が生じる場合がありました。

例えば、以下のようなインタフェースを引数として取るような関数を考えてみます。

type InterfaceA interface {
  M() string
}

func callM(a InterfaceA) {
  a.M()
}

関数callMは引数としてInterfaceA型の値を取ります。
さて、ここで以下のように関数callMを変数にいれることを考えてみましょう。

var f func(InterfaceA) = callM

次に、以下のようにInterfaceB型の値を引数として取る関数を考えてみましょう。

type InterfaceB interface {
  M() string
}

func callM2(b InterfaceB) {
  b.M()
}

さて、関数callM2は以下のようにfunc(InterfaceA)型の変数fに代入できるでしょうか?

var f func(InterfaceA) = callM2

答えはできません。
変数fの型はfunc(InterfaceA)です。
引数の型がInterfaceAである点がcallM2と異なっています。
たとえInterfaceAInterfaceBが持つメソッドリストが同じであっても、型としては別ものであるため、代入ができません。

そのため、golang.org/x/net/context.Contextを引数に取る関数とcontext.Contextを引数にとる関数は別の型という扱いになってしまいます。
例えば、Cloud Datastoreを扱うためのパッケージであるgoogle.golang.org/appengine/datastoreパッケージのRunInTransactionでは引数にfunc(context.Context) error型の関数を取ります。

この関数の第1引数のコンテキストは、執筆時は標準パッケージのcontext.Contextではなく、golang.org/x/net/context.Contextです。

例えば、以下のようなコードを書いていたとします。
コンテキストは、Go1.6まで使用されていた準標準パッケージのgolang.org/x/net/context.Contextを用いています。

import "golang.org/x/net/context"
// ...略

func doSomething(ctx context.Context) {
  // ...略
  err := datastore.RunInTransaction(ctx, func(ctx context.Context) error {
    // 何か処理
  }, nil)
  // ...略
}

標準パッケージのcontext.Contextを用いるように修正することを考えます。
importの部分を以下のように書き換えればようさそうです。

import "context"
// ...略

func doSomething(ctx context.Context) {
  // ...略
  err := datastore.RunInTransaction(ctx, func(ctx context.Context) error {
    // 何か処理
  }, nil)
  // ...略
}

しかし、このままだとコンパイルエラーが発生してしまいます。
datastore.RunInTransaction関数の第1引数のコンテキストの型はgolang.org/x/net/context.Context型なので、標準パッケージのcontext.Context型の値を渡してもコンパイルエラーにはなりません。
一方で、第2引数に取る関数の型はfunc(context.Context) errorです。
この関数が引数に取っているコンテキストは、golang.org/x/net/context.Context型です。
そのため、標準パッケージのcontext.Contextを引数に取る関数を渡してしまうと型が異なるため、コンパイルエラーになってしまいます。

コンパイルエラーを避けるためには、以下のように関数をラップして型を変換する必要があります。

import "context"
import netctx "golang.org/x/net/context"
// ...略

func wrap(f func(ctx context.Context) error) func(netctx.Context) error {
  return func(nctx netctx.Context) error {
    return f(ctx)
  }
}

func doSomething(ctx context.Context) {
  // ...略
  err := datastore.RunInTransaction(ctx, wrap(func(ctx context.Context) error {
    // 何か処理
  }), nil)
  // ...略
}

このように、同じメソッドリストを持つインタフェース同士は、一見、互換性があるように見えます。
しかし、実際には型が異なるため完全な互換性というものはありません。
そのため、Go1.8までは準標準パッケージのgolang.org/x/net/context.Context型から標準パッケージのcontext.Context型に移行するためには一筋縄ではいきませんでした。
このようなリファクタリングの問題を解決するために、Go1.9では型エイリアスという機能が導入されました。
次に型エイリアスとそれがGoogle App Engineにおいて、どのような恩恵を与えるかを解説します。

型エイリアスとGoogle App Engineにおける恩恵

型エイリアスはGo1.9で導入された概念です。
以下のような定義があった場合、Bar型はFoo型の型エイリアスになります。

type Foo struct {
  S string
}

type Bar = Foo

Bar型は、Foo型を基にした新しい型ではなく、完全に同じ型です。
型エイリアスで定義した型には、新しくメソッドを追加することはできません。

例えば、以下のように、int型を基にした型とint型のエイリアス型の名前を表示してみましょう。
なお、型名の表示するためには、fmt.Printf関数で書式を%Tを指定しています。

type N int
type M = int
var n N = 10
var m M = 10
// main.N, int
fmt.Printf("%T, %T\n", n, m)

このコードを実行すると、コメントで書いているように、main.N, intと表示されます。
変数nの型名は、main.N、変数mintとなります。
変数nの型は、int型を基にして新しい型として定義したN型であるため、その型名が表示されています。
一方でM型は、int型のエイリアス型であるため、intと表示されています。

このように、型エイリアスは既存の型に新しく名前をつけ、同じ型として振る舞ってほしい場合に用います。
さて、この型エイリアスは実際の開発の現場で、どのように使うものなのでしょうか?
GoチームのRuss Cox氏の書いた"Codebase Refactoring (with help from Go)"という記事によると、型エイリアスはリファクタリングのために開発されたと述べられています。

ここで、Google App Engineにおけるコンテキストの遍歴を思い出してみましょう。
コンテキストの型は次のように移り変わってきました。

  • appengine.Context
  • golang.org/x/net/context.Context
  • context.Context

そして、準標準パッケージのgolang.org/x/net/context.Context型から標準パッケージのcontext.Context型に移り変わる際には、前述したようなインタフェースの互換性が問題になる場合があると述べました。

この問題を解決するために、Go1.9のgolang.org/x/net/context.Context型の実装は、以下のように標準パッケージのcontext.Context型エイリアスになっています。

type Context = context.Context

型エイリアスとして定義することで、型の互換性の問題は完全に解決します。
Go1.9以降では、golang.org/x/net/context.Context型は標準パッケージのcontext.Contextと完全に一致します。
そのため、golang.org/x/net/contextパッケージの代わりに、標準のcontextパッケージをインポートするだけで移行が完了します。

執筆時(2018年4月)の最新版のGoogle App EngineのSDKでは、Go1.9がベータ版として利用できるようになっています。
app.yamlファイルのapi_versiongo1.9を指定すれば利用できます。

Go1.9がGoogle App Engineで利用できるようになった際には、ぜひコンテキストの移行をしてみてください。

おわりに

この記事では、Google App EngineのGoのバージョンアップにまつわる話をGoのbuild constraintsや型エイリアスなどの言語機能の側面から議論しました。

ここで述べた事が読者の皆様がご自身の環境でバージョンアップする際にお役に立てば幸いです。

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.