高見龍

iOS app/Ruby/Rails Developer & Instructor, 喜愛非主流的新玩具 :)

Ruby 語法放大鏡之「在 Gemfile 裡看到版本寫法有好幾款,各是代表什麼意思?」

Ruby 語法放大鏡」系列短文主要是針對在大家學習 Ruby 或 Rails 時看到一些神奇但不知道用途的語法介紹,希望可以藉由這一系列的短文幫大家更容易的了解到底 Ruby 或 Rails 是怎麼回事。

隨便打開一個 Rails 專案底下的 Gemfile 檔案,大概會長得像這樣(以 Rails 5.0.0 beta 4 為例):

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
source 'https://rubygems.org'

gem 'rails', '>= 5.0.0.beta4', '< 5.1'
gem 'sqlite3'
gem 'puma', '~> 3.0'
gem 'sass-rails', '~> 5.0'
gem 'uglifier', '>= 1.3.0'
gem 'coffee-rails', '~> 4.1.0'
gem 'jquery-rails'
gem 'turbolinks', '~> 5.x'
gem 'jbuilder', '~> 2.0'

group :development, :test do
  gem 'byebug', platform: :mri
end

group :development do
  gem 'web-console'
  gem 'listen', '~> 3.0.5'
  gem 'spring'
  gem 'spring-watcher-listen', '~> 2.0.0'
end

# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]

移除部份註解節省點空間。在這個例子裡,有些 gem 的後面有加註版本號碼,有的沒有,這些分別是代表什麼意思呢?

沒加註版號

先從最簡單的來看。當後面沒有加註版本號碼的時候,像這樣:

1
2
gem 'sqlite3'
gem 'jquery-rails'

這樣將會在安裝的時候選用「最新的穩定(stable)版本」,要注意這裡的重點是「穩定」而不是「最新」。以 Rails 來說,雖然目前最新的版本是 5.0.0 beta 4,但最新的「穩定」版本是 4.2.6 版,所以當沒有加註版本號的時候,它會安裝 4.2.6 版本。

加註明確版號

例如像這樣:

1
gem "rails", "4.2.6"

這相當明顯了,這就是說「我要安裝 rails 4.2.6 版」,應該不需要特別解釋。

大於、小於版號

1
gem 'uglifier', '>= 1.3.0'

我想這個光用看的就猜得出來,就是要選用大於或等於 1.3.0 版本。

1
gem 'rails', '>= 5.0.0.beta4', '< 5.1'

這樣則是會選用在 5.0.0.beta4 跟 5.1 之間的版本。

差不多…

1
gem 'coffee-rails', '~> 4.1.0'

這是指會選用 4.1.0 以上,但 4.2 以下(不含括 4.2)的最新版本。

為什麼這麼麻煩? 舉個例子來說,例如版本號 4.2.6426 三個數字分別代表主要版號(Major)、次要版號(Minor)以及修訂版號(Patch),分別表示:

  • 主要版號:功能大改,公開的 API 做了不少修正,通常沒辦法向下不相容
  • 次要版號:加了某些新功能,但不影響其它功能,向下相容
  • 修訂版號:對現有的功能做了小幅度的修正,可向下相容

這是個不成文的規定(語義化版本),雖然沒有強制,但幾乎大部份的 gem 作者都會依照這個規範。而這個 ~> 可以比較確保在進行 bundle install 指令的時候,因為只會更新到次要或修訂版號的套件,所以比較不會因為原作推出了最新力作(例如 5.0.0),而把原本正常運作的系統弄壞了。

參考資料:

  1. Bundler http://bundler.io/gemfile.html
  2. 語意化版本 http://semver.org/lang/zh-TW/

Ruby 語法放大鏡之「你知道 require 幫你做了什麼事嗎?」

Ruby 語法放大鏡」系列短文主要是針對在大家學習 Ruby 或 Rails 時看到一些神奇但不知道用途的語法介紹,希望可以藉由這一系列的短文幫大家更容易的了解到底 Ruby 或 Rails 是怎麼回事。

