之前由于CodeJam要做搜索引擎相关的功能,而我经过了多个市面上比较流行的搜索引擎的对比之后,最终选择了Solr,主要是Solr在各方面都比较切合需求,DSL的查询语言也足够Easy和Reasonable。唯一的缺陷就是Solr是Java程序,具有很强的Java风格,这种风格对我这样一个Ruby程序员来说非常不适应。这种风格总的来说就是,完全XML配置,必须放在Tomcat或是Jetty中才能使用。

开始的Index配置的还是很成功,参考资料主要是:http://www.gm100861.com/519.html,这是我Google到目前为止我认为最靠谱的一篇文章了。

但是由于我们的搜索需要两个索引,而Solr不像Sphinx或是ElasticSearch天然支持多索引的特性,不过关于Solr的多索引配置的文档依然相当多,手段主要是Multi-indexes, Multi-cores和Multi-collections。我一开始选中了Multi-indexes,按照网上的一些文章一步一步做,试遍了各种方法,但最后全部都失败了。失败原因主要是不了解Tomcat的运行机制,而我出现的错误又很尴尬,访问localhost:8080/solr1出现404错误,没有任何错误消息,完全不知道该怎么办才好了,而且日志既不出现在/var/log里,也没有标准输出,Tomcat自身的错误日志又很多,找了半天,不知道哪个是哪个(后来才知道Tomcat和Ruby程序习惯不一样的地方就是,Tomcat在启动是加载webapp的时候就已经发生错误了,而Ruby程序都是访问时才检测错误,所以我每次在出现404的时候去找错误信息,显然什么都没找到)。再加上Tomcat是Java写的,没有办法通过调试源码来发现错误。后来又尝试了Multi-cores,也是完全不成功。

于是求助于Ruby China@yesmeck给了我一个gist,里面提及了一种简单的依靠调用REST API创建Collection的方法,这种方法真的简单,而且一弄就成功了!然后可以通过localhost:8080/solr/collection{n}/的REST API来指定相应的Collection。但是很奇怪的是,只要我一重启Tomcat,那些自己创建的Collection都会自动丢失,必须重新创建,很无语。网上我也没有找到好的解决方案。虽然如此,我还是用了很久,从CodeJam开始到今天第一次Demo,我还写了个脚本,在每次重启Tomcat后执行用来创建Collection并进行data import,还是蛮方便的。

今天搞完第一次Demo后,我终于稍微松口气,可以再试一次Multi-indexes的配置了,由于做了一个多礼拜的Solr,我对Tomcat和Solr的理解已经开始清晰了。所以第二次做的时候,我不再想之前一样在网上跟各种步骤,而是只看http://www.gm100861.com/519.html这篇我最相信的文章,以及这篇官方文档

在基本做完了所需步骤后,其中Tomcat,随即查看tomcat/logs/catalina.out,经过一个多礼拜的使用,我已经确信这个日志文件综合了Tomcat的各种日志输出。从日志中我很快发现了错误:

java.lang.IllegalArgumentException: Document base /usr/local/apache-tomcat-7.0.42/webapps/solr1 does not exist or is not a readable directory

我按照官方文档在tomcat/conf/Catalina/localhost下创建了solr1.xml和solr2.xml,然后就出现了这个错误。我猜测在Tomcat中创建什么名字的配置文件就代表什么名字的webapp,就像Rails的Convention一样,而不管这个文件里面如何指定了war文件的路径,这点我在之前任何一个Google的搜索结果中都没有看到。随后我在webapps下将solr更名为solr1,并创建了符号链接solr2。为了防止放在目录下的solr.war重新创建solr目录,我把solr.war也改名为solr1.war,同时也创建了符号链接solr2.war。这步完成后,错误信息改为

SEVERE: Error filterStart

这个错误就更人无语了,我按照网上的文章把filter去掉没有起效,把JDK1.7装上去之后还是不行,最后突然看到一个帖子说有可能是哪个必要的jar包找不到,于是立刻想起http://www.gm100861.com/519.html中的最后部分提及了要拷贝一些jar包到指定目录的,而我之前没做这步,完成后重启,访问localhost:8080/solr1和localhost:8080/solr2就都成功了,而且也不会再出现重启后消失的诡异情况。

上礼拜工作太无聊了,但是为了完成公司的一些Performance的Goal,所以就尝试把我们在维护的一个已经有相当年龄体态臃肿的大项目升级到Ruby 1.9.3,看看这样一次升级大约需要花费多少工作量。
首先升级Gemfile的时候,有三个Gem就不再支持Ruby 1.9,他们是rcovsystem_timerfastercsv。其中rcov在Ruby 1.9中的替代品是simplecovsystem_timer本来就是由于Fix 1.8中的一个问题,1.9中就不再需要了而fastercsv则因为Ruby 1.9自带csv库而主动退役了。升级到1.9之后,原先rcov和fastercsv的代码自然就要重写了。
接着执行
find . -name '*.rb' -exec ruby -c {} \; | grep -v 'Syntax OK'
命令,可以立即扫描出项目中不兼容Ruby 1.9语法的代码的位置,1.8和1.9主要有下列几个不兼容的部分:
1. case when在1.8中可以用冒号分割条件和值,例如`when condition: value`,而1.9中只能通过回车,分号或者then来分割,与此类似的还有while和if等。
2. 一部分原本在1.8下可以省略的括号在1.9不能省略,例如`[fn param1, param2]`这样的代码在1.9中须写成`[fn(param1, param2)]`
3. 编码问题,在1.8中我们可以直接在脚本文件中放入任何一个国家的语言而不必担心,因为Ruby 1.8完全不处理这些跨国字符,而在1.9中,如果代码中出现异国字符就必须在文件开头加入Magic Encoding信息,否则默认的ASCII编码将会发生错误。
 
