2019年4月10日水曜日

golang の net_http をいろいろ使ってみた

概要

過去に簡単な Web アプリを作りましたが他にもいろいろな使い方ができます
フレームワークでも net/http がベースになっているものが多く覚えていて損はないので勉強がてらいろいろな使い方を試してみました
参考サイトを元に書いているので基本的には写経ですが写経でも実際に手を動かしながらやると勉強になります

環境

  • macOS 10.14.4
  • golang 1.11.5

追記: 20210114

ボディを読み込む場合に r.Body.Read ではなく ioutil.ReadAll(buffer) を使ったほうが良いというご指摘があったので修正しました
修正版は以下になります

package main

import (
    "encoding/json"
    "io"
    "io/ioutil"
    "net/http"
    "time"
)

type MyHandler struct {
}

func (mh MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    switch r.Method {
    case http.MethodPost:
        if r.Header.Get("Content-Type") == "application/json" {
            buffer, err := ioutil.ReadAll(r.Body)
            if err != nil && err != io.EOF {
                w.WriteHeader(http.StatusConflict)
                return
            }
            defer r.Body.Close()
            b := map[string]string{}
            err = json.Unmarshal(buffer, &b)
            if err != nil {
                w.WriteHeader(http.StatusBadRequest)
                return
            }
            w.Write(buffer)
            return
        } else {
            w.WriteHeader(http.StatusUnsupportedMediaType)
            return
        }
    default:
        w.WriteHeader(http.StatusMethodNotAllowed)
        return
    }
}

func main() {
    srv := &http.Server{
        Addr:         ":8080",
        Handler:      MyHandler{},
        ReadTimeout:  3 * time.Second,
        WriteTimeout: 3 * time.Second,
    }
    srv.ListenAndServe()
}

クロージャ関数を使う

package main

import (
        "fmt"
        "net/http"
)

func main() {
        http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
                fmt.Fprintf(w, "hello")
        })
        http.ListenAndServe(":8080", nil)
}

http.HandleFunc でリクエストを受けるのが基本です
http.HandleFunc は第二引数に関数 (handler func(ResponseWriter, *Request)) を受け取り対象のルーティングにアクセスがあった場合に関数が実行されます
golang ではクロージャが使えるので関数を外部に宣言しないで直接引数に関数を渡すことができます

簡単な処理の場合はこれでいいと思います

http.Handle を使う

http.HandleFunc ではなく http.Handle を使ってルーティングを定義することもできます

package main

import (
    "fmt"
    "net/http"
)

type MyStruct struct {
    name string
}

func (my MyStruct) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, my.name)
}

func main() {
    http.Handle("/", MyStruct{"hawksnowlog"})
    http.ListenAndServe(":8080", nil)
}

構造体を用意して、その構造体に ServeHTTP(w http.ResponseWriter, r *http.Request) を実装すれば OK です
あとは http.Handle の第二引数に構造体を指定すれば OK です
この場合は構造体のメンバが ServeHTTP 関数内で参照できるのが便利なのとルーティングごとに構造体を分けて実装することができるのでリファクタリングなどにも使えると思います

POST で application/json だけを許可する

ボディに JSON が設定されて POST で受け取るケースはよくあると思います
コードは少し長くなります

package main

import (
    "encoding/json"
    "io"
    "net/http"
    "strconv"
)

func postHandler(w http.ResponseWriter, r *http.Request) {
    switch r.Method {
    case http.MethodPost:
        if r.Header.Get("Content-Type") == "application/json" {
            length, err := strconv.Atoi(r.Header.Get("Content-Length"))
            if err != nil {
                w.WriteHeader(http.StatusLengthRequired)
                return
            }
            buffer := make([]byte, length)
            _, err = r.Body.Read(buffer)
            if err != nil && err != io.EOF {
                w.WriteHeader(http.StatusConflict)
                return
            }
            b := map[string]string{}
            err = json.Unmarshal(buffer, &b)
            if err != nil {
                w.WriteHeader(http.StatusBadRequest)
                return
            }
            w.Write(buffer)
            return
        } else {
            w.WriteHeader(http.StatusUnsupportedMediaType)
            return
        }
    default:
        w.WriteHeader(http.StatusMethodNotAllowed)
        return
    }
}

func main() {
    http.HandleFunc("/", postHandler)
    http.ListenAndServe(":8080", nil)
}

r.Header.Get("Content-Type") で Content-Type をチェックします
application/json 以外だった場合は http.StatusUnsupportedMediaType を返します

次に r.Header.Get("Content-Length") をチェックします
これはボディに JSON が設定されていれば (大抵の場合) 同時にヘッダにその JSON の長さを設定してくれます

ボディ を取得するには r.Body.Read(buffer) を使います
buffer はバイト配列で Content-Length 分用意されています
今回の場合、ボディは JSON 形式なので json.Unmarshal(buffer, &b) で変換します
とりあえず map[string]string{} に変換していますが型などが決まっている場合は struct を使っても良いと思います

ちゃんと JSON に変換することができれば成功でそのまま指定された JSON を返却しています

  • curl -XPOST -H 'content-type: application/json' -d '{"key":"value"}' localhost:8080

こんな感じでリクエストする成功します

r.Body.Read でタイムアウトしない

上記のコードの場合、実は以下のようなリクエストを送信すると r.Body.Read の部分でタイムアウトしません

  • curl -XPOST -H 'content-type: application/json' -H 'content-length: 10' localhost:8080

なので http.Serverhttp.Handler を使ってタイムアウト (ReadTimeout/WriteTimeout) を設定したほうが良いです

package main

import (
    "encoding/json"
    "io"
    "net/http"
    "strconv"
    "time"
)

type MyHandler struct {
}

