Golang 作為一個偏向 server 應用的程式語言,一般的 web server 並不會直接使用原生的 package net/http,而更多的使用 gin-gonic/gin 或是 gorilla/mux,後來也有 labstack/echo 以及 go-chi/chi 等等選擇,在效能、輕量、好維護、好擴充中,都能找到對應的 third party package,其中的原因不外乎是原生的 package 提供的功能過於簡潔。

好在 1.22 中,官方改進了 net/http 中對於多工器、路由,甚至出了一篇部落格,現在更可以「大膽的」直接使用 standard library。

Path Parameter

若要將應用的 Web API 定義成 RESTful,我們會使用 /資源/{資源唯一識別符}/子資源/{子資源唯一識別符} 來定義路徑。假如要獲取一個使用者的訂單,則會使用 GET /users/1/orders 來獲取。在 1.22 以前,我們只能定義到 /users,再自行解析往後的 path:

http.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
    subPath := strings.TrimPrefix(req.URL.Path, "/users/")
    if len(subPath) == 0 {
        xxx
    } else {
        ooo
    }
    ...
})

而在 1.22 中新增了 net.http 對 path parameter 的支持,我們可以直接使用 (*http.Request).PathValue("xxx") 來獲取:

http.HandleFunc("/users/{user_id}", func(w http.ResponseWriter, r *http.Request) {
    userID := r.PathValue("user_id")
    ...
})

不過也帶來一些限制

相容 go 1.x

眾所周知 Golang 是一個極度在意簡單性、向後相容的程式語言,為了不要因為升到 1.22 而發生非預期的錯誤,是可以讓 path parameter 的路由與一般路由並存的。

The precedence rule is simple: the most specific pattern wins. This rule matches our intuition that posts/latests should be preferred to posts/{id}, and /users/{u}/posts/latest should be preferred to /users/{u}/posts/{id}. It also makes sense for methods. For example, GET /posts/{id} takes precedence over /posts/{id} because the first only matches GET and HEAD requests, while the second matches requests with any method.

舉例來說:

mux.HandleFunc("/orders/{order_id}", xxx)
mux.HandleFunc("/orders/latest",xxx)

若是使用 gin 的話,這種路由註冊將在 runtime 出現 panic,也是由於 gin 是一個基於 valyala/fasthttp 的 package,而 valyala/fasthttp 又是基於 radix 這種資料結構,node 間發生了衝突才引發 panic。

net/http 則是使其相容於舊版本:

只有在發生模糊不清的路徑時,才會在 runtime 發生 panic:

mux.HandleFunc("/orders/latest",xxx)
mux.HandleFunc("/{other_resource}/latest")
//  pattern "/{other_resource}/latest" (registered at /home/raiven/go-http-22/main.go:110) conflicts with pattern "/orders/{order_id}"

更進一步的相容可以打開 GODEBUG=httpmuxgo121=1 來使其單獨 rollback 回 1.21。

package http

type ServeMux struct {
    mu       sync.RWMutex
    tree     routingNode
    index    routingIndex
    patterns []*pattern  // TODO(jba): remove if possible
    mux121   serveMux121 // used only when GODEBUG=httpmuxgo121=1
}

指定 Method

可以直接將 http method 寫在路由判斷內,這個改動相較簡單,卻又能大幅度的減少程式碼,直接寫範例:

before upgrading to 1.22:

mux.HandleFunc("/orders", func(w http.ResponseWriter, r *http.Request) {
    if r.Method == http.MethodGet {
        xxx
    } else if r.Method == http.MethodPost {
        xxx
    } else {
        w.WriteHeader(http.StatusMethodNotAllowed)
        return
    }
})

after upgrading to 1.22:

mux.HandleFunc("GET /orders", func(w http.ResponseWriter, r *http.Request) {})
mux.HandleFunc("POST /orders", func(w http.ResponseWriter, r *http.Request) {})

比較編譯大小

如此一來有一些很小的 package 就不在需要引入碩大的 third party package,比較一下不同的 package 在 handle localhost:8080/hello 的 binary 大小:

全部基於 linux,amd64,go1.22.0

  • net/http
package main

import "net/http"

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("Hello, World!"))
    })
    http.ListenAndServe(":8080", mux)
}
  • gin-gonic/gin
package main

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

func main() {
    router := gin.Default()

    router.GET("/hello", func(c *gin.Context) {
        c.JSON(http.StatusOK, "Hello, World!")
    })

    http.ListenAndServe(":8080", router)
}
  • go-chi/chi
package main

