Why Y

Explore CarrierWave

CarrierWave 是一个 Ruby 应用的上传组件,很多 Rails 程序都会用到它。与它类似的 gem 还有 PaperClip、Dragonfly 等。因为遇到过的项目都是用 CarrierWave,所以想从零开始学习一下他的使用方法。在 GitHub 上的 master 分支是一个正在开发的版本,下面用的都是 0.10.0 版本。

在 Rails 项目的 Gemfile 中添加 CarrierWave 并执行 bundle install 后就可以使用它了。CarrierWave 提供了一个 generator 来方便的生成 uploader。用法如下:

1
rails generate uploader Avatar

上面的命令会生成 app/uploaders/avatar_uploader.rb 文件。从文件的路径中可以看出一些 CarrierWave 的约定。所有的 uploader 都放在 app/uploaders 目录下,这个目录不是 Rails 默认有的目录,如果不存在 CarrierWave 会自动创建。所有的 uploader 文件名都以 uploader 结尾,对应的类名也是 XyzUploader

在 Rails 程序中很少单独使用 uploader,一般都会与 ORM 配合使用。如果使用 ActiveRecord,可以用 mount_uploader 将 uploader 与模型关联。比如:

1
2
3
class User < ActiveRecord::Base
  mount_uploader :avatar, AvatarUploader
end

mount_uploader 的第一个参数对应数据库的一个字符串类型的字段,用于存储上传文件的文件名,第二个参数是使用的 uploader 类名。

默认生成的 uploader 这样就可以使用了,如果发现不能正常使用的话需要重启服务器。但是在 Rails console 里貌似没有自动加载 app/uploaders 目录下的文件,这样会出现 Uninitialized constant ... 的异常。如果遇到这样的情况又想在 Rails console 中测试,需要在 config/application.rb 中将目录加入到自动加载的列表中。