当然,大部分不兼容的部分都不会在语法检查时就被发现,还必须通过更多的操作去发现:
1. YAML引擎变更,1.9后默认的YAML引擎是psych,我们项目中很多自动生成的YAML之前是用1.8默认引擎Syck,换了1.9之后无法解析,随后我就修改了config/boot.rb把YAML引擎重新改回了psych。这样还不够,由于psych版本上的不同,之前我们手动编写的config/database.yml文件(由于用了些比较高端的语法)也必须做些修改才能正常使用(我在牺牲掉了两个开发数据库所有数据之后才发现了这点)。很奇怪的是,想要完美替换YAML引擎并非这么容易,对psych的默认支持的代码存在于多个地方,最后我的代码是这个样子的:
begin
  require 'syck'
rescue LoadError
end
require 'yaml'
YAML::ENGINE.yamler = 'syck' if defined?(YAML::ENGINE)
ENV['TEST_SYCK'] = 'true'
可以看到,相当麻烦。
2. require不再支持当前目录,这个我相信大家都知道。最佳解决方案就是将路径换成绝对路径即可(如果还希望兼容1.8的话),我们项目中有数百个地方因此需要替换,我在Vim写了个正则表达式把他们全部替换了。
3. 在老版本中我们会用Symbol#to_i的方法来求得一个唯一的对应值,在1.9中这个方法不复存在,可以使用Symbol#object_id的方法,虽然计算得到的值并不相同,但效果类似。
4. 我们原来的代码中用到了1.8的ParseDate库,可以将一个日期字符串转换为一个数组。1.9中这个库不复存在(可能感觉功能过于薄弱且类似功能完全可以交给Date库完成),但是要恢复它并不困难,只要阅读1.8中ParseDate库的源代码,就可以发现其实ParseDate.parsedate方法其实只是简单调用了Date._parse这一内部方法而已,而这一内部方法依然存在,所以我们可以很容易地将它恢复回去。
5. ftools这个库没有了,它的方法被分散在了File和FileUtils这两个库中实现,直接替换即可。
6. Date#emit_sn这个方法没有了,其实这是个私有方法,被剔除完全没有任何问题。只是由于我们的项目中在扩展Date类功能的时候多次调用了这一方法,并且这个方法由于缺乏文档又没有给出任何替代品因此难以重写。解决方案仍然是阅读1.8版本的源代码,将其实现拷贝过来(其实这不是一个好办法,但是却可以保证完美的兼容)。
7. `Time.now`返回值的字符串版本的格式发生了变化,1.8中格式类似于"Tue Aug 27 22:14:05 +0800 2013"这样的美式写法,而1.9中则是"2013-08-27 22:14:52 +0800"这样的本地写法。
8. 在Ruby 1.8中,所有Object均拥有to_a方法,而在1.9中,to_a方法被从Object类中移除,解决方案是调用Array方法。
9. 同样关于Array方法,在1.8中,将String转换成数组时会将String按照回车分割成多个元素放入数组,而1.9中则始终创建只有一个元素的数组,这是因为在1.8中字符串默认返回的迭代器就是按行遍历数组的,而1.9中字符串不再拥有默认的迭代器,因此只能采用默认行为,将字符串包装成数组返回了。解决方案是调用数组的`each_line.to_a`方法,将得到一致的效果。这个区别导致Rails 2.3的Cookie没法正常工作,因为Cookie字符串在Rails中默认就是用回车分割的。
10. String#[]在1.8中返回一个整数,而在1.9中却返回一个字符。对此我们统一用chr方法将数字转换成字符(在1.9中如果已经是字符,chr就会直接返回)
11. String#length在1.8中相当于1.9的String#bytesize,而1.9的String#length将会返回正确的字符的数量
12. Array#choice更名为Array#sample
13. 在Ruby 1.8中Date有个new系列方法,即new0,new1,new2,new!,均与不同的new方法不同,其中new!和new0相同,并且我们代码里用到了这个方法。在1.9中统统没有了,连替代品都没有。我最后找到了https://developer.uservoice.com/blog/2012/03/04/how-to-upgrade-a-rails-2-3-app-to-ruby-1-9-3/这张页面,里面有关于new!的简易实现。
14. Date#exist?更名为Date#valid_date?,后者在1.8中其实也有。
15. Array#to_s在1.8中其行为几乎等同于不加参数的join,也就是元素直接拼合成字符串,而在1.9中则会用逗号分割并且用方括号包围。应对方法就是不再调用Array#to_s,这个问题还是挺难检查出来的。
16. 在1.8中你可以在遍历hash时为当前hash增加元素,而可能因为改为链表实现的缘故,1.9中不能再这样做了,否则将直接抛掷异常。不过数组还依然是可以的。
17. Rails为1.8增加了一个处理多字节字符的模块ActiveSupport::Multibyte,可以实现一些比较高级的功能。但Rails认为Ruby 1.9的字符串天然支持多字节字符功能,因此String#mb_chars在1.9中将返回字符串本身。在我们的项目中我们有些利用Multibyte实现的特殊功能,比如将带重音字节的a字符转换成普通的英语a字符。我后来通过强制创建ActiveSupport::Multibyte对象才恢复了这一功能。
18. bigdecimal/util库中Float#to_d方法的实现不同,1.9版本中可以设置精度,默认精度是16,这造成数据库中decimal类型的column经过ActiveRecord的类型转换后返回的数据在精度上有差异。
19. RDoc::Usage被移除出了Ruby标准库,现在已经不能通过对脚本做简单注释就实现--help的功能了。并且Ruby官方没有提供任何替代品,如果要说的话,可能就是optparser库了。但是在我们项目中已经有将近100个脚本,其中大部分均使用了RDoc::Usage的办法,重写几乎是不可能的。因此我故伎重施,将1.8源代码中的与RDoc::Usage相关部分的代码提取出来,也就是rdoc目录下的usage.rb文件和markup/和ri/目录而已。
20. 由于我按照https://developer.uservoice.com/blog/2012/03/04/how-to-upgrade-a-rails-2-3-app-to-ruby-1-9-3/里的建议将Ruby 1.9的默认读写的Encoding都设置为了UTF-8,因此如果要返回二进制文件作为数据返回的话,将遭遇到`invalid byte sequence`的错误,这是因为二进制数据在render的时候会调用一次String#blank?方法,而该方法的实现就是令其与/\S/做比较,如果字符串是二进制,这一比较将发生错误,因此我修改了这一方法,在遭遇到错误后将会将字符串强制编码为Encoding::BINARY,重试后即可解决。
21. `activesupport-2.3.18/lib/active_support/core_ext/string/output_safety.rb:22: warning: regexp match /.../n against to UTF-8 string`警告,这个警告其实并不重要,它的意思是/.../n的正则表达式只匹配二进制数据。可以用silence_warnings方法忽略到这个警告或者检测版本为1.9的话就移除这个n,或者简单的将ERb替换成Erubis
22. 对于多国语言的网站,参数中存在异国文字也有可能发生编码错误,直接采用https://developer.uservoice.com/blog/2012/03/04/how-to-upgrade-a-rails-2-3-app-to-ruby-1-9-3/中的脚本即可解决这个问题。
23. 最后这个是最重要的,在Ruby 1.8的lambda中,有个众所周知的Bug,当外部存在a变量时,调用block,并且block的参数中存在同名变量时,对这一变量的赋值将导致外部变量一起被修改。我很不能理解的是,这明显是一个必然要被修复的Bug,却有人利用这一特性为外部变量赋值,这种问题甚至还出现在一些著名的Gem里。而且这个错误非常难以发现,我就有找这个错误找到睡觉在梦里继续找这个错误的痛苦经历。
 
