はじめに
gorilla/mux、rs/cors、justinas/aliceを組み合わせてサーバーを作る方法を紹介します。特に、パスによって適用するミドルウェアを分けようとした時に、CORSでハマったので、そこに関わる部分を重点的に説明します。掲載するコード中では、基本的にエラーを無視する書き方なので、適宜書き換えてください。また、コード中の ...
は単純に省略を意味し、文法的な意味はありませんのでコピペする際は注意してください。
tl;dr
プリフライトリクエストの OPTIONS
メソッドに気をつけましょう。
mux、cors、aliceの簡単な概要
いくつかの例を示しますが、それぞれのREADMEに書かれているような基本的な内容ですので、分かっている方は実装本題まで飛ばしてください。
gorilla/mux
muxを用いたサーバーの例を示します。
package main
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
)
type Character struct {
Name string `json:"name"`
}
func pilotFunc(w http.ResponseWriter, r *http.Request) {
res, _ := json.Marshal(Character{"Shinji"})
w.WriteHeader(http.StatusOK)
w.Write(res)
}
func angelFunc(w http.ResponseWriter, r *http.Request) {
res, _ := json.Marshal(Character{"Adam"})
w.WriteHeader(http.StatusOK)
w.Write(res)
}
func main() {
r := mux.NewRouter() // r は *mux.Router 型
r.Methods("GET").Path("/pilot").HandlerFunc(pilotFunc) // r.routes に *mux.Route を追加する。
r.Methods("GET").Path("/angel").HandlerFunc(angelFunc) // r.routes は []*mux.Route 型なので、どんどん追加できる。
http.ListenAndServe(":8000", r)
}
これだけで、ちょっとしたサーバーが完成です。
$ go run main.go
$ curl http://localhost:8000/pilot
{"name": "Shinji"}
しかし、ブラウザーから fetch
を使った場合、以下のようなエラーが発生します。
Access to fetch at 'http://localhost:8000/user' from origin 'null' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
rs/cors
そこで登場するのが、 rs/corsです。CORSについては、MDNのページが分かりやすいです。CORSに対応するためには、サーバー側でレスポンスヘッダに適切な値を設定する必要があります。そこら辺を担ってくれるのが、 rs/corsです。先ほどの例にrs/corsを追加したものです。
import (
...
"github.com/rs/cors"
)
func main() {
r := mux.NewRouter()
r.Methods("GET").Path("/pilot").HandlerFunc(pilotFunc)
r.Methods("GET").Path("/angel").HandlerFunc(angelFunc)
c := cors.Default().Handler(r) // 追加した。
http.ListenAndServe(":8000", c) // r から c に変更した。
}
一行追加するだけでCORSに対応することができます。
justinas/alice
さらに、リクエストの内容を都度ログに出力するようなミドルウェアや、メインの処理を行う前にリクエストヘッダから値を取得するミドルウェアを追加したいと思った時に活躍するのが、justinas/aliceです。リクエストの情報をログに出力するミドルウェアを自作し、追加した例を示します。
import (
...
"log"
"github.com/justinas/alice"
)
func logHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("Method: %v; URL: %v; Protocol: %v", r.Method, r.URL, r.Proto)
h.ServeHTTP(w, r)
})
}
func main() {
r := mux.NewRouter()
r.Methods("GET").Path("/pilot").HandlerFunc(pilotFunc)
r.Methods("GET").Path("/angel").HandlerFunc(angelFunc)
c := cors.Default()
chain := alice.New(c.Handler, logHandler).Then(r) // 追加した。
http.ListenAndServe(":8000", chain) // c から chainに変更した。
}
実装本題
さて、gorilla/mux、rs/cors、justinas/aliceを全て組み合わせたものを再掲します(packge、import、structの定義は省略)。
...
func pilotFunc(w http.ResponseWriter, r *http.Request) {
res, _ := json.Marshal(Character{"Shinji"})
w.WriteHeader(http.StatusOK)
w.Write(res)
}
func angelFunc(w http.ResponseWriter, r *http.Request) {
res, _ := json.Marshal(Character{"Adam"})
w.WriteHeader(http.StatusOK)
w.Write(res)
}
func logHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("Method: %v; URL: %v; Protocol: %v", r.Method, r.URL, r.Proto)
h.ServeHTTP(w, r)
})
}
func main() {
r := mux.NewRouter()
r.Methods("GET").Path("/pilot").HandlerFunc(pilotFunc)
r.Methods("GET").Path("/angel").HandlerFunc(angelFunc)
c := cors.Default()
chain := alice.New(c.Handler, logHandler).Then(r)
http.ListenAndServe(":8000", chain)
}
しかし、この方法だと、全てのリクエストに対して、設定したミドルウェアを適用してしまいます。
例えば /pilot
はログインしているかどうかをチェックして、ログインしている場合のみデータを返すけど、 /angel
はログインしていなくてもデータを返すというような要望に対応できません。Qiitaであれば、記事のページはログインしていなくても見ることができるが、マイページの下書きなどはログインしていないと見ることができないというような状況をイメージしてください。
そこで、以下のように工夫してみます。先に言っておきますが、下の書き方だとハマります(笑)。
type funcHandler struct {
handler func(w http.ResponseWriter, r *http.Request)
}
func (h funcHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.handler(w, r)
}
func pilotFunc(w http.ResponseWriter, r *http.Request) {...}
func angelFunc(w http.ResponseWriter, r *http.Request) {...}
func logHandler(h http.Handler) http.Handler {...}
func authHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Println("Authentication")
h.ServeHTTP(w, r)
})
}
func main() {
c := cors.Default()
logChain := alice.New(c.Handler, logHandler)
authChain := logChain.Append(authHandler)
r := mux.NewRouter()
r.Methods("GET").Path("/pilot").Handler(authChain.Then(funcHandler{pilotFunc}))
r.Methods("GET").Path("/angel").Handler(logChain.Then(funcHandler{angelFunc}))
http.ListenAndServe(":8000", r)
}
これで、 /pilot
に fetch
した場合のみ、標準出力に Authentication
と出力されます。これで、パスによって適用するミドルウェアを分けることが可能となりました。
$go run main.go
2020/09/29 20:41:18 Method: GET; URL: /pilot; Protocol: HTTP/1.1
2020/09/29 20:41:18 Method: GET; URL: /pilot; Protocol: HTTP/1.1; Authentication
2020/09/29 20:41:18 Method: GET; URL: /angel; Protocol: HTTP/1.1
これがハマる理由の説明は一度置いておいて、このように書き換えられるメカニズムを簡単に説明します。ハマる理由を先に見たい方は、ハマる理由に飛んでください。
書き換えられる理由(補足)
まず、 http.ListenAndServer
がやっていることを確認しようかと思いますが、【Go】 net/httpパッケージを読んでhttp.HandleFuncが実行される仕組みがかなり詳しいので、こちらに丸投げします。少し情報が古いようで表記されている行番号と実際の行番号が一致していなかったり、多少書き方が変わっていたりしますが、基本的な理解をする上で支障はないと思います。
結論を言うと、 http.ListenAndServe
では、受け取ったリクエストを、第二引数(今回は mux.Router
) の ServeHTTP
に渡します。したがって、第二引数は ServeHTTP
が実装されている必要があります。 mux.Router
には ServeHTTP
が実装されている他、 http
パッケージ内で、
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
と定義されているため、任意の http.Handler
型の変数は ListenAndServe
の第二引数になり得ます。この ServeHTTP
内で、他の http.Hanlder
の ServeHTTP
を呼び、さらにその ServeHTTP
内で、他の http.Hanlder
の ServeHTTP
を呼び、、というように、 http.Handler
を連鎖させることで、リクエストに対して順に処理を行っていくことができます。
つまり、 http.Handler
を引数として受け取り、 http.Handler
型を返す関数、つまりミドルウェア、を用いることで、ハンドラーを連鎖させることができます。例として、 logHandler
と authHandler
の連鎖を考えると、
chain := logHandler(authHandler(router))
というような連鎖が考えられます。ただ、この方法だと、ミドルウェアの数が増えるほど分かりにくくなるので、aliceを用いて、
chain := alice.New(logHandler, authHandler).Then(router)
と簡潔に書いています。
ここで、パスによって適用するミドルウェアを変えられるように書き換える前と書き換えた後を比較してみます。
// 変更前
func main() {
r := mux.NewRouter()
r.Methods("GET").Path("/pilot").HandlerFunc(pilotFunc)
r.Methods("GET").Path("/angel").HandlerFunc(angelFunc)
c := cors.Default()
chain := alice.New(c.Handler, logHandler).Then(r)
http.ListenAndServe(":8000", chain)
}
// 変更後
func main() {
c := cors.Default()
logChain := alice.New(c.Handler, logHandler)
authChain := logChain.Append(authHandler)
r := mux.NewRouter()
r.Methods("GET").Path("/pilot").Handler(authChain.Then(funcHandler{pilotFunc}))
r.Methods("GET").Path("/angel").Handler(logChain.Then(funcHandler{angelFunc}))
http.ListenAndServe(":8000", r)
}
したがって、変更前後のリクエストがたどるフローは、
前: request -> CORS -> logging -> routing -> pilotFunc or angelFunc
後: request -> routing -> CORS -> logging (-> auth) -> pilotFunc or angelFunc
となり、ルーティングの順番が変化していることが分かります。これが、ハマる一因です。
次に、この記事の本題である、CORSでハマる理由を説明します。
ハマる理由
まず、認証の仕組み(詳しくは後述)を少し現実のものに近づけて実装したものを記します。
func authHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Token")
if token == "" {
w.WriteHeader(http.StatusUnauthorized)
return
}
h.ServeHTTP(w, r)
})
}
コード全体
package main
import (
"encoding/json"
"log"
"net/http"
"github.com/gorilla/mux"
"github.com/justinas/alice"
"github.com/rs/cors"
)
type Character struct {
Name string `json:"name"`
}
type funcHandler struct {
handler func(w http.ResponseWriter, r *http.Request)
}
func (h funcHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.handler(w, r)
}
func pilotFunc(w http.ResponseWriter, r *http.Request) {
res, _ := json.Marshal(Character{"Shinji"})
w.WriteHeader(http.StatusOK)
w.Write(res)
}
func angelFunc(w http.ResponseWriter, r *http.Request) {
res, _ := json.Marshal(Character{"Adam"})
w.WriteHeader(http.StatusOK)
w.Write(res)
}
func logHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("Method: %v; URL: %v; Protocol: %v", r.Method, r.URL, r.Proto)
h.ServeHTTP(w, r)
})
}
func authHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Token")
if token == "" {
w.WriteHeader(http.StatusUnauthorized)
return
}
h.ServeHTTP(w, r)
})
}
func main() {
c := cors.Default()
logChain := alice.New(c.Handler, logHandler)
authChain := logChain.Append(authHandler)
r := mux.NewRouter()
r.Methods("GET").Path("/pilot").Handler(authChain.Then(funcHandler{pilotFunc}))
r.Methods("GET").Path("/angel").Handler(logChain.Then(funcHandler{angelFunc}))
http.ListenAndServe(":8000", r)
}
この状態で、
fetch(url, {
headers: new Headers({
Token: "abcd"
})
})
を実行すると、、、、
Access to fetch at 'http://localhost:8000/pilot' from origin 'null' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
エラーが生じます。
ハマる理由を理解するには、
- 認証の仕組み
- プリフライトリクエスト
- リクエストの処理の順番
の3点がを把握しておく必要があります。順に説明していきます。
1. 認証の仕組み
認証を行う際には、Cookieを使う場合もTokenを使う場合もリクエストヘッダに載せてフロントエンドからバックエンドに渡す必要があります。そのため、例で使用した authHandler
をより現実に近いものに実装し直すと、
func authHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Token")
if token == "" {
...
}
...
h.ServeHTTP(w, r)
})
}
というようなものになります。ここで重要なのは、CookieやTokenであると分かるようなヘッダ名を新たに追加する必要があるということです。
2. プリフライトリクエスト
実はCORSには制限があって、メソッドおよびヘッダに設定できる値が下記の条件を満たした時にのみ「単純リクエスト」を行うことができます。ここで、単純リクエストとは、一般的なリクエストのことを指します。
method : GET, POST, HEAD
header : Accept, Accept-Language, Content-Language, Content-Type(application/x-www-form-urlencoded, multipart/form-data, text/plain), DPR, Downlink, Save-Data, Viewport-Width, Width
したがって、ヘッダに Cookie
や Token
のような値を独自で設定する場合、単純リクエストはできません。この場合、プリフライトリクエストというリクエストを事前に行います。詳しくは、MDNを見てください。必要な情報のみ書くと、まず、プリフライトリクエストとして、 OPTIONS
メソッドでリクエストが送られます。このプリフライトリクエストに対して、正常なレスポンスが返ってきた場合にのみ、続けて、 Token
などの値をヘッダに載せて、 GET
や POST
リクエストを送ります。したがって、 fetch
を使う場合、裏では2回リクエストが送られています。
3. リクエストの処理の順番
ここまでをまとめると、独自に設定したHeaderの値を使用する場合、プリフライトリクエストが送信されます。ここで、複数のミドルウェアを適用できるように変更する前と後のフローを再掲します。
前: request -> CORS -> logging -> routing -> pilotFunc or angelFunc
後: request -> routing -> CORS -> logging (-> auth) -> pilotFunc or angelFunc
CORSのミドルウェアのの Handler
を見てみると、
func (c *Cors) Handler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
...
w.WriteHeader(http.StatusNoContent)
} else {
...
h.ServeHTTP(w, r)
}
})
}
というように、Optionsメソッドの場合とそうでない場合で処理を分岐し、適切にプリフライトリクエストに対処しています。次に、 mux.Router
の ServeHTTP
を見てみると、
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
...
if r.Match(req, &match) {
handler = match.Handler
...
}
if handler == nil && match.MatchErr == ErrMethodMismatch {
handler = methodNotAllowedHandler()
}
if handler == nil {
handler = http.NotFoundHandler()
}
handler.ServeHTTP(w, req)
}
リクエストの url
の値が一致している handler
を探し、あればそれを、なければ、メソッドがエラーな場合と、そもそもルーティングがエラーである場合で分岐し、エラーを処理しています。
したがって、複数のミドルウェアを適用できるように変更する前後で、プリフライトリクエストに対しての処理が以下のように変わっています。
前: preflight request -> CORS check -> 204 No content
後: preflight request -> routing check -> 405 Method Not Allowed
このような理由で、CORSエラーが生じています。
エラー解消方法
ここまで分かれば、対処は簡単です。例えば、 mux.Router
のルーティング時に OPTINOS
を追加するのが一番単純でしょうか。後出しで申し訳ないですが、 rs/cors では、独自ヘッダを追加する場合、どのようなヘッダを許可するかを指定する必要があります。詳しくはREADMEを見てください。それを踏まえると、以下のような実装になります。
func main() {
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{
http.MethodHead,
http.MethodGet,
http.MethodPost,
},
AllowedHeaders: []string{"*"},
AllowCredentials: false,
})
logChain := alice.New(c.Handler, logHandler)
authChain := logChain.Append(authHandler)
r := mux.NewRouter()
r.Methods("GET", "OPTIONS").Path("/pilot").Handler(authChain.Then(funcHandler{pilotFunc}))
r.Methods("GET", "OPTIONS").Path("/angel").Handler(logChain.Then(funcHandler{angelFunc}))
http.ListenAndServe(":8000", r)
}
全体のコード
package main
import (
"encoding/json"
"log"
"net/http"
"github.com/gorilla/mux"
"github.com/justinas/alice"
"github.com/rs/cors"
)
type Character struct {
Name string `json:"name"`
}
type funcHandler struct {
handler func(w http.ResponseWriter, r *http.Request)
}
func (h funcHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.handler(w, r)
}
func pilotFunc(w http.ResponseWriter, r *http.Request) {
res, _ := json.Marshal(Character{"Shinji"})
w.WriteHeader(http.StatusOK)
w.Write(res)
}
func angelFunc(w http.ResponseWriter, r *http.Request) {
res, _ := json.Marshal(Character{"Adam"})
w.WriteHeader(http.StatusOK)
w.Write(res)
}
func logHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("Method: %v; URL: %v; Protocol: %v", r.Method, r.URL, r.Proto)
h.ServeHTTP(w, r)
})
}
func authHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Token")
if token == "" {
w.WriteHeader(http.StatusUnauthorized)
return
}
h.ServeHTTP(w, r)
})
}
func main() {
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{
http.MethodHead,
http.MethodGet,
http.MethodPost,
},
AllowedHeaders: []string{"*"},
AllowCredentials: false,
})
logChain := alice.New(c.Handler, logHandler)
authChain := logChain.Append(authHandler)
r := mux.NewRouter()
r.Methods("GET", "OPTIONS").Path("/pilot").Handler(authChain.Then(funcHandler{pilotFunc}))
r.Methods("GET", "OPTIONS").Path("/angel").Handler(logChain.Then(funcHandler{angelFunc}))
http.ListenAndServe(":8000", r)
}
もしくは、 PathPrefix
を使って、 OPTIONS
を全部処理する方法もあります。(この方法が良いかどうかはわからないです、すみません。)
func main() {
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{
http.MethodHead,
http.MethodGet,
http.MethodPost,
},
AllowedHeaders: []string{"*"},
AllowCredentials: false,
})
logChain := alice.New(c.Handler, logHandler)
authChain := logChain.Append(authHandler)
r := mux.NewRouter()
r.Methods("OPTIONS").PathPrefix("/").HandlerFunc(c.HandlerFunc)
r.Methods("GET").Path("/pilot").Handler(authChain.Then(funcHandler{pilotFunc}))
r.Methods("GET").Path("/angel").Handler(logChain.Then(funcHandler{angelFunc}))
http.ListenAndServe(":8000", r)
}
ちなみに、 Pathprefix
はその名の通り、urlに共通の接頭辞をつけます。例えば、
r.PathPrefix("/products/")
と設定すると、 http://localhost:8000/products/hoge
やhttp://localhost:8000/products/fuga
といったリクエストが該当するようになります。
結論
CORSに対応したい場合は、ちゃんと OPTIONS
に対応しましょう。
余談
長文読んでいただきありがとうございます。分かると、単純なことじゃないかという感じのハマりかたですが、もう一つハマりどころがあります。実は、スクラッチから書いた訳ではなく、他の人が書いてくれたものを変更しており、その過程でハマりました。他の人が書いてくれたコードというのが、
func main() {
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{
http.MethodHead,
http.MethodGet,
http.MethodPost,
},
AllowedHeaders: []string{"*"},
AllowCredentials: false,
})
logChain := alice.New(c.Handler, logHandler)
authChain := logChain.Append(authHandler)
r := mux.NewRouter()
r.Methods("GET").Path("/pilot").Handler(authChain.Then(funcHandler{pilotFunc}))
r.Methods("GET").Path("/angel").Handler(logChain.Then(funcHandler{angelFunc}))
r.PathPrefix("").Handler(logChain.Then(http.StripPrefix("/img", http.FileServer(http.Dir("./img"))))) // これがあった。
http.ListenAndServe(":8000", r)
}
というようなものでした。画像を返すために、 r.PathPrefix("").Handler(logChain.Then(http.StripPrefix("/img", http.FileServer(http.Dir("./img")))))
というものがありました。しかし、自分のサーバーでは画像を返す機会がなかったため、この一行を削除しました。すると、いきなりCORSでエラーが発生しだし、panicでした。さらに、この一行を先頭に移動しても、エラーが生じました。しかし、なかなかエラーの原因を特定することができませんでした。
この記事を書いている時に知ったのですが、
CORS は様々なエラーで失敗することがありますが、セキュリティ上の理由から、エラーについて JavaScript から知ることができないよう定められています。コードからはエラーが発生したということしか分かりません。何が悪かったのかを具体的に知ることができる唯一の方法は、ブラウザーのコンソールで詳細を見ることです。
とのことです。つまり、どのようなエラーが生じたとしても、
Access to fetch at 'http://localhost:8000/pilot' from origin 'null' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
が表示されます。これのせいでデバッグが困難になっていました。今になって思うと、chromeであればNetworkのタブから、4XXのエラーの種類を見て、どういうエラーがあり得るか推測できたのにな、と時間を無駄にしてしまった感が否めないです。実際、 Pathprefix("")
を削除したときと、先頭に持って行ったときで、コンソールに表示されるエラーの文章は同じでしたが、ステータスはそれぞれ、405と404でした。
では、なぜ r.PathPrefix("").Handler(logChain.Then(http.StripPrefix("/img", http.FileServer(http.Dir("./img")))))
が追加されているときは、エラーが生じなかったかというと、 OPTIONS http://localhost:8000/pilot
というリクエストは、
r.Methods("GET").Path("/pilot").Handler(authChain.Then(funcHandler{pilotFunc}))
に対しては、Method Not Allowedエラーになるのですが、
r.PathPrefix("").Handler(logChain.Then(http.StripPrefix("/img", http.FileServer(http.Dir("./img")))))
に対しては、適合してしまうのです。実は、gorilla/muxで登録するパスは、正規表現に直されるのですが、 PathPrefix
に空文字""を登録した場合、対応する正規表現は "^"
となります。したがって、 /pilot
は上記のルーティングに対して、Not Foundとならずに、処理が進んでしまいます。
また、メソッドが OPTIONS
だった場合、 rs/cors は即座に204 No Contentを返すため、その後の http.StripPrefix("/img", http.FileServer(http.Dir("./img"))))
でエラーが生じるようなリクエストも通ってしまいます。そのため、画像を返すために設定したルーティングが、意図せずにCORSのエラーを回避するものとなっていたのです。
最後に
Go言語はシンプルなものを組み合わせる、という考え方がありますが、今回のgollira/mux、rs/cors、justinas/aliceについても、挙動を理解するために読むべきファイルの数が1つか2つであったため、処理を順に読み解いていくことが容易に行えました。この記事の説明では全てを完全には説明できていないと思うので、分からないことがあれば、それぞれの実装を自分の目で見ていただければと思います。