最近よく書く HTTP サーバ基礎部分

この記事は Go3 Advent Calendar 2019 13日目の記事です qiita.com

TL;DR

  • x/sync/errgroup はいいぞ

本編

最近よく書くサーバの起動部分のコードを紹介します
分かる人には見るのが一番早いと思うので、早速コード全体を載せます

やりたいことは

  • localhost:8888 で HTTP サーバを起動
  • SIGINT を受けると HTTP サーバを graceful shutdown
  • 各所での ctx.Done() のハンドリング

これらを混乱しないように記述したかったのが最初のモチベーションです

package main

import (
    "context"
    "fmt"
    "net/http"
    "os"
    "x/sync/errgroup"
)

func main() {
    os.Exit(run(context.Background()))
}

func run(ctx context.Context) int {
    var eg *errgroup.Group
    eg, ctx = errgroup.WithContext(ctx)

    eg.Go(func() error {
        return runServer(ctx)
    })
    eg.Go(func() error {
        return signal(ctx)
    })
    eg.Go(func() error {
        <-ctx.Done()
        return ctx.Err()
    })

    if err := eg.Wait(); err != nil {
        fmt.Println(err)
        return 1
    }
    return 0
}

func signal(ctx context.Context) error {
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, os.Interrupt)

    select {
    case <-ctx.Done():
        signal.Reset()
        return nil
    case sig := <-sigCh:
        return fmt.Errorf("signal received: %v", sig.String())
    }
}

func runServer(ctx context.Context) error {
    s := &http.Server{
        Addr:    ":8888",
        Handler: nil,
    }

    errCh := make(chan error)
    go func() {
        defer close(errCh)
        if err := s.ListenAndServe(":8888", nil); err != nil {
            errCh <- err
        }
    }()

    select {
    case <-ctx.Done():
        return s.Shutdown()
    case err := <-errCh:
        return err
    }
}

解説

x/sync/errgroup

godoc.org

単純に一つ HTTP サーバを立てるだけであれば必要ありませんが、今回のように HTTP サーバも起動したいし、シグナルハンドリングもしたい、なんなら他のサーバも起動したい、かつそれらの error 処理も纏めてやりたいというような要件を達成するために x/sync/errgroup は非常に便利なパッケージです
指定した goroutine をグループ化し、そのエラー処理を取り纏めてくれます

以下のように使います

  • errgroup.Group 型の値(0値が使用可能)を用意する
  • errgroup.Group.Go() で error を返す関数を goroutine として起動する
  • errgroup.Group.Wait()errgroup.Group.Go() で起動した goroutine の全てが終了するまで待機し、一番最初に返ってきた non-nil error を返す

一番最初に返ってきた non-nil error を強調しましたが、どうしても起動した goroutine の全ての error を補足したいときは x/sync/errgroup は使用できないことに注意してください
// この場合は sync.WaitGroup などで自力で頑張りましょう

加えて errgroup.WithContext() で初期化をしておくと、最初に non-nil error が返ってきた瞬間に context がキャンセルされます
今回のコードでは run() がこの処理をしている部分です

  • runServer() が error を返す
  • signal() が error を返す

のどちらかが起きると、ctx のキャンセル処理により他方も終了処理が出来る、という作りです

func run(ctx context.Context) int {
    var eg *errgroup.Group
    eg, ctx = errgroup.WithContext(ctx)

    eg.Go(func() error {
        return runServer(ctx)
    })
    eg.Go(func() error {
        return signal(ctx)
    })
    eg.Go(func() error {
        <-ctx.Done()
        return ctx.Err()
    })

    if err := eg.Wait(); err != nil {
        fmt.Println(err)
        return 1
    }
    return 0
}

runServer()/signal()

次は errgroup.Group.Go() で呼び出される側の関数です
単純に http.ListenAndServe() を実行するだけでもよいですが、ctx のキャンセル待受処理を両立するために http.ListenAndServe() の error を chan 経由でやりとりします

func runServer(ctx context.Context) error {
    // サーバ初期化
    s := &http.Server{Addr: ":8888", Handler: nil}
    // error 伝達用 chan
    errCh := make(chan error)
    go func() {
        defer close(errCh)
        if err := s.ListenAndServe(":8888", nil); err != nil {
            // error を chan 経由で伝える
            errCh <- err
        }
    }()
    select {
    case <-ctx.Done(): // 上位で ctx のキャンセルされた場合は graceful shutdown
        return s.Shutdown()
    case err := <-errCh: // `s.ListenAndServe()` が error を返した場合、そのまま error を返す
        return err
    }
}

シグナル待受処理も同様です シグナルを受ける chanctx.Done()select 文で待ち受けるように書きます

2019/12/13 追記: 取りこぼす可能性があるので、chan にバッファを付けた

func signal(ctx context.Context) error {
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, os.Interrupt)

    select {
    case <-ctx.Done():
        signal.Reset()
        return nil
    case sig := <-sigCh:
        return fmt.Errorf("signal received: %v", sig.String())
    }
}

やってみて

シグナルハンドリングも x/sync/errgroup にのせることで、制御フローをシンプルにできてるのではないかなぁと感じています

私が実際に導入した要件では、HTTP サーバ、gRPC サーバが一つのコードで動いている中、追加で別ポートに gRPC サーバを立てる必要が出てきました
その際もコードの拡張は eg.Go() 部分と runServer() 部分の追加でよく、gRPC サーバ自身の後処理も runServer() で完結できているので苦労なく追加することができました

x/sync/errgroup 便利