主要的差异就这么多吧,其实升级的过程并没有非常困难,无非就是 测试-发现问题-调试代码-版本比对-解决问题 这样循环往复的过程。关键就是如何通过测试尽量去发现所有的问题,只要问题能被定位就总能解决。目前,我也只能通过跑测试用例来发现不兼容的地方,但由于我们项目测试用例其实并不健全,所以究竟有多少可能的问题就不得而知了。
 

补充

我后来又在RubyTuesday 9/21号的活动上以这个主题做了一个演讲,演讲用的Slides可以点击这里观看,这个Slides用mdpress编写,源代码请这里下载

上次由于公司里机器要面临重装,开始仓皇地将Mac本里的文件scp到我的台式机上。忙乱之中本来要删除一个无用的文件夹的,结果用rm -rf的时候tab了一下,补全出来的文件都没看清就按下了回车,毫无疑问,文件删错了。由于rm命令是不走回收站的,连删前确认都不会有,所以就这么把一个有用的文件夹删掉了。幸亏后来发现我曾在台式机上备份过这个文件,否则美好的回忆就因为这个操作失误而丢失了。

为了防止此类事件的再度发生,我曾在Twitter上询问过别人关于实现rm时只把文件送到回收站而不删除的命令。有些人说用mv命令就行了,但是这显然是不够的,因为mv和rm命令选项并不兼容,尤其是本人惯用的rm -rf,mv显然做不到。其次把文件mv进回收站以后是不能put back的,那就更不行了。网上还有些方法提供了能够将文件送入回收站,并且能够put back的方法,这个还是不错,但是就如我之前所说,并未完全实现rm的选项功能。因此我就花了几个晚上的实现自己实现了一个符合我这一需求的工具:https://gitcafe.com/bachue/rm-trash

  • 这个工具是完全用Ruby编写的(一直以来都对纯Ruby情有独钟),考虑到Mac OS X自带Ruby 1.8.7,因此所有代码也在1.8.7上编写,并且保证在运行时绝对不依赖任何Gem(之所以不希望依赖Gem是因为以前在Linux上用Python写的实用工具的时候出错说包缺失,然后我从github的issues找出了错误的原因是没有安装依赖包,我显然不会搞Python,于是只能现场Google学习安装pip,再用pip把包装进去,搞得远比使用用C写的程序复杂的多,不能因为自己是这个语言的开发人员就认为其他人也有它的开发环境啊)。
  • 放进回收站的核心功能由AppleScript实现(所以只能跑在Mac OS X上),这里参考了这个Python的实现方法
  • 为了尽可能看起来比较炫比较有趣,所有输出信息全部(除了帮助信息外)彩色化。
  • 尽可能严格要求所有行为均与系统自带的rm命令一致,凡是不一致的都被当做Bug看待,除了一些不可能实现或是没必要实现的功能外(比如三次擦写文件块使得文件不可恢复以及试图恢复文件)。

其实起初我以为这个工具并不难做,只是把文件夹往回收站一放即可,后来做到交互式删除的时候难度倍增,使得我不得不多次大规模重构了代码以符合这种需求。幸亏项目之初就建立了完善的RSpec测试用例,否则肯定无暇顾及如此多的边缘Case。同时随着更深入的开发和研究,发现了越来越多刚开始没有发现的rm的行为细节,这些细节事实上至今为止我都没有完全模仿出来,因为程序目前的算法不允许这么做,不过由于程序已经基本可用而时间宝贵,只能等到以后有空的时间再实现罢。

