翻译 Turbo Rails Tutorial 第 5 章翻译

qinsicheng · 2023年06月08日 · 最后由 zr0243 回复于 2023年06月15日 · 708 次阅读

Real-time updates with Turbo Streams

本章节,我们将学习如何使用 Action Cable 广播 Turbo Stream templates,来让我们的页面进行实时跟新。

Turbo Stream format 可以仅用几行代码就与 Action Cable 结合,来让我们的页面实时更新,当然与:群聊,通知,邮箱服务是类似的。

让我们用邮箱服务来举例,比如当我们收到一封新的邮箱,我们不想去手动的刷新让它显示,相反我们希望它能自己在页面上更新,而不需要我们操作什么。

而实现这一功能对于 Rails 来说很容易,因为在 Rails5 时就发布了 Active Cable。本章将要讨论的 Turbo Rails 的一部分是建立在 Action Cable 之上的,而实现该功能,也就更加简单了。

我们要做什么

来想象一下,如果有许多人同时使用我们的 quote 编辑器,他们更希望实时看到同事们都写了什么。

Quotes#index页面:

  • 任何时候一个成员创建了新的 quote,我们希望该 quote 立刻被加到我们的 quotes 列表的最上面
  • 任何时候一个成员修改了一个 quote,我们希望修改的内容,能立刻显示在页面上
  • 任何时候一个成员删除了一个 quote,我们希望被删除的内容,能立刻消失

这听起来很麻烦。但这个需求可以让我们的学习如何使用 Turbo Stream 来在首页中实时的更新,

使用 Turbo Stream 广播新建的 quotes

为了做到这一点,我们必须告诉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,并把数据放到相应的位置。

现在让我们看看是不是像我们预想的一样,下面会介绍两种方式去测试我们的代码

Testing Turbo Streams in the console

本章中,每次我们对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,这些修改都能被立刻的显示在页面中,我们不再需要刷新页面,我们仅仅使用了几行代码就让我们的系统具有了实时性的特点。

Testing Turbo Streams with two browser windows

另一种方式就是,使用浏览器打开两个页面,一个页面进项操作,看另外一个页面是否可以实时更新。

Turbo Streams conventions and syntactic sugar

让我们来简化一下先前在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 是如何运转的,让我们直接改进我们的增删改查代码。

Broadcasting quote updates with 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

下面我们就来实现,如何实时的删除数据

Broadcasting quote deletion with 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" }
  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页面中。

就这样,我们改造了我们的增删改查,不过在进入下一章前,我们聊聊性能。

Making broadcasting asynchronous with ActiveJob

现在我们的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

Wrap up

让我们的项目具有实时性,我们只需要简单的两行代码

  • 模型中,我们设置增删改的回调方法,而得助于约定,三个回调被定义为一行代码
  • Quotes#index页面中,我们定义关注quotes

剩下的事儿就交给 Turbo 完成吧

下一章,我们将会聊聊安全相关内容,我们将讨论如何让 Turbo Stream 确保被不会广播数据到异常的用户那里。

需要 登录 后方可回复, 如果你还没有账号请 注册新账号