はじめに
前の記事ではContext のキャンセル機能に着目してまとめを行いました。本記事では Context のもう一つの目的であるデータ保管についてまとめます。
並行処理のデータ保管について
以下の例はクライアントから来たリクエストを様々なバックエンドに並行に送信を行っています。
func RegisterHandler(func(w http.ResponseWriter,r *http.Request)) {
requestId := getRequestId(r)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
user := getUserFromRequest(r)
registerToDB(requestId,user)
}()
wg.Add(1)
go func() {
defer wg.Done()
email := getEmailFromRequest(r)
sendConfirmEmail(requestId,email)
}()
wg.Add(1)
go func() {
defer wg.Done()
writeAccessLog(requestId,r)
}()
wg.Wait()
}
func sendConfirmEmail(requestId,email string){
sendEmail(requestId,email)
registerUnConfirmedList(requestId,email)
}
この例のように requestId などのリクエストの範囲内で使いまわされるデータは存在していると思います.
このようなデータを引数でとっても良いのですが、別の方法でも引き回せるようにしたのが Context のもう一つの機能です。
Context にはデータを格納する機能もあり、並行処理で使いまわされるデータを Context に格納し、利用できるようになっています。
前の記事でも述べた通り Context は親から子に渡されていくので、複数の処理が同じデータに対して行われる場合、Context として一緒に渡せてしまうのは一つのパターンとしてあるみたいです。
Context の Value メソッドと WithValue 関数
先ほどの説明では Context にデータを保管できると言いましたが、どのように保管し、利用できるのでしょうか?
Context には Value メソッドがあり、Value メソッドを利用することで保管されているデータにアクセスできます。Value メソッドのシグネチャは以下の通りです。
type Context interface {
...
Value(key interface{}) interface{}
}
このように key を指定するとデータを取得できます。
データの保管に関しては、以下の WithValue 関数で親の Context を引数で受け取って新しい Value を保管している Context を返します。
この新しく返される Context には親の Context に保管されているデータに加えて、WithValue で指定したデータも追加で保管されます。
func WithValue(parent Context, key, val interface{}) Context
つまり、WithValue で key と value を指定してデータを登録し、その Context に対して Value(key)メソッドで保管したデータを取得する、といった流れになります。
Context Value は万能?
Context のキャンセル機能は多くの開発者から好評みたいですが、Value に関しては意見が分かれるようです。これは Value の key と value の指定がどちらも interface 型で型情報が失われるからです。
これを防ぐ方法として、以下のような Context 取得用の関数を用意することと、key 用のエクスポートできない型を作成するのが良いとのことです。
// 非公開
type key string
const userIdKey key = "UserId"
func UserId(ctx Context) string {
return ctx.Value(userId).(string)
}
func ProcessRequest(userId string) {
// 内部のkey用の型でデータを保存
ctx := context.WithValue(context.Background(),userIdKey,userId)
}
func HandleResponse(ctx context.Context) {
// 型情報を保ちつつデータを取得
userId := UserId(ctx)
...
}
なぜ key といったような内部の型をいちいち作るのかと言うと、Context 内では key の値が同じでも型情報が異なっていれば異なる領域として保管してくれるからです。
これにより、エクスポートできない型を宣言してそれを Context の key として利用してあげると、key のもとの値が同一であってもバッティングすることがなくなり、安全になります。エクスポートできないと言うのがキモでして、エクスポートできないため他パッケージで使い回すことができず、他パッケージとの key のバッティングをなくすことができます。
ただし、このやり方にもある程度考慮ポイントはあるみたいです。
例えば上記の ProcessRequest と HandlerResponse が別のパッケージにあるかつ、ProcessRequest が HandleResponse の処理を必要としていると、HandleResponse も ProcessRequest の UserId 関数を必要としており循環参照となってしまいます。Go では循環参照ができないため、UserId 関数をどこのパッケージからも参照できるようなパッケージに移動することが必要になります。
すぐに問題になることでもないのですが、結合度をあげる可能性があるため認識しておく必要があるとのことです。
あとは、そもそも型情報がないので、いくら工夫しても間違える可能性はありますし、上記のような型安全策を実施するかしないかは開発者に任されています。
このように Context にたくさんのデータを保管しておくことは、型安全性を損なう可能性を高め、場合によってはプログラムを複雑にします。
Context に保管するデータに対するガイドライン
context パッケージに書いてあるコメント文が Context に何を保管するのが適切かについて最も普及しているガイドラインのようです。
// Use context Values only for request-scoped data that transits processes and
// APIs, not for passing optional parameters to functions.
コンテキスト値はプロセスや API の境界を通過するリクエストスコープでのデータの絞って使いましょう。
関数にオプションのパラメーターを渡すために利用するべきでないです。
ここでの API の境界を通過するリクエストスコープのデータとは、リクエスト Id や認証情報などの token などが相当すると思います。
このようなデータとしてシンプルだが各処理で使い回す物に絞って Context で利用すると良いと言うことです。
受け取ったデータによって振る舞いを変えてしまうものや、ある処理にしか利用されないものは Context ではなく、別途引数として渡すのが良いと思います。
所感/終わりに
今回 Context の勉強をして、曖昧だった Context について理解が深まったのでよかったです。曖昧な理解で使っている技術を深ぼってみることは大事だな〜と思いました。
ちなみに今回のデータ保管としての Context ですが、個人的にはう〜むと言う感じでした。
前の記事でも書いたように、私は型の重要性に大分ハマっており、interface 型で隠蔽される仕組みはあまり利用しようと思えませんでした。
キャンセル機能は大変有用だったのですが、データ保管としての Context を利用すると、統制をかけないと神 Context が出来上がるのでは?と思いました。
Go の良さの一つに誰が書いても同じようになることがあると思います。このメリットは私としても大変気に入っているのですが、Context の利用については意見が割れそうだなと思いました。
もし、Context の機能を使う上でパターンなどありましたら教えていただきたいです。