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

在本文中,我的目标是对 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 < b.N; i++ {
       a.ByID(context.Background(), 10)
    }
 }

该基准测试评估我们的序列化器的性能。结果显示,序列化一行平均需要 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 < len(items); i++ {
       if items[i] == lookup {
          return i
       }
    }
    return -1
 }
 
 // BinarySearch 期望接收排序后的切片并相应地查找给定值的索引。
 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
       }
    }
 }

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

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 <= 9; i++ {
       assert.Equal(t, i, alg(items, i))
    }
    assert.Equal(t, -1, alg(items, 100))
 }

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

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

这些基准函数创建一个包含 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 < l; i++ {
       items[i] = <-c
    }
    return items, nil
 }

在此示例中,两个并发请求可能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) (<-chan struct{}, func(), error)
    Publish(id string) error
 }

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

信号包(续)

信号包继续:

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),
    }
 }
 
 // 用于信令的订阅和发布功能...

脆片包装

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模板网!

原文地址:https://www.toymoban.com/diary/golang/484.html

如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请联系站长进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用
使用 Go 和 gRPC 构建生产级微服务 - 带有示例的分步开发人员指南
上一篇 2023年10月29日 13:39
如何使用PHP进行网页抓取 | 终极教程
下一篇 2023年10月31日 01:03

相关文章

  • 使用 LibVLC 构建自定义 Android 视频播放器:分步指南,降低延迟/图像失帧(附源码)

    前言 在这篇博文中,我们将深入探讨使用 LibVLC 库的自定义 Android 视频播放器的实现细节。本分步指南将涵盖设置播放器、处理各种事件以及在您的 Android 应用程序中提供无缝视频播放体验的基本方面。 一、LibVLC 概述: 视频播放是许多 Android 应用程序中的常见功能,从流媒

    2024年02月02日
    浏览(29)
  • 在校园跑腿系统小程序中,如何设计高效的实时通知与消息推送系统?

    在校园跑腿系统小程序中,选择一个适合的消息推送服务。例如,使用WebSocket技术、Firebase Cloud Messaging (FCM)、或第三方推送服务如Pusher或OneSignal等。注册并获取相关的API密钥或访问令牌。 使用选定的服务提供商的文档,将其集成到小程序后端。这通常涉及使用相应的SDK或AP

    2024年02月05日
    浏览(22)
  • 如何通过 Docker 使用 AutoGPT:分步指南

    AutoGPT 是一个自治的 GPT-4 代理。AutoGPT 基本上是 ChatGPT 与自己对话。它可以创建代码、执行代码,还可以访问互联网。通过与自身对话,ChatGPT 可以自行验证源代码、创建程序和调试程序。这是 AI 领域的最新大事。在本文中,我将帮助您逐步使用 Docker 运行 AutoGPT。 我假设您知

    2024年02月07日
    浏览(23)
  • 推荐系统架构设计实践:Spark Streaming+Kafka构建实时推荐系统架构

    作者:禅与计算机程序设计艺术 推荐系统(Recommendation System)一直都是互联网领域一个非常火热的话题。其主要目标是在用户多样化的信息环境中,通过分析用户的偏好、消费习惯等数据,提供个性化的信息推送、商品推荐、购物指导等服务。如何设计一个推荐系统的架构及

    2024年02月08日
    浏览(18)
  • 在苹果 macOS m1和m2 使用 MATLAB分步指南

    MATLAB 是用于数值计算和数据分析的强大软件工具。如果您是 Mac 用户并想开始使用 MATLAB,那么本分步指南适合您。 在开始之前,请确保您的 Mac 满足运行 MATLAB 的最低系统要求。 转到 MathWorks 网站 ( https://www.mathworks.com/ )。单击“下载”按钮。创建一个帐户或登录您现有的

    2024年02月01日
    浏览(32)
  • 使用 Python 流式传输来自 OpenAI API 的响应:分步指南

    OpenAI API 提供了大量可用于执行各种 NLP 任务的尖端 AI 模型。但是,在某些情况下,仅向 OpenAI 发出 API 请求可能还不够,例如需要实时更新时。这就是服务器发送事件 (SSE) 发挥作用的地方。 SSE 是一种简单有效的技术,用于将数据从服务器实时流式传输到客户端。 如何在 W

    2023年04月19日
    浏览(21)
  • 实时消息传送:WebSocket实现系统后台消息实时通知

    在现代Web应用中,提供实时通知对于改善用户体验至关重要。WebSocket技术允许建立双向通信通道,从系统后台将消息实时传送给系统用户,并在前端以弹窗的形式通知用户。本文将深入探讨如何使用WebSocket来实现这一功能。 WebSocket是一种双向通信协议,与传统的HTTP通信不同

    2024年02月08日
    浏览(22)
  • SpringBoot使用SSE进行实时通知前端

    项目有个需求是要实时通知前端,告诉前端这个任务加载好了。然后想了2个方案,一种是用websocket进行长连接,一种是使用SSE(Sever Send Event),是HTTP协议中的一种,Content-Type为text/event-stream,能够保持长连接。 websocket是前端既能向后端发送消息,后端也能向前端发送消息。

    2024年02月08日
    浏览(24)
  • Java使用websocket实现消息实时通知

    博客仅做学习记录使用。 做项目中遇到这样一个实时通知需求,因为第一次接触这个,期间查了很多资料,看了很多博客,最后实现功能,查询的博客太多,就不一一放出来了,感谢各位大佬。 websocket方式主要代码来源于这个大佬的博客: https://blog.csdn.net/moshowgame/article/d

    2024年02月08日
    浏览(21)
  • 使用 LSTM 和 TensorFlow 中的注意力机制进行高级股票形态预测:Apple Inc. (AAPL) 数据分步指南

            在瞬息万变的金融市场中,准确的预测就像圣杯一样。当我们寻求更复杂的技术来解释市场趋势时,机器学习成为希望的灯塔。在各种机器学习模型中,长短期记忆(LSTM)网络受到了极大的关注。当与注意力机制相结合时,这些模型变得更加强大,尤其是在分析

    2024年04月17日
    浏览(19)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包