從用了近十年的 C# 轉(zhuǎn)到 Go 是一個有趣的旅程。有時,我陶醉于 Go 的簡潔[1];也有些時候,當(dāng)熟悉的 OOP (面向?qū)ο缶幊蹋?strong>模式[2]無法在 Go 代碼中使用的時候會感到沮喪。幸運的是,我已經(jīng)摸索出了一些寫 HTTP 服務(wù)的模式,在我的團(tuán)隊中應(yīng)用地很好。
當(dāng)在公司項目上工作時,我傾向把可發(fā)現(xiàn)性放在最高的優(yōu)先級上。這些應(yīng)用會在接下來的 20 年運行在生產(chǎn)環(huán)境中,必須有眾多的開發(fā)人員和網(wǎng)站可靠性工程師(可能是指運維)來進(jìn)行熱補丁,維護(hù)和調(diào)整工作。因此,我不指望這些模式能適合所有人。
Mat Ryer 的文章[3]是我使用 Go 試驗 HTTP 服務(wù)的起點之一,也是這篇文章的靈感來源。
代碼組成
Broker
一個 Broker 結(jié)構(gòu)是將不同的 service 包綁定到 HTTP 邏輯的膠合結(jié)構(gòu)。沒有包作用域結(jié)級別的變量被使用。依賴的接口得益于了 Go 的組合[4]的特點被嵌入了進(jìn)來。
type Broker struct {
auth.Client // 從外部倉庫導(dǎo)入的身份驗證依賴(接口)
service.Service // 倉庫的業(yè)務(wù)邏輯包(接口)
cfg Config // 該 API 服務(wù)的配置
router *mux.Router // 該 API 服務(wù)的路由集
}
broker 可以使用阻塞[5]函數(shù) New() 來初始化,該函數(shù)校驗配置,并且運行所有需要的前置檢查。
func New(cfg Config, port int) (*Broker, error) {
r := &Broker{
cfg: cfg,
}
...
r.auth.Client, err = auth.New(cfg.AuthConfig)
if err != nil {
return nil, fmt.Errorf("Unable to create new API broker: %w", err)
}
...
return r, nil
}
初始化后的 Broker 滿足了暴露在外的 Server 接口,這些接口定義了所有的,被 route 和 中間件(middleware)使用的功能。service 包接口被嵌入,這些接口與 Broker 上嵌入的接口相匹配。
type Server interface {
PingDependencies(bool) error
ValidateJWT(string) error
service.Service
}
web 服務(wù)通過調(diào)用 Start() 函數(shù)來啟動。路由綁定通過一個閉包函數(shù)[6]進(jìn)行綁定,這種方式保證循環(huán)依賴不會破壞導(dǎo)入周期規(guī)則。
func (bkr *Broker) Start(binder func(s Server, r *mux.Router)) {
...
bkr.router = mux.NewRouter().StrictSlash(true)
binder(bkr, bkr.router)
...
if err := http.Serve(l, bkr.router); errors.Is(err, http.ErrServerClosed) {
log.Warn().Err(err).Msg("Web server has shut down")
} else {
log.Fatal().Err(err).Msg("Web server has shut down unexpectedly")
}
}
那些對故障排除(比如,Kubernetes 探針[7])或者災(zāi)難恢復(fù)方案方面有用的函數(shù),掛在 Broker 上。如果被 routes/middleware 使用的話,這些僅僅被添加到 webserver.Server 接口上。
func (bkr *Broker) SetupDatabase() { ... }
func (bkr *Broker) PingDependencies(failFast bool)) { ... }
啟動引導(dǎo)
整個應(yīng)用的入口是一個 main 包。默認(rèn)會啟動 Web 服務(wù)。我們可以通過傳入一些命令行參數(shù)來調(diào)用之前提到的故障排查功能,方便使用傳入 New() 函數(shù)的,經(jīng)過驗證的配置來測試代理權(quán)限以及其他網(wǎng)絡(luò)問題。我們所要做的只是登入運行著的 pod 然后像使用其他命令行工具一樣使用它們。
func main() {
subCommand := flag.String("start", "", "start the webserver")
...
srv := webserver.New(cfg, 80)
switch strings.ToLower(subCommand) {
case "ping":
srv.PingDependencies(false)
case "start":
srv.Start(BindRoutes)
default:
fmt.Printf("Unrecognized command %q, exiting.", subCommand)
os.Exit(1)
}
}
HTTP 管道設(shè)置在 BindRoutes() 函數(shù)中完成,該函數(shù)通過 ser.Start() 注入到服務(wù)(server)中。
func BindRoutes(srv webserver.Server, r *mux.Router) {
r.Use(middleware.Metrics(), middleware.Authentication(srv))
r.HandleFunc("/ping", routes.Ping()).Methods(http.MethodGet)
...
r.HandleFunc("/makes/{makeID}/models/{modelID}", model.get(srv)).Methods(http.MethodGet)
}
中間件
中間件(Middleware)返回一個帶有 handler 的函數(shù),handler 用來構(gòu)建需要的 http.HandlerFunc。這使得 webserver.Server 接口被注入,同時所有的安靜檢查只在啟動時執(zhí)行,而不是在所有路由調(diào)用的時候。
func Authentication(srv webserver.Server) func(h http.Handler) http.Handler {
if srv == nil || !srv.Client.IsValid() {
log.Fatal().Msg("a nil dependency was passed to authentication middleware")
}
// additional setup logic
...
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(r.Header.Get("Authorization"))
if err := srv.ValidateJWT(token); err != nil {
...
w.WriteHeader(401)
w.Write([]byte("Access Denied"))
return
}
next.ServeHTTP(w, r)
}
}
}
路由
路由有著與中間件有著類似的套路——簡單的設(shè)置,但是有著同樣的收益。
func GetLatest(srv webserver.Server) http.HandlerFunc {
if srv == nil {
log.Fatal().Msg("a nil dependency was passed to the `/makes/{makeID}/models/{modelID}` route")
}
// additional setup logic
...
return func(w http.ResponseWriter, r *http.Request) {
...
makeDTO, err := srv.Get
}
}
目錄結(jié)構(gòu)
代碼的目錄結(jié)構(gòu)對可發(fā)現(xiàn)性進(jìn)行了高度優(yōu)化。
├── App/
| └── service-api/**
├── cmd/
| └── service-tool-x/
├── internal/
| └── service/
| └── mock/
├── pkg/
| ├── client/
| └── dtos/
├── (.editorconfig, .gitattributes, .gitignore)
└── go.mod
- app/ 用于項目應(yīng)用——這是新來的人了解代碼傾向的切入點。dd
- ./service-api/ 是該倉庫的微服務(wù) API;所有的 HTTP 實現(xiàn)細(xì)節(jié)都在這里。
- cmd/ 是存放命令行應(yīng)用的地方。
- internal/ 是不可以被該倉庫以外的項目引入的一個特殊目錄[8]。
- ./service/ 是所有領(lǐng)域邏輯(domain logic)所在的地方;可以被 service-api,service-tool-x,以及任何未來直接訪問這個目錄可以帶來收益的應(yīng)用或者包所引入。
- pkg/ 用于存放鼓勵被倉庫以外的項目所引入的包。
- ./client/ 是用于訪問 service-api 的 client 庫。其他團(tuán)隊可以使用而不是自己寫一個 client,并且我們可以借助我們在 cmd/ 里面的 CI/CD 工具來 “dogfood it[9]” (使用自己產(chǎn)品的意思)。
- ./dtos/ 是存放項目的數(shù)據(jù)傳輸對象,不同包之間共享的數(shù)據(jù)且以 json 形式在線路上編碼或傳輸?shù)慕Y(jié)構(gòu)體定義。沒有從其他倉庫包導(dǎo)出的模塊化的結(jié)構(gòu)體。/internal/service 負(fù)責(zé) 這些 DTO (數(shù)據(jù)傳輸對象)和自己內(nèi)部模型的相互映射,避免實現(xiàn)細(xì)節(jié)的遺漏(如,數(shù)據(jù)庫注釋)并且該模型的改變不破壞下游客戶端消費這些 DTO。
- .editorconfig,.gitattributes,.gitignore 因為所有的倉庫必須使用 .editorconfig,.gitattributes,.gitignore[10]!
- go.mod 甚至可以在有限制的且官僚的公司環(huán)境[11]工作。
最重要的:每個包只負(fù)責(zé)意見事情,一件事情!
HTTP 服務(wù)結(jié)構(gòu)
└── service-api/
├── cfg/
├── middleware/
├── routes/
| ├── makes/
| | └── models/**
| ├── create.go
| ├── create_test.go
| ├── get.go
| └── get_test.go
├── webserver/
├── main.go
└── routebinds.go
- ./cfg/ 用于存放配置文件,通常是以 JSON 或者 YAML 形式保存的純文本文件,它們也應(yīng)該被檢入到 Git 里面(除了密碼,秘鑰等)。
- ./middleware 用于所有的中間件。
- ./routes 采用類似應(yīng)用的類 RESTFul 形式的目錄對路由代碼進(jìn)行分組和嵌套。
- ./webserver 保存所有共享的 HTTP 結(jié)構(gòu)和接口(Broker,配置,Server等等)。
- main.go 啟動應(yīng)用程序的地方(New(),Start())。
- routebinds.go BindRoutes() 函數(shù)存放的地方。
你覺得呢?
如果你最終采用了這種模式,或者有其他的想法我們可以討論,我樂意聽到這些想法!
via: https://www.dudley.codes/posts/2020.05.19-golang-structure-web-servers/
作者:James Dudley[12]譯者:dust347[13]校對:unknwon[14]
本文由 GCTT[15] 原創(chuàng)編譯,Go 中文網(wǎng)[16] 榮譽推出
參考資料
[1]
簡潔:
https://www.youtube.com/watch?v=rFejpH_tAHM
[2]
模式:
https://en.wikipedia.org/wiki/Software_design_pattern
[3]
Mat Ryer 的文章:
https://pace.dev/blog/2018/05/09/how-I-write-http-services-after-eight-years.html
[4]
Go 的組合:
https://www.ardanlabs.com/blog/2015/09/composition-with-go.html
[5]
阻塞:
https://stackoverflow.com/questions/2407589/what-does-the-term-blocking-mean-in-programming
[6]
閉包函數(shù):
https://gobyexample.com/closures
[7]
Kubernetes 探針:
https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/)0
[8]
特殊目錄:
https://dave.cheney.net/2019/10/06/use-internal-packages-to-reduce-your-public-api-surface
[9]
dogfood it: https://en.wikipedia.org/wiki/Eating_your_own_dog_food
[10]
所有的倉庫必須使用 .editorconfig,.gitattributes,.gitignore:
https://www.dudley.codes/posts/2020.02.16-git-lost-in-translation/
[11]
有限制的且官僚的公司環(huán)境:
https://www.dudley.codes/posts/2020.04.02-golang-behind-corporate-firewall/
[12]
James Dudley: https://www.dudley.codes/
[13]
dust347: https://github.com/dust347
[14]
unknwon: https://github.com/unknwon
[15]
GCTT: https://github.com/studygolang/GCTT
[16]
Go 中文網(wǎng): https://studygolang.com/