最近开始玩Sublime Text,目前还在Evaluate阶段,逐步尝试用它替换掉原来Vim。期间实时语法检查用到了Sublime Linter这个插件,感觉还是蛮不错的。但是用于用了RVM管理Ruby版本,因此不能让Sublime Linter使用系统级的Ruby了。对此其实Sublime Linter是做了准备的,在它的README中就有这样一句:

If you are using rvm or rbenv, you will probably have to specify the full path to the ruby you are using in the "sublimelinter_executable_map" setting. See "Configuring" below for more info.

一般是要求修改sublimelinter_executable_map使之为$HOME/.rvm/bin/rvm-auto-ruby,这么做得话,显然就可以用RVM默认的Ruby版本了。但是这样对我而言还是不够的,由于工作的原因,我不同的项目都使用不同的ruby版本,而默认则是Ruby 1.8.7,当我在编写Ruby 1.9.3的代码时,老是按照Ruby 1.8.7来做语法检查,显然会出现大把大把的错误。(这个不是SublimeLinter的工作不到位,要知道以前用Vim的时候根本没得选。。)

对我这种Case而言,最好的方法是根据文件所在项目目录里的.rvmrc文件来判断这个文件该使用哪个Ruby版本。为此我写了一个shell脚本(起名叫rvm_use):

#!/usr/bin/env bash

[[ -s "$HOME/.rvm/scripts/rvm" ]] && source "$HOME/.rvm/scripts/rvm"
for i in "${@:2}"; do
    if [[ ! "$i" =~ ^- ]]; then
        cd `dirname $i`
        break
    fi
done
$@

这个脚本接受一行完整的Shell命令,比如"ruby -wc ...",然后从第二个参数开始(第一个参数往往是ruby或是rspec这样的可执行文件),如果检测到第一个首字母不是 '-' 的参数,认为这个参数就是项目文件所在位置,用cd命令进入到这个文件所在的目录(rvm已经hack了cd命令,只要进入的目录有.rvmrc就会切换到它指定的Ruby版本),然后执行用户送入的命令。

刚才这个脚本已经适用于Sublime Build之类同样涉及到Ruby的功能,但是要给SublimeLinter用的话,还要额外加一个脚本,这是因为SublimeLinter认为解释器命令一定只有一个参数,如果我把解释器设为 'rvm_use ruby' 的话将会出错。因此额外增加一个rvm_use_ruby脚本:

#!/usr/bin/env bash

`dirname $0`/rvm_use ruby $@

让Sublime Linter使用这个脚本就行了。

本来以为这么做就足够了,但是后来发现依然不够。调试发现Sublime Linter依然没有使用到正确的Ruby版本,阅读插件的Python源码发现(感谢不久前的Python Training,多少掌握了一些Python),Sublime Linter为了尽可能实现实时检测,提供了三种检查策略,通过文件检查,通过临时文件检查,通过STDIN检查。对于C语言这样比较死板的语言来说,文件必须先被保存,然后让检查工具去检查这个被保存的文件的语法才能得到正确的结果。Java则稍微高级一点,无需等到保存,而是在检查前将当前还未保存的文件偷偷写入到一个临时文件,然后检查那个临时文件的语法。至于Ruby由于是脚本语言,支持直接用STDIN将代码输入进去,这样同样无需保存文件即可实现实时检查。但是这个做法显然就无法应用我之前的办法了。因为rvm_use命令参数中将没有一个文件名,也就无从知晓应该用的Ruby版本了。

对于这种情况,一种可行的办法是修改SublimeLinter/sublimelinter/modules/ruby.py,修改它的CONFIG中的"input_method"参数(抱歉,没找到针对User目录的修改方法,只能改源文件了),但是这并不是一种理想的办法。因为这样就彻底失去了实时检查的功能,我并不想做这样的妥协。因此既然要改SublimeLinter/sublimelinter/modules/ruby.py文件,何不改的再彻底点,针对Ruby彻底重写executable_check方法?修改后的文件是这样的:(再次感谢Python Training)

# -*- coding: utf-8 -*-
# ruby.py - sublimelint package for checking ruby files

import re
import os
import subprocess

from base_linter import BaseLinter

CONFIG = {
    'language': 'Ruby',
    'executable': 'ruby',
    'lint_args': '-wc'
}


class Linter(BaseLinter):
    def parse_errors(self, view, errors, lines, errorUnderlines, violationUnderlines, warningUnderlines, errorMessages, violationMessages, warningMessages):
        for line in errors.splitlines():
            match = re.match(r'^.+:(?P<line>\d+):\s+(?P<error>.+)', line)

            if match:
                error, line = match.group('error'), match.group('line')
                self.add_message(int(line), lines, error, errorMessages)

    def executable_check(self, view, code, filename):
        args = [self.executable]
        args.extend(self._get_lint_args(view, code, filename))
        dirname = os.path.dirname(filename)

        process = subprocess.Popen(['bash', '-c',
                               'source $HOME/.rvm/scripts/rvm && \
                                cd ' + dirname + ' && '\
                                + '  '.join(args)],
                               stdin=subprocess.PIPE,
                               stdout=subprocess.PIPE,
                               stderr=subprocess.STDOUT,
                               startupinfo=self.get_startupinfo())
        process.stdin.write(code)
        result = process.communicate()[0]

        return result.strip()

