net/httpのhello worldから始めてどういうタイミングでどのライブラリが必要になるかのメモ

goを始めたばかりの人の質問に対して以下の様な記事や回答はまれによく見る(自分自身もそのように答えることはままある)。

とはいえ、ある程度煩雑になってくるとライブラリなどを追加したくなる1。どのようなタイミングで何が欲しくなるなどをメモしておくと頭の整理も兼ねられて良さそう。というわけでメモ。

大雑把な目次は以下のようなもの。

  1. hello world -- 単一の機能へのエンドポイントがほしい
  2. mux -- 複数の機能を提供したい
  3. path parameters -- 自分の見ている視界を他人にも共有したい
  4. parameter binding -- 文字列型以外の値をいい感じに扱いたい
  5. enhancement -- より高速に。より便利に。

今回は概ねroutingに絞った話。middlewareがどうこうだとかテストがどうこうだとか実際に本番で運用するときのロギングやモニタリングの話などは含まれていない。

1. hello world

net/httpの最もシンプルなコード例は以下の様なもの。ただ単一の機能を外部に向けて公開したいときに使われる。

package main

import (
    "fmt"
    "log"
    "net/http"
    "os"
    "strconv"
)

func Hello(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    fmt.Fprintf(w, `{"message": "hello"}`)
}

func main() {
    port := 8888
    if v, err := strconv.Atoi(os.Getenv("PORT")); err == nil {
        port = v
    }

    log.Print("listen ...", port)
    handler := http.HandlerFunc(Hello)
    addr := fmt.Sprintf(":%d", port)
    if err := http.ListenAndServe(addr, handler); err != nil {
        log.Fatalf("!! %+v", err)
    }
}

が、たいていの場合はより多くのものが求められる事が多い(気がする)。

2. mux

複数の機能を提供したい場合には一つの入力を複数の出力につなげるmultiplexier的なものが欲しくなる。これはnet/http.ServeMuxを使えば良い。ただしpathは固定された値。此処までは標準ライブラリの範囲。

package main

import (
    "fmt"
    "log"
    "net/http"
    "os"
    "strconv"
)

func NewGreeting(message string) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json; charset=utf-8")
        fmt.Fprintf(w, `{"message": %q}`, message)
    }
}

func main() {
    port := 8888
    if v, err := strconv.Atoi(os.Getenv("PORT")); err == nil {
        port = v
    }

    log.Print("listen ...", port)

    mux := new(http.ServeMux)
    mux.Handle("/hello", NewGreeting("hello"))
    mux.Handle("/byebye", NewGreeting("byebye"))

    addr := fmt.Sprintf(":%d", port)
    if err := http.ListenAndServe(addr, mux); err != nil {
        log.Fatalf("!! %+v", err)
    }
}

用途としては、net/http/pprof等が挙げられる。複数のview(機能)が一人のユーザーに対して提供されている。

実際のところ、インフラ用の内部ツール的なものを作っているときなどはここまでで十分なことも多い。

他には、静的ファイルの配信(net/http.ServeFileなども個人的にはこの範疇に含めている2

3. path parameters

今度は自分の見ている視界を他人にも共有したくなってくる。このようなときURLのpathの一部を変数として扱いたい。例えば以下の様なREST APIの一部のようなものがそれ。

GET /articles
GET /articles/{articleId}

このようなものへの対応にはrouterライブラリを使いたくなる。ここで外部ライブラリの利用を検討し始める。別解3も存在するがあまり頑張る意義はない気がしている。 routerライブラリとは例えば go-chi/chijulienschmidt/httproutergorilla/muxのようなもののこと。

package main

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "os"
    "strconv"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    "github.com/go-chi/render"
)

type Article struct {
    Title string `json:"title"`
}

var articles = map[string]*Article{
    "1": {Title: "hello"},
    "2": {Title: "byebye"},
}

func ListArticle(w http.ResponseWriter, r *http.Request) {
    result := make([]*Article, 0, len(articles))
    for _, ob := range articles {
        result = append(result, ob)
    }
    render.JSON(w, r, result)
}

func GetArticle(w http.ResponseWriter, r *http.Request) {
    articleID := chi.URLParam(r, "articleId")
    ob, ok := articles[articleID]
    if !ok {
        // go-chi/renderを使わない場合には w.WriteHeader()を使う
        r := r.WithContext(context.WithValue(r.Context(), render.StatusCtxKey, http.StatusNotFound))
        render.JSON(w, r, map[string]string{"message": "not found"})
        return
    }
    render.JSON(w, r, ob)
}

func main() {
    port := 8888
    if v, err := strconv.Atoi(os.Getenv("PORT")); err == nil {
        port = v
    }

    log.Print("listen ...", port)

    r := chi.NewRouter()
    r.Use(middleware.Logger)

    r.Get("/articles", ListArticle)
    r.Get("/articles/{articleId}", GetArticle)

    r.NotFound(func(w http.ResponseWriter, r *http.Request) {
        r = r.WithContext(context.WithValue(r.Context(), render.StatusCtxKey, http.StatusNotFound))
        render.JSON(w, r, map[string]string{"message": http.StatusText(http.StatusNotFound)})
    })

    addr := fmt.Sprintf(":%d", port)
    if err := http.ListenAndServe(addr, r); err != nil {
        log.Fatalf("!! %+v", err)
    }
}

