はじめに
はじめまして、Goodpatchでバックエンドエンジニアをしている千里と申します。
2024年11月にGoodpatchへジョインし、ReDesignerというサービスでバックエンドを担当しております。
ReDesignerではバックエンド開発の言語としてGolangを採用しており、WebフレームワークとしてGinを利用しています。
私自身、これまでにWeb開発経験はあったものの、Golangを利用したことは業務上で初めてであり、その構文や機能について学ぶことは多く、入社して約1ヶ月半はGolangの畑に慣れようと茂みを掻き分け歩いている状態です。
今回は、Ginにおいて、最初に出てくるgin.Contextという概念について理解していきたいと思います。
というのも、GinのQuick Startには、以下のようなコードが記載されています。
func main() {
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "pong",
})
})
r.Run()
}
これは、GinのEngineインスタンスを生成し、/ping
に対するGETリクエストを受け取ったら、func
で記載されているHandlerを実行し、その結果としてpong
というレスポンスを返すというものです。
この流れは明確で理解できるものの、そのHandler内で引数として受け取るc *gin.Context
というものが何を指しているのか、どういう役割を持っているのかが当初よくわかっていませんでした。そこで、今回はこのGinにおけるContextについて調べ、どのような場面で利用されるのか確認していきます。
gin.Contextとは
早速コードを読んでいきます。context.goのgin.Context structには以下のようなコメントが記載されていました。
// Context is the most important part of gin. It allows us to pass variables between middleware,
// manage the flow, validate the JSON of a request and render a JSON response for example.
なるほど。フレームワークにおける重要な部分であることは見えました。ただ、それがどのようにコードで表現されているのか実際に見ないとわからないので、ここに記載されている機能を実際に確認していきます。
pass variables between middleware
Ginではログ出力や認証、GZIP圧縮などの基本的な機能をMiddlewareという形で追加利用することができます。
今回はgin.Contextの利用を理解するために、以下のようにBasicAuth Middlewareを通して確認してみます。※下記コードはUsing BasicAuth middlewareに記載されているものです。
func main() {
r := gin.Default()
authorized := r.Group("/admin", gin.BasicAuth(gin.Accounts{
"foo": "bar",
"test": "pass",
}))
authorized.GET("/secrets", func(c *gin.Context) {
user := c.MustGet(gin.AuthUserKey).(string)
c.JSON(http.StatusOK, gin.H{"user": user})
})
r.Run(":8080")
}
このコードは、/admin/secrets
に対するGETリクエストを受け取ったら、BasicAuth Middlewareを通して認証を行い、認証に成功した場合にはuser
というキーで認証ユーザー名を返すというものです。この流れをコード上で追ってみていきます。
1)プログラム実行時にBasicAuth
が実行される
2)BasicAuth
は下記のように定義されており、BasicAuthForRealm
が呼び出される
func BasicAuth(accounts Accounts) HandlerFunc {
return BasicAuthForRealm(accounts, "")
}
3)BasicAuthForRealm
は下記のように定義されており、ベーシック認証用のアカウントをprocessAccounts
で処理しておき、リクエストが来た際に実行されるHandlerを返す(このHandlerはリクエストヘッダーのAuthorization
を取得し、searchCredential
で認証情報を検索。見つかった場合は、c.Set(AuthUserKey, user)
で認証ユーザー名をgin.Contextに格納するというもの)
func BasicAuthForRealm(accounts Accounts, realm string) HandlerFunc {
if realm == "" {
realm = "Authorization Required"
}
realm = "Basic realm=" + strconv.Quote(realm)
pairs := processAccounts(accounts)
return func(c *Context) {
// Search user in the slice of allowed credentials
user, found := pairs.searchCredential(c.requestHeader("Authorization"))
if !found {
// Credentials doesn't match, we return 401 and abort handlers chain.
c.Header("WWW-Authenticate", realm)
c.AbortWithStatus(http.StatusUnauthorized)
return
}
// The user credentials was found, set user's id to key AuthUserKey in this context, the user's id can be read later using
// c.MustGet(gin.AuthUserKey).
c.Set(AuthUserKey, user)
}
}
4)実際に/admin/secrets
に対するGETリクエストを受け取る
5)/admin
に対するアクセス処理が先に行われるので、まずBasicAuthForRealm
で返すHandlerが実行される。ここでベーシック認証が正しければ、c.Set(AuthUserKey, user)
で認証ユーザー名をgin.Context内部に格納される(c.Set
はgin.Contextに値を格納するメソッドで、下記のように定義されている)
func (c *Context) Set(key string, value any) {
c.mu.Lock()
defer c.mu.Unlock()
if c.Keys == nil {
c.Keys = make(map[string]any)
}
c.Keys[key] = value
}
6)次に/admin/secrets
に対するアクセス処理が行われ、c.Set(AuthUserKey, user)
でgin.Context内部に格納された認証ユーザー名をc.MustGet(gin.AuthUserKey).(string)
にて取り出し、user
という変数に格納する(c.MustGet
はgin.Contextに格納されている値を取得し、存在しない場合はpanicを発生させるメソッドで、下記のように定義されている)
// MustGet returns the value for the given key if it exists, otherwise it panics.
func (c *Context) MustGet(key string) any {
if value, exists := c.Get(key); exists {
return value
}
panic("Key \"" + key + "\" does not exist")
}
7)最後にc.JSON(http.StatusOK, gin.H{"user": user})
でuser
をJSON形式で返す
このように、ベーシック認証の処理フローでSet
とMustGet
を活用することで、gin.Contextを介してMiddlewareとHandler間で値の受け渡しが可能であることが確認できました。
manage the flow
Ginのcontext.goのコードを読んでいくと、chain
やHandlersChain
という言葉が出てきました。どうやら、1つのリクエストに対して複数のMiddlewareやHandlerが「チェーン」として順序立てて実行されることを想定しているようです。
gin.Contextではその流れを管理しており、実際にAbort
やNext
といったメソッドが用意されていて、context.goには以下のように記載されています。
/************************************/
/*********** FLOW CONTROL ***********/
/************************************/
// Next should be used only inside middleware.
// It executes the pending handlers in the chain inside the calling handler.
// See example in GitHub.
func (c *Context) Next() {
c.index++
for c.index < int8(len(c.handlers)) {
c.handlers[c.index](c)
c.index++
}
}
// IsAborted returns true if the current context was aborted.
func (c *Context) IsAborted() bool {
return c.index >= abortIndex
}
// Abort prevents pending handlers from being called. Note that this will not stop the current handler.
// Let's say you have an authorization middleware that validates that the current request is authorized.
// If the authorization fails (ex: the password does not match), call Abort to ensure the remaining handlers
// for this request are not called.
func (c *Context) Abort() {
c.index = abortIndex
}
// AbortWithStatus calls `Abort()` and writes the headers with the specified status code.
// For example, a failed attempt to authenticate a request could use: context.AbortWithStatus(401).
func (c *Context) AbortWithStatus(code int) {
c.Status(code)
c.Writer.WriteHeaderNow()
c.Abort()
}
// AbortWithStatusJSON calls `Abort()` and then `JSON` internally.
// This method stops the chain, writes the status code and return a JSON body.
// It also sets the Content-Type as "application/json".
func (c *Context) AbortWithStatusJSON(code int, jsonObj any) {
c.Abort()
c.JSON(code, jsonObj)
}
// AbortWithError calls `AbortWithStatus()` and `Error()` internally.
// This method stops the chain, writes the status code and pushes the specified error to `c.Errors`.
// See Context.Error() for more details.
func (c *Context) AbortWithError(code int, err error) *Error {
c.AbortWithStatus(code)
return c.Error(err)
}
こちらも、下記のようなCustom Middlewareを通して確認してみます。※下記コードはCustom Middlewareに記載されているものです。
func MyLogger() gin.HandlerFunc {
return func(c *gin.Context) {
t := time.Now()
c.Set("example", "12345")
c.Next()
latency := time.Since(t)
log.Print(latency)
status := c.Writer.Status()
log.Println(status)
}
}
func main() {
r := gin.New()
r.Use(MyLogger())
r.GET("/test", func(c *gin.Context) {
example := c.MustGet("example").(string)
log.Println(example)
})
r.Run(":8080")
}
このコードは、/test
に対するGETリクエストを受け取ったら、MyLogger
というMiddlewareを通してリクエストの処理時間をログ出力するというものです。MyLogger
にgin.ContextのNext
メソッドが利用されています。同様にこの流れをコード上で追ってみていきます。
1)プログラム実行時にMyLogger
が実行される
2)MyLogger
は先と同様に、Handlerを返す関数として定義されている(このHandlerはリクエストの処理にかかる時間をログ出力するというもので、実行時にtime.Now()
で現在時刻を取得し、c.Next()
で次のHandlerを実行し、その後にtime.Since(t)
で処理にかかった時間を計測している)
3)実際に/test
に対するGETリクエストを受け取る
4)MyLogger
で返すHandlerが実行され、現在時刻を取得して、c.Set("example", "12345")
でgin.Context内部にexample
というキーで12345
という値を格納した後に、c.Next()
で処理が一時中断され、次のHandler(/test
へのGETリクエストを処理するHandler)を実行する(Next
メソッドは下記のように定義されており、c.index++
でインデックスを一つ進めることで、次のHandlerを実行している)
func (c *Context) Next() {
c.index++
for c.index < int8(len(c.handlers)) {
c.handlers[c.index](c)
c.index++
}
}
5)次のHandlerとして/test
に対するアクセス処理が行われ、c.MustGet("example").(string)
でgin.Context内部に格納されたexample
の値を取り出し、それをログ出力。その後、MyLogger
のc.Next()
の後続処理が行われ、処理にかかった時間をログ出力
このように、Middlewareとして登録したMyLogger
の返すHandlerと/test
に対するGETリクエストを処理するHandlerはgin.ContextのNext
メソッドを介して実行順序を制御していることがわかりました。また、先のベーシック認証の例ではBasicAuthForRealm
内で、ベーシック認証が失敗した場合に下記を実行しています。
user, found := pairs.searchCredential(c.requestHeader("Authorization"))
if !found {
// Credentials doesn't match, we return 401 and abort handlers chain.
c.Header("WWW-Authenticate", realm)
c.AbortWithStatus(http.StatusUnauthorized)
return
}
これは、認証に失敗したら、401 Unauthorizedを返すとともに、そのリクエストに対する後続の処理を中断するというものです。このように、gin.Contextによって、リクエストの処理フローを中断することも可能であることが確認できました。
validate the JSON of a request
Ginではリクエストボディに対するバリデーションを行うためにShouldBind
やShouldBindJSON
といったメソッドが用意されています。詳しくはModel binding and validationこちらに記載があります。これらのメソッドでは、リクエストBodyを構造体にバインドし、バリデーションエラーが発生した場合には、そのエラーが返されるのでそれを利用してエラーハンドリングを行うことができます。これらのメソッドを利用した例を見てみます。
type Login struct {
User string `json:"user" binding:"required"`
Password string `json:"password" binding:"required"`
}
func main() {
router := gin.Default()
router.POST("/loginJSON", func(c *gin.Context) {
var json Login
if err := c.ShouldBindJSON(&json); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if json.User != "manu" || json.Password != "123" {
c.JSON(http.StatusUnauthorized, gin.H{"status": "unauthorized"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "you are logged in"})
})
router.Run(":8080")
}
このコードは、/loginJSON
に対するPOSTリクエストを受け取ったら、リクエストボディをLogin
構造体にバインドし、バリデーションエラーが発生した場合には、そのエラーをJSON形式で返すというものです。同様にこの流れをコード上で追ってみていきます。
1)/loginJSON
に対するPOSTリクエストを受け取る
2)ShouldBindJSON
でリクエストボディをLogin
構造体にバインドし、バリデーションエラーが発生した場合には、そのエラーをJSON形式で返す(ShouldBindJSON
は下記のように定義されており、その内部では、bindingパッケージのBind
メソッドを利用している)
func (c *Context) ShouldBindWith(obj any, b binding.Binding) error {
return b.Bind(c.Request, obj)
}
// 間省略
func (c *Context) ShouldBindJSON(obj any) error {
return c.ShouldBindWith(obj, binding.JSON)
}
3)バリデーションエラーが発生しなかった場合には、json.User != "manu" || json.Password != "123"
でユーザー名とパスワードがmanu
と123
でない場合には、401 Unauthorizedを返し、それ以外の場合には200 OKを返す
このように、gin.ContextにあるShouldBindJSON
を利用することで、リクエストボディのバリデーションを行うことができることが確認できました。Bindに関しては、Gin自体で実施されているのではなく、binding
パッケージを利用しているので、そちらを別途利用しても問題なさそうですが、そこをラップして使いやすい形式で提供しているところがWebフレームワークらしさを感じます。そして、そこの利便性を提供するためにgin.Contextが役に立っているということが理解できました。
render a JSON response
JSONレスポンスの出力については、今までの例でもc.JSON
として登場してきており、レスポンスを返す際には頻出すると思われます。その内部は以下のように定義されています。
// Render writes the response headers and calls render.Render to render data.
func (c *Context) Render(code int, r render.Render) {
c.Status(code)
if !bodyAllowedForStatus(code) {
r.WriteContentType(c.Writer)
c.Writer.WriteHeaderNow()
return
}
if err := r.Render(c.Writer); err != nil {
// Pushing error to c.Errors
_ = c.Error(err)
c.Abort()
}
}
// 間省略
// JSON serializes the given struct as JSON into the response body.
// It also sets the Content-Type as "application/json".
func (c *Context) JSON(code int, obj any) {
c.Render(code, render.JSON{Data: obj})
}
こちらも、先のShouldBindJSON
と同様に、外部パッケージとしてrender
パッケージのRender
やWriteContentType
を利用していることがわかります。render
パッケージのREADMEを読むと、下記のように記載されていました。
Render can be used with pretty much any web framework providing you can access the http.ResponseWriter from your handler. The rendering functions simply wraps Go's existing functionality for marshaling and rendering data.
つまり、Ginでも同じくRender
をラップして利用可能な状態にしてあり、それがgin.Contextを介して行われていることがわかりました。
改めてgin.Contextとは
gin.Contextの役割をコードを追っていくことで少し理解することができました。
- MiddlewareやHandler間で共通して扱える情報格納領域として機能し、
Set
やGet
、MustGet
などのメソッドを介してデータの受け渡しが可能 - リクエスト処理の実行フローを制御するためのインデックス管理や
Next
、Abort
、AbortWithStatus
などのメソッドを持ち、HandlersChain上の実行制御が可能 -
ShouldBindJSON
などのメソッドにより、リクエストボディをGoの構造体にバインドし、バリデーションやエラーハンドリングを行うことが可能 -
JSON
やHTML
、XML
などのレスポンス生成用メソッドを通して、レスポンスの返却を簡潔に行うことが可能
総じてまとめると、gin.ContextはGinのリクエスト-レスポンス処理サイクル全体を通して、データの受け渡し、フロー制御、リクエスト/レスポンスハンドリングを一元的に担う役割を持っており、開発者は基本的にこのgin.Contextにあるメソッドを利用することで、ある程度のサポートがGinから受けられるのかと思います。
gin.Contextで提供されていること
上記で調べたのは、gin.Contextの一部です。context.goでは下記のような分類がコメントによって行われていました。全てを利用する必要はないですが、どのような機能が利用可能かを知っておくだけでも効果はあると思います。
-
FLOW CONTROL
- 上述した通り、
Next
やAbort
などのメソッドによる、HandlerChainの制御
- 上述した通り、
-
ERROR MANAGEMENT
- 発生したエラーを現在のContextに紐づけるための
Error
メソッドを提供
- 発生したエラーを現在のContextに紐づけるための
-
METADATA MANAGEMENT
-
Set
やGet
、MustGet
メソッドによる、Context内部のデータ管理
-
-
INPUT DATA
-
Params
やQuery
などのリクエストパラメータの取得、ShouldBind
やShouldBindJSON
によるリクエストボディのバインド、ClientIP
やrequestHeader
などのリクエスト情報の取得
-
-
RESPONSE RENDERING
-
Cookie
やSetCookie
によるクッキーの操作、JSON
やHTML
、XML
などのレスポンス生成
-
-
CONTENT NEGOTIATION
- コンテンツネゴシエーションのサポート(あまり利用例を見ない)
-
GOLANG.ORG/X/NET/CONTEXT
- Golangのcontext.Contextインターフェースへの実装
まとめ
- gin.Contextについて自分の中での理解を深めました
- gin.Contextについて記述があるcontext.goは、1200行と比較的短く、コメントも多く記載されているので理解しやすかったです
- コードを追いかけて確認することで、gin.Contextに対する理解がある程度深まりました。これまで、Webフレームワークの学習といえば「TODOアプリやBBSを作って実践的に身につける」もしくは「ReferenceのExamplesを眺めて実行してみる」といった手法が主流でした。しかし、今回のように特定の機能(gin.Context)の役割をコードベースで丁寧に追っていく手法は、また違った学び方として有効性を感じました。従来、Gin Examplesを見る際は、自分の使いたい機能に近いサンプルを探す見方が中心でしたが、今後は「この機能がシステムの中でどう活用されているのか」という視点で読み解くこともできるようになったと思います
おわりに
今回はGinのContextについて調べてみましたが、他のGolangのWebフレームワーク(echo / Fiber / Humaなど)にも同様のContextが存在します。それらとの比較やそれぞれの特徴を調べてみると各フレームワークへの思想が見えたりとさらに深い理解が得られるのかなと思います。
今後もGinやGolangの学習を続け、バックエンド開発のスキルを磨いていきたいと思います!