在本文中,我的目標(biāo)是對 Golang 進行深入探索,重點關(guān)注項目結(jié)構(gòu)、軟件原理和并發(fā)性。
Go 初學(xué)者面臨的常見挑戰(zhàn)
我一直在從事一個涉及向客戶提供實時通知的副項目。雖然這個名為 Crisp 的項目對于一篇文章來說過于復(fù)雜,但我將討論其主要功能并深入研究多核應(yīng)用程序的各個方面。在繼續(xù)之前,讓我們先解決 Go 初學(xué)者面臨的一些常見挑戰(zhàn),以及在實現(xiàn)通知系統(tǒng)時如何避免這些挑戰(zhàn)。
單一職責(zé)
當(dāng)開始一個新的 Golang 項目時,考慮到 Go 是一種靜態(tài)類型語言是至關(guān)重要的。如果您從一個糟糕的項目結(jié)構(gòu)開始并堅持繼續(xù)下去,那可能是不可原諒的。許多從動態(tài)語言過渡的開發(fā)人員面臨著 Go 包和循環(huán)依賴的挑戰(zhàn)。這通常是由于不遵守單一責(zé)任原則造成的。
基本的通知系統(tǒng)由三個主要部分組成:
客戶端和服務(wù)器之間的通信(WebSocket、HTTP 或 gRPC)。
用于存儲用戶上線時的訪問通知的存儲。
向客戶端發(fā)出信號以接收實時通知。
這些部分中的每一個都應(yīng)該是一個單獨的包,因為它們在系統(tǒng)中具有不同的職責(zé)。雖然可以將它們?nèi)糠旁谝粋€包中,但職責(zé)的分離不僅僅涉及性能;還涉及性能。這關(guān)系到開發(fā)人員的效率。這種分離與依賴注入相結(jié)合,可以快速測試和開發(fā)系統(tǒng)的每個部分,使團隊合作和并行工作更易于管理。
依賴注入
這些部分如何相互作用?他們通過接口來做到這一點。當(dāng)我們想要一個使用存儲和信號包來構(gòu)建業(yè)務(wù)邏輯的服務(wù)時,它不依賴于這些包的類(或 Golang 中的結(jié)構(gòu))名稱。相反,它通過它們的接口依賴于它們。這種方法有利于開發(fā)和測試。
通過依賴接口的實現(xiàn)而不是完全實現(xiàn)的結(jié)構(gòu),我們可以在測試期間輕松模擬接口。例如,當(dāng)處理依賴于另一個服務(wù)的服務(wù)時,您可以模擬該服務(wù)端點的響應(yīng),而無需在本地計算機上運行全新的應(yīng)用程序及其所有依賴項。這種關(guān)注點分離使開發(fā)人員能夠?qū)W⒂谒麄兊奶囟ㄈ蝿?wù),從而使團隊合作更加高效。
寫一個簡單的示例
Golang 為單元測試提供了一個簡單而強大的結(jié)構(gòu)。測試可以與實現(xiàn)一起編寫,以維護代碼組織。讓我們考慮一個例子。我們想要創(chuàng)建并測試一個序列化包,該包從數(shù)據(jù)庫(通常稱為存儲庫)檢索行并將這些行轉(zhuǎn)換為可序列化的結(jié)構(gòu)。為了實現(xiàn)這一目標(biāo),我們討論了一個滿足我們要求的接口。
package repository import ( "context" "errors" ) var ( ErrNotFound = errors.New("article not found") ) type Article struct { ID uint64 Title string Content string } type ArticleRepository interface { ByID(ctx context.Context, id int) (Article, error) }
我們可以將錯誤和實體定義(例如 Article)存儲在單獨的包中。在本教程中,我們將它們放在一起?,F(xiàn)在,讓我們實現(xiàn)序列化程序包,請記住,此實現(xiàn)可能無法準(zhǔn)確反映現(xiàn)實世界的序列化程序。
type SimpleSummaryArticle struct { ID uint64 `json:"id"` Title string `json:"title"` Summary string `json:"summary"` More string `json:"more"` } type Article struct { articles repository.ArticleRepository summaryWordsLimit int } func NewArticle(articles repository.ArticleRepository, summaryWordsLimit int) *Article { return &Article{articles: articles, summaryWordsLimit: summaryWordsLimit} } func (a *Article) ByID(ctx context.Context, id uint64) (SimpleSummaryArticle, error) { article, err := a.articles.ByID(ctx, id) if err != nil { return SimpleSummaryArticle{}, fmt.Errorf("error while retrieving a single article by id: %w", err) } return SimpleSummaryArticle{ ID: article.ID, Title: article.Title, Summary: a.summarize(article.Content), More: fmt.Sprintf("https://site.com/a/%d", article.ID), }, nil } func (a *Article) summarize(content string) string { words := strings.Split(strings.ReplaceAll(content, "\n", " "), " ") if len words > a.summaryWordsLimit { words = words[:a.summaryWordsLimit] } return strings.Join(words, " ") }
此代碼從存儲庫檢索數(shù)據(jù)并將其轉(zhuǎn)換為所需的格式。正如您所看到的,我們可以summarize有效地測試該方法。在Golang中,測試可以放置在帶有_test.go后綴的文件中。例如,如果我們的主文件名為article.go,則測試文件應(yīng)命名為article_test.go。在測試文件中,我們?yōu)槲恼麓鎯靹?chuàng)建了一個模擬:
type mockArticle struct { items map[uint64]repository.Article } func (m *mockArticle) ByID(ctx context.Context, id uint64) (repository.Article, error) { val, has := m.items[id] if !has { return repository.Article{}, repository.ErrNotFound } return val, nil }
我們可以輕松地使用這個模擬來測試我們的序列化程序包:
func TestArticle_ByID(t *testing.T) { ma := &mockArticle{items: map[uint64]repository.Article{ 1: { ID: 1, Title: "Title#1", Content: "content of the first article.", }, }} a := NewArticle(ma, 3) _, err := a.ByID(context.Background(), 10) assert.ErrorIs(t, repository.ErrNotFound, err) item, err := a.ByID(context.Background(), 1) assert.Equal(t, "https://site.com/a/1", item.More) assert.Equal(t, uint64(1), item.ID) assert.Equal(t, "content of the", item.Summary) }
對于斷言,我們使用了該github.com/stretchr/testify/assert包。然而,代碼有一個重要問題:它沒有利用接口來描述序列化器。如果另一個包需要這些序列化器,則需要進行更改。請記住這一點。
讓我們寫一個基準(zhǔn)
Golang 中的基準(zhǔn)測試很簡單。Golang 提供了用于編寫基準(zhǔn)測試的強大實用程序?;鶞?zhǔn)測試與測試放在相同的測試文件中,但以“Benchmark”為前綴,并采用*testing.B包含N屬性的參數(shù),指示函數(shù)應(yīng)執(zhí)行多少次。
func BenchmarkArticle(b *testing.B) { ma := &mockArticle{items: map[uint64]repository.Article{ 1: { ID: 1, Title: "Title#1", Content: "content of the first article.", }, }} a := NewArticle(ma, 3) for i := 0; i < b.N; i++ { a.ByID(context.Background(), 10) } }
該基準(zhǔn)測試評估我們的序列化器的性能。結(jié)果顯示,序列化一行平均需要 15.64 納秒。讓我們實現(xiàn)“使用 Golang 構(gòu)建 Uber 等在線出租車應(yīng)用程序 - 第 3 部分:Redis 來救援!”一文中的示例并對其進行基準(zhǔn)測試。
如果帖子存儲在數(shù)組中,則代碼需要檢查幾乎每個帖子以找到所需的帖子。在最壞的情況下,可能需要進行 10,000 次比較。如果服務(wù)器每秒可以處理 1,000 次比較,則需要 10 秒。讓我們放大影響。
按順序存儲帖子并使用二分搜索等算法將顯著減少所需的比較次數(shù)。在最好的情況下,只需 100 毫秒。
如果我們使用映射來存儲帖子,則每次查找都將是一條指令,導(dǎo)致整個頁面需要 10 毫秒。
以下是兩種具有相似搜索行為的算法。它們獲取一個整數(shù)切片和一個數(shù)字,并返回給定數(shù)字的索引,如果未找到,則返回 -1。
// CheckEveryItem 在切片中查找給定的查找參數(shù),如果存在則返回其索引。 // 否則,返回-1。 func CheckEveryItem(items []int, lookup int) int { for i := 0; i < len(items); i++ { if items[i] == lookup { return i } } return -1 } // BinarySearch 期望接收排序后的切片并相應(yīng)地查找給定值的索引。 func BinarySearch(items []int, lookup int) int { left := 0 right := len(items) - 1 for { if left == lookup { return left } if right == lookup { return right } center := (right + left) / 2 if items[center] == lookup { return center } if center > lookup { right = center } if center < lookup { left = center } if left >= right-1 { return -1 } } }
兩種算法具有相似的行為,它們都采用整數(shù)切片和一個數(shù)字作為輸入。您可以為此行為定義一個類型:
type Algorithm func(items []int, lookup int) int
這種類型允許您編寫一次測試和基準(zhǔn)測試,而不是為每個算法重復(fù)它們。以下是如何為這些算法編寫測試的示例:
func testAlgorithm(alg Algorithm, t *testing.T) { items := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9} for i := 0; i <= 9; i++ { assert.Equal(t, i, alg(items, i)) } assert.Equal(t, -1, alg(items, 100)) }
該功能benchmarkAlgorithm類似,但它執(zhí)行基準(zhǔn)測試:
func benchmarkAlgorithm(alg Algorithm, b *testing.B) { totalItems := int(1e3) items := make([]int, totalItems) for i := 0; i < totalItems; i++ { items[i] = i } b.ResetTimer() for i := 0; i < b.N; i++ { lookup := rand.Intn(totalItems - 1) alg(items, lookup) } }
這些基準(zhǔn)函數(shù)創(chuàng)建一個包含 1,000 個成員的切片,并搜索該范圍內(nèi)的隨機數(shù)。b.ResetTimer()確保創(chuàng)建大切片不會影響基準(zhǔn)測試結(jié)果非常重要。
以下是基準(zhǔn)函數(shù):
func BenchmarkCheckEveryItem(b *testing.B) { benchmarkAlgorithm(CheckEveryItem, b) } func BenchmarkBinarySearch(b *testing.B) { benchmarkAlgorithm(BinarySearch, b) }
現(xiàn)在,讓我們運行這些測試來評估每種算法的性能。結(jié)果表明,該CheckEveryItem算法耗時 143.7 ns/op,BinarySearch完成耗時 58.54 ns/op。然而,這些測試的目的不僅僅是節(jié)省幾納秒。讓我們將切片的大小增加到一百萬:
func benchmarkAlgorithm(alg Algorithm, b *testing.B) { totalItems := int(1e6) items := make([]int, totalItems) // (其余代碼保持不變) }
對于 100 萬個項目,該CheckEveryItem算法需要 199 μs/op,而仍BinarySearch保持在納秒范圍內(nèi),為 145.6 ns/op。讓我們更進一步,用一億個項目:
func benchmarkAlgorithm(alg Algorithm, b *testing.B) { totalItems := int(1e8) items := make([]int, totalItems) // (其余代碼保持不變) }
由于二分搜索是一種對數(shù)算法,因此它僅用 302.6 ns/op 就完成了基準(zhǔn)測試。相比之下,CheckEveryItem花費的時間明顯更長,為 28 ms/op (28,973,093 ns/op)。
這證明了針對特定任務(wù)使用高效算法的重要性。這些基準(zhǔn)測試展示了為您的應(yīng)用程序選擇正確的數(shù)據(jù)結(jié)構(gòu)和算法的好處。
當(dāng)然,這里有完整的內(nèi)容和代碼供您參考:
實施清晰通知系統(tǒng)
在本教程中,我們將實現(xiàn)一個名為 Crisp 的通知系統(tǒng)。該系統(tǒng)遵循的設(shè)計是將新通知發(fā)送到服務(wù)器、存儲,然后向相關(guān)客戶發(fā)出信號。我們將探索 Crisp 的代碼,包括存儲包、實體包和信號包,它們是通知系統(tǒng)的關(guān)鍵組件。我們還將討論并發(fā)注意事項并演示如何使用通道和切片來有效管理通知。
收納包
存儲包管理通知的存儲。它定義了一個接口并提供了兩個實現(xiàn)。該包根據(jù)您的要求使用通道或切片存儲通知。讓我們仔細(xì)看看存儲包:
var ErrEmpty = errors.New("no notifications found") type Storage interface { Push(ctx context.Context, clientID int, notification entity.Notification) error Count(ctx context.Context, clientID int) (int, error) Pop(ctx context.Context, clientID int) (entity.Notification, error) PopAll(ctx context.Context, clientID int) ([]entity.Notification, error) }
在此包中,Push添加新通知、Count返回客戶端的通知數(shù)量、Pop檢索單個通知以及PopAll檢索客戶端的所有通知。
實體包
實體包定義了通知的結(jié)構(gòu)。它包括Notification接口和一些示例實現(xiàn):
type Notification interface { IsNotification() } type BaseNotification struct { CreatedAt time.Time `json:"createdAt"` } func (BaseNotification) IsNotification() {} type UnreadWorkRequest struct { BaseNotification WorkID int `json:"workID"` Title string `json:"title"` } type UnreadMessagesNotification struct { BaseNotification Count int `json:"count"` }
在這里,我們定義了Notification接口并提供了一些通知類型,例如UnreadWorkRequest和UnreadMessagesNotification。您可以根據(jù)應(yīng)用程序的需求添加更多通知類型。
存儲實現(xiàn)
存儲包提供兩種實現(xiàn):一種使用通道,另一種使用切片。
使用渠道:
type memoryWithChannel struct { storage *sync.Map size int } func NewMemoryWithChannel(size int) Storage { return &memoryWithChannel{ storage: new(sync.Map), size: size, } } // 使用通道的 Push、Count、Pop 和 PopAll 函數(shù)...
使用切片:
type userStorage struct { mu *sync.Mutex notifications []entity.Notification } type memoryWithList struct { size int storage *sync.Map } func NewMemoryWithList(size int) Storage { return &memoryWithList{ size: size, storage: new(sync.Map), } } //使用切片的 Push、Count、Pop 和 PopAll 函數(shù)...
兩種實現(xiàn)都支持相同的功能,但底層數(shù)據(jù)結(jié)構(gòu)不同。在處理并發(fā)時,必須考慮競爭條件和線程安全。
使用并發(fā)的技巧:
通道是線程安全的;您可以在多個線程中同時讀取和寫入它們。
Go 中的默認(rèn)映射不是線程安全的。要管理并發(fā)訪問,您可以使用sync.Map線程安全的 。
a 的內(nèi)容sync.Map不是線程安全的。您應(yīng)該為每個切片使用互斥體。
該len函數(shù)是線程安全的。
通道具有固定大小并預(yù)先分配內(nèi)存,這與可以動態(tài)調(diào)整大小的切片和映射不同。
處理競爭條件:
使用并發(fā)時處理競爭條件至關(guān)重要。以下是潛在競爭條件的示例:
func (m *memoryWithChannel) PopAll(ctx context.Context, clientID int) ([]entity.Notification, error) { c := m.get(clientID) l := len(c) items := make([]entity.Notification, l) for i := 0; i < l; i++ { items[i] = <-c } return items, nil }
在此示例中,兩個并發(fā)請求可能len(c)同時調(diào)用,導(dǎo)致兩個請求都嘗試從通道檢索 100 個項目。這可能會導(dǎo)致僵局。基于切片的實現(xiàn)不存在這個問題。
測試和基準(zhǔn)測試:
為了確保您的代碼性能良好并且不會導(dǎo)致內(nèi)存泄漏,您可以編寫測試和基準(zhǔn)測試。以下是測試和基準(zhǔn)測試的示例:
func testNewMemory(m Storage, t *testing.T) { // Test code... } func benchmarkMemory_PushAverage(m Storage, b *testing.B) { // Benchmark code... } func benchmarkMemory_PushNewItem(m Storage, b *testing.B) { // Benchmark code... }
您可以使用基準(zhǔn)測試來衡量不同場景的性能和內(nèi)存使用情況。
信號包
信號包處理向客戶發(fā)送新通知的信號。它利用信道來發(fā)送信號。這是信號包的代碼:
var ( ErrEmpty = errors.New("no topic found") ) type Signal interface { Subscribe(id string) (<-chan struct{}, func(), error) Publish(id string) error }
該Subscribe函數(shù)返回一個只讀通道和一個取消函數(shù)??蛻羰褂迷撏ǖ澜邮胀ㄖ?,取消功能用于清理資源。該Publish功能向訂閱客戶發(fā)出通知信號。
信號包(續(xù))
信號包繼續(xù):
type topic struct { listeners []chan<- struct{} mu *sync.Mutex } type signal struct { listeners *sync.Map topicSize int } func NewSignal() Signal { return &signal{ listeners: new(sync.Map), } } // 用于信令的訂閱和發(fā)布功能...
脆片包裝
Crisp 包作為通知系統(tǒng)的核心。它使用存儲和信號包為用戶提供監(jiān)聽通知和推送新通知的功能。這是 Crisp 包的代碼:
type Crisp struct { Storage storage.Storage Signal signal.Signal defaultTimeout time.Duration } func NewCrisp(str storage.Storage, sig signal.Signal) *Crisp { return &Crisp{ Storage: str, Signal: sig, defaultTimeout: 2 * time.Minute, } } // GetNotifications 和通知函數(shù)...
該GetNotifications功能允許用戶檢索他們的通知。它處理長輪詢以實時或在用戶重新連接時提供通知。該Notify功能允許客戶端將新通知推送到系統(tǒng)中。
HTTP服務(wù)器
HTTP 服務(wù)器為客戶端提供了一種與 Crisp 包通信的方式。這是 HTTP 服務(wù)器的代碼:
func (s *Server) listen(c echo.Context) error { // 監(jiān)聽代碼... } // NotifyRequest 結(jié)構(gòu)體和通知函數(shù)...
該listen函數(shù)向客戶端提供通知,并在必要時使用長輪詢。該notify功能允許客戶端將新通知推送到系統(tǒng)中。
有了這個結(jié)構(gòu),您就可以根據(jù)需要自定義通信層以使用各種方法,例如 HTTP、終端或其他方法。將通信問題與應(yīng)用程序的其他部分分開可以提供靈活性和可擴展性。
Crisp 通知系統(tǒng)的代碼到此結(jié)束。您可以調(diào)整和擴展此代碼以構(gòu)建強大的應(yīng)用程序通知系統(tǒng)。
或者閱讀文章更加深入去了解: 使用 Go 和 gRPC 構(gòu)建生產(chǎn)級微服務(wù) - 帶有示例的分步開發(fā)人員指南 文章來源:http://www.zghlxwxcb.cn/article/484.html
文章來源地址http://www.zghlxwxcb.cn/article/484.html
到此這篇關(guān)于使用 Golang 構(gòu)建實時通知系統(tǒng) - 分步通知系統(tǒng)設(shè)計指南的文章就介紹到這了,更多相關(guān)內(nèi)容可以在右上角搜索或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!