はじめに
メルペイ エキスパートチームの@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_version
にgo1
を指定していた場合、デフォルトのバージョンの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
}
InterfaceA
はM() 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
}
InterfaceA
とInterfaceB
は同じシグニチャのメソッドを持ちます。
そのため、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に移行する際に、大きな問題が起きずにimport
をgolang.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
と異なっています。
たとえInterfaceA
とInterfaceB
が持つメソッドリストが同じであっても、型としては別ものであるため、代入ができません。
そのため、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
、変数m
はint
となります。
変数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_version
にgo1.9
を指定すれば利用できます。
Go1.9がGoogle App Engineで利用できるようになった際には、ぜひコンテキストの移行をしてみてください。
おわりに
この記事では、Google App EngineのGoのバージョンアップにまつわる話をGoのbuild constraintsや型エイリアスなどの言語機能の側面から議論しました。
ここで述べた事が読者の皆様がご自身の環境でバージョンアップする際にお役に立てば幸いです。