程序人生

写优雅的程序,做优雅的人

Ruby on Rails 线程安全代码

| Comments

Ruby on Rails 4.0 的 rc 版本已经 release 了,Rails 4 时代最大的变化当属默认开启多线程模式。Rails 4 的多线程将给大家带来什么好处呢?至少有两方面:

  1. 更高的系统吞吐量 Web 站点多是 IO 密集型的,多线程可以让 Application 在等待 IO 操作的同时,还能接收处理请求,大大提升系统吞吐量,增强系统稳定性和安全性。

  2. 更省内存 Ruby on Rails 是内存消耗大户,一个 Applicaion 占用几百兆是常事,以前使用仅使用多进程的并发模式时,整体内存消耗巨大,使用多进程+多线程的并发模式,不单系统吞吐量大大提供,系统整体使用内存也大幅下降。

但是天下没有免费的午餐,在享用这些好处的同时,我们也必须付出一定的代价,代价就是要应付多线程编程带来的复杂性。程序中需要处理多线程可能导致问题的地方,如果程序中出现问题也变得更加难以发现调试。

好在需要注意的地方也不是太多,下面把这几个需要注意的地方一一说明。

代码加载

Ruby 的 require 并非线程安全的,在多线程中 require,可能会导致严重的不可预期的错误。例如下面的一个演示程序在 Ruby 1.9 环境下执行会发生死锁。

a.rb 文件:

1
2
3
4
puts "#{Thread.current}: a.rb"
sleep 1
puts "#{Thread.current}: requiring b"
require 'b'

b.rb 文件:

1
2
3
puts "#{Thread.current}: b.rb"
puts "#{Thread.current}: requiring a"
require 'a'

test.rb 文件:

1
2
3
4
5
6
$LOAD_PATH << "."

t1 = Thread.new { require 'a' }
t2 = Thread.new { require 'b' }
t1.join
t2.join

在 Ruby 1.9 环境下运行 test.rb 将报死锁错误:

1
2
3
4
5
6
#<Thread:0x007fd2da90a268>: b.rb
#<Thread:0x007fd2da90a268>: requiring a
#<Thread:0x007fd2da90a2e0>: a.rb
#<Thread:0x007fd2da90a2e0>: requiring b
test.rb:5:in </span>join<span class="s1">&#39;: deadlock detected (fatal)</span>
</span><span class='line'><span class="s1">  from test.rb:5:in<main>'

因为 Ruby require 不是线程安全的,所以 Rails 中使用多线程环境时,需要对 require 做一定的限制,简单的说就是在 Application 启动的时候,把所有需要加载的代码全部加载完成,避免启动后还 require。Rails 4 的生产环境配置中该选项已经默认生效。需要注意的时,如果你的代码不在 Rails 默认的几个目录中,你需要手动配置你的目录进入 eager_load_path,例如:

1
config.eager_load_paths << "#{Rails.root}/lib"

全局变量和类变量写操作

在 Rails 多线程环境,所有的全局变量(包括 $var @@var 和 类实例变量),在实例方法中都应该是只读的,尽量应该避免写操作。

下面是一个在实例方法中写类变量导致问题的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class HomeController < ApplicationController
  before_filter :set_site

  def index
  end

  private

  def set_site
    @site = Site.find_by_subdomain(request.subdomains.first)
    if @site.layout?
      self.class.layout(@site.layout_name)
    else
      self.class.layout('default_lay')
    end
  end
end

上面代码的意图是根据域名设置不同的 layout。self.class.layout(value) 中,Rails 把 value 保存在类变量 @@layout_,然后在 render 的时候使用。

设想这样一种情况 UserA 的 subdomain 是 foo1,他的 layout 应该是 foo1, UserB 的 subdomain 是 foo2,他的 layout 应该是 foo2。

UserA 和 UserB 同时请求应用,他们的请求分别在 Thread1 和 Thread2 中执行,执行顺序可能是:

  1. Thread1, 执行进入 set_site 方法,设置 @@layout_ 为 foo1;
  2. Thread2, 执行进入 set_site 方法,设置 @@layout_ 为 foo2;
  3. Thread1, render response,使用最新的 @@layout_ foo2 render;
  4. Thread2,render response,使用最新的 @@layout_ foo2 render;

我们期望 Thread1 使用 foo1 layout render,这样的执行结果和期望的不相符。

线程安全的写法是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class HomeController < ApplicationController
  before_filter :set_site
  layout :site_layout

  def index
  end

  private

  def set_site
    @site = Site.find_by_subdomain(request.subdomains.first)
  end

  def site_layout
    if @site.layout?
      @site.layout_name
    else
      'default_lay'
    end
  end
end

程序在每次需要使用 layout 时,调用实例方法 site_layout,避免写类变量。

IO Connection

