使用 Golang 构建实时通知系统 – 分步通知系统设计指南

14,026次阅读
没有评论

共计 9648 个字符,预计需要花费 25 分钟才能阅读完成。

在本文中,我的目标是对 Golang 进行深入探索,重点关注项目结构、软件原理和并发性。

Golang 通知系统设计示意图

Go 初学者面临的常见挑战

我一直在从事一个涉及向客户提供实时通知的副项目。虽然这个名为 Crisp 的项目对于一篇文章来说过于复杂,但我将讨论其主要功能并深入研究多核应用程序的各个方面。在继续之前,让我们先解决 Go 初学者面临的一些常见挑战,以及在实现通知系统时如何避免这些挑战。

单一职责

当开始一个新的 Golang 项目时,考虑到 Go 是一种静态类型语言是至关重要的。如果您从一个糟糕的项目结构开始并坚持继续下去,那可能是不可原谅的。许多从动态语言过渡的开发人员面临着 Go 包和循环依赖的挑战。这通常是由于不遵守单一责任原则造成的。

基本的通知系统由三个主要部分组成:

  1. 客户端和服务器之间的通信(WebSocket、HTTP 或 gRPC)。

  2. 用于存储用户上线时的访问通知的存储。

  3. 向客户端发出信号以接收实时通知。

这些部分中的每一个都应该是一个单独的包,因为它们在系统中具有不同的职责。虽然可以将它们全部放在一个包中,但职责的分离不仅仅涉及性能;还涉及性能。这关系到开发人员的效率。这种分离与依赖注入相结合,可以快速测试和开发系统的每个部分,使团队合作和并行工作更易于管理。

依赖注入

这些部分如何相互作用?他们通过接口来做到这一点。当我们想要一个使用存储和信号包来构建业务逻辑的服务时,它不依赖于这些包的类(或 Golang 中的结构)名称。相反,它通过它们的接口依赖于它们。这种方法有利于开发和测试。

通过依赖接口的实现而不是完全实现的结构,我们可以在测试期间轻松模拟接口。例如,当处理依赖于另一个服务的服务时,您可以模拟该服务端点的响应,而无需在本地计算机上运行全新的应用程序及其所有依赖项。这种关注点分离使开发人员能够专注于他们的特定任务,从而使团队合作更加高效。

写一个简单的示例

Golang 为单元测试提供了一个简单而强大的结构。测试可以与实现一起编写,以维护代码组织。让我们考虑一个例子。我们想要创建并测试一个序列化包,该包从数据库(通常称为存储库)检索行并将这些行转换为可序列化的结构。为了实现这一目标,我们讨论了一个满足我们要求的接口。

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)存储在单独的包中。在本教程中,我们将它们放在一起。现在,让我们实现序列化程序包,请记住,此实现可能无法准确反映现实世界的序列化程序。

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, " ")
 }

此代码从存储库检索数据并将其转换为所需的格式。正如您所看到的,我们可以 summarize 有效地测试该方法。在 Golang 中,测试可以放置在带有_test.go 后缀的文件中。例如,如果我们的主文件名为 article.go,则测试文件应命名为 article_test.go。在测试文件中,我们为文章存储库创建了一个模拟:

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 包。然而,代码有一个重要问题:它没有利用接口来描述序列化器。如果另一个包需要这些序列化器,则需要进行更改。请记住这一点。

让我们写一个基准

Golang 中的基准测试很简单。Golang 提供了用于编写基准测试的强大实用程序。基准测试与测试放在相同的测试文件中,但以“Benchmark”为前缀,并采用 *testing.B 包含 N 属性的参数,指示函数应执行多少次。

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 

该基准测试评估我们的序列化器的性能。结果显示,序列化一行平均需要 15.64 纳秒。让我们实现“使用 Golang 构建 Uber 等在线出租车应用程序 - 第 3 部分:Redis 来救援!”一文中的示例并对其进行基准测试。