也許你曾在 Ruby 或 Rails 專案中寫過這行語法:

1
2
require "digest"
puts Digest::MD5.hexdigest("I love you")    # => "e4f58a805a6e1fd0f6bef58c86f9ceb3"

上面這段語法的大意是「引用 “digest” 模組,然後使用那個模組裡的某個方法產生 MD5 編碼字串」。但你知道這個 require 到底做了什麼事嗎? 以下將用我自己寫的一個名為 takami 的套件為例,它是一個完全沒功能的空包彈套件,純粹練功用途。

require 是怎麼運作的?

讓我們先打開終端機,直接試著印出 $PATH 這個變數來看看:

$ echo $PATH
/Users/user/.rvm/gems/ruby-2.3.1/bin:/Users/user/.rvm/gems/ruby-2.3.1@global/bin ...[略]...

在你電腦上的輸出結果可能跟我的不太一樣。當你想執行某個程式,例如 git log,系統會依照 $PATH 列出來的順序,一個一個的問「請問在這個路徑是不是有 git 這個程式?」,如果有,就執行它;如果都沒有,就會出現 command not found 的訊息。

在 Ruby 裡有個叫做 $LOAD_PATH 的全域變數(可簡寫成 $:)的功用跟 $PATH 類似,讓我們開 irb 來試試:

