什么是 "最佳 "做法?
有很多做法:你可以自己想出來,在互聯(lián)網上找到,或者從其他語言中拿來,但由于其主觀性,并不總是容易說哪一個比另一個好?!弊罴选钡暮x因人而異,也取決于其背景,例如網絡應用的最佳實踐可能與中間件的最佳實踐不一樣。
為了寫這篇文章,我?guī)е粋€問題看了go的實踐,那就是 “它在多大程度上讓我對寫Go感到舒服?”,當我說"語言的最佳實踐是什么?"時,那是在我剛接觸這門語言,還沒有完全適應寫這門語言的時候。
當然,還有更多的做法,我在這里不做介紹,但如果你在寫go時知道這些做法,就會非常有用,但這三個做法對我在go中的信心影響最大。
這就是我選擇"最佳"做法的原因。現(xiàn)在是該上手的時候了。
實踐1:package布局
當我開始學習go時,最令人驚訝的事情之一是,go沒有像Laravel對PHP,Express對Node那樣的網絡框架。這意味著在編寫網絡應用時,如何組織你的代碼和包,完全取決于你。雖然在如何組織代碼方面擁有自由是一件好事,但如果沒有指導原則,很容易迷失方向。
另外,這也是最難達成一致的話題之一;"最佳 "的含義很容易改變,這取決于程序處理的業(yè)務邏輯或代碼庫的大小/成熟度。即使是同一個代碼庫,當前的軟件包組織在6個月后也可能不是最好的。
雖然沒有單一的做法可以統(tǒng)治一切,但為了補救這種情況,我將介紹一些準則,希望它們能使決策過程更容易。
準則1:從平面布局開始
除非你知道代碼庫會很大,并且需要某種預先的包布局,否則最好從平面布局開始,簡單地將所有的go文件放在根文件夾中。
這是一個來自github.com/patrickmn/g…軟件包的文件結構。
? tree
.
├── CONTRIBUTORS
├── LICENSE
├── README.md
├── cache.go
├── cache_test.go
├── sharded.go
└── sharded_test.go
它只有一個領域的關注:對數(shù)據緩存,對于像這樣的包,甚至不需要包的布局。扁平結構在這種情況下最適合。
但隨著代碼庫的增長,根文件夾會變得很忙,你會開始覺得扁平結構不再是最好的了。是時候把一些文件移到它們自己的包里了。
準則2:創(chuàng)建子包
據我所知,主要有三種模式:直接在根部,在pkg文件夾下,以及在internal文件夾下。
在根部
在根目錄下創(chuàng)建一個帶有軟件包名稱的文件夾,并將所有相關文件移到該文件夾下。這樣做的好處是:
- 沒有深層次/嵌套的目錄
- 導入路徑不雜亂
缺點是根文件夾會變得有點亂,特別是當有其他文件夾如scripts、bin和docs時。
在pkg包下
創(chuàng)建一個名為pkg的目錄,把子包放在它下面。好的方面是:
- 這個名字清楚地表明這個目錄包含了子包
- 你可以保持頂層的清潔
而不好的方面是你需要在導入路徑中有pkg,這并不意味著什么,因為很明顯你在導入包。
然而,這種模式有一個更大的問題,也是前一種模式的問題:有可能從版本庫外部訪問子包。
這對私人倉庫來說是可以接受的,因為如果發(fā)生這種情況,在審查過程中會被注意到,但重要的是要注意什么是公開的,特別是在開放源碼的背景下,向后兼容性很重要。一旦你把它公開,你就不能輕易改變它。
有第三個選擇來處理這種情況。
在internal包下
如果/internal在導入路徑中,go處理包的方式有點不同。如果軟件包被放在/internal文件夾下,只有共享/internal之前的路徑的軟件包才能訪問里面的軟件包。
例如,如果軟件包路徑是/a/b/c/internal/d/e/f,只有/a/b/c目錄下的軟件包可以訪問/internal目錄下的軟件包。這意味著如果你把internal放在根目錄下,只有該倉庫內的包可以使用子包,而其他倉庫不能訪問。如果你想擁有子包,同時保持它們的API在內部,這很有用。
準則3:將main移至cmd目錄下
把主包放在cmd/<命令名稱>目錄下也是一種常見的做法。
假設我們有一個用go編寫的管理個人筆記的API服務器,用這種模式看起來會是這樣。
$ tree
.
├── cmd
│ └── personal-note-api
│ └── main.go
...
├── Makefile
├── go.mod
└── go.sum
要考慮使用這種模式的情況是:
- 你可能想在一個資源庫中擁有多個二進制文件。你可以在cmd下創(chuàng)建任意多的文件夾,只要你想。
- 有時需要將主包移到其他地方,以避免循環(huán)依賴。
準則4:按其責任組織包裝
我們已經研究了何時以及如何制作子包,但還有一個大問題:它們應該如何分組?我認為這是最棘手的部分,需要一些時間來適應,主要是因為它在很大程度上受應用程序的領域關注和功能影響。深入了解代碼的作用是做出決定的必要條件。
對此,最常見的建議是按照責任來組織。
對于那些熟悉MVC框架的人來說,擁有"model"、“controller”、“service"等包可能感覺很自然。建議不要在go中使用它們。
相反,我們建議使用更多的責任/領域導向的包名,如"用戶"或"事務”。
準則5:按依賴關系對子包進行分組
根據它們的依賴關系來命名包,例如"redis"、“kafka"或"pubsub”,在某些情況下提供了明確的抽象性。
想象一下,你有一個這樣的接口:
package bestpractice
type User struct {}
type UserService interface {
User(context.Context, string) (*User, error)
}
而你在redis子包里有一個服務,它是這樣實現(xiàn)的:
package redis
import (
"github.com/thirdfort/go-bestpractice"
"github.com/thirdfort/go-redis"
)
type UserService struct {
...
}
func (s *UserService) User(ctx context.Context, id string) (*bestpractice.User, error) {
...
err := redis.RetrieveHash(ctx, k)
...
}
如果消費者(大概是主函數(shù))只依賴于接口,它可以很容易地被替代的實現(xiàn)所取代,如postgres或inmemory。
附加提示1:給包起一個簡短的名字
關于命名包的幾個要點。
- 短而有代表性的名稱
- 使用一個詞
- 使用縮略語,但不要讓它變得神秘莫測
如果你想使用多個詞(如billing_account)怎么辦?我能想到的選項是:
- 為每個詞設置一個嵌套包:billing/account
- 如果沒有混淆,就簡單地命名為帳戶
- 使用縮略語:billacc
補充提示2:避免重復
這是關于如何命名包內的內容(結構/界面/函數(shù))。go的建議是,在消費包的時候盡量避免重復。例如,如果我們有一個包,內容是這樣的:
package user
func GetUser(ctx context.Context, id string) (*User, error) {
...
}
這個包的消費者要這樣調用這個函數(shù):user.GetUser(ctx, u.ID)
在函數(shù)調用中出現(xiàn)了兩次user這個詞。即使我們把user這個詞從函數(shù)中去掉:user.Get,仍然可以看出它返回了一個用戶,因為從包的名稱中可以看出。go更傾向于簡單的名字。
我希望這些準則在決定包的布局時能有所幫助。
讓我們來看看關于上下文的第二個實踐。
實踐2:熟悉context.Context
在95%的情況下,你唯一需要做的就是將調用者提供的上下文傳遞給需要上下文作為參數(shù)的子程序調用。
func (u *User) Store(ctx context.Context) error {
...
if err := u.Hash.Store(ctx, k, u); err != nil {
return err
}
...
}
盡管如此,由于context在go程序中隨處可見,因此了解何時需要它,以及如何使用它是非常重要的。
context的三種用途
首先,也是最重要的一點是,要意識到上下文可以有三種不同的用途:
- 發(fā)送取消信號
- 設置超時
- 存儲/檢索請求的相關值
發(fā)送取消信號
context.Context提供了一種機制,可以發(fā)送一個信號,告訴收到context的進程停止。
例如,優(yōu)雅關機
當一個服務器收到關閉信號時,它需要"優(yōu)雅地"停止;如果它正在處理一個請求,它需要在關閉之前為其提供服務。context包提供了context.WithCancel API,它返回一個配置了cancel的新上下文和一個取消它的函數(shù)。如果你調用cancel函數(shù),信號會被發(fā)送到接收該上下文的進程中。
在下面的例子中,它調用context.WithCancel后,在啟動服務器時將其傳遞給服務器。當程序收到OS信號時,會調用cancel:
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
sigchan := make(chan os.Signal, 1)
signal.Notify(sigchan, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM)
<-sigchan
cancel()
}()
svr := &ctxpkg.Server{}
svr.Run(ctx) // ← long running process
log.Println("graceful stop")
}
讓我們看看"偽"服務器的實現(xiàn);它實際上什么也沒做,但為了演示,它有足夠的功能:
type Server struct{}
func (s *Server) Run(ctx context.Context) {
for {
select {
case <-ctx.Done():
log.Println("cancel received, attempting graceful stop...")
// clean up process
return
default:
handleRequest()
}
}
}
func handleRequest() {
time.Sleep(time.Duration(rand.Intn(10)) * time.Second)
}
它首先進入一個無限的循環(huán)。在這個循環(huán)中,它檢查上下文是否已經在ctx.Done()通道上使用select取消了。如果取消了,它就清理進程并返回。如果沒有,它就處理一個請求。一旦請求被處理,它就回到循環(huán)中,再次檢查上下文。
這里的重點是通過使用context.Context,你可以允許進程在他們準備好的時候返回。
設置超時
第二種用法是為操作設置超時。想象一下,你正在向第三方發(fā)送HTTP請求。如果由于某些原因,如網絡中斷,請求的時間超過預期,你可能想取消請求,以防止整個過程掛起。通過context.WithTimeout,你可以為這些情況設置超時。
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() // ← cancel should be called even if timeout didn't happen
SendRequest(ctx) // ← subroutine that can get stuck
}
在SendRequest方法中,在不同的goroutine中發(fā)送請求后,它同時在ctx.Done()通道和響應通道中等待。當超時發(fā)生時,你會從ctx.Done()通道得到一個信號,這樣你就可以從該函數(shù)中退出,而不用等待響應。
func SendRequest(ctx context.Context) {
respCh := make(chan interface{}, 1)
go sendRequest(respCh)
select {
case <-ctx.Done():
log.Println("operation timed out!")
case <-respCh:
log.Println("response received")
}
}
func sendRequest(ch chan<- interface{}) {
time.Sleep(60 * time.Second)
ch <- struct{}{}
}
context包也有context.WithDeadline();不同的是,context.WithTimeout需要time.Duration,而context.WithDeadline()需要time.Time。
存儲/檢索請求的相關值
上下文的最后一種用法是在上下文中存儲和檢索與請求相關的值。例如,如果服務器收到一個請求,你可能希望在請求過程中產生的所有日志行都有請求信息,如路徑和方法。在這種情況下,你可以創(chuàng)建一個日志記錄器,設置請求相關的信息,并使用context.WithValue將其存儲在上下文中。
var logCtxKey = &struct{}{}
func handleRequest(w http.ResponseWriter, r *http.Request) {
method, path := r.Method, r.URL.Path
logger := log.With().
Str("method", method).
Str("path", path).
Logger()
ctxWithLogger := context.WithValue(r.Context(), logCtxKey, logger)
...
accessDatabase(ctxWithLogger)
}
在某個地方,你可以用同樣的鍵把記錄器從上下文中取出來。例如,如果你想在數(shù)據庫訪問層留下一個日志,你可以這樣做:
func accessDatabase(ctx context.Context) {
logger := ctx.Value(logCtxKey).(zerolog.Logger)
logger.Debug().Msg("accessing database")
}
這產生了以下包含請求方法和路徑的日志行。
{"level":"debug","method":"GET","path":"/v1/todo","time":"2022-11-15T15:44:53Z","message":"accessing database"}
就像我說的,你需要使用這些上下文API的情況并不常見,但了解它的作用真的很重要,這樣你就知道在哪種情況下你真的需要注意它。
讓我們進入最后一個實踐。
實踐3:了解 Table Driven Test(表格驅動方法)
表驅動測試是一種組織測試的技術,更多地關注輸入數(shù)據/模擬/存根和預期輸出,而不是斷言,這有時可能是重復的。
我選擇這種方法的原因不僅是因為這是一種常用的做法,而且這也使我在編寫測試時更有樂趣。在編寫測試時有一個良好的動機,對于有一個快樂的編碼生活是非常重要的,不用說編寫可靠的代碼。
讓我們來看看一個例子。
假設我們有一個餐廳的數(shù)據類型,它有一個方法,如果它在某一特定時間開放,則返回真。
type Restaurant struct {
openAt time.Time
closeAt time.Time
}
func (r Restaurant) IsOpen(at time.Time) bool {
return (at.Equal(r.openAt) || at.After(r.openAt)) &&
(at.Equal(r.closeAt) || at.Before(r.closeAt))
}
讓我們?yōu)檫@個方法寫一些測試。
如果我們在餐廳開門的時候訪問了它,我們期望它是開放的。
func TestRestaurantJustOpened(t *testing.T) {
r := Restaurant{
openAt: time.Date(2022, time.January, 17, 12, 0, 0, 0, time.UTC),
closeAt: time.Date(2022, time.January, 17, 22, 0, 0, 0, time.UTC),
}
input := r.openAt
got := r.IsOpen(input)
assert.True(t, got)
}
到目前為止還不錯。讓我為邊界條件添加更多測試:
func TestRestaurantBeforeOpen(t *testing.T) {
r := Restaurant{
openAt: time.Date(2022, time.January, 17, 12, 0, 0, 0, time.UTC),
closeAt: time.Date(2022, time.January, 17, 22, 0, 0, 0, time.UTC),
}
input := r.openAt.Add(-1 * time.Second)
got := r.IsOpen(input)
assert.False(t, got)
}
func TestRestaurantBeforeClose(t *testing.T) {
r := Restaurant{
openAt: time.Date(2022, time.January, 17, 12, 0, 0, 0, time.UTC),
closeAt: time.Date(2022, time.January, 17, 22, 0, 0, 0, time.UTC),
}
input := r.closeAt
got := r.IsOpen(input)
assert.True(t, got)
}
你可能已經注意到,這些測試之間的差異非常小,我認為這是表驅動測試的一個典型用例。
表驅動測試的介紹
現(xiàn)在讓我們看看,如果用表驅動的方式來寫,會是什么樣子:
func TestRestaurantTableDriven(t *testing.T) {
r := Restaurant{
openAt: time.Date(2022, time.January, 17, 12, 0, 0, 0, time.UTC),
closeAt: time.Date(2022, time.January, 17, 22, 0, 0, 0, time.UTC),
}
// test cases
cases := map[string]struct {
input time.Time
want bool
}{
"before open": {
input: r.openAt.Add(-1 * time.Second),
want: false,
},
"just opened": {
input: r.openAt,
want: true,
},
"before close": {
input: r.closeAt,
want: true,
},
"just closed": {
input: r.closeAt.Add(1 * time.Second),
want: false,
},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
got := r.IsOpen(c.input)
assert.Equal(t, c.want, got)
})
}
}
首先,我聲明了測試目標。根據情況,它可以在每個測試案例里面。
接下來,我定義了測試用例。我在這里使用了map,所以我可以使用測試名稱作為map鍵。測試用例結構包含每個情況下的輸入和預期輸出。
最后,我對測試用例進行了循環(huán),并對每個測試用例運行了子測試。斷言與之前的例子相同,但這里我從測試用例結構中獲取輸入和預期值。
以表格驅動方式編寫的測試很緊湊,重復性較低,如果你想添加更多的測試,你只需要添加一個新的測試用例,無需更多的斷言。文章來源:http://www.zghlxwxcb.cn/news/detail-785850.html
去嘗試吧!
一方面,了解社區(qū)中共享的實踐很重要。go社區(qū)足夠大,很容易找到它們。你可以找到博客文章、講座、YouTube視頻等等。另外,說到go,很多實踐都來自go的標準庫。表驅動測試就是一個很好的例子。go是一種開源語言。閱讀標準包代碼是個好主意。
另一方面,僅僅知道它們并不能讓你感到舒服。到目前為止,學習最佳實踐的最好方法是在你現(xiàn)在工作的真實代碼庫中使用它們,看看它們有多合適,這實際上是我學習go實踐的方式。所以,多寫go,不要害怕犯錯。文章來源地址http://www.zghlxwxcb.cn/news/detail-785850.html
到了這里,關于go最佳實踐:如何舒適地編碼的文章就介紹完了。如果您還想了解更多內容,請在右上角搜索TOY模板網以前的文章或繼續(xù)瀏覽下面的相關文章,希望大家以后多多支持TOY模板網!