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、邮件和后台任务。
备注:内容仅供参考。