より細かくrouterライブラリの分類を気にしたければ以下の記事が良い。フローチャートでより細かく分類してくれている。

その他補足事項として、routerライブラリには、デフォルトのエラーハンドラーを設定する機構が存在している事が多い。必要なら忘れずに上書きしておきたい。特にweb APIなどを作っている場合には。 ライブラリの利用をここまでに留めてた場合には素のnet/http.Handlerを尊重した作りを意識していることが多い。その場合にも以下の様なミドルウェアは使える。

4. parameter binding

先程利用したpath parameterを異なる型の値にマッピングしたい場合に、もう少し強力な機能を使いたくなる。ただ以下の様にstrconvで頑張ってやれないことはないので辛くなったら利用という感じかもしれない。

var articleID int64  // stringではなくint
v, err := strconv.Atoi(chi.URLParam(r, "articleId")); 
if err != nil {
    ...
} else {
    articleID = v
}

そういうstrconvをwrapしたようなユーティリティを持つWAF(Web Application Framework)も存在する4

もう少し範囲を広げて汎用的に利用できる機能がたいていのgoのWAFには存在している。値のバリデーションではなく文字列として得られた入力を別の型に変換したいときに、reflectの力を使いたくなる。そしてたいていの場合はタグで諸々のメタデータを指定する。機能的にはpathに限らずquery stringやheaderにも対応している事が多い。

package main

import (
    "fmt"
    "log"
    "net/http"
    "os"
    "strconv"

    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)

type Article struct {
    Title string `json:"title"`
}

var articles = map[int64]*Article{
    1: {Title: "hello"},
    2: {Title: "byebye"},
}

func ListArticle(c echo.Context) error {
    result := make([]*Article, 0, len(articles))
    for _, ob := range articles {
        result = append(result, ob)
    }
    return c.JSON(http.StatusOK, result)
}

type GetArticleInput struct {
    ID int64 `param:"articleId"`
}

func GetArticle(c echo.Context) error {
    var input GetArticleInput
    if err := c.Bind(&input); err != nil {
        return err
    }

    ob, ok := articles[input.ID]
    if !ok {
        return c.JSON(http.StatusOK, map[string]string{"message": "not found"})
    }
    return c.JSON(http.StatusOK, ob)
}

func main() {
    port := 8888
    if v, err := strconv.Atoi(os.Getenv("PORT")); err == nil {
        port = v
    }
    log.Print("listen ...", port)

    // Echo instance
    e := echo.New()

    // Middleware
    e.Use(middleware.Logger())
    e.Use(middleware.Recover())

    // Routes
    e.GET("/articles", ListArticle)
    e.GET("/articles/:articleId", GetArticle)

    // Start server
    addr := fmt.Sprintf(":%d", port)
    if err := e.Start(addr); err != nil {
        e.Logger.Fatalf("!! %+v", err)
    }
}

この辺やJSON responseなどを返す時のユーティリティも含んでいる事が多い。これらをそのまま利用したくなった場合にはrouterライブラリだけで過ごすのを辞めたくなるかもしれない。 またコレの他にvalidationの機能をechoやginなどは持っている。

直接利用したい場合にはgorilla/schemaなどをつかう5

5. enhancement

より頑張りたい場合の話。

例えば、以下の様なもの。

  • 高速化のためにreflectの利用の一切を排除したい ( 先程のbindingの話、あるいはjsonのserializerの特殊化も含む )
  • 存在するAPIにドキュメントをつけたい6 (e.g. openAPI doc, graphql)
  • observebility的な話

(TODO: 元気があったら情報を追加する。情報は募集中)

まとめ

まとめると以下。

  1. 単一の機能へのエンドポイントがほしい -> http.HandlerFuncを直に利用
  2. 複数の機能を提供したいだけ -> http.ServeMux
  3. pathに変数を持ち込みたい(自分の見ている視界を他人にも共有したい) -> go-chiやrouterライブラリでも戦える
  4. 異なる型の値に変換して利用したい(parameter binding) -> ginやechoがgo-fiberが便利
  5. より高速に。より便利に。 -> goaなどに進むか何らかの開拓が必要

gist


  1. とはいえ、全部追加したとしても、最高に便利で心地の良い開発体験という感じではないけれど

  2. 機能はパラメタライズされていないので。単にエンドポイントが機能の数だけ存在しているだけ。

  3. net/httpで頑張る別解 How to not use an http-router in go あるいはこれらをまとめてくれた記事 Different approaches to HTTP routing in Go

  4. 正直な話をすると、このあたりやvalidation周りに場当たり感を感じなくもない。元気があれば別の実装に乗り換えたくなる程度には。。

  5. parameter binding用のライブラリとdata validation用のライブラリは別。前者はjson.Unmarshal()なども含まれる。後者の例としてはたとえば https://github.com/go-playground/validator

  6. 厳密に言えばこれらの話は欄外と考える事もできなくはない。特にgraphqlに関しては。net/http.Handlerを生成するという切り口にして。