这里用bash -c启动一个独立环境,在这个环境里执行source命令后用被rvm hack过的cd命令进入项目文件所在文件夹,然后再执行检查命令。这样就可以完美解决问题了。

注意Sublime的配置文件一定要被git托管,否则一旦源码因为升级而还原的话所有修改就都丢失了。

Ruby的class variable由于其奇怪的特性,一直是Ruby中包含争议的部分,大部分Rubist表示不使用class variable,最早我看到的文章是这篇写于2007年的帖子,里面的

@@avar = 1
class A
  @@avar = "hello"
end
puts @@avar  # => hello

成为了一个经典案例,至于其解释就是Ruby的class variable不属于owner类本身,而属于它的继承结构。@@avar实际定义在main所在类Object中,A继承自Object,因此也继承了@@avar这个class variable。

而昨天我又发现了另一个奇怪的特性

class A
  @@a = 1
  def f
    @@a
  end

  def f=(v)
    @@a = v
  end
end

A.new.f #=> 1
A.new.f = 2
A.new.f #=> 2

class A
  def self.f1
    @@a
  end

  class << self
    def f2
      @@a
    end
  end
end

A.f1 #=> 2
A.f2 #=> 2

# 直到这里,所有内容都可以理解
class << A
  def f3
    @@a
  end
end

A.f3 
#=> warning: class variable access from toplevel
#=> NameError: uninitialized class variable @@a in Object

这段代码的问题在于

class << A; ... ;end

class A; class << self; ... ; end; end

这两段代码本该是没有区别的,但是用后者访问的到A的class variable @@a而用前者却访问不到

当晚我把这段代码在Ruby Tuesday上提出过,也没人能回答我。回去以后仔细想想,可能觉得class variable对于gateway scope的敏感程度和其它两种变量类型local variable和instance variable不一样,在Ruby中,local variable不能穿越gateway scope存在,比如

a = 1
class A
  b = 2
  def f
    local_variables
  end
end
A.new.f #=> []

无论是a还是b,都没能进入到A的instance method f的定义中。而instance variable虽然也不能穿越gateway scope,但是等到gateway scope相同的时候却可以恢复出来

class A
  def self.f1
    @a = 1
  end

  def f2
    @a = 2
  end

  def self.f3
    @a
  end

  def f4
    @a
  end
end

A.f1
A.f3 #=> 1

a = A.new
a.f2
a.f4 #=> 2

但是class variable和它们恐怕都不一致,它是可以穿越gateway scope而存在的,比如

class A
  @@a = 1
  def f
    @@a
  end

  def f=(v)
    @@a = v
  end

  def self.f1
    @@a
  end
end

A.new.f #=> 1
A.new.f = 2
A.new.f #=> 2
A.f

可以看到,@@a在class A定义的类定义内就像全部变量一样的存在,轻松突破gateway scope,可以出现在类定义所在的每个角落。如果是这样的话,那就可以猜测了,class variable表现和其他变量类型不一致,对它而言只有class Xxxx的语句才是真正的gateway scope,其它语句统统ignore掉,包括class << Xxxxx在内。可以用以下代码证明:

class << A
  @@a = 1
end
@@a #=> 1
self.class #=> Object
self.class.class_variables #=> [:@@a]

看上去class variable定义在了A类中,但其实定义在了main所在类Object中。

一直感觉arity这个属性的算法提及的很少(虽然arity是个数学概念,但是简单的数学概念显然无法满足Ruby的复杂的需求),即使在Ruby-doc的MethodProc中也是如此。

Returns an indication of the number of arguments accepted by a method. Returns a nonnegative integer for methods that take a fixed number of arguments. For Ruby methods that take a variable number of arguments, returns -n-1, where n is the number of required arguments. For methods written in C, returns -1 if the call takes a variable number of arguments.

这篇Ruby 2.0的文档里面仅仅提到了几种最简单的情况,还远远算不上复杂,现在我这里列出了比文档里更多的例子,以此来分析arity的行为。

测试代码是这样的:

p proc {}.arity
p proc {||}.arity
p proc {|a|}.arity
p proc {|a,b|}.arity
p proc {|a,b,c|}.arity
p proc {|*a|}.arity
p proc {|a,*b|}.arity
p proc {|a,*b, c|}.arity
p proc {|x = 0, *args|}.arity
p proc {|x=0, y=0, *args|}.arity
p proc {|x, y=0, *args|}.arity
p proc {|(x, y), z=0, *args|}.arity
p proc {|a, b = 1, *c, d|}.arity
p proc {|a = 1, b = 1, *c, d|}.arity

p proc   { |x = 0| }.arity
p lambda { |a = 0| }.arity
def f(a = 0) end; p method(:f).arity
p proc   { |x=0, y| }.arity
p lambda { |x=0, y| }.arity
def f(x = 0, y) end; p method(:f).arity
p proc   { |x=0, y=0| }.arity
p lambda { |x=0, y=0| }.arity
def f(x=0, y=0) end; p method(:f).arity
p proc   { |x, y=0| }.arity
p lambda { |x, y=0| }.arity
def f(x, y=0) end; p method(:f).arity
p proc   { |(x, y), z=0| }.arity
p lambda { |(x, y), z=0| }.arity
def f((x, y), z=0) end; p method(:f).arity

然后是结果对比:

code ruby-1.9.3 ruby-2.0.0
p proc {}.arity 0 0
p proc {||}.arity 0 0
p proc {|a|}.arity 1 1
p proc {|a,b|}.arity 2 2
p proc {|a,b,c|}.arity 3 3
p proc {|*a|}.arity -1 -1
p proc {|a,*b|}.arity -2 -2
p proc {|a,*b, c|}.arity -3 -3
p proc {|x = 0, *args|}.arity -1 -1
p proc {|x=0, y=0, *args|}.arity -1 -1
p proc {|x, y=0, *args|}.arity -2 -2
p proc {|(x, y), z=0, *args|}.arity -2 -2
p proc {|a, b = 1, *c, d|}.arity -3 -3
p proc {|a = 1, b = 1, *c, d|}.arity -2 -2
p proc { |x = 0| }.arity 0 0
p lambda { |a = 0| }.arity 0 -1
def f(a = 0) end; p method(:f).arity -1 -1
p proc { |x=0, y| }.arity 0 -1
p lambda { |x=0, y| }.arity 0 -2
def f(x = 0, y) end; p method(:f).arity -2 -2
p proc { |x=0, y=0| }.arity 0 0
p lambda { |x=0, y=0| }.arity 0 -1
def f(x=0, y=0) end; p method(:f).arity -1 -1
p proc { |x, y=0| }.arity 1 1
p lambda { |x, y=0| }.arity 1 -2
def f(x, y=0) end; p method(:f).arity -2 -2
p proc { |(x, y), z=0| }.arity 1 1
p lambda { |(x, y), z=0| }.arity 1 -2
def f((x, y), z=0) end; p method(:f).arity -2 -2

由这一对比结果,可以清楚的看到:

  • 对于proc而言,如果参数中没有可变长参数(比如*args之类的),则在1.9.3中,arity取决于从左往右没有默认值的参数的数量。2.0.0中取决于整个参数列表中没有默认值的参数数量。
  • 如果有可变长参数,则如果n为整个参数列表中没有默认值的参数数量,arity为-n - 1。
  • 对于lambda而言,可以看到,在1.9.3中情况与proc总是一致的。而在2.0.0中,如果存在有默认值的参数,则如果n为整个参数列表中没有默认值的参数数量,arity = -n -1。否则就等于参数数量。
  • 对于method而言,无论1.9.3还是2.0.0,均与2.0.0中的lambda一致(准确的应该说是lambda在2.0.0中与method保持一致)。
  • 额外的&block不影响到arity的值。

大致结果就是如此了,如果有误,请务必留言告知,谢谢。

我发现写Rails,尤其是测试部分,需要reload的次数相当频繁。比方说

user = users(:xxxxx)
xhr :put, 'change_xxxxx', :id => user.id
user.reload # necessary!
assert user.xxx, xxx
assert user.yyy, yyy

由于这里创建的user对象和被测试的action里的user对象(在内存概念上)不是同一个对象,因此,被测试的action修改了user对象在数据库里的数据,会导致外围的user信息过时,因此必须reload,否则接下来的断言语句都无法通过。

这种情况同样也有可能存在与关联对象上,比如

assert child1.father, child2.father
child1.father.name = 'new name'
child1.save
child2.reload # necessary!
assert child2.father.name, 'new name'

总是写很多reload语句显然很不舒服,为此我今天在回家的地铁上想了一种解决方案,就是在后台全局的部分,以ActiveRecord类名和实例为key简单的缓存一部分数据,这部分数据与数据库里的数据在本次request返回内一致,request结束后自动清除以免内存泄露和影响下一个request。而前面的ActiveRecord对象,本身仅存储那些还未被保存的数据。

方案大致是这样的(以Rails 2.3为例):

require 'active_record'

module ActiveRecord
  class Base
    def request_cache
      self.class.request_cache[self]
    end

    def request_cache=(v)
      self.class.request_cache[self] = v
    end
  end

  class << Base
    def request_cache
      @request_cache ||= Hash.new {|h, k| h[k] = Hash.new {|h, k| h[k] = {}}}
      @request_cache[name]
    end
  end
end

ActionController::Dispatcher.middleware.use(Class.new do
  def initializer(app)
    @app = app
  end

  def call(env)
    @app.call(env)
  ensure
    ActiveRecord::Base.request_cache.clear
  end
end)

这里我把缓存就设计在ActiveRecord的class variable内(三级Hash,分别是类-实例对象-属性名),然后用Middleware的方式去清理缓存,之所以选择用Middleware的方式是因为ActiveRecord原本和HTTP Request是不能发生关联的,否则Console和Unit test里就无法执行了,设计成Middleware则能很好的解决这一问题。

由于选择的三级Hash的设计中没有直接和数据库关联的信息,因此其实也可以用于非ActiveRecord类(唯一要做的可能仅仅是自己实现一种标示方法),毕竟当前可以用于存储的服务早已不止数据库一种了。

至于对于ActiveRecord的修改我现在对ActiveRecord的实现还不够熟悉,无法完成,以后学习这一部分了再说吧。但是大致的思路是这样的:

  • 当从数据库读入数据的时候(包括reload),将缓存里的数据翻新
  • 当保存数据进数据库的时候,将数据同样保存进缓存
  • 当从一个ActiveRecord对象读取属性的时候,现在被当前对象修改过的属性中搜索,如果搜索不到再从缓存里搜索,如果还是搜索不到就采用默认方法

大致就是这些了,其实想法还是很简单的,希望早日自己能把它变成现实。

标题来自CoolShell的博文http://coolshell.cn/articles/9308.html,文章说得非常好,毫无疑问的支持啊。