func (mh MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    switch r.Method {
    case http.MethodPost:
        if r.Header.Get("Content-Type") == "application/json" {
            length, err := strconv.Atoi(r.Header.Get("Content-Length"))
            if err != nil {
                w.WriteHeader(http.StatusLengthRequired)
                return
            }
            buffer := make([]byte, length)
            _, err = r.Body.Read(buffer)
            if err != nil && err != io.EOF {
                w.WriteHeader(http.StatusConflict)
                return
            }
            defer r.Body.Close()
            b := map[string]string{}
            err = json.Unmarshal(buffer, &b)
            if err != nil {
                w.WriteHeader(http.StatusBadRequest)
                return
            }
            w.Write(buffer)
            return
        } else {
            w.WriteHeader(http.StatusUnsupportedMediaType)
            return
        }
    default:
        w.WriteHeader(http.StatusMethodNotAllowed)
        return
    }
}

func main() {
    srv := &http.Server{
        Addr:         ":8080",
        Handler:      MyHandler{},
        ReadTimeout:  3 * time.Second,
        WriteTimeout: 3 * time.Second,
    }
    srv.ListenAndServe()
}

テンプレートを使う

例えば HTML なんか配信したい場合にテンプレートが使えます
標準のライブラリで html/template というライブラリがあるのでそれを使ってみます

  • vim index.tpl
<html>
<head>
  <title>template test</title>
</head>
<body>
  <ul>
  {{ range $key, $val := . }}
  <li>{{ $key }} => {{ $val }}</li>
  {{ end }}
  </ul>
</body>
</html>

go 側から map を渡してその中身を range で取り出して表示する HTML です

package main

import (
    "html/template"
    "net/http"
)

func templateHandler(w http.ResponseWriter, r *http.Request) {
    t := template.Must(template.ParseFiles("./index.tpl"))
    p := map[string]string{
        "key1": "value1",
        "key2": "value2",
        "key3": "value3",
    }
    err := t.Execute(w, p)
    if err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        return
    }
}

func main() {
    http.HandleFunc("/", templateHandler)
    http.ListenAndServe(":8080", nil)
}

まず template.Must で使用するテンプレートを初期化します
template.ParseFiles を使えば複数のテンプレートファイルを読み込むことができます
[]string を使えば複数のテンプレートファイルを指定可能です

テンプレートをコールする場合は t.Execute(w, p) を使います
template.ExecuteTemplate を使えば複数テンプレートを読み込んでいる場合にテンプレートを指定して実行することができます

実行するとわかりますが、出力される HTML のテンプレートが改行されているわかると思います
もし改行したくない場合は trim モードを使うと改行されなくなります
改行させたくない場合はテンプレートの最後にハイフンを入れると改行されなくなります

{{ range $key, $val := . -}}
<li>{{ $key }} => {{ $val -}}</li>
{{ end -}}

また今回は map をテンプレート側に渡しましたが構造体を渡すとメンバを {{ .Name }} のように参照することが可能です

静的ファイルを配信する

  • mkdir public
  • cp icon.png public/
// 一部省力

func main() {
    http.HandleFunc("/", templateHandler)
    http.Handle("/img/", http.StripPrefix("/img/", http.FileServer(http.Dir("./public"))))
    http.ListenAndServe(":8080", nil)
}

http.FileServer を使います
public ディレクトリ配下に配置したファイルを /img/ というパスで配信します
なので上記の場合は localhost:8080/img/icon.png で画像ファイルを参照することができます

ロギングを考える

http.ListenAndServe の第二引数を使うのが一番簡単かなと思います
所謂ミドルウェア的な使い方ができます

package main

import (
    "html/template"
    "log"
    "net/http"
)

func templateHandler(w http.ResponseWriter, r *http.Request) {
    t := template.Must(template.ParseFiles("./index.tpl"))
    p := map[string]string{
        "key1": "value1",
        "key2": "value2",
        "key3": "value3",
    }
    err := t.Execute(w, p)
    if err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        return
    }
}

func logRequest(handler http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        log.Printf("%s %s %s\n", r.RemoteAddr, r.Method, r.URL)
        handler.ServeHTTP(w, r)
    })
}

func main() {
    http.HandleFunc("/", templateHandler)
    http.Handle("/img/", http.StripPrefix("/img/", http.FileServer(http.Dir("./public"))))
    http.ListenAndServe(":8080", logRequest(http.DefaultServeMux))
}

こうすることで HandleFund などで登録したハンドラが実行される前に logRequest がコールされるようになります
logRequest 内で handler.ServeHTTP(w, r) することで次のハンドラが自動的にコールされるイメージです
DefaultServeMuxServeMux 構造体になります

また r.Body に関しては実際にボディを扱うハンドラ内で参照しましょう
なぜなら r.Body を複数回参照すると 2 回目以降は空になってしまうためです (参考)

最後に

net/http パッケージをいろいろと試してみました
デフォルトのパッケージだけでも結構いろいろできるなと思いました
もちろんサードパーティのフレームワークもたくさんあるのでそれらを使ったほうが ORM があったりロギングがあったりエラーハンドリングが用意されていたりするので便利ではあると思います

http.Handlehttp.HandleFunc、あとは http.Server あたりを理解して使いこなせるようになると良いかなと思います

参考サイト

2 件のコメント:

  1. r.Body.Read(buffer)だと最後まで読み込まれない可能性があり、ioutil.ReadAll(buffer)を使用する必要があるようです。
    https://qiita.com/nyamage/items/e07de57d486238567ba7#comment-2fe78bb08ff9187aac6b

    返信削除
  2. ご指摘ありがとうございます、ioutil.ReadAll を使ったバージョンを追記しました

    返信削除