Rails 应用通常会用到多个 IO Connection,比如 ActiveRecord 的数据库 Connection,缓存 Memcached 的 Connection,Redis 的 Connection 等等。这些 IO Connection 在 Rails 多线程环境下并不都是线程安全的。

ActiveRecord 的 Connection 是线程安全的,而且 ActiveRecord 还可配置 Connection Pool,这样可以更高效率的利用数据库连接。

Memcached 的 Connection memchached-client 并不是线程安全的,最新的 dalli 是线程安全的。不过 dalli 的线程安全机制是在每个读写操作时加上互斥信号控制,这意味着同一时间只有一个线程可以操作,如果操作非常频繁的话,可能有性能上的问题,这个时候可以使用一个单独的 Connection Pool Gem 解决。

这个 Connection Pool Gem 的地址是 https://github.com/mperham/connection_pool。

Redis 的 Connection 和 dalli 类似,本身通过加上互斥信号控制保证线程安全,可以通过 Connection Pool 增强效率。

使用互斥信号控制非线程安全操作

在程序中,如果存在某些不希望多个线程同时执行的操作,可以使用互斥信号控制其执行,这样当已经有一个线程进入执行时,其他进入的 thread 都会被 block 住,等前面的进程执行结束后才会进入执行,从而保证在一个时间只有一个线程会执行。

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class HomeController < ApplicationController
  @@lock = Mutex.new

  def index
    @@lock.synchronize do
      thread_unsafe_code
    end
  end

  private

  def thread_unsafe_code
    if @@something == 'hello'
      do_hello
    elsif @@something == 'world'
      do_world
    else
      @@something = 'nothing'
    end
  end
end

总之,Rails 的多线程为我们提供了简便的提升系统伸缩性的能力,这也意味的程序复杂性的增加,有几处地方使我们需要注意的,只有这样才能很好的利用 Rails 多线程能力。

参考:

多 Ruby 版本和 多 Gem 版本测试

| Comments

admin_gateway 是用于我们所有后台统一认证和授权的组件,而我们后台程序运行在多个 Ruby 版本上,主要是 Ruby 1.9.3 和 2.0,Ruby 2.1 也已经发布了,很快也需要提供 Ruby 2.1 的支持,另外 Rails 版本有 3.0,3.2 和 4.0 三大主要版本,因此一个完整的测试方案是需要包括这 3 * 3 = 9 种组合才行。

首先是多个 Ruby 版本测试。这个问题比较简单,我们现在的 Ruby 环境通常是由 RVM 管理的,RVM 可以很容易的以多个 Ruby 版本重复执行 Ruby 程序,比如下面的例子: rvm ruby-1.9.3,ruby-2.0.0,ruby-2.1.0 exec ruby -e "puts RUBY_VERSION" 如果运行的机器安装了这3个版本的 Ruby 的话,就会依次执行打出各自的 Ruby 版本号。 所以下面的命令就可以依次运行3个Ruby版本的测试了:

rvm ruby-1.9.3,ruby-2.0.0,ruby-2.1.0 exec bundle install rvm ruby-1.9.3,ruby-2.0.0,ruby-2.1.0 exec bundle exec rake test

解决多 Ruby 版本测试的问题,然后就是多 Rails 版本测试的问题。admin_gateway 这个 gem 是一个 Rails Engine,因此需要依赖 Rails。多 Rails 版本测试意味着 test 的 gem 环境是不同的,而现在 gem 环境是由 bundle 管理的,运行 bundle 命令时可以手动指定 Gemfile 文件,因此 建立多个 Gemfile 文件,重复上面的命令就可以了,例如可以建立 rails_4_0.gemfile 这个 Gemfile,然后通过命令 bundle install --gemfile rails_4_0.gemfile 执行 gem 环境安装, 然后通过命令: BUNDLE_GEMFILE=rails_4_0.gemfile bundle exec rake test 执行测试。

组合上面的方法,可以形成一个完整的多 Ruby 版本和 Rails 版本的解决方案: 1. 首先根据需要安装多个 Ruby 版本,如 ruby 1.9.3, 2.0.0 和 2.1.0,根据需要生成多个 gemfile,如 rails_3_0.gemfile, rails_3_2.gemfile 和 rails_4_0.gemfile 2. 然后使用下面的 shell 安装 gem 环境和运行测试。 rvm ruby-1.9.3,ruby-2.0.0,ruby-2.1.0 exec bundle install --gemfile=rails_3_0.gemfile BUNDLE_GEMFILE=rails_3_0.gemfile rvm ruby-1.9.3,ruby-2.0.0,ruby-2.1.0 exec bundle exec rake test 3.2 和 4.0的类似,就不重复了。

