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 已經足夠使用了。