紙芝居用の簡易 Web サーバを Go 言語で書く【叱られ編】

このネタも3回目なので強引にシリーズ化(笑)

  1. 紙芝居用の簡易 Web サーバを Go 言語で書く
  2. 紙芝居用の簡易サーバを書く【Go 1.16 版】
  3. 紙芝居用の簡易 Web サーバを Go 言語で書く【叱られ編】 ←イマココ

net.JoinHostPort 関数を使え!

これまでの2回の記事を受けて,今回はこのコードからスタート。

package main

import (
    "embed"
    "flag"
    "fmt"
    "io/fs"
    "net/http"
    "os"
)

//go:embed html
var assets embed.FS

func main() {
    port := flag.Int("p", 3000, "port number")
    host := flag.String("host", "", "hostname")
    flag.Parse()

    addr := fmt.Sprintf("%s:%d", *host, *port)
    if len(*host) == 0 {
        fmt.Printf("Open http://localhost%s/\n", addr)
    } else {
        fmt.Printf("Open http://%s/\n", addr)
    }
    fmt.Println("Press ctrl+c to stop")

    root, err := fs.Sub(assets, "html")
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        return
    }

    http.Handle("/", http.FileServer(http.FS(root)))
    if err := http.ListenAndServe(addr, nil); err != nil {
        fmt.Fprintln(os.Stderr, err)
    }
}

このうち,色付きの行に関連する以下の tweet を見かけた。

なるほど。 というわけで,先程の部分を

addr := net.JoinHostPort(*host, strconv.Itoa(*port))

に置き換えた。 これを実行すると

$ go run main.go -host "127.0.0.1" -p 8080
Open http://127.0.0.1:8080/
Press ctrl+c to stop

うんうん。 ちゃんと動くな。

またまた lint に叱られる

今回のコードに対して念のため lint をかけてみる。