1
config.autoload_paths += %W(#{config.root}/app/uploaders)

这个问题可能是 CarrierWave 的一个 BUG,参考

http://www.codeomnib.us/rails-4-carrierwave-throws-uninitialized-uploader-console/ https://github.com/carrierwaveuploader/carrierwave/issues/399

存储方式

CarrierWave 支持多种存储方式,既可以将文件存储在本地的硬盘中,也可以使用各种云存储。CarrierWave 默认生成的 uploader 中有一行 storage :file,表示将文件存在本地硬盘。

存储目录

如果使用本地文件的存储方式,需要指定所有上传文件的存储目录。存储目录由 uploader 中的 store_dir 指定,默认生成的 uploader 文件中已经有一个可以使用的 store_dir 方法:

1
2
3
def store_dir
  "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
end

从中可以看出一些 CarrierWave::Uploader::Base 类的一些方法。model 方法表示该 uploader 对应的模型,mounted_as 方法的值就是在模型中使用 mount_uploader 的第一个参数,也就是数据库中对应的字段名。

扩展名限制

在实际的上传情景中,可能需要对上传文件的扩展名做一些限制,CarrierWave 提供了白名单和黑名单两种方式,两种方式分别通过 extension_white_listextension_black_list 方法实现。CarrierWave 默认生成的 uploader 在注释中给出了这两个方法的实现方式。

1
2
3
  def extension_white_list
    %w(jpg jpeg gif png)
  end

方法只要返回允许或拒绝的扩展名数组即可,而且不区分大小写。数组的元素除了可以是字符串,还可以是正则表达式,比如 [/jpe?g/, 'gif', 'png']

文件名

CarrierWave 在保存文件名时会对原始文件名进行修改,默认的行为是只保留英文字符、数字以及 .-+_ 四个符号,其他字符会被转化为 _,实现方式在 CarrierWave 源码的 lib/carrierwave/sanitized_file.rb 中:

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
29
30
31
32
33
34
35
# lib/carrierwave/sanitized_file.rb

module CarrierWave
  class SanitizedFile
    # ...

    class << self
      attr_writer :sanitize_regex

      def sanitize_regex
        @sanitize_regex ||= /[^a-zA-Z0-9\.\-\+_]/
      end
    end

    # ...

    def sanitize_regexp
      CarrierWave::SanitizedFile.sanitize_regexp
    end

    # ...

    # Sanitize the filename, to prevent hacking
    def sanitize(name)
      name = name.gsub("\\", "/") # work-around for IE
      name = File.basename(name)
      name = name.gsub(sanitize_regexp,"_")
      name = "_#{name}" if name =~ /\A\.+\z/
      name = "unnamed" if name.size == 0
      return name.mb_chars.to_s
    end

    # ...
  end
end

从代码中可以看到,默认使用 /[^a-zA-Z0-9\.\-\+_]/ 对文件名进行处理,匹配的字符被替换为 _。同时看到也可以使用其他的正则对文件名进行处理。比如保留原始文件名:

1
2
3
# config/initializers/carrierwave.rb

CarrierWave::SanitizedFile.sanitize_regex = /[^[:word:]\.\-\+]/

创建 config/initializers/carrierwave.rb 文件,写入这行代码 。这是官方文档提供的正则,需要注意的是,这个正则表达式匹配的是所有不允许的字符。

如果想要自定义上传文件的文件名,需要重写 filename 方法。下面的例子参考 CarrierWave 的 Wiki:

1
2
3
4
5
6
7
8
9
10
11
class PhotoUploader < CarrierWave::Uploader::Base
  def filename
    "#{secure_token}.#{file.extension}" if original_filename.present?
  end

  protected
  def secure_token
    var = :"@#{mounted_as}_secure_token"
    model.instance_variable_get(var) or model.instance_variable_set(var, SecureRandom.uuid)
  end
end

目前还不太明白为什么要把实例变量存在模型对象而不是 uploader 对象中。但是一定要像例子中那样将 SecureRandom.uuid 的值用实例变量缓存起来,否则数据库中存储的文件名会与本地硬盘中的文件名不同,因为在处理过程中 filename 方法会被多次使用。

需要注意的是 filename 不能返回空字符串,我在尝试的过程中又一次不小心返回空字符串,结果出现了奇怪的错误。看了源码后发现 CarrierWave 的处理方式稍微有些问题。首先,对于重写 filename 返回空字符串或 nil 的做法 CarrierWave 并没有做检查,直接认为是合法的文件名。其次,CarrierWave 会改变上传文件的权限,目录的默认权限是 755,文件的默认权限是 644,这本身没问题,不过当文件名是空字符串或 nil 时,CarrierWave 还是会尝试将文件的权限改为 644,而这时的路径实际上对应的是一个目录。最终的结果就是将目录的执行权限去掉了,没有执行权限的目录是无法进入的,自然会出现一些奇怪的错误,比如 Permission Denied 。与这个问题相关的文件是 lib/carrierwave/uploader/store.rb

图片处理

很多时候需要对上传的图片进行裁剪等处理,然后再保存。CarrierWave 通过 MiniMagick 或 RMagick 来实现对图片的处理。官方推荐使用 MiniMagick,下面的例子也都使用 MiniMagick。

如果使用 MiniMagick,需要在 Gemfile 中将 MiniMagick 包含进来。如果修改了 Gemfile 然后再执行 bundle install,通常都需要重启服务器,Rails 的自动重新加载对 gem 无效。

将 MiniMagick 加入项目之后,只需将默认生成的 uploader 文件中的一行代码取消注释就可以使用图片处理功能了。

1
2
3
4
5
6
7
8
9
class AvatarUploader < CarrierWave::Uploader::Base

  # Include RMagick or MiniMagick support:
  # include CarrierWave::RMagick
  # include CarrierWave::MiniMagick
  include CarrierWave::MiniMagick

  # ...
end

CarrierWave::MiniMagick 这个模块包含了几个用于处理图片的方法:

  • convert 转换图片格式。
  • resize_to_limit 改变图片的大小,不超过指定的宽高,也就是只对超过限制的图片处理,不会有小图片变大的情况。
  • resize_to_fit 改变图片的大小,适应到指定的宽高,小图片会变大,大图片会变小。
  • resize_to_fill 改变图片的大小,填充到指定的宽高,可能会将比较大的维度裁减掉一部分。
  • resize_and_padresize_to_fit 类似,不足的部分用指定的颜色填充。
  • manipulate! 比较底层的方法,可以实现对图片的自定义处理,前面的方法都是用该方法实现的。

以 resize 开头的四个方法只改变图片的大小,不会改变宽高比,所以用这些方法是不会把图片拉伸的。

要在 uploader 中使用这几个方法来处理图片,要用到类方法 process,根据注释,process 方法本质上是注册一个在文件上传时执行的回调,接受的参数既可以是 uploader 中的方法名或方法名列表,也可以是一个 Hash,Hash 的键是方法名,值是调用方法需要的参数数组。方法的注释和实现如下:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
    ##
    # Adds a processor callback which applies operations as a file is uploaded.
    # The argument may be the name of any method of the uploader, expressed as a symbol,
    # or a list of such methods, or a hash where the key is a method and the value is
    # an array of arguments to call the method with
    #
    # === Parameters
    #
    # args (*Symbol, Hash{Symbol => Array[]})
    #
    # === Examples
    #
    #     class MyUploader < CarrierWave::Uploader::Base
    #
    #       process :sepiatone, :vignette
    #       process :scale => [200, 200]
    #       process :scale => [200, 200], :if => :image?
    #       process :sepiatone, :if => :image?
    #
    #       def sepiatone
    #         ...
    #       end
    #
    #       def vignette
    #         ...
    #       end
    #
    #       def scale(height, width)
    #         ...
    #       end
    #
    #       def image?
    #         ...
    #       end
    #
    #     end
    #
    def process(*args)
      new_processors = args.inject({}) do |hash, arg|
        arg = { arg => [] } unless arg.is_a?(Hash)
        hash.merge!(arg)
      end

      condition = new_processors.delete(:if)
      new_processors.each do |processor, processor_args|
        self.processors += [[processor, processor_args, condition]]
      end
    end

可以看到如果键是 :if 会把它当做判断回调是否执行的条件。

有了 CarrierWave::MiniMagickprocess,二者组合起来就可以实现对上传图片的处理了:

1
2
3
4
5
6
7
8
class AvatarUploader < CarrierWave::Uploader::Base
  include CarrierWave::MiniMagick

  process convert: 'png'
  process resize_to_limit: [200, 200]
  process resize_to_fit: [200, 200]
  process resize_to_fill: [200, 200]
end

process 可以注册多个回调,不过多次执行改变图片大小的回调会把图片变成什么样就不清楚了。

其实 CarrierWave::MiniMagick 模块中还提供了简化的类方法来实现同样的作用,在网上找到的教程好像都没提到这一点,代码如下:

1
2
3
4
5
6
7
8
class AvatarUploader < CarrierWave::Uploader::Base
  include CarrierWave::MiniMagick

  convert 'png'
  resize_to_limit 200, 200
  resize_to_fit 200, 200
  resize_to_fill 200, 200
end

多版本

CarrierWave 支持将上传的文件处理成多个版本分别保存,比如生成上传图片的缩略图。在 uploader 中使用 version 方法创建一个新版本,版本可以嵌套。

1
2
3
4
5
6
7
8
9
10
class AvatarUploader < CarrierWave::Uploader::Base
  include CarrierWave::MiniMagick

  version :thumb

  version :foo do
    version :bar
    version :baz
  end
end

上面的代码一共创建了四个版本,分别是 thumbfoofoo_barfoo_baz。创建新版本并不会影响默认的文件版本,所以使用上面的 uploader 每次上传会在本地硬盘存储五个文件。

单纯创建版本只会生成多个内容相同的文件,并没有什么作用,只有搭配使用 process 方法才能发挥它的作用。例子如下:

1
2
3
4
5
6
7
8
9
class AvatarUploader < CarrierWave::Uploader::Base
  include CarrierWave::MiniMagick

  process resize_to_limit: [800, 800]

  version :thumb do
    process resize_to_fill: [50, 50]
  end
end

这样每次上传会生成两个文件,默认版本大小限制在 800x800 像素以内,thumb 版本处理为 50x50 像素。

参考

CarrierWave

Uninitialized uploader

CarrierWave issue #399

用 jQuery 同步输入框内容

前几天在项目中发现有不能正确同步输入框内容的页面,经过上网查找后找到了一种比较好的办法。

要想同步输入框内容,就要监听各种可能使输入框内容发生改变的事件,第一个想到的就是监听键盘输入的事件,与键盘输入相关的事件有 keydown, keypress, keyup 等,keydownkeypress 发生在输入框内容改变之前,应该使用 keyup 事件。除了键盘输入外,通过粘贴的方式也会使输入框内容发生变化。 使用键盘快捷键粘贴的方式也会触发 keyup 事件,不过用鼠标右键粘贴的方式不触发 keyup 事件,鼠标右键粘贴会触发 paste 事件,但是该事件发生在输入框内容改变之前,不能正确同步内容。

针对鼠标右键粘贴等方式没有找到太好的办法,只想出了一个折衷的办法:当输入框失去焦点时同步内容,这样虽然不能保持实时同步,但能保证最终同步。输入框失去焦点的事件是 blur

1
2
3
4
5
6
7
8
9
10
11
12
13
<div>
  <label>Input:</label>
  <input id="input-field" type="text">
  <br>
  <label>Output:</label>
  <span id="output-field"></span>
</div>
<script>
  $('#input-field').on('keyup blur', function() {
    console.log('asdf');
    $('#output-field').text($(this).val());
  });
</script>

Redis Database Number

Redis 使用 DB number 实现类似关系型数据库中 schema 的功能。不同 DB number 表示的数据库是隔离的,但是目前只能使用数字来表示一个数据库,Ubuntu 默认的配置文件配置了16个数据库,DB number 是从0开始的,并且默认连接0号数据库。

使用命令行连接其他数据库

可以使用 -n 选项指定连接的数据库号。

1
2
3
4
$ redis-cli -n <dbnumber>

$ redis-cli -n 2
127.0.0.1:6379[2]>

如果已经执行 redis-cli 进入了 Redis 控制台,可以使用 SELECT 命令选择其他数据库。

1
2
3
127.0.0.1:6379> SELECT 1
OK
127.0.0.1:6379[1]>

在 Redis 控制台中,如果当前连接的不是0号数据库,提示符中会在方括号内显示当前连接的数据库号。

使用URL连接其他数据库

在连接 URL 后面添加数据库号即可。

1
2
3
redis://127.0.0.1:6379/<dbnumber>

redis://127.0.0.1:6379/1

连接到不存在的数据库号

在命令行中,如果指定的数据库号超出了配置文件中的范围,貌似都会连接到0号数据库。使用 redis-cli -n 10000 这样的方式连错误都没有提示,而在Redis控制台中用 SELECT 10000 这样的方式会有错误提示,不过依然可以正常使用。不知道这算不算是 BUG。

1
2
$ redis-cli -n 10000
127.0.0.1:6379[10000]>
1
2
3
4
5
127.0.0.1:6379> SELECT 10000
(error) ERR invalid DB index
127.0.0.1:6379[10000]> set number 10000
OK
127.0.0.1:6379[10000]>

A Swift Tour 笔记

最近看完了 The Swift Programming Language 中的 A Swift Tour 一节,对 Swift 有了一点初步的了解,在此记录一下学习过程中遇到的一些问题。

可变参数列表

Swift 的函数可以接受多个参数,把它们收集到一个数组中。这本来应该是一个不错的语言特性,但是在写对应的 experiment 时就发现问题了,Swift 只能把多个参数收集到数组中,却不能将一个数组打散成多个参数。Ruby 中可以用 sumOf(*numbers) 这样的方式实现将数据打散成多个参数,Swift 目前没有这样的功能,所以无法用下面的代码实现 average 函数。如果实在想用 sumOf 函数来实现 average,那只能将 sumOf 的参数改为数组类型的。只能期待Swift能在以后实现这样的功能了,目前来说尽量少用这样的蹩脚的语言特性吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func sumOf(numbers: Int...) -> Int {
    var sum = 0
    for number in numbers {
        sum += number
    }
    return sum
}
sumOf()
sumOf(42, 597, 12)

func average(numbers: Int...) -> Double {
    let count = numbers.count
    // The code below do not work!
    // let avg = sumOf(*numbers) / count
    return avg
}

枚举类型遍历

Swift 可以使用 enum 定义枚举类型,但是目前貌似没有非常方便的方法返回所有的枚举值。希望以后能添加这样的方法吧。

命名空间

Swift 有命名空间的功能,不过很少有地方提到。在解决下面与命名空间有关的一个问题时找到了 Lattner 大神的 Twitter,提到了显式使用命名空间。链接:Chris Lattner suggests

与命名空间有关的问题代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
extension Int {

    func restrictToRange(#minValue: Int, maxValue: Int) -> Int {
        var ret: Int

        // 'Int' does not have a member named 'min'
        //ret  = min(max(self, minValue), maxValue)

        // use explicit namespace
        ret = Swift.min(Swift.max(self, minValue), maxValue)
        return ret
    }

}

我想用 extensionInt 中添加一个方法,在其中用到了 minmax 两个全局函数,但是直接使用会有 'Int' does not have a member named 'min' 这样的错误提示,在函数名前加上显式的命名空间 Swift 后问题解决,但是引起该错误的原因没有找到,可能与作用域和符号查找有关吧,希望在以后能找出该问题的具体原因。

OSX 中挂载 NTFS 分区

我的移动硬盘是在用 Mac 之前买的,格式化为 NTFS 格式,可以在 Windows 和 Linux 中正常使用。换用 Mac 后,一开始只是从移动硬盘中读文件,没发觉有什么问题,一度以为网上说的 OSX 不支持 NTFS 的说法是针对老版本。直到又一次想要向移动硬盘中存文件,发现连新建目录都不行,这才了解到 OSX 默认把 NTFS 分区挂载为只读。

原来 OSX 从 Snow Leopard 开始就内置了对 NTFS 分区的读写支持,但是默认将 NTFS 分区挂载为只读分区,具体原因不详。既然是内置读写支持,那就有办法将 NTFS 分区挂载为可读可写的分区。

实现这个功能的命令是 /sbin/mount_ntfs,系统在实现移动硬盘自动挂载功能时也是间接使用该命令,所以网上有修改该文件来实现自动挂载为可读可写分区的方法。个人觉得修改系统原文件不是一个很好的方法,没有采用。不过也学到了使用该命令的方法。

1
mount_ntfs -o rw,nobrowse DEVICE MOUNT_POINT

其中 DEVICE 是硬盘分区对应的设备文件, 比如 /dev/disk2s1MOUNT_POINT 是挂载点,比如 /Volumes/data。通过 -o rw,nobrowse 这样的参数,可以将 NTFS 分区挂载为可读可写分区,其中的 rw 顾名思义,nobrowse 表示不在桌面和 Finder 中显示。不在桌面和 Finder 中显示很不方便,于是尝试只是用 -o rw 这样的参数,挂载的分区出现在了桌面和 Finder中,但是依然是只读分区,尝试失败。可能是系统做了限制,挂载为可读可写分区时不允许在桌面和 Finder 中显示。目前我的做法是将 /Volumes 目录放到 Finder 的个人收藏中,然后手动挂载分区到这个目录下的某个子目录。

Install Emacs on OSX

原来在 Linux 和 Windows 系统都是用 Emacs,换了 MacBook Pro 后就考虑如何在 OSX 上安装 Emacs。其实 OSX 默认就安装了 Emacs,只不过版本太老,还是22的,而且貌似没有图形界面,所以还是需要安装新版本的有图形界面的Emacs。OSX 上貌似有好几种 Emacs,因为我使用 Homebrew 管理软件包,所以选择了用 Homebrew 安装 Emacs。在这里记录一下安装配置时遇到的问题。

使用 Homebrew 安装 Emacs

Homebrew 有 Emacs 的 formula,那就像其他软件一样安装吧。

1
2
 # Do not use this command, it does not work well!
brew install emacs

通过命令行的输出可以看到 Homebrew 默认选择 bottle 的方式安装 Emacs,bottle 方式就是直接下载已经编译好的软件包。一会儿安装完成,赶紧在命令行输入 emacs --version,确认是版本是新的,以为大功告成了。但是找了半天没找到怎么启动图形界面的 Emacs,遂上网查找问题。查找的结果是用上面的命令安装的 Emacs 只能在终端使用,根本就没有图形界面。想用图形界面,只能用编译的方式安装 Emacs。命令如下:

1
2
3
# brew install emacs --cocoa
# 24.5 版本使用下面的命令
brew install emacs --with-cocoa

这样就会编译 Cocoa 版本的 Emacs,也就是有图形界面的。在写这篇文章时,Homebrew 中的 Emacs 版本为 24.4,安装这个版本不需要再加 --srgb 选项,貌似已经是默认行为,不需要写在命令中了。网上查到的资料大多都有 --srgb 选项,是24.4版本之前需要的选项,现在已经不需要了。

安装完后仍然是不能直接启动图形界面的,需要做一个符号链接。

1
ln -s /usr/local/Cellar/emacs/24.4/Emacs.app /Applications/Emacs.app

需要注意链接的路径,如果修改了 Homebrew 的安装路径,需要修改成对应的。到此,终于大功告成了。

修改 Emacs 修饰键

MacBook Pro 的键盘有一点让我非常不习惯,Control 键竟然只有一个!Command、Option、Shift 键都有两个,Control 键竟然只在左边有,右边的位置被方向键霸占了。OSX 的快捷键大多以 Command 开头,影响不大,但是 Emacs 可是大量使用 Control 键啊。原来的解决方案是使用外接键盘,现在不用外接键盘了,只能考虑其他方式了。用 Command 代替 Control 的功能是个不错的方法,经过上网查找,找到了几个 Emacs 的变量,可以实现该功能,只需添加下面的代码到配置文件即可。

1
2
3
4
(if (eq system-type 'darwin)
    (progn
      ;(setq mac-control-modifier 'super)
      (setq mac-command-modifier 'control)))

system-type 变量表示当前系统类型,darwin 表示 OSX,mac-command-modifier 变量表示按下 Command 键输入哪个前缀。注释掉的那一行表示,用 Control 键实现原来 Command 键的功能,因为发生过误操作,所以注释不用了。

总结

这篇文章介绍了如何用 Homebrew 安装有图形界面的 Emacs,安装版本为24.4。原来安装过24.3版本的,有一个非常严重的BUG,一旦触发用 x-popup-dialog 打开的对话框,整个 Emacs 就没有响应了,只能通过系统的强制退出功能关掉程序。这其中就包括 cmd + p 这样的组合键,有时想按 ctrl +p,结果按成了 cmd + p,弹出和打印有关的对话框,然后整个 Emacs 就没有响应了,这也是更改 Command 键的功能的部分原因。现在重新安装了24.4,BUG没有了,使用起来更顺畅了。

更新 2015-10-06

Homebrew 里的 Emacs 更新到了 24.5,编译的选项发生了变化,可以使用 brew info emacs 查看。

1
brew install emacs --with-cocoa

符号链接也可以用下面的命令实现,会链接到 /Applications/ 目录。

1
brew linkapps emacs

参考

http://emacsredux.com/blog/2014/01/11/a-peek-at-emacs-24-dot-4-srgb-colours-on-os-x/

http://stackoverflow.com/questions/1817257/how-to-determine-operating-system-in-elisp

Explore ActiveSupport 1

Ruby on Rails 框架的 Active Support 组件提供了很多方便使用的方法,有些方法是通过 Monkey Patch 的方式添加到 Ruby 原有的类中的。

Object#blank?

只截取了主要部分的代码。看过源码后发现,#blank? 不仅添加到 Object 类,还添加到了 NilClass,FalseClass,TrueClass,String,Numeric,Array,Hash 等类中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# File activesupport/lib/active_support/core_ext/object/blank.rb
class Object
  def blank?
    respond_to?(:empty?) ? !!empty? : !self
  end
end

class String
  BLANK_RE = /\A[[:space:]]*\z/

  def blank?
    BLANK_RE === self
  end
end

判断一个对象是否是空白的。空白的定义是如果一个对象是 false,empty 或空白字符,那么就是空白的。比较的特殊的是对 String 类的处理,只有零个或多个空格字符的字符串都认为是空白的。根据注释,制表符换行符等都算作空白字符,还支持 Unicode 的空白字符。

1
2
3
4
5
6
7
''.blank?       # => true
'   '.blank?    # => true
"\t\n\r".blank? # => true
' blah '.blank? # => false

# Unicode whitespace is supported:
"\u00a0".blank? # => true

所有的数字都不是空白的,因为向 Numeric 类添加的 blank? 方法永远返回 false。

Object#present?

Object#present?非常常用,实现非常简单,所有不是空白的对象都返回 true。

1
2
3
def present?
  !blank?
end

ActiveSupport::HashWithIndifferentAccess

随着 Ruby 版本的发展,String 和 Symbol 对象的差别越来越小,在 Ruby 和 Rails 中很多时候都是可以混用的,但是有一个地方是不能混用的,那就是 Hash 的键。:foo.hash != 'foo'.hash,Active Support 中的 HashWithIndifferentAccess 类就是解决这个问题的。

1
2
3
4
5
6
7
8
9
10
# File activesupport/lib/active_support/hash_with_indifferent_access.rb
rgb = ActiveSupport::HashWithIndifferentAccess.new

rgb[:black] = '#000000'
rgb[:black]  # => '#000000'
rgb['black'] # => '#000000'

rgb['white'] = '#FFFFFF'
rgb[:white]  # => '#FFFFFF'
rgb['white'] # => '#FFFFFF'

好了,再也不用担心搞错了,不过这名字也太长了。Active Support还添加了Hash#with_different_access方法,可以通过已有的 Hash 对象转化。

1
rgb = { black: '#000000', white: '#FFFFFF' }.with_indifferent_access

算是短一点了。

使用libreadline6.3编译Ruby

Debian unstable版的软件包一般都很新,但是想尝试最新的ruby,还是不能依赖操作系统的升级。原来一直不太想用RVM、rbenv等工具,觉得系统自带的ruby足够了。现在想要尝试新版本了,就只能尝试一下了,顺便学习一下这些工具的使用。

原来曾经尝试过rbenv,感觉还不错,又听到过一些RVM负面的信息,所以决定还是从rbenv入手。rbenv的安装很简单,按照官网的README来就好。所有的文件都安装在 ~/.rbenv/目录中,还是比较好管理的。当时做得比较急,安装完成就准备安装ruby,但是tab了半天也没出install这个子命令。咋回事儿?继续看README吧,原来把编译安装ruby的功能做成了插件,需要单独安装。安装好后,终于有install子命令了,挑个比较新的版本,走起。

1
$ rbenv install 2.1.1

当时的网速不太好,历尽千辛万苦,终于下载完了。解压、编译,然后等来的是一个编译错误。rbenv把所有的临时文件全部删除了,只留下一个日志文件。初看这个日志文件,貌似与OpenSSL相关,因为日志的最后几行确实有OpenSSL的痕迹。但仔细查看日志,发现上面几行才是出错的真正原因,在日志中有类似下面的两行:

1
2
make[1]: *** [ext/readline/all] 错误 2
make[1]: *** 正在等待未完成的任务....

也就是说下面的应该是正常输出,是因为上面出错了才停止的。造成编译错误的罪魁祸首是这两行上面的日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
make[2]: Entering directory `/tmp/ruby-build.20140519215343.7209/ruby-2.0.0-p451/ext/readline'
compiling readline.c
readline.c: In function ‘Init_readline’:
readline.c:1886:26: error: ‘Function’ undeclared (first use in this function)
     rl_pre_input_hook = (Function *)readline_pre_input_hook;
                          ^
readline.c:1886:26: note: each undeclared identifier is reported only once for each function it appears in
readline.c:1886:36: error: expected expression before ‘)’ token
     rl_pre_input_hook = (Function *)readline_pre_input_hook;
                                    ^
readline.c: At top level:
readline.c:530:1: warning: ‘readline_pre_input_hook’ defined but not used [-Wunused-function]
 readline_pre_input_hook(void)
 ^
make[2]: *** [readline.o] 错误 1
make[2]: Leaving directory `/tmp/ruby-build.20140519215343.7209/ruby-2.0.0-p451/ext/readline'
make[1]: *** [ext/readline/all] 错误 2
make[1]: *** 正在等待未完成的任务....
...

从上面的错误日志中可以发现,编译错误是在编译ext/readline/readline.c时,1886行的Function未声明造成的。上网搜索关键字rbenv readline Function,发现这还不是个别现象,应该算是ruby的一个BUG。问题的根本原因是ruby依赖的libreadline库从6.2升级到6.3时,将Function的typedef去掉了,但ruby使用libreadline的代码中还存在对Function的使用,因此就出现了符号未声明的编译错误。找到了问题的根源,修复的措施就简单了,使用libreadline新风格的typedef,将Function替换成rl_hook_func_t就行了。

rbenv除了可以用rbenv install来自动编译安装ruby外,也可以手动编译,最后安装到~/.rbenv/versions/对应版本的目录中。比如2.1.1版本的ruby,在configure时添加--prefix=~/.rbenv/versions/2.1.1即可。

1
2
3
4
$ ./configure --prefix=~/.rbenv/versions/2.1.1
$ make
$ make check
$ make install

在查找这一问题时,通过ruby的版本控制系统,还发现了一些比较有趣的东西。这才是本文的重点。:) Ruby有自己的独立版本库,使用的应该是svn,不过在GitHub上有镜像版本库,提交日志是基本相同的。因为比较熟悉GitHub,下面描述的提交日志都来自GitHub。

关于该问题的代码修改都在ext/readline目录中,看一下与该目录有关的提交日志,哇,从2014年3月1日到2014年4月4日,总共用了5次提交才比较好的修复了本问题。依次浏览这五次提交,看看到底是怎么会事儿。

2014年3月1日,提交ed6a2d3bf6,修改了ext/readline/readline.c。修正方法与我上面描述的相同,就是把Function换成了rl_hook_func_t,貌似问题解决。

2014年3月2日,前一天刚修复了BUG,怎么又有针对同一问题的提交,而且一天就提交了两次?第一次提交2bb8811484rl_hook_func_t for old readline。好吧,前一天的修改简单的改了类型名,没有考虑向前兼容老版本的readline,从提交信息看是这样的。修正方法也比较简单,就是判断一下有没有rl_hook_func_t这个类型,没有的话就通过宏定义将rl_hook_func_t替换成Function。第二次提交083bf23759,添加了针对上一次提交的注释。难道是修改得太急,改了问题,忘了用注释解释一下?

1
2
3
+  # rl_hook_func_t is available since readline-4.2 (2001).
+  # Function is removed at readline-6.3 (2014).
+  # However, editline (NetBSD 6.1.3, 2014) doesn't have rl_hook_func_t.

貌似是为了兼容NetBSD的editline库,那第一次提交里提到的for old readline就不太准确了。也就是说第二次提交可能是为了补救上一次提交出现的歧义。

但仍然没有结束。2014年3月31日,提交6648136779fix typo,哦,是$defs而不是$DEFS,但为什么过了将近一个月才发现?

2014年4月4日,提交d2a8e28597,不是检查rl_hook_func_t而应该检查rl_hook_func_t*。我的疑问与上一次提交一样,为什么过了一个月才发现?

通过对这一系列提交的追溯,我们发现是不是有些似曾相识?在修正BUG的时候不小心又引入了新的BUG,然后再修正新BUG,然后因为匆忙修正的不彻底。类似这样的问题有很多,如何才能在开发过程中尽量避免?Ruby的代码是有单元测试的,但是在不同库版本、不同操作系统的环境中单元测试的结果可能是不同的。我猜测单元测试可能只在比较主流的环境中运行,相当于跳过了这些兼容的代码,因此也就没有及时发现问题,导致一个BUG花了一个月才彻底解决。

PS:Ruby 2.1.2已经合并了这些修改,编译2.1.2不需要再手动修改代码。

Debian禁用不必要的开机自启动服务

经常为了尝试安装各种软件,比如Mongodb、Redis、Nginx等,但实际上用到的时候不多。这些软件都是用apt-get方式安装的,安装完默认都是开机启动的,慢慢开始影响开机启动的速度了。不让他们自启动可以直接去/etc/rc?.d修改,把S改成K就可以,但是手动改太麻烦了。Debian有一个命令,但是每次改都要现查,老是记不住。写一篇博客加深一下印象吧。

这个命令是update-rc.d,看名字就知道是修改rc?.d的。根据man update-rc.d,用法如下:

1
2
3
4
5
6
7
update-rc.d [-n] [-f] name remove

update-rc.d [-n] name defaults

update-rc.d [-n] name

update-rc.d [-n] name disable|enable [ S|2|3|4|5 ]

用到的是最后一个,如果最后面的运行级别不指定则修改所有级别。

1
2
3
4
# 禁止Redis开机启动
sudo update-rc.d redis-server disable
# Redis开机启动
sudo update-rc.d redis-server enable

写这篇博客时顺便学会了一个查看所有服务运行状态的命令,+表示正在运行,-表示没有运行,?表示不详(?),可能是不支持用status查询状态吧。

1
2
3
4
5
6
7
8
sudo service --status-all
# ==>
 [ + ]  acpi-fakekey
 [ - ]  acpi-support
 [ + ]  acpid
 [ ? ]  alsa-utils
 [ - ]  anacron
...

安装docker并手工构建一个image

最近貌似docker这玩意儿比较火,有可能是虚拟化的新趋势。比较感兴趣,正好有时间就玩了玩,遇到一些问题就记下来了。

docker的官网做得不错,对于新手能很方便的找到所需信息。最让人喜欢的是那个Get started!交互式命令行教程,简直就是“手把手得教,一对一得学”。通过docker versiondocker search tutorial等一步步深入,看完教程就能熟悉个大概了。

首先是安装docker。我的系统是Debian unstable,官方有Ubuntu的安装教程,我是按照Ubuntu的教程来的,但是并没有执行sudo apt-get install linux-image-extra-\uname -r“,因为Debian貌似没有linux-image-extra-*这个软件包。安装docker只用下面几行命令即可。

1
2
3
4
5
sudo sh -c "wget -qO- https://get.docker.io/gpg | apt-key add -"

sudo sh -c "echo deb http://get.docker.io/ubuntu docker main > /etc/apt/sources.list.d/docker.list"
sudo apt-get update
sudo apt-get install lxc-docker

安装好docker我就按Get started!的命令操作,但不凑巧的是我在本机执行这些命令时可没有在线教程那么顺利,docker pull ubuntu并不能正常下载image,卡了一会儿输出个错误,超时了。原因猜也能猜个差不多,只能说句“F*K GFW”然后找其他办法了。还好我有个VPN,连上VPN,再试,结果还是不行,错误信息和不连VPN的还不太一样,具体原因还不清楚,再找其他解决方案吧。最终在一篇博客的评论里找到了一种解决方案,有一点麻烦,但确实可行。

这个解决方案就是自己在本地构建一个image,然后导入到docker中,最终就和用docker pull从远程下载的效果一样了。该方案只能在Debian或Ubuntu上操作,制作的image也只能是Debian或Ubuntu。

首先,确认已经安装了debootstrap这个工具,如果没安装用下面命令安装。

1
2
sudo apt-get update
sudo apt-get install debootstrap

debootstrap貌似是一个构建Debian系统的工具,可以从指定的源获取deb安装包并安装。过程应该和安装系统差不多,只不过是将文件都安装到某一个目录下。根据man debootstrap,用法如下,用一句话解释就是:”debootstrap bootstraps a basic Debian system of SUITE into TARGET from MIRROR by running SCRIPT.”

1
debootstrap [OPTION...]  SUITE TARGET [MIRROR [SCRIPT]]

TARGET这里应该是写一个路径,最终构建的系统就在这个路径中。如果该路径不存在会自动创建。

MIRROR指定deb包的获取路径,与sources.list文件中写的路径一样,比如http://mirrors.sohu.com/ubuntu/

SUITE是一个名字,起初我以为可以随便写,经过测试发现必须与/usr/share/debootstrap/scripts/目录中的文件名对应,并且需要与MIRROR对应,下面有说明。在我的机器上这个目录有如下内容:

1
2
3
4
5
6
7
8
9
10
11
$ ls /usr/share/debootstrap/scripts/
breezy            intrepid          potato            stable
dapper            jaunty            precise           testing
edgy              jessie            quantal           trusty
etch              karmic            raring            unstable
etch-m68k         lenny             sarge             warty
feisty            lucid             sarge.buildd      warty.buildd
gutsy             maverick          sarge.fakechroot  wheezy
hardy             natty             saucy             woody
hoary             oldstable         sid               woody.buildd
hoary.buildd      oneiric           squeeze

显然,SUITE这一项只能写Debian或Ubuntu的代号。否则错误提示为:

1
2
$ sudo debootstrap abc target http://mirrors.sohu.com/ubuntu
E: No such script: /usr/share/debootstrap/scripts/abc

但只满足了本机的要求也不够,如果该suite在MIRROR对应源中不存在也无法执行,毕竟源中没有对应版本的deb包那什么也干不了。比如:

1
2
3
$ sudo debootstrap karmic target http://mirrors.sohu.com/ubuntu
I: Retrieving Release
E: Failed getting release file http://mirrors.sohu.com/ubuntu/dists/karmic/Release

例子,从http://mirrors.sohu.com/ubuntu下载并构建Ubuntu saucy 13.10,存放在./saucy中:

1
2
# Example:
sudo debootstrap saucy ./saucy http://mirrors.sohu.com/ubuntu

构建好系统就可以用tar打包了。需要注意的是路径问题,要保证tar包里面的/目录要对应上面的TARGET目录。简单的办法就是先进入TARGET目录,再执行tar命令:

1
2
3
cd ./saucy
tar -cf ../ubuntu-saucy.tar .
cd ..

有了tar包就可以导入到docker中了,用以下命令导入:

1
cat ./ubuntu-saucy.tar | sudo docker import - saucy

docker的import子命令接受两种形式的参数,一种是URL,另一种是标准输入。上面的命令中的短横杠-表示从标准输入导入,saucy是给这个image起的名字,类似learn/tutorialubuntu。因为要从标准输入导入,所以用cat命令将tar包内容输出到标准输出,再用管道连到docker的标准输入。

到这儿就完成了,可以试用一下刚导入的image

1
2
3
$ sudo docker run -i -t saucy cat /etc/issue
WARNING: IPv4 forwarding is disabled.
Ubuntu 13.10 \n \l

最后一行就是image的输出。

最后再说一遍“F*K GFW”,搞这么多就为了替代docker pull一行命令。