My External Storage

Feb 21, 2020 - 6 minute read - Comments - Go

[Go] context.TODO()を使って漸進的にcontext対応を始める

Goではメソッドや関数の引数にcontext.Contextが含められていると何かと便利だ。
とはいえ、最初からアプリケーションがcontext.Contextを考慮していない場合もある。
アプリケーションを漸進的にcontext.Contextに対応させる方法を書いておく。

TL;DR

  • キャンセル通知や透過的な情報をやりとりするための仕組みがcontext.Context
    • ある操作のキャンセルを親gorotuineから伝える
    • リクエストIDなどをネストしたメソッドに伝える(透過的な情報しか含めてはいけない!)
  • 運用状態のアプリケーションの各メソッドを全てcontext.Contextに対応させるのは大変
  • context.TODO()を使って少しずつ始めよう
  • contextパッケージの思想を知るにはThe Go Blogか、「Go言語による並行処理」を読むといいだろう。

contextパッケージとは

Goはcontextパッケージというキャンセル意思や特定のデータを透過的に呼び出し先の関数に伝えるための仕組みがある。
具体的にいうと、goroutineによる並行処理中の実行停止に利用することができる。標準パッケージやメジャーな3rdパッケージはほぼ対応している。
また、APM(Application Perfomance Monitoring)やエラー通知サービスにデータを送信するさい、リクエストIDを含めたりしたいときがあるだろう。
context.Contextは、WithValueメソッドを使ってそのような本来そのメソッドのロジックに無関係の(トレースなどに利用した)透過的なデータを内包させることができる。
詳しい説明はパッケージドキュメントやThe Go Blogを読めばよいだろう。

透過的なデータの定義(決定指針)は「4.12 contextパッケージ」の章がわかりやすい。
(Goの辞書であるプログラミング言語GoはGo1.6時代の本なので、残念ながらGo1.7で追加されたcontextに関する解説はない)

context.Contextに対応させる

では、実際にcontext.Contextを利用するメソッドにリファクタリングしていくにはどうしたらよいだろうか。 最初は引数にcontext.Contextを含めるだけから始めればよいだろう。慣例的に、context.Contextは第一引数にする。 (context.Contextが第一引数になっていないと警告するlinterも存在する)

ここは単純にメソッドの引数を変更していくだけだ。

 // CompanyRepository is the repository to get company resource.
 type CompanyRepository interface {
-       Get(CompanyID) (domain.Company, error)
+       Get(context.Context, CompanyID) (domain.Company, error)
 }

API呼び出し(http.Request構造体)やdatabase/sqlパッケージを使ったDB操作を行なっている場合は、その生成、呼び出しもcontext.Contextに対応させると良いだろう。
context.Contextを新しく引数に加えても、(呼び出し時に与えたcontext.Contextのキャンセル処理などを実施しなければ、)何も挙動に影響を与えない。
一通り修正を終えた後に実際にcontext.Contextを使ったログ生成や、キャンセル処理の実装を加えていけばよいだろう。

-func (c *CompanyServiceClient) Get(cid CompanyID) (domain.Company, error) {
+func (c *CompanyServiceClient) Get(ctx context.Context, cid CompanyID) (domain.Company, error) {
        url := c.URL + "/companies"

-       req, err := http.NewRequest("GET", url, nil)
+       req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
        if err != nil {
                // ...

Go1.13からはhttp.Requestを生成する際に、context.Contextを含んだNewRequestWithContext関数を利用できるようになっている。
database/sqlパッケージもGo1.8からQueryメソッドの引数にcontext.Contextを含んだQueryConntextメソッドなどが用意されている。

あとはこのリファクタリングを他のメソッドにも適用していくのだが、業務で行なっているアプリケーションのリファクタリングを一気に行うのは無理だろう。
では、途中まで対応したとき、context.Contextを引数にしためメソッドを呼び出すためのcontext.Contextはどこから用意すればよいのだろうか。

暫定的にcontext.Contextを引数に与える場合は、context.TODO()を利用する。

context.Contextを生成するとき、通常は上位から受け取ったcontext.Contextを利用するか、context.Background()関数でcontext.Contextを生成する。

しかし、contextパッケージにはこのようなときのために利用する、context.TODO()関数が用意されている。

 // ListEmployees gets employees in comapany.
 func (e *EmployeesService) ListEmployees(cid CompanyID) (domain.Company, *ErrorResult) {
-       company, err := s.CompanyRepository.Get(cid CompanyID)
+       company, err := s.CompanyRepository.Get(context.TODO(), cid CompanyID)
        if err != nil {

context.TODO()で生成されるcontext.Contextは関数名のとおり、一時的なcontext.Contextである。 GoDocにも「どのcontext.Contextを使うかわからないとき、他の関数が対応していなくてcontext.Contextが用意できないときに使ってね。」のように記載されている。

TODO returns a non-nil, empty Context. Code should use context.TODO when it’s unclear which Context to use or it is not yet available (because the surrounding function has not yet been extended to accept a Context parameter).

あとは少しずつcontext.Contextを引数にとるメソッドを増やしていけば良い。

終わりに

context.Contextの使い方ではなく、context.Contextを使うための準備方法をまとめた。
context.Contextが用意されていると、並行処理を挟むようになったとき、リクエストIDやトレーシングデータをSentryなどのエラー通知サービスやAPMへのデータを送信することが非常に簡単になる。
新しく作るアプリケーションの場合は、ひとまず対応しておいたほうが良いだろう(必ず必要になるときがくる)。
context.Context対応を終えたアプリケーションでどのようにcontext.Contextを利用していくのかもいずれまとめたい。

参考文献

関連記事