Psych、Syck、YAML 和编码
这次 is-programmer 升级碰到了一个很棘手的问题,花了好长时间才把大致的原因搞明白,这里记录一下经过。
背景
在 Ruby 1.9.2 之前,YAML 的解析使用的是 Syck 这个引擎,而从 1.9.3 之后,默认的引擎变成了 Psych。Psych 相比 Syck 有以下优点:
- 可以正确处理 UTF-8 字符。
- 基于的底层库是 YAML 标准组所写的 libyaml。
- 支持 YAML 1.1。
- Syck 已经不再维护了。
UTF-8 字符串的处理是 Psych 和 Syck 之间最主要的一个区别:
Syck 因为无法直接处理 UTF-8 字符,所以如果导出的值中含有 UTF-8 字符串的话,Syck 会把它 Base64 编码,然后保存为 binary 格式:
# Syck $ YAML.dump(:title => "标签云") => --- :title: !binary |- 5qCH562+5LqR
Psych 可以正确序列化 UTF-8 字符,所以 YAML 中保存的就是原始字符:
# Psych $ YAML.dump(:title => "标签云") => --- :title: 标签云
当然 Syck 这个引擎在 ruby 1.9.3 里面还是存在的,可以通过设置 YAML:ENGINE 来在程序中切换:
$ YAML::ENGINE.yamler = 'syck' # 使用 Syck 引擎 $ YAML::ENGINE.yamler = 'psych' # 使用 Psych 引擎
问题
is-programmer 早在 ruby 1.8.x 的时代就开始使用 YAML 来保存设置,自然 UTF-8 字符串都是 Syck 的 binary 格式,这次升级将 ruby 从 1.9.2 升级到了1.9.3,YAML 的默认引擎也就切换到了 Psych,因为本地测试都是使用全新的数据,因此并没有发现问题。
结果上线之后,发现大量的中文设置出现类似
æ ç¾äºæ ç¾äºæ ç¾äº
的乱码。
不用说,问题肯定出在 Psych 上。
原因
既然出现乱码的都是含有中文字符串的设置,那么问题就比较明朗了,那就是:
Psych 似乎不能正确解析在 Syck 下序列化的 binary 格式的字符串。
我猜测是因为 Psych 不知道 binary 字符串原来的编码,也可能是 Psych 的 bug 或是标准不兼容。总之,如果使用 Psych 来读取 Syck 下保存的 UTF-8 字符串,会出现很诡异的结果:
$ YAML::ENGINE.yamler = 'psych' $ YAML.load("---\n:title: !binary |-\n5qCH562+5LqR") => {:title=>"\xE6\xA0\x87\xE7\xAD\xBE\xE4\xBA\x91"}
然后,在网页上显示的就是更加诡异的诸如以下的字符:
æ ç¾äº
解决
本来,要解决这个问题很简单,只需要把 YAML 引擎切换回 Syck 就行了:
YAML::ENGINE.yamler = 'syck'
这样虽然无法使用新引擎,但是至少显示不会出现问题。
不过,由于上线之后,一些用户又重新保存了设置,问题一下就复杂化了。
把引擎切换回 Syck 已经来不及了,因为 Syck 也不能正确解析 Psych 下保存过得 YAML。
不仅仅是如此,更加郁闷的是,现在 YAML 中不仅有老的 binary 格式字符串,还有被 Psych 保存过的乱码字符串,而且还有保存正确的 Psych 格式的中文 UTF-8 字符串……
这可肿么办呐
。
。
。
经过一番 之后,决定还是先想想怎么把乱码恢复成正确的中文字符串。
。
。
。
之后又经过了一番 ……终于发现,Psych 似乎在解析了 binary 字符串之后,并没有把字符串标示为 UTF-8,而是标示为 ISO-5589-1(也就是 LATIN-1),之后由于 ruby 内部的编码被设成成了 UTF-8,所以字符串又被按照 ISO-8859-1 的过程转换了一番,于是就得到了网页上显示的那样诡异的字符。
这样的话,如果想把乱码转换回中文,那么就需要:
"xxx乱码xxx".force_encoding("UTF-8")\ # 先强制标示为 UTF-8 .encode("ISO-8859-1")\ # 再转换成 ISO-8859-1 .force_encoding("UTF-8") # 再强制标示为 UTF-8
这样正确的字符串就回来啦~
不过,YAML 还有已经被 Psych 正确保存的中文字符串,还有一些非中文的英文设置,怎么把乱码和这些正确的字符串区分呢?
幸运的是,如果是英文字符串,在这个过程中内容并不会改变(英文没有编码问题就是爽),而如果是已经正确保存的中文字符串,那么在 encode("ISO-8859-1") 的时候,会 raise 一个 Encoding::UndefinedConversionError 异常,这样只要捕捉这个异常并保留原值就行了。
于是,如果想把混乱的 old_hash 修复的话,就要:
def fix_yaml_hash(old_hash) new_hash = {} old_hash.each do |key, value| if value.is_a?(String) begin new_value = value.clone.force_encoding("UTF-8").encode("ISO-8859-1").force_encoding("UTF-8") rescue new_value = value end else new_value = value end new_hash[key] = new_value end return new_hash end
这样的话,问题就全面解决了~
结论
- 如果你已经使用上了 Psych 并且一切正常,那么就用吧,Psych 在今后应该可以成为 ruby 处理 YAML 的标准。
-
如果你还在使用 Syck,还是先
YAML::ENGINE.yamler = 'syck'
用着吧。 - 如果还没有开始使用 YAML,那么强烈建议趁着这个机会去转向更加流行的 JSON。
Ruby 1.8.6 p230 p238 有内存泄漏 Bug
这两天有些奇怪,服务器上 Rails 进程的内存占用总是不断上涨,只要一天时间,内存就暴满,然后开始啃虚拟内存,网站也变得很慢。
开始以为是 ruby-gettext 的问题,因为 1.90 的 ruby-gettext 确实有这么个内存泄漏的 bug,不过我当初打过了补丁,应该不会出现问题。尝试升级到 1.91,问题依旧……
接着怀疑是代码本身的问题,不过前些日子一直正常,这两天又没有改过网站代码,所以排除。
后来想起来,上个星期把服务器上的 ruby 升级为最新的 1.8.6_p230,难道问题出在这里? Google 了一下,果然:
[Ruby 1.8 - Bug #216] (Open) Memory leaks in 1.8.6p230 and p238
看来 production 下不能使用 p230 和 p238 了,即使是 is-Programmer 这样流量很小的网站,问题都很严重。
降级回 1.8.6_p114,问题解决~
带有默认值的 to_i
Ruby 中的 to_i 方法可以把对象转换成数字,"123".to_i 就返回 123,用来将用户输入的文本转换成数字非常实用。
不过如果对象无法转换成数字,to_i 就返回 0 了,假如我想在用户的输入无意义的情况下,取一个默认值 ( 比如 50 ),而不是 0,就要:
-
if input.to_i == 0
-
return 50
-
else
-
input.to_i
-
end
这样的代码很让人郁闷…… 更好的办法是利用 Ruby 方便的语法特性,定义一个 to_num 方法:
-
module Kernel
-
def to_num(default=0)
-
self.to_i == 0 ? default : self.to_i
-
end
-
end
这样,所有的对象都可以应用这个带有默认值的 to_num 方法了~
-
irb > "123".to_num
-
=> 123
-
irb > "abc".to_num
-
=> 0
-
irb > "abc".to_num(50)
-
=> 50
完全可以取代 to_i 方法。
Ruby 数组转换成 Javascript 参数
有的时候数据存储在 Ruby 的数组里,如果你想把这个数组当作 Javascript 的函数的参数,比如:
你想把他作为参数传给 Javascript 代码,达到这样的效果:
需要把 @names 转换成字符串的表达形式,你可能想到迭代数组中的每项,拼出最终的字符串,其实简单的方法是 inspect:
因为在 Ruby 和 Javascript 中数组的表达是基本一样的,所以这样刚好达到了要求。
不过如果数组中有不期望的数据,比如 nil 或者一个复杂的对象什么的,结果可能就不是你希望的了。那么可以试试 array_or_string_for_javascript:
不过,array_or_string_for_javascript 会把数组中所有的项目都转换成字符串,所以如果数组是 [100, 200, 300] 就会被转换成 ['100', '200', '300'] 如果你想传入数值的话就用前面的 inspect 了。
另外还有一个 options_for_javascript,把 Ruby 的 Hash 转成 Javascript 的格式:
=> {a:1, b:2, c:3}
Rails 中的 Helper 也用这俩函数生成 Javascript 的参数。
inspect 只适合偷懒用,毕竟 inspect 的格式并不保证不会变,也许 Ruby 下一般换个更漂亮的方式显示 inspect 结果,这招就不灵了。
被 Ruby 耍了一下
Fixnum 有个实例方法 [ ],返回数字二进制表示的第 n 位,比如:
-
> 8[3]
-
1
-
> 10.downto(0){|n| print 255[n]}
-
00011111111
如果传入个符号会怎么样呢? 本以为会出错,发现运行正常,返回 0:
-
> 5[:asdasdasd]
-
0
果不其然,[ ] 对传入的符号做了隐式转换,符号的 to_int 方法返回一个唯一代表本身的整数,一般这个整数很大,也就是返回数字的很高位,自然是 0。
这样的话,像 val[:something] 这样的形式,有可能 val 是个 hash,拿符号当作键,也有可能像上面那样 val 只是个数字。
前两天写个东西,类似下面的代码:
-
@user.data ||= {:a => 1, :b => 2, :xxx => xxx ....... }
-
....
-
val = @user.data[:someting]
运行发现,不管我怎么取,val 的值都是 0 ……百思不得其解 。
后来发现 users 表的 data 这项,不小心在数据库中指定默认值为 0 了,所以 @user.data 就不是 nil,根本没有被赋值为后面的 hash,val 就是 0[:something],怪不得取什么都是 0。
符号可以响应 to_int,字符串无法响应 to_int,所以传入个字符串就报错了,不过数字的 [ ] 方法传入非字符的变量有啥意义呢?检测参数不是 Fixnum 就 raise,这样就好了……
Update: 值得庆幸的是,最新的 Ruby 1.9.0 中已经拿掉了 Symbol 的 to_int 方法~
:foo.to_int
# ~> -:2: undefined method `to_int' for :foo:Symbol (NoMethodError)