发布于 2026-01-06 0 阅读
0

使用 Rails 6 中的 Sidekiq、Redis 和 Devise 构建实时通知系统

使用 Rails 6 中的 Sidekiq、Redis 和 Devise 构建实时通知系统

免责声明:
刚刚发布了一篇关于如何在 Rails 7 中使用 Hotwire 实现此功能的新文章。

你也可以看看这篇,我在这里解释一下新篇里没提到的内容 ;)


在这篇文章中,我们将深入探讨异步函数,这是如今非常流行的技术,但在几年前却并不流行。

我的意思并不是说这一定是什么新鲜事,但我相信,得益于如今的 JS 生态系统,世界正在实时运转。

在这篇文章中,我想讲解其中的关键概念。但和往常一样,我们不会停留在理论层面,而是会看到一个实际的应用实例,例如我们应用程序的实时通知功能。

我会尽量言简意赅。

定义和概念 -

异步编程:指的是程序中发生的事件独立于主程序执行,并且互不干扰。在此之前,我们不得不等待响应才能继续执行,这严重影响了用户体验。

并发、并行、线程、进程、异步和同步——它们之间有关联吗?🤔

WebSocket: WebSocket 代表了客户端/服务器 Web 技术期待已久的一次革新。它允许在客户端和服务器之间建立一个长期保持的单个 TCP 套接字连接,从而实现双向、全双工消息的即时分发,开销极低,最终形成极低延迟的连接。继续阅读

换句话说,它允许我们在客户端和服务器之间建立点对点连接。在此之前,只有客户端知道服务器在哪里,反之则不然。

这样一来,我们就可以向服务器发送请求,并继续执行程序,而无需等待您的响应。然后服务器就能知道客户端的位置,并向您发送响应。

WebSocket 图

开始吧👊

以上内容对于我们的通知系统来说都很有意义,对吧?
在继续之前,请确保您已安装 Redis。Sidekiq 使用 Redis 来存储所有任务和操作数据。

👋如果您还不了解 Redis,可以在其官方网站上了解一下。

Sidekiq帮助我们以超级简单高效的方式在后台工作。(也是我最喜欢的工具之一♥️)

我创建这个项目是为了配合这篇文章,以便更专注于我们感兴趣的内容。这个项目是一个简单的博客,具备用户身份验证功能以及显示通知所需的前端界面。您可以下载它,并和我一起阅读这篇文章。

注意:您可以在“notifications”分支中查看完整的实现。

初始化配置...

我们将挂载ActionCable(基于 WebSocket 的实时通信框架)config/routes.rb的路由。

Rails.application.routes.draw do
  # everything else...
  mount ActionCable.server => '/cable'
end
Enter fullscreen mode Exit fullscreen mode

现在,你还记得 WebSocket 的工作原理吗?点对点连接,换句话说,也是一个通道(就像我们在 Rails 中称呼的那样),在这个通道中,我们必须始终标识每个用户。这样服务器才能知道要回复谁,以及是谁发出的请求。在这个例子中,我们将使用 user.id 来标识用户(我使用的是 devise)。

所以,在app/channels/application_cable/connection.rb

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      self.current_user = find_user
    end

    def find_user
      user_id = cookies.signed["user.id"]
      current_user = User.find_by(id: user_id)

      if current_user
        current_user
      else
        reject_unauthorized_connection
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

我们将保存已登录用户的 cookie(这有助于我们从其他地方获取该用户信息,稍后会详细介绍),一个有趣的解决方案(至少在 Devise 中)是使用Warden Hooks。

为此,我们可以在应用程序中创建一个初始化程序。config/initializers/warden_hooks.rb

  Warden::Manager.after_set_user do |user, auth, opts|
    auth.cookies.signed["user.id"] = user.id
    auth.cookies.signed["user.expires_at"] = 30.minutes.from_now
  end

  Warden::Manager.before_logout do |user, auth, opts|
    auth.cookies.signed["user.id"] = nil
    auth.cookies.signed["user.expires_at"] = nil
  end
