如何在 Rails 中寫 Rake Tasks

一些爬 Rails 原始碼以及實作 Rake 的筆記

本文使用當下最新的 Ruby 3.0.1 和 Rails 6.1.4 作為示範

什麼是 Rake

Rake 就像是 Ruby 的 Make,或是 Golang 的 Mage,其實也就是可以讓我們寫一些基本的 task 和 build 程序的地方。

在 Ruby on Rails 裡面,已經自帶了幾個常用的 rake tasks(有時候我們也會簡稱 rake):

rake about
rake db:create
rake db:migrate
rake db:seed

當然還有更多,可以用 rake -T 來查看。

Rails 的 Rakefile

Rakefile 其實就是 Ruby 的 Makefile,是用來定義 rake 的地方。

不過如果你在 Rails 的框架中打開 Rakefile,會發現只有簡單兩行:

require_relative "config/application"

Rails.application.load_tasks

如果你把 Rails 的原始碼打開,發現其中第二行會去讀取 lib/tasks 裏面的所有檔案:

# https://github.com/rails/rails/blob/v6.1.4/railties/lib/rails/engine.rb
def load_tasks(app = self)
  require "rake"
  run_tasks_blocks(app)
  self
end

...

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

也就是說我們不只可以在 Rakefile 寫 task,也可以在 lib/tasks 寫,更方便大型專案的管理。

附帶一提,因為 Rails.application 繼承了上面的 Engine class,他還會另外去讀取 Rails 自定義的常用 rake 們,也就是剛剛提到的 db:create 之類的 rakes:

# https://github.com/rails/rails/blob/v6.1.4/railties/lib/rails/application.rb#L526
def run_tasks_blocks(app) #:nodoc:
  railties.each { |r| r.run_tasks_blocks(app) }
  super
  load "rails/tasks.rb"
  task :environment do
    ActiveSupport.on_load(:before_initialize) { config.eager_load = config.rake_eager_load }

    require_environment!
  end
end

而且在 Rails 中最基本的 environment rake 也是定義在這裡,他會去讀取 config/environment 這個檔案。

# https://github.com/rails/rails/blob/v6.1.4/railties/lib/rails/application.rb#L365
def require_environment! #:nodoc:
  environment = paths["config/environment"].existent.first
  require environment if environment
end

Rake 怎麼寫

實際打開一個 Rails 新專案,會發現 lib/tasks 這個資料夾已經幫我們保留下來了。在裡面新增一個 rake task:

# lib/tasks/hello.rake
namespace :hello do
  desc 'print hello world'
  task :world do
    puts 'Hello, world!'
  end
end

這裡出現了三個 function,或著是說,Rake 的 DSL:

  • namespace: 如其名,可以幫助 rake task 命名與分類管理
  • desc: rake -T 時顯示的描述,rake 必須加上 desc 才會顯示在 rake -T 的列表中
  • task: 定義 task 名稱與內容

附上 Rake 的文件,裡面有更完整的用法:https://ruby.github.io/rake/Rake/DSL.html

執行剛寫好的 rake task:

$ bundle exec rake hello:world
Hello, world!

Task Dependencies

task 這個 DSL 可以吃除了名稱之外的參數:

task task_name
task task_name: dependencies
task task_name, arguments → dependencies

剛剛我們用的是第一個定義。
接下來介紹 dependencies,其實就是在執行這個 rake task 時,哪些 rake 需要被確保執行。

我們舉最常用的 database 操作相關的 rake 為例子(只截取部分片段):

# https://github.com/rails/rails/blob/v6.1.4/activerecord/lib/active_record/railties/databases.rake
db_namespace = namespace :db do
  desc "Creates the database, loads the schema, and initializes with the seed data (use db:reset to also drop the database first)"
  task setup: ["db:create", :environment, "db:schema:load", :seed]
end

這是 db:setup 這個 rake task 的定義,傳給 task 的是個 hash,一個 key value pair,括號在這裡省略了,setuptask_name,而後面的 array 則是 dependencies
這段 code 的意思是說,當我執行 db:setup 時,會需要跑 dependencies 裡面的 task。
這裡的「先」指的是和 task 後面接的 block 相比較,會先跑 dependencies 才跑 block 內的東西。不巧 db:setup 剛好沒有 block,所以就是把 db:createenvironmentdb:schema:loadseed 這幾個 rake 依序執行。

我們可以修改剛剛的 hello:world task 來測試 block 和 dependencies 的順序:

# lib/tasks/hello.rake
namespace :hello do
  desc 'print hello world'
  task world: %w[hello:man] do
    puts 'Hello, world!'
  end

  task :man do
    puts 'Hello, man.'
  end
end

再跑一次 hello:world

$ bundle exec rake hello:world
Hello, man.
Hello, world!

會發現寫在 dependencies 的 hello:man 的確會先被執行,接著才執行 block 內的東西。

那既然叫做 dependencies,如果被呼叫很多遍,會發生什麼事呢?
可以在同個 namespace 內再定義一個 rake 來實驗看看:

# lib/tasks/hello.rake
namespace :hello do
  desc 'print hello world'
  task world: %w[hello:man hello:earth] do
    puts 'Hello, world!'
  end

  task earth: %w[hello:man] do
    puts 'Hello, Earth~'
  end

  task :man do
    puts 'Hello, man.'
  end
end

現在 world depends on manearth,而 earth 也 depends on man。根據我們對 dependency (相依)這個詞的了解,hello:man 應該只會被執行一遍。
而事實正是如此:

$ bundle exec rake hello:world
Hello, man.
Hello, Earth~
Hello, world!

可以使用 --trace 來追蹤 rake task 彼此之間被 invoke 和執行的過程:

$ bundle exec rake hello:world --trace
** Invoke hello:world (first_time)
** Invoke hello:man (first_time)
** Execute hello:man
Hello, man.
** Invoke hello:earth (first_time)
** Invoke hello:man 
** Execute hello:earth
Hello, Earth~
** Execute hello:world
Hello, world!