import (
    "net/http"

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

func main() {
    r := chi.NewRouter()

    r.Get("/hello", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("Hello, World!"))
    })

    http.ListenAndServe(":8080", r)
}
  • gorilla/mux
package main

import (
    "net/http"

    "github.com/gorilla/mux"
)

func main() {
    r := mux.NewRouter()
    r.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("Hello, World!"))
    })

    http.ListenAndServe(":8080", r)
}
  • labstack/echo
package main

import (
    "net/http"

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

func main() {
    e := echo.New()

    e.GET("/hello", func(c echo.Context) error {
        return c.String(http.StatusOK, "Hello, World!")
    })

    e.Logger.Fatal(e.Start(":8080"))
}

使用 go build main.go 來編譯成可執行檔,並使用 du -sh main 來查看執行檔大小:

package size
net/http 6.8M
gin-gonic/gin 11M
go-chi/chi 7.1M
gorilla/mux 7.1M
labstack/echo 7.5M

假如自己的 side project 每天都要編譯一個 nightly version 的 docker image,使用 gin 將比原生的 net/http 多出 1.5G 的存儲空間。

:::info 2024-10-30 更新 :::

實戰

我寫了一個簡易的記帳系統 bookly,這是使用到的 module:

module github.com/omegaatt36/bookly

go 1.23.0

require (
	github.com/go-gormigrate/gormigrate/v2 v2.1.2
	github.com/golang-jwt/jwt v3.2.2+incompatible
	github.com/samber/slog-zap/v2 v2.6.0
	github.com/shopspring/decimal v1.4.0
	github.com/stretchr/testify v1.9.0
	github.com/urfave/cli/v2 v2.27.4
	go.uber.org/zap v1.27.0
	golang.org/x/crypto v0.27.0
	gorm.io/driver/postgres v1.5.9
	gorm.io/driver/sqlite v1.5.6
	gorm.io/gorm v1.25.12
)

裡面在 http 的處理僅使用標準庫的 http package,詳細可以參考 app/api/router.go, 正是將 http method 寫進 path 內進行註冊,並

func (s *Server) registerRouters() {
	authenticators := make(map[domain.IdentityProvider]domain.Authenticator)
	if s.jwtSalt != nil && s.jwtSecret != nil {
		authenticators[domain.IdentityProviderPassword] = auth.NewJWTAuthorizator(*s.jwtSalt, *s.jwtSecret)
	}

	publicRouter := http.NewServeMux()
	internalRouter := http.NewServeMux()
	v1Router := http.NewServeMux()

	repo := repository.NewGORMRepository(database.GetDB())
	{
		bookkeepingX := bookkeeping.NewController(repo, repo)

		v1Router.HandleFunc("POST /accounts", bookkeepingX.CreateAccount())
		v1Router.HandleFunc("GET /accounts", bookkeepingX.GetAllAccounts())
		v1Router.HandleFunc("GET /accounts/{id}", bookkeepingX.GetAccountByID())
		v1Router.HandleFunc("PATCH /accounts/{id}", bookkeepingX.UpdateAccount())
		v1Router.HandleFunc("DELETE /accounts/{id}", bookkeepingX.DeactivateAccountByID())
		v1Router.HandleFunc("GET /users/{user_id}/accounts", bookkeepingX.GetUserAccounts())
	}
	{
		userOptions := make([]user.Option, 0)
		for identityProvider, authenticator := range authenticators {
			userOptions = append(userOptions, user.WithAuthenticator(identityProvider, authenticator))
		}

		userX := user.NewController(repo, userOptions...)

		internalRouter.HandleFunc("POST /auth/register", userX.RegisterUser())
		publicRouter.HandleFunc("POST /auth/login", userX.LoginUser())
	}

	authMiddlewares := []middleware{}
	if s.jwtSalt != nil && s.jwtSecret != nil {
		jwtAuthenticator := auth.NewJWTAuthorizator(*s.jwtSalt, *s.jwtSecret)
		authMiddlewares = append(authMiddlewares, authenticated(jwtAuthenticator))
	}

	router := http.NewServeMux()
	router.Handle("/v1/", http.StripPrefix("/v1", chainMiddleware(authMiddlewares...)(v1Router)))
	router.Handle("/internal/", http.StripPrefix("/internal", onlyInternal(*s.internalToken)(internalRouter)))
	router.Handle("/public/", http.StripPrefix("/public", publicRouter))

	s.router = chainMiddleware(rateLimiter(10, 100), logging)(router)
}

同時在 app/api/engine/chain.go 內實現一個 http request/response 綁定的操作,進而增進程式碼的可維護性。在如此小型的 side project 中,標準庫的 http package 已經足夠使用了。