其实我本来以为程序员都是不用百度的,随便对比下就发现百度和Google技术差距实在太过明显,但是上周五我推荐我们公司里一个前Python程序员(不久前被迫转Ruby)pry这个工具的时候,他竟然用了百度(当然毫无疑问,首页没有一个正确结果)。当时我就差点没昏过去,三十多岁了,不知道用Github也就算了,我当年还是懵懂少年的时候好歹也是用Google搜的,而且也能搜到正确结果的(当然,质量上肯定还是Github的略胜一筹)。哎,我三观还是显得太幼稚了啊。

以前的这个回复也是啊:

作为对反对技术人员用百度的响应,本博客使用了Coolshell那篇博文里贴出来的代码:

<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.9.0/jquery.min.js"></script>
<script src="http://coolshell.cn/wp-content/themes/inove/js/jquery.bpopup-0.8.0.min.js"></script>
 
<script type="text/javascript">
;(function($) {
    $(function() {
        var url=document.referrer;
        if ( url && url.search("http://")>-1) {
            var refurl =  url.match(/:\/\/(.[^/]+)/)[1];
            if(refurl.indexOf("baidu.com")>-1){
                $('#nobaidu_dlg').bPopup();
            }
        }
    });
 
})(jQuery);
</script>
 
<div id="nobaidu_dlg" style="background-color:#fff; border-radius:15px;color:#000;display:none;padding:20px;min-width:450px;min-height:180px;">
    <img src="http://coolshell.cn/wp-content/themes/inove/img/nobaidu.jpg" align="left">
     <p style="margin-left:200px;margin-top: 20px; line-height: 30px;">
     检测到你还在使用百度这个搜索引擎,<br/>
     做为一个程序员,这是一种自暴自弃!<br/>
     <br/>
     </p>
     <p align="center" style="margin-top:20px;">
     <b><a href="http://coolshell.cn/articles/7186.html">作环保的程序员,从不用百度开始!</a></b>
     </p>
</div>

虽然原文还给出了一个Github项目的地址,这个项目直接给出了一个JavaScript脚本,在源码里只要引用就可以了,比CoolShell的方法更加简单点,但是考虑到那个JavaScript脚本直接放在Github上可能造成Github性能不佳(不过貌似本博客的点击率远远达不到可以搞垮Github的程度),并且还可能遭到屏蔽(毕竟用百度的人肯定不会处于翻墙状态的)。所以还是决定直接用CoolShell的方法更好。因此,以后如果各位用百度访问本博客,将会看到如下对话框:

希望那些看到了这个对话框的程序员们不要介意,我就是这样的人。

为什么这么说呢?从没有见过运用如此广泛,几乎是所有Ruby Web Framework基础的开源项目中,这个文件里竟然有如此之多令人费解的部分(完全可以认为是Bug)

简单的举个例子吧,首先,对于Rack来说,.ru文件和.rb文件的区别在哪里?