如果帖子存储在数组中,则代码需要检查几乎每个帖子以找到所需的帖子。在最坏的情况下,可能需要进行 10,000 次比较。如果服务器每秒可以处理 1,000 次比较,则需要 10 秒。让我们放大影响。

按顺序存储帖子并使用二分搜索等算法将显着减少所需的比较次数。在最好的情况下,只需 100 毫秒。

如果我们使用映射来存储帖子,则每次查找都将是一条指令,导致整个页面需要 10 毫秒。

以下是两种具有相似搜索行为的算法。它们获取一个整数切片和一个数字,并返回给定数字的索引,如果未找到,则返回 -1。

// CheckEveryItem 在切片中查找给定的查找参数,如果存在则返回其索引。// 否则,返回 -1。func CheckEveryItem(items []int, lookup int) int {
    for i := 0; i  lookup {right = center}
       if center = right-1 {return -1}
    }
 }

两种算法具有相似的行为,它们都采用整数切片和一个数字作为输入。您可以为此行为定义一个类型:

type Algorithm func(items []int, lookup int) int

这种类型允许您编写一次测试和基准测试,而不是为每个算法重复它们。以下是如何为这些算法编写测试的示例:

func testAlgorithm(alg Algorithm, t *testing.T) {items := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
    for i := 0; i 

该功能 benchmarkAlgorithm 类似,但它执行基准测试:

func benchmarkAlgorithm(alg Algorithm, b *testing.B) {totalItems := int(1e3)
    items := make([]int, totalItems)
    for i := 0; i 

这些基准函数创建一个包含 1,000 个成员的切片,并搜索该范围内的随机数。b.ResetTimer() 确保创建大切片不会影响基准测试结果非常重要。

以下是基准函数:

func BenchmarkCheckEveryItem(b *testing.B) {benchmarkAlgorithm(CheckEveryItem, b)
 }
 
 func BenchmarkBinarySearch(b *testing.B) {benchmarkAlgorithm(BinarySearch, b)
 }

现在,让我们运行这些测试来评估每种算法的性能。结果表明,该 CheckEveryItem 算法耗时 143.7 ns/op,BinarySearch 完成耗时 58.54 ns/op。然而,这些测试的目的不仅仅是节省几纳秒。让我们将切片的大小增加到一百万:

func benchmarkAlgorithm(alg Algorithm, b *testing.B) {totalItems := int(1e6)
    items := make([]int, totalItems)
    // (其余代码保持不变)
 }

对于 100 万个项目,该 CheckEveryItem 算法需要 199 μs/op,而仍 BinarySearch 保持在纳秒范围内,为 145.6 ns/op。让我们更进一步,用一亿个项目:

func benchmarkAlgorithm(alg Algorithm, b *testing.B) {totalItems := int(1e8)
    items := make([]int, totalItems)
    // (其余代码保持不变)
 }

由于二分搜索是一种对数算法,因此它仅用 302.6 ns/op 就完成了基准测试。相比之下,CheckEveryItem 花费的时间明显更长,为 28 ms/op (28,973,093 ns/op)。

这证明了针对特定任务使用高效算法的重要性。这些基准测试展示了为您的应用程序选择正确的数据结构和算法的好处。

当然,这里有完整的内容和代码供您参考:

实施清晰通知系统

在本教程中,我们将实现一个名为 Crisp 的通知系统。该系统遵循的设计是将新通知发送到服务器、存储,然后向相关客户发出信号。我们将探索 Crisp 的代码,包括存储包、实体包和信号包,它们是通知系统的关键组件。我们还将讨论并发注意事项并演示如何使用通道和切片来有效管理通知。

收纳包

存储包管理通知的存储。它定义了一个接口并提供了两个实现。该包根据您的要求使用通道或切片存储通知。让我们仔细看看存储包:

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 返回客户端的通知数量、Pop 检索单个通知以及 PopAll 检索客户端的所有通知。

实体包

实体包定义了通知的结构。它包括 Notification 接口和一些示例实现:

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。您可以根据应用程序的需求添加更多通知类型。

存储实现

存储包提供两种实现:一种使用通道,另一种使用切片。

使用渠道:

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 函数...

使用切片:

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 函数...

两种实现都支持相同的功能,但底层数据结构不同。在处理并发时,必须考虑竞争条件和线程安全。

使用并发的技巧:

  • 通道是线程安全的;您可以在多个线程中同时读取和写入它们。

  • Go 中的默认映射不是线程安全的。要管理并发访问,您可以使用 sync.Map 线程安全的。

  • a 的内容 sync.Map 不是线程安全的。您应该为每个切片使用互斥体。

  • 该 len 函数是线程安全的。

  • 通道具有固定大小并预先分配内存,这与可以动态调整大小的切片和映射不同。

处理竞争条件:

使用并发时处理竞争条件至关重要。以下是潜在竞争条件的示例:

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 

在此示例中,两个并发请求可能 len(c) 同时调用,导致两个请求都尝试从通道检索 100 个项目。这可能会导致僵局。基于切片的实现不存在这个问题。

测试和基准测试:

为了确保您的代码性能良好并且不会导致内存泄漏,您可以编写测试和基准测试。以下是测试和基准测试的示例:

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...}

您可以使用基准测试来衡量不同场景的性能和内存使用情况。

信号包

信号包处理向客户发送新通知的信号。它利用信道来发送信号。这是信号包的代码:

var (ErrEmpty = errors.New("no topic found")
 )
 
 type Signal interface {Subscribe(id string) (

该 Subscribe 函数返回一个只读通道和一个取消函数。客户使用该通道接收通知,取消功能用于清理资源。该 Publish 功能向订阅客户发出通知信号。

信号包(续)

信号包继续:

type topic struct {listeners []chan

脆片包装

Crisp 包作为通知系统的核心。它使用存储和信号包为用户提供监听通知和推送新通知的功能。这是 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 和通知函数...

该 GetNotifications 功能允许用户检索他们的通知。它处理长轮询以实时或在用户重新连接时提供通知。该 Notify 功能允许客户端将新通知推送到系统中。

HTTP 服务器

HTTP 服务器为客户端提供了一种与 Crisp 包通信的方式。这是 HTTP 服务器的代码:

func (s *Server) listen(c echo.Context) error {// 监听代码...}
 
 // NotifyRequest 结构体和通知函数...

该 listen 函数向客户端提供通知,并在必要时使用长轮询。该 notify 功能允许客户端将新通知推送到系统中。

有了这个结构,您就可以根据需要自定义通信层以使用各种方法,例如 HTTP、终端或其他方法。将通信问题与应用程序的其他部分分开可以提供灵活性和可扩展性。

Crisp 通知系统的代码到此结束。您可以调整和扩展此代码以构建强大的应用程序通知系统。

或者阅读文章更加深入去了解:  使用 Go 和 gRPC 构建生产级微服务 - 带有示例的分步开发人员指南 

文章来源地址 https://www.toymoban.com/diary/golang/484.html

到此这篇关于使用 Golang 构建实时通知系统 - 分步通知系统设计指南的文章就介绍到这了, 更多相关内容可以在右上角搜索或继续浏览下面的相关文章,希望大家以后多多支持 TOY 模板网!

    正文完
     0
    Yojack
    版权声明:本篇文章由 Yojack 于1970-01-01发表,共计9648字。
    转载说明:
    1 本网站名称:优杰开发笔记
    2 本站永久网址:https://yojack.cn
    3 本网站的文章部分内容可能来源于网络,仅供大家学习与参考,如有侵权,请联系站长进行删除处理。
    4 本站一切资源不代表本站立场,并不代表本站赞同其观点和对其真实性负责。
    5 本站所有内容均可转载及分享, 但请注明出处
    6 我们始终尊重原创作者的版权,所有文章在发布时,均尽可能注明出处与作者。
    7 站长邮箱:laylwenl@gmail.com
    评论(没有评论)