本章节,我们将学习如何使用 Action Cable 广播 Turbo Stream templates,来让我们的页面进行实时跟新。
Turbo Stream format 可以仅用几行代码就与 Action Cable 结合,来让我们的页面实时更新,当然与:群聊,通知,邮箱服务是类似的。
让我们用邮箱服务来举例,比如当我们收到一封新的邮箱,我们不想去手动的刷新让它显示,相反我们希望它能自己在页面上更新,而不需要我们操作什么。
而实现这一功能对于 Rails 来说很容易,因为在 Rails5 时就发布了 Active Cable。本章将要讨论的 Turbo Rails 的一部分是建立在 Action Cable 之上的,而实现该功能,也就更加简单了。
来想象一下,如果有许多人同时使用我们的 quote 编辑器,他们更希望实时看到同事们都写了什么。
在Quotes#index
页面:
这听起来很麻烦。但这个需求可以让我们的学习如何使用 Turbo Stream 来在首页中实时的更新,
为了做到这一点,我们必须告诉Quote
模型去广播新建的 quote 的 HTML 在创建后,让我们来改改
# app/models/quote.rb
class Quote < ApplicationRecord
# All the previous code
after_create_commit -> { broadcast_prepend_to "quotes", partial: "quotes/quote", locals: { quote: self }, target: "quotes" }
end
让我们先写下这些代码,当我们在浏览器中感受一下,就能更清晰的知道代码的含义了
首先,我们使用了after_create_commit
回调去通知 Rails,在每次向数据库内新加一条数据时,执行这个 lambda 表达式
第二段 lanbda 表达式中的代码就更复杂了,它会通知 rails,新建的 quote 对应的 HTML 应该被广播到那些订阅了quotes stream
的用户那里,并在 DOM 中放到 id 为quotes
的节点前面
我们将会解释到底该怎么做,但现在我们应注意生成的 HTML 是什么样子的。
<turbo-stream action="prepend" target="quotes">
<template>
<turbo-frame id="quote_123">
<!-- The HTML for the quote partial -->
</turbo-frame>
</template>
</turbo-stream>
有没有感觉这个代码很眼熟,和我们上一节中QutoesController#create
将新创建的数据放到 quotes 列表的前面,所生成的 HTML 代码是一样的。
唯一不同的是,这次的 HTML 是通过 WebSocket 传递的,而不是通过 ajax 的响应
注意:这里的例子我们是将新加的数据放到最前面,我们当然也可以使用broadcast_append_to
去把新加的数据,放到列表的后面
为了能够订阅到quotes
流,我们需要在Quotes#index
中加入下面的代码
<%# app/views/quotes/index.html.erb %>
<%= turbo_stream_from "quotes" %>
<%# All the previous HTML markup %>
而这段代码生成的 HTML 是这样子的:
<turbo-cable-stream-source
channel="Turbo::StreamsChannel"
signed-stream-name="very-long-string"
>
</turbo-cable-stream-source>
可以看到生成了一段来源于 Turbo JavaScript Library 的自定义标签,用订阅用户到在 channel 属性中命名的通道,更具体地说,是在 signed-stream-name 属性中命名的流。
Turbo::StreamsChannel
里面的channel
参数名就是 Action Cable channel 的名字,Turbo Rails 总会使用这个 channel,所以这个参数名始终是一样的。
signed-stream-name
参数是使用quotes
的一个签名,它是为了防止一些恶意用户干预并从流中获取我们的 HTML。这个我们会在下一章中细讲,现在你只需要知道这个长的字符串,解码后就quotes
现在所有的Quotes#index
页面中的用户都能监听到这个Turbo::StreamsChannel
,并等待quotes
流中的订阅数据,每当给数据库添加一个新的数据时,这些用户将收到Turbo Stream format
中的 HTML,并把数据放到相应的位置。
现在让我们看看是不是像我们预想的一样,下面会介绍两种方式去测试我们的代码
本章中,每次我们对Quote
模型做修改时,我们都需要重启 rails console 在测试之前,否则会出现些意料之外的事儿
注意:我们在 console 中测试前,需要确保 Redis 被正确的配置在应用中。
在开发环境,你的config/cable.yml
应该长下面的样子:
# config/cable.yml
development:
adapter: redis
url: redis://localhost:6379/1
# All the rest of the file
如果情况一致,那你可以忽略下面的提示了。
否则,你应该下载 Redis,然后运行:bin/rails turbo:install
,它会修改config/cable.yml
文件中的配置,如果没问题了,就可以继续往下了
现在我们在浏览器中,打开Quotes#index
页面,然后在 rails console 中创建一个新的 quote:
Quote.create!(name: "Broadcasted quote")
然我们在 console logs 中看看发生了什么?第一件事儿:
TRANSACTION (0.1ms) begin transaction
Quote Create (0.4ms) INSERT INTO "quotes" ("name", "created_at", "updated_at") VALUES (?, ?, ?) [["name", "Broadcasted quote"], ["created_at", "2021-10-16 12:03:54.401034"], ["updated_at", "2021-10-16 12:03:54.401034"]]
TRANSACTION (0.8ms) commit transaction
可以看到,插入一条新的数据,然后事务提交,再往下:
Rendered quotes/_quote.html.erb (Duration: 0.5ms | Allocations: 285)
[ActionCable] Broadcasting to quotes: "<turbo-stream action=\"prepend\" target=\"quotes\"><template><turbo-frame id=\"quote_908005754\">\nThe HTML of our quotes/_quote.html.erb partial</turbo-frame></template></turbo-stream>"
内容很长,但确实很有趣的一部分
首先我们注意到,通过ActionCable
广播了一段 HTML 到名字为quotes
的流中,由于我们刚才在Quotes#index
页面中加入了turbo_stream_from 'quotes'
,所以我们可以订阅到 Stream,并获取到它广播通知的 HTML
其次我们注意到,被广播通知的 HTML 是在 Turbo Stream format 中,它会通知 Turbo 去将中的内容放到quotes
的前面,这不这是我们让模型去做的事儿吗?
最后我们看到了生成的中的 HTML 正是quotes/_quote.html.erb
的数据,并且是我们刚刚创建的数据,当 Turbo 在前端获取到模版时,它就会放到 id 为 quotes 中 DOM 节点前面。
我们画个草图来说明一下,现在的Quotes#index
页面长下面的样子:
想象一下,一个同事新创建了一条数据
由于after_create_commit
的回调,当新创建数据后,broadcasts_prepend_to
方法将被调用
而在浏览器中,我们应该可以看到命名为“Broadcasted quote”已经被实时的加到列表的前面
由于构建于 Action Cable 之上的 Turbo Rails,这些修改都能被立刻的显示在页面中,我们不再需要刷新页面,我们仅仅使用了几行代码就让我们的系统具有了实时性的特点。
另一种方式就是,使用浏览器打开两个页面,一个页面进项操作,看另外一个页面是否可以实时更新。
让我们来简化一下先前在Quote
模型中的操作
# app/models/quote.rb
class Quote < ApplicationRecord
# All the previous code
after_create_commit -> { broadcast_prepend_to "quotes", partial: "quotes/quote", locals: { quote: self }, target: "quotes" }
end
上面的代码中,我们指定了target: "quotes"
,而默认的 target 就是模型的复数形式,也就相当于我们这里的 quotes,所以根据约定,target 这部分我们可以省略
# app/models/quote.rb
class Quote < ApplicationRecord
# All the previous code
after_create_commit -> { broadcast_prepend_to "quotes", partial: "quotes/quote", locals: { quote: self } }
end
还有两个约定,可以缩减我们的代码,底层中,partial and locals
选项都有默认的值
partial
的默认值等于 model 示例调用to_partial_path
,对于Quote
模型,就相当于quotes/quote
。
locals
默认值等于{ model_name.element.to_sym => self }
,对于Quote
模型,就相当于{quote:self}
。
所以最终我们的代码被简化为下面的样子:
# app/models/quote.rb
class Quote < ApplicationRecord
# All the previous code
after_create_commit -> { broadcast_prepend_to "quotes" }
end
根据约定大于配置,我们的代码只需要几行代码就可以完成任务了。
现在我们已经知道了 Turbo Streams 是如何运转的,让我们直接改进我们的增删改查代码。
增加的效果已经出来了,现在我们让修改也生效
修改模型:
# app/models/quote.rb
class Quote < ApplicationRecord
# All the previous code
after_create_commit -> { broadcast_prepend_to "quotes" }
after_update_commit -> { broadcast_replace_to "quotes" }
end
如果你去浏览器或者控制台测试,会发现功能已经做完了。
让我们在 rails console 测试一下,并解释一下发生了什么
Quote.first.update!(name: "Update from console")
Quote Load (0.3ms) SELECT "quotes".* FROM "quotes" ORDER BY "quotes"."id" ASC LIMIT ? [["LIMIT", 1]]
TRANSACTION (0.0ms) begin transaction
Quote Update (0.3ms) UPDATE "quotes" SET "name" = ?, "updated_at" = ? WHERE "quotes"."id" = ? [["name", "Update from console"], ["updated_at", "2021-10-16 12:48:02.987708"], ["id", 908005754]]
TRANSACTION (1.6ms) commit transaction
可以看到还是修改数据库,然后提交事务,当事务提交完毕后,Quote
模型的 after_update_commit
回调被触发,并且调用broadcast_replace_to
方法
Rendered quotes/_quote.html.erb (Duration: 0.6ms | Allocations: 285)
[ActionCable] Broadcasting to quotes: "<turbo-stream action=\"replace\" target=\"quote_908005754\"><template><turbo-frame id=\"quote_908005754\">\nHTML from the quotes/quote partial</turbo-frame></template></turbo-stream>"
像上次一样,我们看到了quotes/quote
局部页面的 HTML 被广播到quotes
流中,与上次不同,这次是replace
而不是prepend
,目标的 DOM 节点是 id=quote_908005754 的 quote card,而它也就是要被更新的内容。
而 Turbo 拦截被获取的 HTML,并替换这个 quote
下面我们就来实现,如何实时的删除数据
修改模型:
# app/models/quote.rb
class Quote < ApplicationRecord
# All the previous code
after_create_commit -> { broadcast_prepend_to "quotes" }
after_update_commit -> { broadcast_replace_to "quotes" }
after_destroy_commit -> { broadcast_remove_to "quotes" }
end
测试一下,发现功能又完成了,我们在 rails console 中看看到底发生了什么
执行:确保数据库有数据
Quote.last.destroy!
删除数据,提交事务
Quote Load (0.3ms) SELECT "quotes".* FROM "quotes" ORDER BY "quotes"."id" DESC LIMIT ? [["LIMIT", 1]]
TRANSACTION (0.1ms) begin transaction
Quote Destroy (0.4ms) DELETE FROM "quotes" WHERE "quotes"."id" = ? [["id", 908005754]]
TRANSACTION (1.4ms) commit transaction
提交事务后,进行after_destroy_commit
的模型回调,并调用broadcast_remove_to
。
[ActionCable] Broadcasting to quotes: "<turbo-stream action=\"remove\" target=\"quote_908005754\"></turbo-stream>"
页面中用户从quotes
流中获取数据,并且让 Turbo 去删除 id 为quote_908005754
的 DOM 节点,然后这部分就是要被删除的。
最终,这条 quote 数据就消失在Quotes#index
页面中。
就这样,我们改造了我们的增删改查,不过在进入下一章前,我们聊聊性能。
现在我们的Quote
模型长这个样子
# app/models/quote.rb
class Quote < ApplicationRecord
# All the previous code
after_create_commit -> { broadcast_prepend_to "quotes" }
after_update_commit -> { broadcast_replace_to "quotes" }
after_destroy_commit -> { broadcast_remove_to "quotes" }
end
我们可以通过使广播异步化去提升我们代码的性能,为了这一点,我们需要使用异步等价的语法去修改回调内容。
# app/models/quote.rb
class Quote < ApplicationRecord
# All the previous code
after_create_commit -> { broadcast_prepend_later_to "quotes" }
after_update_commit -> { broadcast_replace_later_to "quotes" }
after_destroy_commit -> { broadcast_remove_to "quotes" }
end
注意:prepend,replace 都有_later_to 方法,但 remove 没有,因为当一条 quote 被数据库删除了,那异步任务就没法在之后去检索这条数据执行任务了
让我们在 rails console 中测试一下,看一些有什么区别
Quote.create!(name: "Asynchronous quote")
看看最新的日志,我们发现创建数据的日志和之前一样,但是广播的部分被异步化了,一个Turbo::Streams::ActionBroadcastJob
加入了队列,并附带了必要的数据,用来后续的广播
Enqueued Turbo::Streams::ActionBroadcastJob (Job ID: 1eecd0c8-53fd-43ed-af8a-073b7d85c2fe) to Async(default) with arguments: "quotes", {:action=>:prepend, :target=>"quotes", :targets=>nil, :locals=>{:quote=>#<GlobalID:0x00007f9a39e861a8 @uri=#<URI::GID gid://hotwire-course/Quote/908005756>>}, :partial=>"quotes/quote"}
然后这个任务就被渲染为quotes/_quote.html.erb
局部视图那样
Performing Turbo::Streams::ActionBroadcastJob (Job ID: 1eecd0c8-53fd-43ed-af8a-073b7d85c2fe) from Async(default) enqueued at 2021-10-16T17:24:32Z with arguments: "quotes", {:action=>:prepend, :target=>"quotes", :targets=>nil, :locals=>{:quote=>#<GlobalID:0x00007f9a3e03a630 @uri=#<URI::GID gid://hotwire-course/Quote/908005756>>}, :partial=>"quotes/quote"}
异步广播 Turbo Stream 是我们性能优化的首选之举。
如果我们的模型拥有多个实时性任务,我们会注意到回调函数写的都很类似,而 Rails 就是一个约定大于配置的框架,所以让我们使用语法题去避免重复的语句,让我们来修改模型吧。
# app/models/quote.rb
class Quote < ApplicationRecord
# All the previous code
# after_create_commit -> { broadcast_prepend_later_to "quotes" }
# after_update_commit -> { broadcast_replace_later_to "quotes" }
# after_destroy_commit -> { broadcast_remove_to "quotes" }
# Those three callbacks are equivalent to the following single line
broadcasts_to ->(quote) { "quotes" }, inserts_by: :prepend
end
三个回到等同于下面的一行代码,我们将会在下一章(安全性)中讨论为什么需要 lambda 表达式。现在我们只需要知道,我们的增删改都被异步的广播到了quotes
流中。
我们的模型别简化为:
# app/models/quote.rb
class Quote < ApplicationRecord
# All the previous code
broadcasts_to ->(quote) { "quotes" }, inserts_by: :prepend
end
让我们的项目具有实时性,我们只需要简单的两行代码
Quotes#index
页面中,我们定义关注quotes
流剩下的事儿就交给 Turbo 完成吧
下一章,我们将会聊聊安全相关内容,我们将讨论如何让 Turbo Stream 确保被不会广播数据到异常的用户那里。