def self.parse_file(config, opts = Server::Options.new)
  options = {}
  if config =~ /\.ru$/
    cfgfile = ::File.read(config)
    if cfgfile[/^#\\(.*)/] && opts
      options = opts.parse! $1.split(/\s+/)
    end
    cfgfile.sub!(/^__END__\n.*\Z/m, '')
    app = new_from_string cfgfile, config
  else
    require config
    app = Object.const_get(::File.basename(config, '.rb').capitalize)
  end
  return app, options
end

代码中告诉了我们答案,如果是.ru文件,Rack::Builder不仅仅会将其读出,而且还会从中解析出以#\开头的部分,作为Rack::Server启动的选项,举个例子:

#\--port 3000
run Proc.new {|env| [200, {"Content-Type" => "text/html"}, "Hello Rack!"]}

比如这个config.ru文件,rackup在执行这个文件是将启动出3000端口的服务器而非9292。

这本来是个不错的设计啦,但是仔细看代码就会发现,只支持一个选项,第二个选项用方括号运算符是匹配不到的,额。。。你妹。。

不得不承认Rack::Builder支持map包含map,也就是形成树结构,这点不错。更重要的是Rack仅仅执行那些不得不执行的map的代码块,如果路由没匹配到就不予执行这些都很好。但是当你发现Rack::Builder没接收到一个请求就会创建一个新的URLMap对象然后把路由计算成正则表达式,并且计算出的正则表达式不予缓存,每次都重新计算,就会感觉之前争取到的性能在这里又浪费了的惋惜啊。。好吧,这一定是考虑到开发模式下的需求。

接着就是最坑的一个Bug了

require 'rack'

infinity = Proc.new {|env| [200, {"Content-Type" => "text/html"}, 'root']}
builder = Rack::Builder.new do
  map '/' do
    run infinity
  end

  use Rack::CommonLogger

  map '/version' do
    map '/' do
      run Proc.new {|env| [200, {"Content-Type" => "text/html"}, "infinity 0.1"] }
    end

    map '/last' do
      run Proc.new {|env| [200, {"Content-Type" => "text/html"}, "infinity beta 0.0"] }
    end
  end
end
Rack::Handler::Thin.run builder, :Port => 9292
curl 0.0.0.0:9292/version/last

虽然看上去结果应该是infinity beta 0.0,但实际上结果是root,额。。

这个Bug的关键在于use Rack::CommonLogger这句语句的位置,可以看到,它写在两个map的中间。而use方法是这么实现的:

def use(middleware, *args, &block)
  if @map
    mapping, @map = @map, nil
    @use << proc { |app| generate_map app, mapping }
  end
  @use << proc { |app| middleware.new(app, *args, &block) }
end

为了让middleware处于middleware stack的外面,代码先判断当前有没有执行过map方法,如果执行过的话,先将它放进middleware stack,外面放middleware对象。这样执行的时候就能先执行到middleware了(大概作者是这么想的,但是这么做完全是错的,实际情况是先放进middleware stack的先被执行到)。如果你没有执行过map方法,那么虽然现在middleware stack上看上去只有middleware对象,但是等会执行的时候会把map包进来,这样就解决问题了。但是它显然没有想到,如果我把use写两个map中间的情况。此时,middleware stack对象会先把第一个map写进去,然后跟着middleware。然后在下一个map执行的时候,重新赋值map对象,并在最后被middleware stack的两个元素包住。这样请求一进来的时候就会发现,竟然是第一个map直接接受请求,连middleware都不是(之前讲过,实际情况是先写进middleware stack的先被执行到)。

算起来好像只有把use语句写在所有map之前的时候才是能真正正常工作的。大概确实很少人不这么做吧,否则这么明显的问题为何不被解决呢。

虽然我们可以看到,Rack::Builder实现了自己的路由和middleware调用方法,但是无论是Rails还是Sinatra,都无一基于Rack::Builder,不仅仅是因为Rack::Builder没有更多的功能,已有功能也不容易扩展,而且,里面坑太多了,还不如自己从头写呢。

最近在帮我们最大的一个Rails项目(多大?想象下上万行Ruby代码的项目吧)做Rails升级,从2.3.2升级到2.3.17(别笑,这种项目升3是没有指望的),升级过程总体上顺利。升级完后跑Test Case,有一些小错误发生。一个小错误似乎是由于Rails 2.3.2存在Bug使得明明应该发生错误的Test Case竟然能通过,而在2.3.17的时候已经修复,导致这个Test Case理所当然的发生了错误。还有另外一个错误是在测试中向response header中写入了被标记为secure和httponly的cookie,但是测试发现这些cookie不存在的错误。起先我还不以为然,以为是redirect后造成cookie在测试时不能正常读取,但是后来总觉得有点奇怪,毕竟这个Test Case在2.3.2的时候是通过的,还有就是调试发现,在执行好redirect_to方法后,被写入的cookie依然是存在的,只有当请求结束后的测试时cookie才会消失。太诡异了!于是仔细跟踪代码流,并对比了下2.3.2和2.3.17的区别,发现了在2.3.17的actionpack/lib/action_controller/cookies.rb中的CookieJar类(这个是存储Cookie的最核心的数据结构了,继承自Hash)的[]=方法中,在向response header写入cookie前,调用了下一个叫做write_cookie?的方法,这个方法在2.3.2中是不存在的。代码如下:

# Sets the cookie named +name+. The second argument may be the very cookie
# value, or a hash of options as documented above.
def []=(key, options)
  if options.is_a?(Hash)
    options.symbolize_keys!
  else
    options = { :value => options }
  end

  options[:path] = "/" unless options.has_key?(:path)
  super(key.to_s, options[:value])
  @controller.response.set_cookie(key, options) if write_cookie?(options)
end

从代码中可以看到,write_cookie?方法并不阻止super的调用,但是阻止了set_cookie的调用。而通过调试可知,在测试时这个write_cookie?这个方法的的确确返回了false,造成了cookie在测试时没有正常写入!

而这个问题的始作俑者,write_cookie?的代码是这样的:

def write_cookie?(cookie)
  @secure || !cookie[:secure] || defined?(Rails.env) && Rails.env.development?
  # 其中@secure来自initialize时controller.request.ssl?的结果
end

在测试过程中,三个条件均为false,导致最终结果是false。

由于这个站点的安全级别较高,在生产环境中一定是用HTTPS协议运行的,所以在测试的时候也模拟HTTPS的环境,在cookie中写入了secure标志并在测试中有相应的assert。在2.3.2中,由于没有这个方法的存在,测试没有任何问题。但是在2.3.17中,明确要求了,要么cookie是被设置成secure的,要么必须是https请求,要么当前是development模式。但是在我们的测试中,我们并没有让所有测试都用https来做,这也是不合理的嘛。因此我认为,在这段代码中,Rails犯了三个错误,第一,应该把Rails.env.test?也加入或条件,即如果是测试环境,也总会让write_cookie?返回true。第二,如果write_cookie?返回false,super方法也不应当调用,不该造成cookie已经被写入的假象。第三,后台应该有安全警告,以说明本次cookie写入失败的原因,否则对开发者而言实在是莫名其妙。

本来想给Rails提交个patch的,但是得知Rails 2.3除了Fix严重安全漏洞以外不再接受任何Patch了(链接),因此还是写成Blog让大家看到吧。我最后在项目中加了这个一个Monkey Patch:

# The write_cookie? always returns false in our test cases because 
# we set secure in cookie and our request in test env is http 
# rather than https, so Rails will refuse to write value in cookie. 
# This hack will resolve this problem. 
class ActionController::CookieJar < Hash
  alias_method :__origin_write_cookie?, :write_cookie?
  def write_cookie?(cookie)
    __origin_write_cookie?(cookie) || defined?(Rails.env) && Rails.env.test?
  end
end

解决掉了这个问题。至于另外两点就懒得用Monkey Patch做了,还是算了吧。

就这样了。由此可见,Rails程序员熟悉Rails本身的代码是很重要的吧,仅仅局限在使用Rails框架上实在是太肤浅了,根本对不起四年大学本科的学习嘛,何况Rails这种项目由于是开源的本来就问问多多嘛,不熟悉的话稍微有点什么问题就束手无策了。我们组有些同事就是这样的,拿了个比VIM先进的多的RubyMine,叫他调试下出错的Test Case就各种震惊各种迷茫的,实在是,哎,不说了,说出来比我自己干还累啊。。