$ golangci-lint run --enable gosec
main.go:35:12: G114: Use of net/http serve function that has no support for setting timeouts (gosec)
    if err := http.ListenAndServe(addr, nil); err != nil {
              ^

おぅふ。 そっちかよ orz

http.ListenAndServe() 関数の中身を見ると

// ListenAndServe listens on the TCP network address addr and then calls
// Serve with handler to handle requests on incoming connections.
// Accepted connections are configured to enable TCP keep-alives.
//
// The handler is typically nil, in which case the DefaultServeMux is used.
//
// ListenAndServe always returns a non-nil error.
func ListenAndServe(addr string, handler Handler) error {
    server := &Server{Addr: addr, Handler: handler}
    return server.ListenAndServe()
}

となっている。 たしかにタイムアウト関連のフィールドがまるっと無視(つまりゼロ値が設定)されてるな。

試しに

if err := http.ListenAndServe(addr, nil); err != nil {
    fmt.Fprintln(os.Stderr, err)
}

を以下に置き換えてみる。

server := &http.Server{
    Addr:    addr,
    Handler: nil,
}
if err := server.ListenAndServe(); err != nil {
    fmt.Fprintln(os.Stderr, err)
}

これで機能は全く同じになる。 これを lint にかけてみる。

$ golangci-lint run --enable gosec
main.go:35:13: G112: Potential Slowloris Attack because ReadHeaderTimeout is not configured in the http.Server (gosec)
    server := &http.Server{
        Addr:    addr,
        Handler: nil,
    }

おおっ。 内容が変わった。 ふむふむ。 http.Server.ReadHeaderTimeout フィールドに値を設定しろということか。

ちなみに ReadHeaderTimeout は,名前の通り,リクエストヘッダ読み込み時のタイムアウト時間を指定する time.Duration 型のフィールドで,ゼロ値がセットされているとタイムアウトが設定されないらしい。 つまり ReadHeaderTimeout フィールドは,悪意を持った巨大リクエストヘッダを読み込まされることで処理全体が stall しないようにするための措置のようだ。

というわけで書き直す。

server := &http.Server{
    Addr:              addr,
    Handler:           nil,
    ReadHeaderTimeout: 3 * time.Second,
}
if err := server.ListenAndServe(); err != nil {
    fmt.Fprintln(os.Stderr, err)
}

3秒という値には特に意味はない。 どのくらいが適当なのかねぇ。

とにかく,これで問題なく動作することを確認した後,3たび lint をかけてみる。

$ golangci-lint run --enable gosec

なんとか lint は通ったようだ。

余談だが http.Server.Handler フィールドにゼロ値(=nil)がセットされていると http.DefaultServeMux が既定のハンドラとして使われる。 また http.Handle() 関数の中身は

// Handle registers the handler for the given pattern
// in the DefaultServeMux.
// The documentation for ServeMux explains how patterns are matched.
func Handle(pattern string, handler Handler) { DefaultServeMux.Handle(pattern, handler) }

となっていて http.DefaultServeMux にハンドラを登録していることが分かる。 さらに http.DefaultServeMux

// DefaultServeMux is the default ServeMux used by Serve.
var DefaultServeMux = &defaultServeMux

var defaultServeMux ServeMux

と定義されている。 なので,先程のコードは

serverMux := http.NewServeMux()
serverMux.Handle("/", http.FileServer(http.FS(root)))
server := &http.Server{
    Addr:              addr,
    Handler:           serverMux,
    ReadHeaderTimeout: 3 * time.Second,
}
if err := server.ListenAndServe(); err != nil {
    fmt.Fprintln(os.Stderr, err)
}

と等価だ。 こちらのほうが却って分かりやすいかもしれない。

Ctrl+C でサーバを Graceful にシャットダウンする

http.Server のドキュメントに Ctrl+C の SIGNAL を受信したら Shutdown() メソッドを走らせて graceful にシャットダウンするサンプルが載っていたので,それを参考に組み込んで今回の最終コードとしてみた。 全体としてはこんな感じでどうだろう。

package main

import (
    "context"
    "embed"
    "errors"
    "flag"
    "fmt"
    "io/fs"
    "net"
    "net/http"
    "os"
    "os/signal"
    "strconv"
    "time"
)

//go:embed html
var assets embed.FS

func main() {
    port := flag.Int("p", 3000, "port number")
    host := flag.String("host", "", "hostname")
    flag.Parse()

    addr := net.JoinHostPort(*host, strconv.Itoa(*port))
    if len(*host) == 0 {
        fmt.Printf("Open http://localhost%s/\n", addr)
    } else {
        fmt.Printf("Open http://%s/\n", addr)
    }
    fmt.Println("Press ctrl+c to stop")

    root, err := fs.Sub(assets, "html")
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        return
    }

    serverMux := http.NewServeMux()
    serverMux.Handle("/", http.FileServer(http.FS(root)))
    server := &http.Server{
        Addr:              addr,
        Handler:           serverMux,
        ReadHeaderTimeout: 3 * time.Second,
    }

    idleConnsClosed := make(chan struct{})
    go func() {
        defer close(idleConnsClosed)
        sigint := make(chan os.Signal, 1)
        signal.Notify(sigint, os.Interrupt)
        <-sigint

        if err := server.Shutdown(context.Background()); err != nil {
            fmt.Fprintln(os.Stderr, "shutdown error:", err)
            return
        }
        fmt.Println("\ngraceful shutdown")
    }()

    if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
        if err != nil {
            fmt.Fprintln(os.Stderr, "server error:", err)
        }
        return
    }
    <-idleConnsClosed
}

これを実行する。

$ go run main.go -host "127.0.0.1" -p 8080
Open http://127.0.0.1:8080/
Press ctrl+c to stop
^C
graceful shutdown

Ctrl+C でちゃんとシャットダウンしてるかな。 よしよし。

ブックマーク

参考図書

photo
プログラミング言語Go
アラン・ドノバン (著), ブライアン・カーニハン (著), 柴田芳樹 (著)
丸善出版 2016-06-20 (Release 2021-07-13)
Kindle版
B099928SJD (ASIN)
評価     

Kindle 版出た! 一部内容が古びてしまったが,この本は Go 言語の教科書と言ってもいいだろう。感想はこちら

reviewed by Spiegel on 2021-05-22 (powered by PA-APIv5)

photo
初めてのGo言語 ―他言語プログラマーのためのイディオマティックGo実践ガイド
Jon Bodner (著), 武舎 広幸 (翻訳)
オライリージャパン 2022-09-26
単行本(ソフトカバー)
4814400047 (ASIN), 9784814400041 (EAN), 4814400047 (ISBN)
評価     

2021年に出た “Learning Go” の邦訳版。私は版元で PDF 版を購入。 Go 特有の語法(idiom)を切り口として Go の機能やパッケージを解説している。 Go 1.19 対応。

reviewed by Spiegel on 2022-10-11 (powered by PA-APIv5)