虽然大环境下“分离先行”的今天 flash 越发淡出众人的视野,不过坚持用 ActiveAdmin 写后台的我跟它打交道还是蛮多的,今天就想简单聊聊它的内部实现。原文链接:https://www.lanzhiheng.com/posts/source-code-of-rails-flash
Flash,中文可翻译成闪现(LOL 玩家应该很熟悉),在 Ruby On Rails 中我们对它最直观的体验就是在控制器的动作中操作 flash
这个变量(还是方法?)。官方对 flash
的描述如下:
The flash provides a way to pass temporary primitive-types (String, Array, Hash) between actions.
Just remember: They'll be gone by the time the next action has been performed.
简单概括一下它的功能,主要用于在控制器之间传输 Ruby 的“原生类型的数据”。在当前控制器的动作中存储一个值,这个值能够在下一次动作里面通过对应的方式来获取。然而这个值将会在下个动作完成的时候被销毁掉。考虑下面的示例代码:
class PostsController < ApplicationController
def a
flash.notice = 'From Action a'
redirect_to action: :b
end
def b
flash.notice #=> "From Action a"
redirect_to action: :c
end
def c
flash.notice #=> nil
end
end
篇幅有限,对应的视图代码我就不写了。虽然大环境下“分离先行”的今天 flash
越发淡出众人的视野,不过坚持用ActiveAdmin写后台的我跟它打交道还是蛮多的,今天就想简单聊聊它的内部实现。
抛开中间件的原理先不谈(我其实也不太懂),先把灯光聚焦在 flash
上
> flash.class
=> ActionDispatch::Flash::FlashHash
它是一个 ActionDispatch::Flash::FlashHash
的实例,初始化方法如下:
class FlashHash
def initialize(flashes = {}, discard = []) #:nodoc:
@discard = Set.new(stringify_array(discard))
@flashes = flashes.stringify_keys
@now = nil
end
def initialize_copy(other)
if other.now_is_loaded?
@now = other.now.dup
@now.flash = self
end
super
end
end
initialize
主要用于实例的初始化,这个大家都比较熟悉了,从这里面可以看出 FlashHashActionDispatch::Flash::FlashHash
从外部采集哈希表 flashes
,以及数组 discard
,并分别设置对应的实例变量 @flashes
, @discard
。还初始化了一个实例变量 @now
。
其实这个 @now
放不放这里都无所谓,它可以在其他方法里面再初始化,放在这个方法里面我觉得好处就在于: 代码阅览者可以一目了然,从初始化方法就可以了解当前的类的实例会囊括到哪些实例变量,是个人比较喜欢的编码风格。
也就是说构造一个 flash
就如此简单:
> ActionDispatch::Flash::FlashHash.new
=> #<ActionDispatch::Flash::FlashHash:0x00007fb109c6aab8 @discard=#<Set: {}>, @flashes={}, @now=nil>
> ActionDispatch::Flash::FlashHash.new({name: 'lanzhiheng', age: 28}, [:age])
=> #<ActionDispatch::Flash::FlashHash:0x00007fb109bbb3b0 @discard=#<Set: {"age"}>, @flashes={"name"=>"lanzhiheng", "age"=>28}, @now=nil>
而 initialize_copy
方法则会在对象拷贝的时候调用。根据一些网上的文章。这个方法会在对象拷贝的时候调用。比方说调用 Object#dup
的时候,伪代码大概如下
class Object
def dup
dup = self.class.allocate
dup.copy_instance_variables(self)
dup.initialize_dup(self)
dup
end
def initialize_dup(other)
initialize_copy(other)
end
end
这时候 initialize_dup
接收的参数 other 就是 Object#dup
方法的接受者(调用它的对象本身)。拷贝的时候,它会通方法 ActionDispatch::Flash::FlashHash#now_is_loaded?
来检测 @now
对象是否已经加载,如果已经加载,则在拷贝出来的对象中也设置这个值,它是原来 now
的一个副本
@now = other.now.dup
@now.flash = self
OK,关于 flash
的数据结构还有初始化就介绍到这里,接下来看看它是怎么管理数据的。
从一个动作设置的 flash
字段能够在下一个动作被读取到,并在下一个动作完成的时候销毁,这个销毁操作主要依靠 @discard
这个数组。回到最开始 abc
的例子,从 b
中查看 flash
会得到如下的值:
> flash
=> #<ActionDispatch::Flash::FlashHash:0x00007fa73ba9b868 @discard=#<Set: {"notice"}>, @flashes={"notice"=>"From Action a"}, @now=nil>
b
是 a
的下一个动作,所以 b
能够读取到 flash.notice
在 a
中所设置的值,并且会在 @discard
中记录即将要销毁的键 "notice" , ActionDispatch::Flash::FlashHash
里面提供了一个名为 sweep
的方法:
class FlashHash
def sweep #:nodoc:
@discard.each { |k| @flashes.delete k }
@discard.replace @flashes.keys
end
end
如果有需要其实我们也可以手动调用这个方法来抹除那些在 @discard
数组中记录过的数据,就像
> flash
=> #<ActionDispatch::Flash::FlashHash:0x00007fa73ba9b868 @discard=#<Set: {"notice"}>, @flashes={"notice"=>"From Action a"}, @now=nil>
> flash.sweep
=> #<Set: {}>
> flash
=> #<ActionDispatch::Flash::FlashHash:0x00007fa73ba9b868 @discard=#<Set: {}>, @flashes={}, @now=nil>
另外如果想要维护 @discard
这个数组中的数据,其实可以通过 ActionDispatch::Flash::FlashHash::discard
以及 ActionDispatch::Flash::FlashHash::keep
这一组方法,源码如下
class FlashHash
def discard(k = nil)
k = k.to_s if k
@discard.merge Array(k || keys) # 这里有个奇怪的keys
k ? self[k] : self
end
def keep(k = nil)
k = k.to_s if k
@discard.subtract Array(k || keys) # 这里有个奇怪的keys
k ? self[k] : self
end
end
其中的 keys
则是 @flashes
这个哈希表里面的键
class FlashHash
def keys
@flashes.keys
end
end
如果给 FlashHash#discard
方法提供特定键作为参数的时候,它就会把这个键合并进数组当中,如果没有提供任何参数,那么它就会把 @flashes
哈希表的所有键弄进数组中,在销毁操作的时候一并处理掉。 FlashHash#keep
的能力也差不多,只不过干的是相反的事情,把指定的键从数组中删除,或者把哈希表的所有键都从数组中删除。
那么稍微调整一下 abc
的例子其实完全可以做到把 flash
里的数据,维持到再下一个动作 c
。
class PostsController < ApplicationController
def a
flash.notice = 'From Action a'
redirect_to action: :b
end
def b
flash.notice #=> "From Action a"
flash.keep(:notice)
redirect_to action: :c
end
def c
flash.notice #=> "From Action a"
end
end
哈哈,不过一般不会这样做吧。
其实严格来说 flash
还不算是真的闪现,因为毕竟它所保存的东西的生命周期跨越了两个控制器动作。有没有可能设置一组数据,他们的生命周期只延续到当前控制器动作的完结?我们往往会通过在动作中设置实例变量来达到这个目的。
class PostsController < ApplicationController
def d
@hello = "Hello World"
end
end
<!-- posts/d.html.erb -->
<div>
<%= @hello %>
</div>
@hello
实例变量并不会在控制器动作之间共享,只有当前控制器动作以及对应的视图能够访问。这其实用 flash
也能实现。
class PostsController < ApplicationController
def d
flash.now.notice = 'Hello World'
end
end
<!-- posts/d.html.erb -->
<div>
<%= flash.now.notice %>
</div>
这里的 now
是什么玩意?
def now
@now ||= FlashNow.new(self)
end
调用 now
方法会设置 flash
的实例变量 @now
,它是 ActionDispatch::Flash::FlashNow
的一个实例。代码也比较简单:
class FlashNow
attr_accessor :flash
def initialize(flash)
@flash = flash
end
def []=(k, v)
k = k.to_s
@flash[k] = v
@flash.discard(k)
v
end
#...
end
初始化的时候就是设置了实例变量 @flash
,方法 FlashNow#[]=
会调用 @flash
本身的 []=
方法,来设置对应的值,只是在这之后它还会吧对应的键给 discard
掉,于是当前请求结束之后,这个键值对就会销毁掉。
再者,又因为访问器的存在,所以能够直接获取对应的实例变量 @flash
。于是乎在动作 d
里面我们其实可以
> flash.now.flash.now.flash.now.flash == flash
=> true
flash
的数据一般都需要在两个控制器之间共享(从一个控制器设置的值能够在下一个控制器中被获取到)。这种时候就需要一个中转站作为两个控制器动作之间数据共享的桥梁,从代码上看 flash
是通过 session
来做的中转的。
module RequestMethods
# Access the contents of the flash. Use <tt>flash["notice"]</tt> to
# read a notice you put there or <tt>flash["notice"] = "hello"</tt>
# to put a new one.
def flash
flash = flash_hash
return flash if flash
self.flash = Flash::FlashHash.from_session_value(session["flash"])
end
# ...
end
首先从 RequestMethods#flash_hash
中去获取 flash
,如果不存在则从 session
中去获取,并调用内部的 RequestMethods#flash=
方法,把对应的值设置进来。它们的源码范例如下
module RequestMethods
KEY = "action_dispatch.request.flash_hash"
def flash=(flash)
set_header Flash::KEY, flash
end
def flash_hash # :nodoc:
get_header Flash::KEY
end
end
作用一目了然,一个是根据 KEY
从头部获取 flash
。另一个则是把 flash
的值设置到头部,而从最开始的 RequestMethods#flash
方法可以看出,这个值是从 session
中获取的。我理解就是两个控制器动作之间的信息传递就是通过 session
来做中转,而有些时候我们需要从视图中去读取 flash
的相关数据,这种时候如果已经把相关的值设置到头部了,就可以从头部去获取。这个模块会被导入到 request
这个对象上,所以我们还可以这样去获取 flash
> request.flash
=> #<ActionDispatch::Flash::FlashHash:0x00007f9c05eebe68> ....
> flash == request.flash
=> true
最后看看它的存储方式,如果 flash_hash
获取不到对应的值,那么它就会尝试从 session
中去获取。这种情况应该会发生在一个新的动作开始的时候,它需要从 session
中去读取 flash
相关的信息,以知道上一个动作有什么要传递过来。而 flash
的存储模式如下
class FlashHash
# ...
def to_session_value #:nodoc:
flashes_to_keep = @flashes.except(*@discard)
return nil if flashes_to_keep.empty?
{ "discard" => [], "flashes" => flashes_to_keep }
end
end
可见,在存入 session
之前它会先根据 @discard
中的内容对 @flashes
进行一次清理。把剩余的值存到 session
中,如果这个时候哈希表已经空了,那么就返回 nil
值。而读取的方式也很简单
class FlashHash
def self.from_session_value(value) #:nodoc:
case value
when FlashHash # Rails 3.1, 3.2
flashes = value.instance_variable_get(:@flashes)
if discard = value.instance_variable_get(:@used)
flashes.except!(*discard)
end
new(flashes, flashes.keys)
when Hash # Rails 4.0
flashes = value["flashes"]
if discard = value["discard"]
flashes.except!(*discard)
end
new(flashes, flashes.keys)
else
new
end
end
end
老版本的 Rails 我们应该都不用管(第一次接触 Rails 已经是 5.x+ 了),我们常用的版本应该就是走 Hash
那条分支,这里就有点怪了,数据保存到 session
的时候已经经历过一次 flashes
的清理,这里是否有必要再清理一次?接下来就是利用得到的 flashes
哈希表,以及 discard
数组来初始化一个 ActionDispatch::Flash::FlashHash
对象。另外,如果 session
中获取的值为 nil
的时候,就直接不带参数地去调用 new
方法。
读到这里,也很好理解了为什么 ActionDispatch::Flash::FlashNow
的实例只在当前动作范围内有用,无法传到到下一个动作。因为在初始化的时候,它除了设置 @flashes
中的键值对,还把对应的键追加到了 @discard
数组中,这样在持久化之前调用的 FlashHash#to_session_value
就会把对应的东西给清理掉。下一个动作就获取不到了。而常规情况去设置 flash
中的数据,并不会不会把对应的键追加到 @discard
数组中,如下
> flash.clear
=> {}
> flash.now.notice = 'I am notice'
=> "I am notice"
> flash[:error] = 'I am error'
=> "I am error"
> flash
=> #<ActionDispatch::Flash::FlashHash:0x00007ff4f7c5d638
@discard=#<Set: {"notice"}>,
@flashes={"notice"=>"I am notice", "error"=>"I am error"},
@now=
#<ActionDispatch::Flash::FlashNow:0x00007ff4f7c5cf30
@flash=#<ActionDispatch::Flash::FlashHash:0x00007ff4f7c5d638 ...>>>
这个时候 @discard
数组里面就只有 "notice" 而没有 "error" 。
以上就是对 Rails 中经常会用到的那个 flash
所做的源码分析。笔者平时都用ActiveAdmin来做后台管理的开发,所以跟它打交道会稍微多一些,就找空闲时间稍微窥探了一下源码。并不能算特别全面,有错误之处,还望指正。