解决 gem 多版本的方法有一点不干净,因为 gemfile 有很多重复内容,shell命令也比较重复,因此有一个 gem 叫 appraise (https://github.com/cfedermann/Appraise)就是用于解决这个问题,它只需要配置一个 Appraisals 文件,在 Appraisals 声明不同的 gem 依赖就可以了,比如 admin_gateway 的 Appraisals 内容如下:

1
2
3
4
5
6
7
8
9
10
11
appraise "rails-3-0" do
  gem "rails", "3.0.20"
end

appraise "rails-3-2" do
  gem "rails", "3.2.16"
end

appraise "rails-4-0" do
  gem "rails", "4.0.0"
end

然后就可以通过 rake appraisal 完成多个 rails 版本测试,它的原理和上面提到的完全一样。

总之,多 Ruby 版本和多 gem 版本的测试并不难,利用 rvm 和 bundle 一些特性可以快速做到,利用 appraisal 可以让其更简化。

一种轻量的移动应用离线操作和数据同步策略

| Comments

对移动应用而言,离线操作和数据同步是一件烦人的事情,组合使用本地数据,远程数据缓存和服务请求暂存的策略,可以使之变得比较轻松简单。

使用本地数据

为了 app 在离线状态下部分功能依然能够使用,可以把部分数据直接和应用打包在一起发布。这样当应用检测到网络不通时,可以使用本地数据。比如对于食物查询功能,在网络通的时候是请求服务器完成查询的,当网络不通时,就使用本地的数据做查询。使用本地数据有两个制约,一是本地数据不能太大,太大的话应用打包后的体积会很大;二是本地数据的功能有所限制。比如对于食物查询而言,通过请求服务器完成时可以使用比较智能的搜索,使用本地查询的化就只能使用简单的文本匹配了。所以在离线操作时,需要提示用户使用的是本地数据,功能所限,联网使用的话才能使用完整功能。

远程数据缓存

因为本地数据有很多限制,数量不全,然后内容也不全,使用远程数据缓存可以减轻本地数据的限制。比如,食物数据只包括了最基本的名称和单位热量数据,没有包括额外的介绍、评价等数据。但是用户已经查询的食物数据可以缓存起来,离线操作查询的时候也可以显示这些信息。另外,缓存数据的一大好处是能大幅提高应用的性能。毕竟大部分操作是以读为主的,数据的变化也不是很频繁,如果利用缓存的话,能够大幅减少请求次数和网络带宽。

暂存数据操作请求

对于写请求的操作,在离线的时候,可以简单的把整个请求 URL 和 参数先保存起来,等到网络通了只会,再依次按顺序发送给服务器完成操作。这种方法,对比应用和服务器之间互相跟踪数据变化记录,然后根据变化记录操作数据的方法,要简单得多,而且如果中间出现问题,故障处理也要简单可靠的多。

实例说明 Ruby 多线程的潜力和弱点

| Comments

Web 应用大多是 IO 密集型的,利用 Ruby 多进程+多线程模型将能大幅提升系统吞吐量。其原因在于:当Ruby 某个线程处于 IO Block 状态时,其它的线程还可以继续执行。但由于存在 Ruby GIL (Global Interpreter Lock),MRI Ruby 并不能真正利用多线程进行并行计算。JRuby 去除了 GIL,是真正意义的多线程,既能应付 IO Block,也能充分利用多核 CPU 加快整体运算速度。

上面说得比较抽象,下面就用例子一一加以说明。

Ruby 多线程和 IO Block

先看下面一段代码(演示目的,没有实际用途):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# File: block_io1.rb

def func1
  puts "sleep 3 seconds in func1\n"
  sleep(3)
end

def func2
  puts "sleep 2 seconds in func2\n"
  sleep(2)
end

def func3
  puts "sleep 5 seconds in func3\n"
  sleep(5)
end

func1
func2
func3

代码很简单,3 个方法,用 sleep 模拟耗时的 IO 操作。 运行代码(环境 MRI Ruby 1.9.3) 结果是:

1
2
3
4
5
6
7
8
$ time ruby block_io1.rb
sleep 3 seconds in func1
sleep 2 seconds in func2
sleep 5 seconds in func3

real  0m11.681s
user  0m3.086s
sys 0m0.152s

比较慢,时间都耗在 sleep 上了,总共花了 10 多秒。

采用多线程的方式,改写如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# File: block_io2.rb

def func1
  puts "sleep 3 seconds in func1\n"
  sleep(3)
end

def func2
  puts "sleep 2 seconds in func2\n"
  sleep(2)
end

def func3
  puts "sleep 5 seconds in func3\n"
  sleep(5)
end

threads = []
threads << Thread.new { func1 }
threads << Thread.new { func2 }
threads << Thread.new { func3 }

threads.each { |t| t.join }

运行的结果是:

1
2
3
4
5
6
7
8
$ time ruby block_io2.rb
sleep 3 seconds in func1
sleep 2 seconds in func2
sleep 5 seconds in func3

real  0m6.543s
user  0m3.169s
sys 0m0.147s

总共花了 6 秒多,明显快了许多,只比最长的 sleep 5 秒多了一点。

上面的例子说明,Ruby 的多线程能够应付 IO Block,当某个线程处于 IO Block 状态时,其它的线程还可以继续执行,从而使整体处理时间大幅缩短

Ruby GIL 的影响

还是先看一段代码(演示目的):

1
2
3
4
5
6
7
8
# File: gil1.rb

require 'securerandom'
require 'zlib'

data = SecureRandom.hex(4096000)

16.times { Zlib::Deflate.deflate(data) }

代码先随机生成一些数据,然后对其进行压缩,压缩是非常耗 CPU 的,在我机器(双核 CPU, MRI Ruby 1.9.3)运行结果如下:

1
2
3
4
5
$ time ruby gil1.rb

real  0m8.572s
user  0m8.359s
sys 0m0.102s

更改为多线程版本,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
# File: gil2.rb

require 'securerandom'
require 'zlib'

data = SecureRandom.hex(4096000)

threads = []
16.times do
  threads << Thread.new { Zlib::Deflate.deflate(data) }
end

threads.each {|t| t.join}

多线程的版本运行结果如下:

1
2
3
4
5
$ time ruby gil2.rb

real  0m8.616s
user  0m8.377s
sys 0m0.211s

从结果可以看出,由于 MRI Ruby GIL 的存在,Ruby 多线程并不能重复利用多核 CPU,使用多线程后整体所花时间并不缩短,反而由于线程切换的影响,所花时间还略有增加。

JRuby 去除了 GIL

使用 JRuby (我的机器上是 JRuby 1.7.0)运行 gil1.rb 和 gil2.rb,得到很不一样的结果。

1
2
3
4
5
$ time jruby gil1.rb

real  0m12.225s
user  0m14.060s
sys 0m0.615s

1
2
3
4
5
$ time jruby gil2.rb

real  0m7.584s
user  0m22.822s
sys 0m0.819s

可以看到,JRuby 使用多线程时,整体运行时间有明显缩短(7.58 比 12.22),这是由于 JRuby 去除了 GIL,可以真正并行的执行多线程,充分利用了多核 CPU。

总结:Ruby 多线程可以在某个线程 IO Block 时,依然能够执行其它线程,从而降低 IO Block 对整体的影响,但由于 MRI Ruby GIL 的存在,MRI Ruby 并不是真正的并行执行,JRuby 去除了 GIL,可以做到真正的多线程并行执行

Ruby 2.0的新特性,用例子说明

| Comments

Ruby 2.0 发布已经有一段时间了,之前从各种报道上大概了解到它的一些主要特性,但是没有认真仔细研究,所以印象并不深。这个周末好好研究了一番,写下这篇 Blog,算是这次学习的笔记。

Ruby 2.0 升级变动并不是很大,至少比 Ruby 1.8 到 1.9 的变动小,之所以把版本号定为 2.0,是为了纪念 Ruby 诞生 20 周年,所以特意选择了 Ruby 诞生 20 周日的日子 – 2013年2月24日发布。

虽然说变化不是特别大,但是新的特性还是挺让人兴奋的,因为它们对开发带来不少便利,让 Ruby 变得越来越性感。主要的新特性有 4 个,下面一一讲解。

1. Keyword Arguments

Keyword Arguments 特性让 Ruby 2.0 开始支持关键字参数,这对处理有默认值的参数带来非常大的便利。相比于以前使用 Hash 传值方法,Keyword Arguments 可以让代码更直观简洁。

例子1:

1
2
3
4
5
6
7
8
9
10
11
12
# Ruby 1.9:
  # (From action_view/helpers/text_helper.rb)
def cycle(first_value, values)
  options = values.extract_options!
  name = options.fetch(:name, 'default')
  # ...
end

# Ruby 2.0:
def cycle(first_value, values, name: 'default')
  # ...
end

例子2:

1
2
3
4
5
6
7
8
9
10
11
12
# Ruby 1.9
def render(source, opts = {})
  opts = {fmt: 'html'}.merge(opts)
  r = Renderer.for(opts[:fmt])
  r.render(source)
end

# Ruby 2.0
def render(source, fmt: 'html')
  r = Renderer.for(fmt)
  r.render(source)
end

例子3:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Ruby 1.9
def accepts_nested_attributes_for(attr_names)
  options = {
    :allow_destroy => false,
    :update_only => false
  }
  options.update(attr_names.extract_options!)
  options.assert_valid_keys(
    :allow_destroy,
    :reject_if,
    :limit,
    :update_only
  )
  # ...
end

# Ruby 2.0
def accepts_nested_attributes_for(attr_names,
  allow_destroy: false,
  update_only: false
  reject_if: nil,
  limit: nil
)
 # ...
end

2. Refinement

Refinement 的目标是通过减少补丁的应用范围使打动态补丁(monkey patching)更为安全。下面是由Matz给出的一个例子,MathN模块包含进来之后“/”操作符才能在Fixnum上使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
module MathN
  refine Fixnum do
    def /(other) quo(other) end
  end
end

class Foo
  using MathN

  def foo
    p 1 / 2
  end
end

Rails 中有不少对 Ruby 的 monkey patching,使用 Refinement 特性重写的话可以让这些代码更安全。 但是目前 Refinement 还不是很成熟,属于体验特性,所以最好不要在生产环境使用。

3. Module Prepend

Module Prepend 特性让常见的 alias_method patten 扩展一个已用方法的写法变得简洁不少。 例如,下面的代码想对 Template 的 render 方法扩展计时钩子,使用 Ruby 1.9 的写法非常臃肿。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Template
  def initialize(erb)
    @erb = erb
  end
  def render values
    ERB.new(@erb).result(binding)
  end
end

module RenderProfiler
  def self.included base
    base.send :alias_method, :render_without_profiling, :render
    base.send :alias_method, :render, :render_with_profiling
  end
  def render_with_profiling values
    start = Time.now
    render_without_profiling(values).tap {
      $stderr.puts "Rendered in #{Time.now - start}s."
    }
  end
end

class Template
  include RenderProfiler
end

Template.ancestors
  #=> [Template, RenderProfiler, Object, Kernel, BasicObject]

使用 Ruby 2.0 的写法将变得非常简洁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module RenderProfiler
  def render values
    start = Time.now
    super(values).tap {
      $stderr.puts "Rendered in #{Time.now - start}s."
    }
  end
end

class Template
  prepend RenderProfiler
end

Template.ancestors
  #=> [RenderProfiler, Template, Object, Kernel, BasicObject]

注意 include 和 prepend 的区别在于,执行后 ancestors 有明显不同,include 置于后方,而 prepend 置于前方,这就导致了方法查找路径的差异,从而导致 super 执行结果的差异。

4. Lazy Enumerable

Lazy Enumerable 可以让 Enumerable 不立即执行,这对函数式编程大有用处,例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def natural_numbers
  (1..Float::INFINITY).lazy
end

def primes
  natural_numbers.select {|n|
    (2..(n**0.5)).all? {|f|
      n % f > 0
    }
  }
end

primes.take(10).to_a
  #=> [1, 2, 3, 5, 7, 11, 13, 17, 19, 23]

除了上面 4 大特性,还有一些小的改变,比如:

  • 默认使用 utf-8 encoding 解析代码
  • Symbol 数组 %i(a b c) –> [:a, :b, :c]
  • 新的 GC
  • Ruby的性能也有所提升

Ruby 2.0 让 Ruby 变得愈发性感了,我喜欢。

参考资料

升级 Ruby 1.8.7 到 1.9.3

| Comments

这几天对一个大型的 Rails 项目做 Ruby 升级,把升级过程中遇到的几个主要问题和解决方法记录下来以备参考。

1. 带中文字符的源代码需要加上 utf-8 encoding 声明

在 Ruby 1.9 中,如果源代码中包含中文字符,必须声明源代码文件的字符集,具体做法是在文件头部增加一个注释行: # encoding: utf-8

做法很简单,但是当文件量很大时,一个一个手工修改文件也很麻烦,好在有一个 gem – magic_encoding 可以轻易解决这个烦恼。用法相当简单,通过 gem install magic_encoding 安装 gem,然后在 Rails 项目目录下执行 magic_encoding 命令,它自动把 Rails 项目所有源代码文件头部加上 # -*- encoding : utf-8 -*-,非常简单方便。

参考:magic_encoding in github

2. 改变 case var when value : 用法

在 Ruby 1.9 中,象下面这种 case when 语法已经不支持

1
2
3
4
5
6
7
8
  case var
    when 1:
      "value of 1"
    when 2,3:
      "value of 2 and 3"
    else
      "others"
  end

修改很简单,把 when value 后的冒号去掉就行了。

3. 字符串字符集问题

在 Ruby 1.9 中,字符串对象带有字符集属性,不同字符集的字符串之间拼接会报异常,通常我们都是用 utf-8 encoding 的字符串,但有些类库返回的字符串返回的字符串并非 utf-8 encoding,这时候可能导致问题,需要做一些额外处理。

例如,Base64 解码后的字符串并不是 utf-8 的,拼接会报异常,需要做一次 force_encoding,看下面的代码。

1
2
3
4
5
6
7
8
9
require 'base64'
s1 = "薄荷网"
puts s1.encoding.to_s
s2 = Base64.encode64(s1)
s3 = Base64.decode64(s2)
puts s3.encoding.to_s
s4 = s3.force_encoding('utf-8')
puts s4.encoding.to_s
puts "#{s3}很棒"

结果是

1
2
3
4
5
UTF-8
ASCII-8BIT
UTF-8
Encoding::CompatibilityError: incompatible character encodings: ASCII-8BIT and UTF-8
  blabla ...

所以在 Base64 处理字符串的地方要相当小心。

4. YAML 引擎改变引起的问题

Ruby 1.9.3 中,YAML 引擎 由 Syck 改成了 Psych,Psych 和 Syck 在处理 UTF-8 字符串时有明显的区别,详见 Psych、Syck、YAML 和编码

总结:把 Ruby 从 1.8 升级到 1.9 还是比较轻松的,遇到的问题比想象中少,而且解决起来都不算麻烦。Ruby 兼容性方法做得很棒,赞一个。

重构 NICE 方案程序

| Comments

最近的工作需要对原有的 NICE 方案生成程序做重构,应用了流水线作业设计模式,让整个 NICE 方案生成代码变得结构清晰,而且易于扩展。

NICE 方案生成程序比较复杂,整体上分成两大步骤:一,根据用户的输入生成评测报告,二,根据用户输入和评测报告构建方案。以前的代码是非常典型的过程式的代码,简单的说就是把生成工作分解成多个步骤,为每一个步骤定义一个方法,然后在一个主控方法中分别调用各个方法。这看起来是比较简单的,但是升级维护变得很困难,比如要增加一个新版本的方案生成方法,不得不在代码中很多地方加入 if else 判断。

这次的工作就是要增加一个新版本的 NICE 方案生成方法,而且要求对之前的所有方案生成提供兼容。考虑再三,不打算在之前的代码上增加许多 if else 完成工作,于是对整个生成代码做一次大的重构。

从工厂流水线的工作方式得到启发,感觉 NICE 的评测和方案生成就象是流水线作业。NICE 评测从输入到评测报告的过程,可以抽象为 input 经过一系列的 analyzer 的处理,最后得到一个 evaluation result的过程。而 NICE 方案可以抽象为,输入是 input 和评测报告,经过一系列 builder 的处理,最后得多一个 solution result.

重构后,NICE 评测生成过程的核心类图如下: Analyzer

重构后,NICE 评测的主控过程代码:

1
2
3
4
5
6
7
8
9
  result = EvaluationResult.new
  # Order of analyzer must be restricted
  [
    Evaluation::BodyAnalyzer,
    Evaluation::TagAnalyzer,
    Evaluation::TipAnalyzer
  ].each { |analyzer| analyzer.new(input, result).analyze }

  # save result ...

评测分析过程基类是 Analyzer,BodyAnalyzer,TagAnalyzer 和 TipAnalyzer 都从它继承而来,每一个 Analyzer 完成一部分的评测分析工作。重构之后,扩展评测变得很容易,只要增加一种特定的 Analyzer 即可,而且这个 Analyzer 完全可以从之前的 Analyzer 继承已达到代码复用。

重构后,NICE 方案生成过程的核心类图如下: Analyzer

重构后,NICE 评测的主控过程代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  result = SolutionResult.new
  input_wrapper = Solution::InputWrapper.new(input)
  # Order of builder must be restricted
  [
    Solution::PreparingBuilder,
    Solution::WeekPlanBuilder,
    Solution::EatTipBuilder,
    Solution::ActivityPlanBuilder,
    Solution::ActTipBuilder,
    Solution::NoticeBuilder
  ].each do |builder|
    builder.new(input_wrapper, result).build
  end

  # save result ...

方案生成过程基类是 Builder,PreparingBuilder,WeekPlanBuilder 和 NoticeBuilder 都从它继承而来,每一个 Builder 完成一部分的方案生成工作。

流水线作业的设计模式很适合象 NICE 方案生成的工作,它的特点是:整体过程复杂,可以分解为多个类似的步骤,这些步骤共享相同的输入和输出对象。使用这种设计模式后,代码结果变成简单清晰,而且易于扩展维护。

Ruby 常量查找路径

| Comments

Ruby 的常量查找路径问题是一直困扰我的一个问题,在工作中遇到过好几次,一直没有彻底弄清楚到底为什么,最近在读一本书《Ruby 元编程》,对 Ruby 对象模型有了更深入的认识,另外读了一篇 blog《Everything you ever wanted to know about constant lookup in Ruby》, 让我总算把 Ruby 常量查找路径这个问题搞得比较清楚。

第一个遇到的问题,我还曾经在 Ruby-China 上发过帖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
module M1
  CT = "ok"
end

class C1
  CK = "ck"
  include M1

  def self.method1
    puts self
    puts "#{CK} in method1"
    puts "#{CT} in method1"
  end

  class << self
    def method2
      puts self
      puts "#{CK} in method1"
      puts "#{CT} in method2"
    end
  end
end

C1.method1
C1.method2

输出结果是

1
2
3
4
5
6
7
C1
ck in method1
ok in method1
C1
ck in method2
NameError: uninitialized constant Class::CT
    from (irb):16:in `method2'

这是我在重构薄荷网代码时候遇到的问题,method1 和 method2 都是常见的类方法的定义方面,我向来认为它们是等价可替换的写法,但是从实际执行的结果看,它们里面的常量查找路径不一样。

如果我把 M1 的定义改成下面的样子:

1
2
3
4
5
6
module M1
  def self.included(base)
    base.extend(self)
  end
  CT = "ok"
end

执行结果是:

1
2
3
4
5
6
C1
ck in method1
ok in method1
C1
ck in method2
ok in method2

还有一个问题是也是经常遇到的,抽象成问题代码如下:

1
2
3
4
5
6
7
8
9
10
11
module A
  module M
    def a_method
      #...
    end
  end
end

class A::B
  include M
end

会报异常:

1
2
NameError: uninitialized constant A::B::M
  from (irb):10:in `<class:B>'

Ruby 常量查找时依据两条路径

  • A. Module.nesting
  • B. open class/module 的 ancestors

A 比 B 优先,A 找不到了才到 B 中查找。

A.Module.nesting 的概念比较容易理解,它是指代码位置的 module 嵌套情况,它是一个数组,从最内层的嵌套一直到最外层的嵌套,如果没有嵌套,数组为空。任何一处代码位置都有 Module.nesting 值,可以通过下面的代码打印出各个位置的 Module.nesting 值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
p Module.nesting

module A
  module B
    p Module.nesting
    module C
      p Module.nesting
    end
  end
end

module A::B
  p Module.nesting
end

输出是:

1
2
3
4
[]
[A::B, A]
[A::B::C, A::B, A]
[A::B]

大家有没有注意到,module A::B 这种快捷写法会导致 A 不在 Module.nesting 里,这就是上述第二个问题的根源,因为 M 是 A module 下的常量,module A::B 写法导致不会查找 A::M

说完 A Module.nesting,再说一下 B open class/module 的 ancestors,这个问题相对复杂很多。简单的说,在 Ruby 代码的任何位置,都有一个 self 存在,同样也有一个 open class/module 存在,在模块和类定义处,它通常就是对应的模块和类,在方法内部,它是方法对应的类。对于 ancestors,我们可以通过代码位置 open class/module 的 ancestors 方法取得。

(备注:ancestors 在引入 singleton_class 概念之后变得有点复杂,如不清楚可参考《Ruby 元编程》)

上述第一个问题: 在method1 中 A 是 [C1] open class/moduleC1,所以 ancestors 是 [C1, M1, Object, Kernel, BasicObject] CK 在 A 可以找到,CT 在 B 可以找到。

method2 中 A 是 [C1] open class/moduleC1 的 singleton_class , 所以 ancestors 是 [Class, Module, Object, Kernel, BasicObject] CK 在 A 可以找到,CT 在 A 和 B 都找不到。

对于

1
2
3
4
5
6
module M1
  def self.included(base)
    base.extend(self)
  end
  CT = "ok"
end

可运行,是因为这时,在 method2 中,open class/module C1 的 singleton_class 扩展了 M1,所以 ancestors 变成了 [M1, Class, Module, Object, Kernel, BasicObject]

至此,这两个困扰我多时的问题终于彻底搞清楚了。这个过程给我的一个体会是:面对技术上的一些疑问,如果只是浅尝辄止,是永远不能够真正掌握它的,只有深入专研,透彻理解它的原理,才能够真正掌握它,获得真正的能力提升。

文章提到的 blog 网址: Everything you ever wanted to know about constant lookup in Ruby

TestUnit的替代者MiniTest

| Comments

MiniTest 是新一代的 Ruby 测试框架,它已经成为 Ruby 1.9 的内置测试框架,据说它也将成为Rails 4的默认测试框架,可谓前途一片光明。

MiniTest 为什么成为最新 Ruby 和 Rails 的首选,它有哪些吸引人的东西呢?

Ruby 1.8时代,Ruby 和 Rails 的默认测试框架都是 TestUnit,TestUnit 历史悠久,它最大的问题是太慢,太臃肿了,它包含了一堆现在很少使用的第三方库,比如GTk v1, GTk v2, FxRuby,另外一个大问题的是它缺乏一些基本的测试特性,比如 spec DSL的测试风格,比如 mock 支持等等。

MiniTest 相当于对 TestUnit 做了一次大的重构翻新,它继承了 TestUnit 大部分用法,消除 TestUnit 中不恰当的依赖,另外增加了基本的测试特性,比如 spec 和 mock等,整体上变得相当快速,简单整洁。

在 Ruby 和 Rails 的世界,有一个测试框架 Rspec 使用也是非常广泛的,甚至目前使用广泛程度超过 TestUnit 和 MiniTest,它为什么没有成为默认框架呢?我想 MiniTest 相对于 Rspec 最大的优势是简单和延续性,Rspec 相比 MiniTest 要庞大复杂得多,当然功能也更强大,对于内置 Ruby 的类库来说,还是简单和保持延续(相对 TestUnit)更有优势。对于 Rails 来说,Rails 之父 DHH 和 Rspec 一直对不上眼,我想是 Rails 最终选择 MiniTest 最大的原因,哈哈。

MiniTest 使用示例

如果你用过 TestUnit,MiniTest 使用非常简单,下面是一个测试的 Hello World

1
2
3
4
5
class HelloWold
  def get_word
    "Hello World!"
  end
end

测试代码

1
2
3
4
5
6
7
require 'minitest/autorun'

class HelloWoldTest < MiniTest::Unit::TestCase
  def test_get_word
    assert_equal "Hello World!", HelloWold.new.get_word
  end
end

测试代码, spec风格

1
2
3
4
5
6
7
require 'minitest/autorun'

describe HelloWold do
  it "should return hello world" do
    HelloWold.new.get_word.must_equal "Hello World!"
  end
end

如果要在 Rails 项目使用 MiniTest,可以使用 gem minitest-rails,使用 minitest-rails 之后,rails generator 生成的 test 就都转换为 minitest 风格代码。

总结

MiniTest 相当简单快速,它延续 TestUnit用法,上手非常快,同时它又提供了一些非常棒的特性,试用下来觉得非常不错,难怪它成为最新 Ruby 和 Rails 的首选,我打算今后就用它了。

参考资料

  1. minitest home
  2. minitest-rails home

Ruby的运算符和语句优先级

| Comments

Ruby 是一种表达能力很强的语言,这得意于它异常丰富的运算符和语法糖,虽然 Ruby 一直把最小惊讶原则作为它的哲学之一,但还是常常看到让人惊讶不已,难于理解的代码,这可能是因为对它运算符和语句优先级理解不透导致,今天就和大家聊一聊 Ruby 运算符和语句的优先级。

先看一句简单的代码,猜一猜它的输出是什么。

1
  puts {}.class

很多人一定以为结果是 Hash,但实事上结果是空,不信可以在 irb 里试一试。

再看一段代码。

1
2
3
4
5
6
puts "5 && 3 is #{5 && 3}"
puts "5 and 3 is #{5 and 3}"
a = 5 && 3
b = 5 and 3
puts "a is #{a}"
puts "b is #{b}"

结果是:

1
2
3
4
5 && 3 is 3
5 and 3 is 3
a is 3
b is 5

有没有觉得奇怪 b 怎么是 5 而不是 3 呢。

如果这两个例子你也觉得奇怪,那说明你对 Ruby 一些运算符和语句的优先级理解还不透彻,判断有误。 puts {}.class 实际上相当于 (puts {}).class -> nil.class 所以输出为空。{}相当于一个空的 block,优先和方法 puts 结合。 && 和 and 的优先是不同的,而且和 = 号的优先级顺序比较, && > = > and,所以 a = 5 && 3 相当于 a = ( 5 && 3),而 b = 5 and 3 相当于 ( b = 5 ) and 3,所以结果 a 和 b的值是不同的。

下面一张表格是 Ruby 中常见的运算符和语句的优先级列表,从上到下优先级递减。

Ruby operators (highest to lowest precedence)
Method Operator Description
Yes [ ] [ ]= Element reference, element set
Yes ** Exponentiation (raise to the power)
Yes ! ~ + - Not, complement, unary plus and minus (method names for the last two are +@ and -@)
Yes * / % Multiply, divide, and modulo
Yes + - Addition and subtraction
Yes >> << Right and left bitwise shift
Yes & Bitwise `AND’
Yes ^ | Bitwise exclusive `OR’ and regular `OR’
Yes <= < > >= Comparison operators
Yes <=> == === != =~ !~ Equality and pattern match operators (!= and !~ may not be defined as methods)
&& Logical `AND’
|| Logical `AND’
.. ... Range (inclusive and exclusive)
? : Ternary if-then-else
= %= { /= -= += |= &= >>= <<= *= &&= ||= **= Assignment
defined? Check if specified symbol defined
not Logical negation
or and Logical composition
if unless while until Expression modifiers
begin/end Block expression

几条便于记忆的原则:

  1. 关键字类如if and 等的优先级是要比符号类低;
  2. 赋值符号 = ||= 等优先级也比较低,仅次于关键字类;
  3. [] []= 元素引用的优先级非常高。