目次
はじめに
サーバーサイドエンジニアのYukiです。乃木恋の開発に携わっております。
10ANTZのエンジニアの間では不定期で勉強会が開催されており、業務で扱う技術に拘らず幅広いテーマが取り上げられています。本記事では、2022年12月12日
に開催された社内勉強会の内容をご紹介したいと思います。
今回のテーマはGo ctx & err
です。
発表者:t.mutaguchi
Go
Goは特徴的なプログラミング言語として、人気を見せています。10ANTZでもGoを採用しているプロジェクトがありますが、乃木恋のサーバーサイドはPHPで実装されており、私は業務外でもGoを触ったことがありませんでした。
私のようなGo初心者にも今回の内容を楽しんでいただけるよう、まず関連するGoの特性をご紹介したいと思います。Goに慣れている方は次のセクションまで飛ばしても大丈夫です!
Simplicity
Goについて、開発者であるRob Pike氏が2015/11/09に行われたdotGoで下記のように述べています。
What makes Go successful?
My answer: Simplicity.
Go is simple, at least compared to established languages.
Simplicity has many facets.
Simplicity is complicated.
Goの癖のある特性はすべてこの思想に基づいていると実感しています。
並行性:goroutineとchannel
Goの並行性はCSPスタイルを採用しており、await
/async
の代わりに「goroutine」と「channel」を使用します。
以下の例では、goroutineを作成し、printHello()
を同時に実行します。スレッドの管理はGoに任せることになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
package main import "fmt" func main() { // Create a new goroutine that executes the `printHello` function. go printHello() // The main goroutine continues to execute concurrently with the // `printHello` goroutine. fmt.Println("Hello from the main goroutine.") } func printHello() { // Print a message from the `printHello` goroutine. fmt.Println("Hello from the printHello goroutine.") } |
そして、goroutine同士の通信・同期を担うのが「channel」という仕組みです。
以下の例では、二つのgoroutineを作成し、違う範囲の整数の和を同時に計算します。それぞれのgoroutineが計算を実行し、結果をresults
channelに送信します。その後、部分和を受信し総和が得られます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
package main import "fmt" func main() { // Create a channel to receive integers results := make(chan int) // Launch two goroutines that calculate the sum of the integers from 1 to 100 go func() { results <- sumRange(1, 100, 1) }() go func() { results <- sumRange(101, 200, 1) }() // Wait for both goroutines to finish and collect their results x := <-results y := <-results // Print the total sum fmt.Println(x + y) // Output: 20100 } func sumRange(start int, end int, step int) int { sum := 0 for i := start; i <= end; i += step { sum += i } return sum } |
ここではchannelのおかげでmain
goroutineは両方の計算 goroutineが終了し、その結果を受け取ってから最終の結果を出力することが保証されます。
Go ctx & err
では、今回の勉強会の内容に移ります。
Context
GoにおいてのContextは並行処理パターンであり、下記の課題を解決するための手段です。
- Go製サーバーは各リクエストを独自goroutineで処理する。
- 一連のgoroutineはリクエスト範囲内の値にアクセスする必要がある(エンドユーザーID、承認トークン、リクエスト期限等)。
- リクエストがキャンセルされた場合、動作しているすべてのgoroutineはすぐに終了し、システムが使用中のリソースを再利用できるようにする必要がある。
責務
Contextは意図された機能=責務以外もできることがありますが、GoのSimplicityを保つために、責務ではないことに使わないようにするのが重要になります。
責務
- キャンセルの伝達
- リクエスト範囲内の値の伝達
1 2 |
ctx := context.Background() client, err := spanner.NewClient(ctx, "projects/foo/instances/bar/databases/zoo") |
責務ではないことの一例として関数のオプショナル引数が考えられます。
1 2 3 4 |
var name string func rename(ctx context.Context) { name = ctx.Value("name") } |
代わりにFunctional Option Pattern ( FOP )を使うと下記になります。
1 2 3 4 |
options := []option.ClientOption { option.WithCredentialsFile("PATH_TO_CREDENTIALS_FILE"), } client, err := spanner.NewClient(ctx, dbName, options...) |
データ構造
- 親の参照を持つ隣接リストで実装されている(子から親への参照が可能)。
- キャンセルの伝達は親から子へのみ行われる。
子の参照を持たない親からキャンセルを伝達するのに用いられているのがchannelになります。
1 2 3 4 5 6 |
type Context interface { Done() <-chan struct{} Err() error Deadline() (deadline time.Time, ok bool) Value(key interface{}) interface{} } |
C10K問題
10数年前のApacheサーバーにおいて、クライアントが10,000台を超えた辺りからプロセス数の上限(32767, 32bit Linux)にあたり、パフォーマンスが急落する問題が存在していました。
そこで、前述のようにGoの開発者はCSPスタイル採用し、goroutineとchannelによってGoの並行性を実装しました。
Goのasync/awaitを採用しない理由は公式な情報源が見当たらないが、デメリットが多いと考えられます。
Error
Simplicityに基づき、Goはエラーハンドリングにtry-catch
ではなく、早期リターンスタイルを採用しています。
1 2 3 4 5 6 |
i, err := strconv.Atoi("42") if err != nil { fmt.Printf("couldn't convert number: %v\n", err) return } fmt.Println("Converted integer:", i) |
Source: https://go-tour-jp.appspot.com/methods/19
また、エラーを無視することは可能ですが、発生し得るエラーのハンドリングは呼び出し側の責任なので適切ではないとされています。
PanicとError
PanicもGoの異常系の一つで、標準パッケージで使われています。以下の特徴があります。
- アプリケーションの継続が困難な場合(null pointerやsegmentation fault等)に異常終了させるのに使われる。
- 強制力が強いためサービス層での利用は基本的に推奨されない。
一方でErrorは
- アプリケーションの継続が可能だが正常でないため、呼び出し元に判断を委ねる目的で使われる。
- エラーをボトムアップした結果、最終的にPanicさせるケースは考えられる。
Must
内部でPanicを起こす関数に付ける接頭辞であり、標準パッケージでは8箇所で定義されています。下記は一部の例です。
template
パッケージ
変数の初期化
1 2 3 |
func Must(t *Template, err error) *Template {} var t = template.Must(template.New("name").Parse("html")) |
regexp
パッケージ
グローバル変数の初期化・簡素化
1 |
func MustCompile(str string) *Regexp {} |
syscall
パッケージ
ロード操作
1 |
func MustLoadDLL(name string) *DLL {} |
サーバーの起動処理で異常が発生するとアプリケーションの継続困難に該当するため、Must命名パターンを使用すればコードのSimplicityが保たれます。
エラートラッキング
ローカル開発環境では、step debuggerとbreak pointを活用することができますが、それ以外ではスタックトレースが欲しくなるかもしれません。
Goではエラーハンドリング時にラッピング処理を行うことで対応可能ですが、標準パッケージだけでは、フレームや該当コードの行数などの情報が含まれません。
1 2 |
> go run cmd/err_trace_std/main.go wrapped error: original error |
代わりに以下のパッケージが存在しています。
1 2 3 4 5 6 7 |
> go run cmd/err_trace_pkg/main.go original error wrapped error main.main.func1 /path/to/main.go:16 main.main /path/to/main.go:20 |
※Public Archiveされメンテナンスされていません。
※メンテナンス不要なほど安定しているとも言えます。
golang.org/x/xerrorsパッケージ
1 2 3 4 5 6 7 |
> go run cmd/err_trace_x/main.go wrapped error: main.main.func1 /path/to/main.go:15 - original error: main.main.func1.1 /path/to/main.go:12 |
※Go 1.13に一部機能が取り込まれ役目を終えました。
Goのエラーに関する取り組み
Goは後方互換性をとても大事にしている言語であり、発表されて10年以上経つもメジャーバージョンは上がっていません。2017年に発表されたGo 2に向けた計画でも、Go 1のエコシステムを崩さない形になっています。
その中で、2019年に提案されたerrorsパッケージは前述のgolang.org/x/xerrorsになります。
しかしながら、コミュニティー間で行われた様々な議論の末、エラーに別のエラーを内包することの複雑さに対し、満足のいく解決に辿り着けませんでした。
結果として、Wrappingに関する機能のみがリリースされ、x/xerrorsパッケージが役目を終え、Goのビルトイン機能としてのスタックトレースへの試みは一旦終了しました。
サードパーティサービス
現実解として、前述のいずれかのパッケージとSentryといったサービスとの組み合わせが考えられます。
最後に
Goが特徴的な言語だと言われている理由を少しご理解いただけたら幸いです。筆者はGoを扱う上で、こういった言語開発側の思想と意図を理解しておくことが大切だと感じました。
ご興味があれば、Go Proverbs – Simple, Poetic, Pithyはいかがでしょうか。
※一部コード例はChatGPTに生成されたものを基にしています。
※注記やリンクのない限り、引用元は今回勉強会の資料になります。