Enter fullscreen mode Exit fullscreen mode

现在,让我们在数据库中创建一个表来保存我们创建的每个通知,为此,$ rails g model Notification user:references item:references viewed:boolean

注意:: item是一个多态关联,我这样做是为了方便他们添加各种类型的通知。

让我们在迁移过程中详细说明这一点以及其他细节(db/migrate/TIMESTAMP_create_notifications.rb):

class CreateNotifications < ActiveRecord::Migration[6.0]
  def change
    create_table :notifications do |t|
      t.references :user, foreign_key: true
      t.references :item, polymorphic: true
      t.boolean :viewed, default: false

      t.timestamps
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

和,$ rails db:migrate

接下来app/models/notification.rb我们将做一些事情,到时候再看情况。

class Notification < ApplicationRecord
  belongs_to :user
  belongs_to :item, polymorphic: true # Indicates a polymorphic reference

  after_create { NotificationBroadcastJob.perform_later(self) } # We make this later

  scope :leatest, ->{order("created_at DESC")}
  scope :unviewed, ->{where(viewed: false)} # This is like a shortcut

  # This returns the number of unviewed notifications
  def self.for_user(user_id)
    Notification.where(user_id: user_id).unviewed.count
  end
end
Enter fullscreen mode Exit fullscreen mode

让我们创建一个关注点,记住 Rails 最受推崇的理念之一是 DRY(不要重复自己),目前,每个通知都需要相同的内容才能正常工作(在模型中)(再次强调,在这个项目中我们只有发布,但我们可能还有许多其他东西想要集成到我们的通知系统中,所以使用这种形式非常简单)。

为此,app/models/concerns/notificable.rb

module Notificable
  extend ActiveSupport::Concern # module '::'

  included do # this appends in each place where we call this module
    has_many :notifications, as: :item
    after_commit :send_notifications_to_users
  end

  def send_notifications_to_users
    if self.respond_to? :user_ids # returns true if the model you are working with has a user_ids method
      NotificationSenderJob.perform_later(self)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

现在我们可以把它添加到我们的代码中app/models/post.rb。请记住,我们的代码send_notifications_to_users期望该方法user_ids返回相应的修复结果。让我们来做一下(app/models/post.rb):

class Post < ApplicationRecord
  include Notificable
  belongs_to :user

  def user_ids
    User.all.ids # send the notification to that users
  end
end
Enter fullscreen mode Exit fullscreen mode

我们将创建一个负责发送通知的任务,这些通知将在后台发送,并由 Sidekiq 进行处理。为此,$ rails g job NotificationSender

工作内容(app/jobs/notification_sender_job.rb):

class NotificationSenderJob < ApplicationJob
  queue_as :default

  def perform(item) # this method dispatch when job is called
    item.user_ids.each do |user_id|
      Notification.create(item: item, user_id: user_id)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

最后,我们需要安装 Sidekiq(以及 Sinatra,以便更轻松地完成一些操作),所以,开始/结束Gemfile

# everything else...
gem 'sinatra', '~> 2.0', '>= 2.0.8.1'
gem 'sidekiq', '~> 6.0', '>= 6.0.7'
Enter fullscreen mode Exit fullscreen mode

别忘了,$ bundle install

我们将告诉 Rails,我们将使用 Sidekiq 来处理队列适配器上的任务config/application.rb

# everything else...
module Blog
  class Application < Rails::Application
    # everything else...
    config.active_job.queue_adapter = :sidekiq
  end
end

Enter fullscreen mode Exit fullscreen mode

我们还将设置 Sidekiq 提供的路由,其中​​包括一个后台管理系统(稍后您可以通过 localhost:3000/sidekiq 访问),这非常有趣config/routes.rb

require 'sidekiq/web'
Rails.application.routes.draw do
  # everything else...
  mount Sidekiq::Web => '/sidekiq'
end
Enter fullscreen mode Exit fullscreen mode

现在我们将创建用于传输通知的渠道。$ rails g channel Notification

在此频道后台(app/channels/notification_channel.rb),我们将订阅用户:

class NotificationChannel < ApplicationCable::Channel
  def subscribed
    stream_from "notifications.#{current_user.id}" # in this way we identify to the user inside the channel later
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end
end
Enter fullscreen mode Exit fullscreen mode

在频道前端,app/javascript/channels/notification_channel.js向浏览器发送推送通知会很有意思,有很多 JS 库可以轻松实现这一点(例如这个),但为了避免帖子过大,我们将在控制台打印一条简单的消息。所以:

// everything else...
consumer.subscriptions.create("NotificationChannel", {
  // everything else...
  received(data) {
    if(data.action == "new_notification"){
      cosole.log(`New notification! Now you have ${data.message} unread notifications`) 
    } // we will define action & message in the next step
  }
});
Enter fullscreen mode Exit fullscreen mode

现在我们已经运行了很多任务,让我们把通知发送给用户吧!为此,我们将创建一个新的任务来执行此操作。记住,之前的任务负责创建通知,而这个任务负责广播。所以,$ rails g job NotificationBroadcast

里面app/jobs/notification_broadcast_job.rb

class NotificationBroadcastJob < ApplicationJob
  queue_as :default

  def perform(notification)
    notification_count = Notification.for_user(notification.user_id)

    ActionCable.server.broadcast "notifications.#{ notification.user_id }", { action: "new_notification", message: notification_count }
  end
end
Enter fullscreen mode Exit fullscreen mode

太棒了,我们已经全部搞定了!🎉
我接下来会在后端添加一些东西来结束这个示例。

首先,我打算在用户模型中添加一个方法,以便统计我尚未查看的通知数量。模型是执行此查询的理想位置app/models/user.rb

class User < ApplicationRecord
  # everything else...
  def unviewed_notifications_count
    Notification.for_user(self.id)
  end
end
Enter fullscreen mode Exit fullscreen mode

我还要创建一个控制器$ rails g controller Notifications index。在控制器内部app/controllers/notifications_controller.rb,我将添加一些方法:

class NotificationsController < ApplicationController
  def index
    @notifications = Notification.where(user: current_user).unviewed.leatest

    respond_to do |format|
      format.html
      format.js
    end
  end

  def update
    @notification = Notification.find(params[:id])

    message = @notification.update(notification_params) ? "Viewed notification" : "There was an error"

    redirect_to :back, notice: message
  end

  private
  def notification_params
    params.require(:notification).permit(:viewed)
  end
end
Enter fullscreen mode Exit fullscreen mode

我将创建一个 js 视图,以便能够远程响应并在导航栏的下拉菜单中显示最新通知app/helpers/notifications_helper.rb

module NotificationsHelper
  def render_notifications(notifications)
    notifications.map do |notification|
      render partial: "notifications/#{notification.item_type.downcase}", locals:{notification: notification}
    end.join("").html_safe
  end
end
Enter fullscreen mode Exit fullscreen mode

在导航栏中添加链接,例如我的示例中的链接是(app/views/partials/notifications.html.erb):

<%= link_to notifications_path, remote: true, data:{ type:"script" } %>
Enter fullscreen mode Exit fullscreen mode

别忘了app/config/routes.rb给这个新控制器添加路径()。

# everything else...
Rails.application.routes.draw do
  # everything else...
  resources :notifications, only: [:index, :update]
end
Enter fullscreen mode Exit fullscreen mode

只需为此项目创建一个局部视图(例如app/views/notifications/_post.rb)。他们可以添加一个“标记为已读”的链接,如下所示:

<%= link_to notification_path(id: notification, notification:{viewed: true}), method: :put %>
Enter fullscreen mode Exit fullscreen mode

要在本地运行此程序,您需要运行 Redis ( $ redis-server) 和 Sidekiq ( $ bundle exec sidekiq) + $ rails s,并打开 3 个终端窗口,并行运行这 3 个命令。

就这些了,希望对你有用👋

文章来源:https://dev.to/matiascarpintini/real-time-notification-system-with-sidekiq-redis-and-devise-in-rails-6-33l9