PAYATHON 2026

Rails 如何使用 Action Cable 向指定用户发送通知?

支付小周

结论

问题不是 ActionCable.server.broadcast 被多次调用,而是 notification_render 使用了控制器的 render。

控制器中的 render 会生成 HTTP 响应,而同一个 action 只能响应一次。循环执行到第二次时,就会触发 AbstractController::DoubleRenderError。这里应该改用 render_to_string,先把 partial 渲染成字符串,再通过 Action Cable 广播。

渲染 partial 的正确方式

def notification_render(notification)
  render_to_string(
    partial: 'homepage/notification',
    locals: { notification: notification },
    formats: [:html]
  )
end

render_to_string 只返回渲染后的 HTML,不会把当前 action 标记为已响应,所以可以在循环中多次调用。

Controller 示例

如果每条 payment 都要生成一条通知,可以这样写:

def notify_users
  @payments = Payment.includes(:user).all

  @payments.each do |payment|
    notification = build_notification_for(payment)

    html = render_to_string(
      partial: 'homepage/notification',
      locals: {
        notification: notification
      },
      formats: [:html]
    )

    ActionCable.server.broadcast(
      "notifications:#{payment.user_id}",
      {
        html: html,
        count: unseen_notification_number(payment.user)
      }
    )
  end

  head :no_content
end

build_notification_for 和 unseen_notification_number 只是业务方法示例,需要换成项目中的实际实现。关键是循环内使用 render_to_string,整个 action 最后只生成一次 HTTP 响应。

如果 action 还要返回页面或 JSON,也应等循环结束后再执行一次:

render json: { success: true }

此时不要再调用 head、redirect_to 或其他 render。

Channel 与广播名称要一致

Channel 订阅的 stream 名称必须和广播时使用的名称完全相同:

class WebNotificationsChannel < ApplicationCable::Channel
  def subscribed
    if current_user
      stream_from "notifications:#{current_user.id}"
    else
      reject
    end
  end
end

广播代码如下:

ActionCable.server.broadcast(
  "notifications:#{payment.user_id}",
  {
    html: html,
    count: count
  }
)

当 payment.user_id == 15 时,消息只会发送到 notifications:15 这个 stream。

为了安全,最好使用服务端认证得到的 current_user.id 建立 stream,不要直接信任客户端传入的 user_id,否则用户可能订阅其他人的通知频道。

同一个用户只广播一次

如果 payment table 中同一个用户可能有多条记录,而每个用户只需要收到一次通知,可以先查询不重复的用户 ID:

def notify_users
  html = render_to_string(
    partial: 'homepage/notification',
    locals: { notification: notification },
    formats: [:html]
  )

  Payment.where.not(user_id: nil).distinct.pluck(:user_id).each do |user_id|
    ActionCable.server.broadcast(
      "notifications:#{user_id}",
      {
        html: html,
        count: unseen_notification_number_for(user_id)
      }
    )
  end

  head :no_content
end

如果 partial 的内容会随用户或 payment 改变,就需要在循环中分别调用 render_to_string:

Payment.includes(:user).find_each do |payment|
  notification = build_notification_for(payment)

  html = render_to_string(
    partial: 'homepage/notification',
    locals: {
      notification: notification,
      payment: payment,
      user: payment.user
    },
    formats: [:html]
  )

  ActionCable.server.broadcast(
    "notifications:#{payment.user_id}",
    {
      html: html,
      count: unseen_notification_number(payment.user)
    }
  )
end

数据量较大时

如果 payment 数量很多,不要在一次 HTTP 请求中同步完成遍历、渲染和广播。可以把这些工作放到后台任务中:

class BroadcastPaymentNotificationsJob < ApplicationJob
  queue_as :default

  def perform
    Payment.includes(:user).find_each do |payment|
      notification = build_notification_for(payment)

      html = ApplicationController.render(
        partial: 'homepage/notification',
        locals: {
          notification: notification,
          payment: payment
        }
      )

      ActionCable.server.broadcast(
        "notifications:#{payment.user_id}",
        {
          html: html,
          count: unseen_notification_number(payment.user)
        }
      )
    end
  end
end

然后由 controller 启动任务,并只返回一次响应:

def notify_users
  BroadcastPaymentNotificationsJob.perform_later
  head :accepted
end

后台任务不依赖当前控制器的响应,因此可以使用 ApplicationController.render 生成 partial 内容。具体使用哪种任务适配器以及如何执行,取决于项目现有的 Active Job 配置。

需要区分以下两类渲染方式:

  • render:生成当前 controller action 的 HTTP 响应,每个 action 最多执行一次。
  • render_to_string 或 ApplicationController.render:把模板渲染成字符串,适用于 Action Cable、邮件和后台任务。

备注:内容仅供参考。