$ irb
>> $LOAD_PATH
=> ["/Users/user/.rvm/gems/ruby-2.3.1@global/gems/did_you_mean-1.0.0/lib", ...[略]...

>> $:
=> ["/Users/user/.rvm/gems/ruby-2.3.1@global/gems/did_you_mean-1.0.0/lib", ...[略]...

當你執行 require "takami" 之後,它會把這個 gem 加到 $LOAD_PATH 裡,並且把它加到另一個全域變數 $LOADED_FEATURES(可簡寫成 $") 裡:

$ irb
>> $LOAD_PATH.count
=> 9

>> $LOADED_FEATURES.count
=> 59

>> require "takami"
=> true

>> $LOAD_PATH.count
=> 10

>> $LOAD_PATH
=> ["/Users/user/.rvm/gems/ruby-2.3.1@global/gems/did_you_mean-1.0.0/lib", "/Users/user/.rvm/gems/ruby-2.3.1/gems/takami-0.0.1/lib", ...[略]...

>> $LOADED_FEATURES
=> ["enumerator.so", "thread.rb",  ...[略] ... "/Users/user/.rvm/gems/ruby-2.3.1/gems/takami-0.0.1/lib/takami.rb"]

>> $LOADED_FEATURES.count
=> 61

在 require 之前 $LOAD_PATH 只有 9 個,require 之後變 10 個了,而 $LOADED_FEATURES 的數量也從原本的 59 變成 61 了。除了可以在 $LOADED_FEATURES 看到新增加跟 takami 套件有關的東西之外,也可以看的出來 takami 套件的確在 $LOAD_PATH 裡了,這樣一來就可以直接使用我那個空包彈的 Takami 套件了。

原始碼挖挖挖!

首先,require 並不是關鍵字,它在 Ruby 裡只是個一般的方法(定義在 Kernel 模組裡),所以就讓我們試著挖 Ruby 的原始碼出來看看它到底做了什麼事。

但在開挖之前,要先講一下歷史故事,免得挖錯地方。在 Ruby 1.8 時代,Ruby 的 require 就是普通的 require,在 Ruby 1.9 之後,require 的事情則是交給 rubygems 套件來管理,所以我們現在應該要去挖 rubygems 套件的原始碼才能挖得到 require 真正的行為。

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# 檔案: rubygems/lib/rubygems/core_ext/kernel_require.rb
require 'monitor'

module Kernel

  RUBYGEMS_ACTIVATION_MONITOR = Monitor.new # :nodoc:

  if defined?(gem_original_require) then
    remove_method :require
  else
    alias gem_original_require require
    private :gem_original_require
  end

  def require path
    RUBYGEMS_ACTIVATION_MONITOR.enter

    path = path.to_path if path.respond_to? :to_path

    spec = Gem.find_unresolved_default_spec(path)
    if spec
      Gem.remove_unresolved_default_spec(spec)
      gem(spec.name)
    end

    if Gem::Specification.unresolved_deps.empty? then
      RUBYGEMS_ACTIVATION_MONITOR.exit
      return gem_original_require(path)
    end

    spec = Gem::Specification.find_active_stub_by_path path

    begin
      RUBYGEMS_ACTIVATION_MONITOR.exit
      return gem_original_require(path)
    end if spec

    found_specs = Gem::Specification.find_in_unresolved path

    if found_specs.empty? then
      found_specs = Gem::Specification.find_in_unresolved_tree path

      found_specs.each do |found_spec|
        found_spec.activate
      end

    else

      names = found_specs.map(&:name).uniq

      if names.size > 1 then
        RUBYGEMS_ACTIVATION_MONITOR.exit
        raise Gem::LoadError, "#{path} found in multiple gems: #{names.join ', '}"
      end

      valid = found_specs.reject { |s| s.has_conflicts? }.first

      unless valid then
        le = Gem::LoadError.new "unable to find a version of '#{names.first}' to activate"
        le.name = names.first
        RUBYGEMS_ACTIVATION_MONITOR.exit
        raise le
      end

      valid.activate
    end

    RUBYGEMS_ACTIVATION_MONITOR.exit
    return gem_original_require(path)
  rescue LoadError => load_error
    RUBYGEMS_ACTIVATION_MONITOR.enter

    begin
      if load_error.message.start_with?("Could not find") or
          (load_error.message.end_with?(path) and Gem.try_activate(path)) then
        require_again = true
      end
    ensure
      RUBYGEMS_ACTIVATION_MONITOR.exit
    end

    return gem_original_require(path) if require_again

    raise load_error
  end

  private :require
end

上面這段我把部份的註解拿掉省一點空間。當 rubygems 被 require 進來之後,原本 Kernel 模組的 require 方法就被換成 rubygems 裡的 require 方法,並且把原本 Kernel 模組定義的 require 方法 alias 成 gem_original_require

如果該套件本來就已經在 $LOAD_PATH 上的話,它會直接呼叫原本 Kernel 模組定義的 require 方法來載入套件;但如果在 $LOAD_PATH 上沒有找不到的話,它就會試著開始去找看看是不是在系統上有安裝這個套件,然後執行 activate 方法,將它加到 $LOAD_PATH 裡。

讓我們來玩一下分解動作:

$ irb
>> $LOAD_PATH
=> ["/Users/user/.rvm/gems/ruby-2.3.1@global/gems/did_you_mean-1.0.0/lib", ...[略]...

# 原本只有 9 個
>> $LOAD_PATH.count
=> 9

# 找到我的套件
>> my_gem = Gem::Specification.find_by_name("takami")
=> #<Gem::Specification:0x3fdcf54346c8 takami-0.0.1>

# activate 方法會把這個 gem 加到 $LOAD_PATH 裡
>> my_gem.activate
=> true

# 套件現在加到 $LOAD_PATH 裡了
>> $LOAD_PATH
=> ["/Users/user/.rvm/gems/ruby-2.3.1@global/gems/did_you_mean-1.0.0/lib", "/Users/user/.rvm/gems/ruby-2.3.1/gems/takami-0.0.1/lib", ...[略]...

# 數量也變 10 個
>> $LOAD_PATH.count
=> 10

# 咦? 不是在 $LOAD_PATH 了嗎? 怎麼不行
>> Takimi
NameError: uninitialized constant Takimi
  from (irb):8
  from /Users/user/.rvm/rubies/ruby-2.3.1/bin/irb:11:in `<main>'

# 呼叫原本 Kernel 的 require 方法
>> gem_original_require 'takami'
=> true

# 可正常執行!
>> Takami
=> Takami

並不是有在 $LOAD_PATH 裡就可以直接使用,事實上它還是得靠原本 Kernel 模組的 require 方法把它加到 $LOADED_FEATURES 之後才能使用。所以說穿了,rubygems 其實只算是在幫你管理 $LOAD_PATH 的路徑而已(當然也沒這麼單純啦),真正 require 的行為還是在 Kernel 模組裡。

順帶一提,如果 require 是 Ruby 內建的標準函式庫(StdLib),例如本文一開始範例裡提到的 digest,$LOAD_PATH 本身並不會有任何變化(因為這些內建的本來就找得到了啊),但還是會把該模組加到 $LOADED_FEATURES 裡。

原本 Kernel 模組的 require

繼續挖 Ruby 原始碼來看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 檔案:load.c

VALUE
rb_require_safe(VALUE fname, int safe)
{
  int result = rb_require_internal(fname, safe);

  if (result > TAG_RETURN) {
    JUMP_TAG(result);
  }
  if (result < 0) {
    load_failed(fname);
  }

  return result ? Qtrue : Qfalse;
}

這段是在 Kernel 模組裡 require 方法的實作,真正載入的行為是在 rb_require_internal 這個 function 裡,有興趣的朋友可以繼續往下追。Ruby 會檢查那個檔案是不是已經在 $LOADED_FEATURES 裡,如果不在就會把它加進來。

另外,rb_require_internal 這個 function 的回傳結果可能有以下 4 種:

1
2
3
4
5
6
7
/*
 * returns
 *  0: if already loaded (false)
 *  1: successfully loaded (true)
 * <0: not found (LoadError)
 * >1: exception
 */

所以,如果 require 成功,rb_require_safe 這個 function 會回傳 true,但如果之前就已經 require 過的話則會回傳 false,如果什麼都找不到,則是直接產生 LoadError 的錯誤訊息:

$ irb
# 第一次 require,回傳 true
>> require "takami"
=> true

# 第二次 require 相同的套件,回傳 false
>> require "takami"
=> false

# require 一個找不到的 gem
>> require "the_gem_not_install"
LoadError: cannot load such file -- the_gem_not_install

小結

也許這個簡單的 require 指令一下子就跑完了,但當你知道它背後是怎麼運作之後,你對 Ruby 就可以有更深一層的了解。在挖原始碼的過程有點辛苦但也很有趣,除了可以看看大師們是怎麼設計的,還可以看到一些平常看不到的東西 :)

Ruby 語法放大鏡之「常在終端機裡下 rake db:migrate 指令,這個 rake 是什麼,後面那個 db:migrate 又是怎麼回事?」

Ruby 語法放大鏡」系列短文主要是針對在大家學習 Ruby 或 Rails 時看到一些神奇但不知道用途的語法介紹,希望可以藉由這一系列的短文幫大家更容易的了解到底 Ruby 或 Rails 是怎麼回事。

在開發 Ruby on Rails 專案的過程中,一定都看過或用過 rake db:migrate 這個指令,大部份的教學資料可能只會跟你說「只要照著打這個指令就行了」,沒有太多詳細的介紹。

其實這個 rake 指令,是一位已過世但我非常景仰的大師 Jim Weirich 所開發的。如果各位曾經聽說過 Make 這個工具,Rake 就像是 Ruby 版的 Make,你可以用 Ruby 語法來編寫 makefile。

動手練習

隨便建一個目錄來練習一下:

$ cd /tmp
$ mkdir rake_demo
$ cd rake_demo
$ rake
rake aborted!
No Rakefile found (looking for: rakefile, Rakefile, rakefile.rb, Rakefile.rb)
/Users/user/.rvm/gems/ruby-2.3.1/bin/ruby_executable_hooks:15:in `eval'
/Users/user/.rvm/gems/ruby-2.3.1/bin/ruby_executable_hooks:15:in `<main>'
(See full trace by running task with --trace)'

執行 rake 指令後,它會預期在這個目錄有 rakefile, Rakefile, rakefile.rb, Rakefile.rb 這四個檔案任何一個檔案,但因為什麼都沒有所以出現上面的錯誤訊息。即然沒有,就做一個給它:

$ touch Rakefile
$ rake
rake aborted!
Don't know how to build task 'default' (see --tasks)
/Users/user/.rvm/gems/ruby-2.3.1/bin/ruby_executable_hooks:15:in `eval'
/Users/user/.rvm/gems/ruby-2.3.1/bin/ruby_executable_hooks:15:in `<main>'
(See full trace by running task with --trace)'

咦? 有 Rakefile 但還是有錯誤訊息,那是因為我們什麼都還沒寫,所以先打開 Rakefile 來寫一個簡單的寄信任務:

1
2
3
4
5
6
7
desc "寄發會員通知信"
task :send_email do
  # ... 寄發信件功能實作
  puts "Email Sent!"
end

task default: [:send_email]

task 方法可以定義任務的名字,後面接的 block 就是它實作的內容(實作功能就留給大家了,這裡僅先使用 puts 方法把結果印出來)。最上面的 desc 則僅是這個任務的描述,如果有描述的話,在 rake -T 指令列出任務清單的時候就看得到說明了:

$ rake -T
rake send_email  # 寄發會員通知信

因為我在上面定義了一個叫做 send_email 的任務,如果要執行它的話,就是這樣下指令:

$ rake send_email
Email Sent!

又,因為這邊我在最後一行把預設的任務設定成 send_email,所以即使我只輸入 rake 指令,它也會執行 rake send_email 任務。

另外,如果任務沒有在 rake -T 的清單上不表示任務不存在,它可能只是 desc 沒寫而已,但一樣是可以執行的喔。

那 rake db:migrate 中間的冒號?

了解 rake 的基本操作後,回來看看 Rails 裡常用的那個 rake db:migrate 是怎麼做的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
desc "寄發會員通知信"
task :send_email do
  # ... 寄發信件功能實作
  puts "Email Sent!"
end

namespace :db do
  desc "Migrate the database"
  task :migrate do
    puts "migrating database!"
  end
end

task default: [:send_email]

使用 namespace 方法,然後把任務包進去,這樣一來,任務的名字就會長得像這樣:

$ rake -T
rake db:migrate  # Migrate the database
rake send_email  # 寄發會員通知信

而且可以正常執行:

$ rake db:migrate
migrating database!

大概就是這樣囉! 關於更多 Rake 的使用方式,請參閱 Rake 的 Github 網站說明 https://github.com/ruby/rake

Rails 裡也是這樣嗎?

隨便開一個 Rails 專案,在根目錄應該可以看到一個 Rakefile,它的內容長這樣:

1
2
3
4
5
6
# Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.

require_relative 'config/application'

Rails.application.load_tasks

咦? 怎麼都空空的? 讓我們直接挖 Rails 的原始檔出來看看(以 Rails 5.0.0 beta 4 版本為例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 檔案:railties/lib/rails/tasks.rb

require 'rake'

# Load Rails Rakefile extensions
%w(
  annotations
  dev
  framework
  initializers
  log
  middleware
  misc
  restart
  routes
  tmp
).tap { |arr|
  arr << 'statistics' if Rake.application.current_scope.empty?
}.each do |task|
  load "rails/tasks/#{task}.rake"
end

這段程式碼的意思就是一口氣載入一堆放在 railties/lib/rails/tasks/ 目錄的一些檔案(.rake 檔),這些都是 Rails 預設會載入的任務。而那個 rake db:migrate 任務內容就藏在(這裡)。

要在 Rails 專案加上自己的 rake 指令?

在剛剛看到根目錄的 Rakefile 的最後一行寫到:

1
Rails.application.load_tasks

再繼續練習挖一下原始碼,可以翻到這個檔案,這個檔案大概 700 行左右,我只列出相關的程式碼。

1
2
3
4
5
6
7
8
9
10
11
12
# 檔案:railties/lib/rails/engine.rb

def load_tasks(app=self)
  require "rake"
  run_tasks_blocks(app)
  self
end

def run_tasks_blocks(*)
  super
  paths["lib/tasks"].existent.sort.each { |ext| load(ext) }
end

看得出來 Rails 除了載入內建的任務外,其中這個 load_tasks 方法會把在專案裡 lib/tasks 目錄裡的任務檔也都一併讀進來。

除了挖原始碼看得出來之外,其實在 Rails 專案根目錄的 Rakefile 一打開的那兩行說明:

# Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.

如果你想要加自己的 task 的話,就是寫在專案的 lib/tasks/ 目錄裡。寫法跟上面寫 Rakefile 沒什麼兩樣,更多細節可參閱 Rails Guide 上的 Command Line 的 Custom Rake Tasks 章節。

Rails 5 的變化

提醒一下,在 Rails 5 之後,原本那些 rake 指令,例如:

$ rake db:migrate
$ rake routes

也都搬一份到 rails 指令底下囉,像是這樣:

$ rails db:migrate
$ rails routes

下次看到這樣的指令,不要以為是打錯字囉 :)

Ruby 語法放大鏡之「gem install 之後,那些 gem 安裝到哪裡去了?」

Ruby 語法放大鏡」系列短文主要是針對在大家學習 Ruby 或 Rails 時看到一些神奇但不知道用途的語法介紹,希望可以藉由這一系列的短文幫大家更容易的了解到底 Ruby 或 Rails 是怎麼回事。

在 Ruby 要安裝套件超簡單的,只要 gem install 指令加上套件的名字,敲完按下 Enter 鍵,就自動會連上網路、下載套件、安裝套件,一氣呵成:

$ gem install takami
Fetching: takami-0.0.1.gem (100%)
Successfully installed takami-0.0.1
Parsing documentation for takami-0.0.1
Installing ri documentation for takami-0.0.1
Done installing documentation for takami after 0 seconds
1 gem installed

如果該套件有需要其它套件,它也會一併順便一起下載、安裝。這個 takami gem 是我自己做的,其實沒有任何內容在裡面,所以可安心安裝!(咦?!)

所以我說那個套件呢?

安裝套件很簡單,但安裝好了之的那些檔案放哪去了? 執行 gem environmentgem env 可列出目前在這台電腦的設定:

$ gem env
RubyGems Environment:
  - RUBYGEMS VERSION: 2.5.1
  - RUBY VERSION: 2.3.1 (2016-04-26 patchlevel 112) [x86_64-darwin14]
  - INSTALLATION DIRECTORY: /Users/user/.rvm/gems/ruby-2.3.1
  - USER INSTALLATION DIRECTORY: /Users/user/.gem/ruby/2.3.0
  - RUBY EXECUTABLE: /Users/user/.rvm/rubies/ruby-2.3.1/bin/ruby
  - EXECUTABLE DIRECTORY: /Users/user/.rvm/gems/ruby-2.3.1/bin
  - SPEC CACHE DIRECTORY: /Users/user/.gem/specs
  - SYSTEM CONFIGURATION DIRECTORY: /Users/user/.rvm/rubies/ruby-2.3.1/etc
  - RUBYGEMS PLATFORMS:
  ... 略

那個 INSTALLATION DIRECTORY 就是 gem 安裝的地方,裡面翻一下應該就可以找得到剛剛安裝的 takami 套件了。因為我是使用 RVM,所以 gem 的安裝路徑會在 .rvm 目錄裡。

打包帶走

如果想要下載這些套件的原始檔,可至 RubyGems 官網下載:

image

下載的回來的是一個 .gem 檔(例如:takami-0.0.1.gem),這個 .gem 本身也可以直接拿來安裝,同樣是使用 gem install 指令:

$ gem install takami-0.0.1.gem

這樣一樣可以達到安裝的目的。如果你對這個 .gem 檔的內容有興趣,可以使用 gem unpack 指令把它解開:

$ gem unpack takami-0.0.1.gem
Unpacked gem: '/private/tmp/takami-0.0.1'

其實 .gem 檔案本質上就只是個壓縮檔,很多解壓縮程式應該都打得開。或是你本來就已經有安裝那個 gem 的話,也可以直接這樣做:

$ gem unpack takami
Unpacked gem: '/private/tmp/takami-0.0.1'

gem unpack 這個指令可以把 gem 解開到目前所在的目錄了(所以如果你解開我這個 takami 的 gem 就會真的看到裡面一點料都沒有了,是個空包彈)

不過通常這些 gem 的作者都會順便提供 Github 連結,直接上去看會更方便。

Ruby 語法放大鏡之「常在 controller 裡看到 before_action,它是一個方法嗎? 跟一般用 def 定義的有何不同?」

Ruby 語法放大鏡」系列短文主要是針對在大家學習 Ruby 或 Rails 時看到一些神奇但不知道用途的語法介紹,希望可以藉由這一系列的短文幫大家更容易的了解到底 Ruby 或 Rails 是怎麼回事。

在 Rails 專案中,常會在 Controller 裡看到這樣的寫法:

1
2
3
4
5
6
7
8
9
10
class ProductController < ApplicationController
  before_action :find_product, only: [:show, :edit, :update, :destroy]

  # .. 中略

  private
  def find_product
    @product = Product.find_by(id: params[:id])
  end
end

這是指在執行特定 Action 之前,先去執行 find_product 方法。

在 Model 裡也常看到類似的寫法:

1
2
3
4
5
6
7
8
9
class Article < ApplicationRecord
  before_save :encrypt_password
  validates :title, presence: true

  private
  def encrypt_password
    # ....
  end
end

這是說在資料儲存之前,先執行 encrypt_password 方法對密碼進行加密。

在一般的 Ruby 專案裡也常會看到這樣的寫法:

1
2
3
4
5
6
7
8
9
10
class Cat
  attr_accessor :age, :name
end

kitty = Cat.new
kitty.name = "nancy"
kitty.age = 10

puts kitty.name   # => nancy
puts kitty.age    # => 10

所以,在 Controller 裡的 before_action 或是 Model 裡 before_savevalidates,或是那個 attr_accessor 到底是什麼來歷? 感覺好像是什麼設定還是屬性,或是關鍵字之類的東西?

類別方法

其實,這些看起來像設定或是屬性的東西,它們不過就是類別方法罷了。

1
2
3
4
5
6
7
class Cat
  def self.my_attr_accessor(*field_name)
    # ... 實作
  end

  my_attr_accessor :age, :name
end

跟寫一般的方法沒什麼不同,一樣是使用 def 這個關鍵字來定義,只是在定義類別方法的時候,需要在方法名字前面加個 self ,這樣就可以定義出類別方法(事實上是 Singleton Method),然後就可以直接在類別裡面使用。

至於這些類別方法裡面的實作稍微有些複雜,有的需要知道怎麼操作 Block,有的還需要知道怎麼動態的定義方法(例如使用 define_method),我們另外再開一篇來介紹。

關鍵字?

有些人以為 privateprotected 這些語法是關鍵字,事實上這些都只是方法罷了(Ruby 什麼東西沒有,方法最多了)。

Ruby 裡的關鍵字不算太多,有興趣可翻 Ruby 的原始碼出來看看,Ruby 大概定義了 40 個左右的關鍵字,大概就是 ifelsedefclass 之類的。參考連結

但即使是關鍵字也不表示全部不能用,你要拿來定義方法也是可以的,例如:

1
2
3
def if
  puts "this is a if"
end

在定義的時候不會出錯,但使用的時候就沒辦法了:

1
if()

會得到錯誤訊息如下:

1
2
-:51: syntax error, unexpected end-of-input
shell returned 1

因為 Ruby 在判讀語法的時候,期待 if 後面還有一些東西,所以 Ruby 認為你還沒寫完然後被判定成語法錯誤。但如果是包在類別裡的話:

1
2
3
4
5
6
7
class Joke
  def if
    puts "this is a if"
  end
end

Joke.new.if

這樣就能正常執行了 :)