寫隻 rake 讓你一行指令輕鬆跑報表

什麼是 Rake?

Rake 是由大師 Jim Weirich 所開發的任務程式工具,就像是 Ruby 版的 Make,可以用來執行各式任務。

Rake 是以 Ruby 語法編寫任務於 Rakefile 檔案中,並用 command-line interface(CLI)輸入指令 $ rake 來執行任務。Rake 可以使用在任何環境,並非只有 Ruby 專案可以使用,只要有 Rakefile 就可以使用 Rake 來執行任務。

文章之後會簡稱 command-line interface 為 CLI,並稱任務為 task。

開始練習寫 Rake!

既然 Rake 是以 Ruby 語法編寫 task,那我們先練習用 Ruby 寫一個可以用 rake 執行的 task 吧!
一起了解如何編寫 Rakefile 和 Rake 提供哪些方法及其用途。

首先要確認 rake 是否存在,請在 CLI 輸入指令 $ gem list --local 來進行確認,若 gem list 中沒有 rake,請先執行 $ gem install rake

Task

確認 rake 存在後,就來隨意建立一個新的資料夾 rake_practicing,接著用 CLI 執行 $ rake

$ cd rake_practicing
$ rake
rake aborted!
No Rakefile found (looking for: rakefile, Rakefile, rakefile.rb, Rakefile.rb)
/Library/Ruby/Gems/2.3.0/gems/rake-13.0.1/exe/rake:27:in `<top (required)>'
(See full trace by running task with --trace)

當我們執行 rake 時,他會去找 rakefileRakefilerakefile.rbRakefile.rb 中任一種 rake 可執行的檔案,既然原本就沒有,那就直接新增一個 Rakefile,並再次執行 $ rake

$ touch Rakefile
$ rake
rake aborted!
Don't know how to build task 'default' (See the list of available tasks with `rake --tasks`)
/Library/Ruby/Gems/2.3.0/gems/rake-13.0.1/exe/rake:27:in `<top (required)>'
(See full trace by running task with --trace)

查看錯誤訊息可以發現是找不到 task,缺什麼就補什麼,接下來我們就在 Rakefile 中簡單寫一個輸出報表的 task 吧!你可以試著實作輸出報表的流程,或是查看本文之後的示範,這邊我們先用 puts 方法印出結果。

# Rakefile
desc 'Export report with result'
task :export_report do
  # you can do some process here to export report
  puts 'Already exported report with result, please check tmp folder.'
end

task default: [:export_report]
  • desc 是對任務的描述,實際上並不會執行任何動作,在輸入指令 $ rake -T 列出 rake 帶描述的 task 有哪些時,會顯示描述文字在 # 後面,若省略不寫 desc,則在執行 $ rake -T 時,就不會列出 task,但不代表 task 不存在,沒有寫 desc 還是可以執行 task 喔!
$ rake -T
rake export_report  # Export report with result
  • task 是在 Rakefile 中主要執行的部分,透過 task 方法給予任務一個名字(通常型態為 Symbol 或 String),在輸入 rake 指令時,可以指定要跑的 task 名稱,而 block 裡的程式碼就是 task 要執行的內容。

  • Rakefile 中有兩個 task

    • export_report task:輸出報表的 task。
    • defualt task:default task 本身並不會有什麼動作,但可以指定相依(dependency)的 task,等同於執行 $ rake 指令不帶任何選項(option)時,預設會去執行 $ rake export_report
    • 由此可知,一個 Rakefile 中可以有多個 tasks。

我們可以直接指定執行的 task,輸入 $ rake export_report,就可以看到執行 task 後輸出的結果,或執行 $rake 指令,因 default task 的關係,也會得到相同的結果。

$ rake export_report
Already export report with result, please check tmp folder.
$ rake
Already export report with result, please check tmp folder.

建立目錄 Task

開發者會根據規格需求而建立所需目錄,在 Rake 中提供非常方便的 directory 方法。
使用時機通常是在 rake 需要執行具有先決條件(Prerequisites)的 task 時,而 directory 任務會擺放在該 task 的先決條件中。

# Rakefile
desc "Export report with result under directory 'report/exporting_report'"
task :export_report => "report/exporting_report" do
  # go to directory which create from prerequisite task
  cd 'report/exporting_report'
  # export report under specify directory with result, here only create file
  touch 'report_with_result.csv'
end

desc "Create directory 'report' and 'report/exporting_report'"
directory "report/exporting_report"
  • 使用 directory 方法取代 task 方法,並以 String 的型態呈現所需的目錄結構和名稱。
$ rake -T
rake export_report            # Export report with result under directory 'report/exporting_report'
rake report/exporting_report  # Create directory 'report' and 'report/exporting_report'
$ rake export_report
mkdir -p report/exporting_report
cd report/exporting_report
touch report_with_result.csv
  • 執行帶先決條件的 $ rake export_report,會在執行 $ rake export_report 之前先執行 $ rake report/exporting_report 來產生所需目錄,之後將產出的報表放在該目錄下。

給予 Task 參數(parameters)

開發中,有時會因需求問題而期望在執行 rake 時給予 task 參數。
例如:應需求關係要在 task 中加入時間判斷,但開發階段得知時間條件區間可能隨時會因商業需求而更改,為避免需求變動一次就要改一次程式碼,可以用給予 task 參數的方式來保持執行 rake 的彈性。

# Rakefile
# directory "report/exporting_report" ...
desc 'Give coupon if user replies between activity period'
task :give_coupon, [:activity_started_at, :activity_finished_at] do |task, args|
  puts "Give coupon if user replies date between #{args.activity_started_at} to #{args.activity_finished_at}."
end
  • task 方法後面給予的第一個引數(argument)為任務名稱,而 rake 給予 task 的參數則是 task 接收的第二個引數(Array 型態),陣列中的兩個物件就是執行 rake 時要保持彈性而給予 task 的參數。
  • block 裡的 |task, args| 對應前面 task 的兩個引數:
    • task 為第一個引數 :give_coupon
    • args 為第二個引數 [:activity_started_at, :activity_finished_at],因此,若要在 block 中使用 rake 執行時所帶入的參數,則需使用 #{args.activity_started_at}#{args.activity_finished_at}
  • 若執行 rake 時帶入的參數超過在 task 中宣告的第二個引數數量,則多出來的參數會被忽略(ignored)。

給予 Task 參數預設值

Rake 也提供給予參數預設值的方法 with_defaults,假設商業需求為「有給予時間條件區間,但不確定事後是否會再更改區間」時,可以給予預設值保留彈性。
當執行 $ rake give_coupon 不給予參數,條件判斷則以預設值為主;坦若之後時間條件區間有所變更,也毋須擔心,只需要再執行 rake 時給予參數覆寫預設值即可。

# Rakefile
# directory "report/exporting_report" ...
desc 'Give coupon if user replies between activity period'
task :give_coupon, [:activity_started_at, :activity_finished_at] do |task, args|
  args.with_defaults(activity_started_at: '2019-12-01', activity_finished_at: '2019-12-15')
  puts "Give coupon if user replies date between #{args.activity_started_at} to #{args.activity_finished_at}."
end

執行 rake 時需留意

  • 執行 rake 給予 task 參數時不可以有空格(space),例如:$rake give_coupon['2019-12-01', '2019-12-31'] 或是 $ rake name[lesley wu, chao]
  • 如果必須要有空格,請使用雙引號(quoted)將 task 的名稱和引數(arguments)包起來,例如:$rake "give_coupon['2019-12-01', '2019-12-31']" 或是 $ rake "name[lesley wu, chao]"
$ rake -T
rake export_report                                          # Export report with result
rake give_coupon[activity_started_at,activity_finished_at]  # Give coupon if user replies between activity period
rake report/exporting_report                                # Create directory 'report' and 'report/exporting_report'
$ rake give_coupon['2019-12-01','2019-12-31']
Give coupon if user replies date between 2019-12-01 to 2019-12-31.
$ rake give_coupon
Give coupon if user replies date between 2019-12-01 to 2019-12-15.
$ rake "give_coupon['2019-12-01', '2019 12 31']"
Give coupon if user replies date between '2019-12-01' and '2019 12 31'

給予 Task 參數並帶先決條件(Prerequisites)

當 task 使用參數時,也可以加入先決條件,也就是在執行帶參數的 task 之前,rake 會先執行先決條件指定的 task,而表示先決條件是以箭頭符號 => 來呈現。

# Rakefile
# ...
task :name, [:first_name, :last_name] => [:pre_name] do |task, args|
  puts "First name is #{args.first_name}."
  puts "Last name is #{args.last_name}."
end

task :pre_name, [:pre_name] do |task, args|
  args.with_defaults(pre_name: 'Timana')
  puts "Forename is #{args.pre_name}."
end
$ rake name['Luis','Filipe']
Forename is Timana.
First name is Luis.
Last name is Filipe.

使用 Namespace 為 task 命名

當有多個 task 是具相關性或是專案成長到一定程度時,可能會發生任務名稱命名上的衝突。 例如:現在我們有 main program 和 samples program 都要建立各自的 Rakefile,而在「建立」這個行為的 task 命名上都是 build,為了避免命名衝突,可以使用 namespace 將 main program 和 samples program 做區分,並在 namespace 中各自建立屬於該 program 的 build 任務。

namespace 'main' do
  task :build do
    # Build the main program rakefile
  end
end

namespace 'samples' do
  task :build do
    # Build the sample programs rakefile
  end
end

task build: %w[main:build samples:build]

有接觸過 Rails 的朋友,對於指令 $ rake db:migrate 應該很熟悉,而這樣 rake 指令就是透過 namespace 產生而成的。

小結

介紹完在 Rakefile 中常使用的幾個方法及用途,以及如何使用 rake 指令執行 task 後,我相信你對於編寫 Rakefile 已經有基本的概念了!也許你已經發現我們能透過 Rake 製作許多不同的 task 來完成需求。

而實際開發專案時,我們並不會只在同一個 Rakefile 中做事,依需求可能會使用到其他的 Rakefile 或是 Ruby Module,這時可以透過 require 方法將其他 RakefileRuby ModuleRuby Library 引入到正在實作的 Rakefile 中,比較常見的像是 require 'yaml'require 'erb' 等等。

接下來,會介紹如何在 Ruby 的好朋友 Rails 中來使用 Rake,並 Step by Step 示範在 Rails 中如何用 Rake 寫一隻輸出含簡單結果的報表喔!

Rails 中的 rake 指令

在 Rails 專案中,我們一樣用 CLI 輸入 $ rake -T 來查看在 Rails 專案中可以使用的 rake 指令有哪些,如前面所介紹,有些沒有 desc 的 task 並不會陳列在其中,但這並不代表那些 task 不存在喔!

$ rake -T
rake db:create       # Creates the database from DATABASE_URL or config/database.yml for the ...
rake db:drop         # Drops the database from DATABASE_URL or config/database.yml for the cu...
rake db:migrate      # Migrate the database (options: VERSION=x, VERBOSE=false, SCOPE=blog)
rake routes          # Print out all defined routes in match order, with names
# ...
輸入 $ rake -T 後,可以看到許多我們平常在 Rails 專案使用的指令。

而在 Rails 5.0 以後的版本,Rails 提供可以使用 $ rails 代替 $ rake 多數指令的操作,所以你也能使用 $ rails db:migrate$ rails routes 來執行,會得到與 rake 相同的結果。

一起動手寫隻能輸出報表的 Rake 吧!

接下來會簡單示範如何透過 rake 指令傳入 CSV 檔,並藉由資料庫現有資料與簡單的判斷條件,將判斷結果輸出在 CSV 檔上,不用再人工一行一行慢慢對資料填結果!

情境與需求

活動部門寄來一份 CSV 檔,內含多筆使用者 Email 資訊,要判斷這些 Email 的註冊時間是否在 12 月聖誕節期間內(2019-12-24 ~ 2019-12-31),活動部門希望回傳的 CSV 檔案能包含簡單的結果:「此 Email 註冊於期間內」、「此 Email 非在期間內註冊」,以利活動部門能根據結果來進行抽籤贈獎的活動。

事前準備

  • 準備一個 Rails 專案,並使用 gem Devise 做一個簡單可註冊的會員功能。
    你也可以直接到 GitHub clone 示範專案 export report by running rake,輕鬆跟我一起用 rake 一行指令輸出報表!
  • 準備一份 CSV 檔,header 需包含 Email 及 Result 兩個欄位,而資料需有多筆 Email 資訊。

開始寫能滿足需求的任務

Step 1. 新增 Rake 執行檔案

在 Rails 專案中,rakefile 會統一放在目錄 lib/tasks 下,並且檔名使用 .rake,那麼我們先新增 lib/tasks/inspect_registration_date.rake 檔案。

Step 2. 給予 task 名字和簡單的說明

給予 task 一個執行名字和簡短的說明,以利多年後回頭執行 $ rake -T 時,能根據描述清楚知道這個 task 主要操作的行為或欲得到的結果。

# inspect_registration_date.rake
desc "Inspect user registration date and export CSV file with result"
task :inspect_registration_date do
  # do something magic here
end
$ rake -T
rake inspect_registration_date    # Inspect user registration date and export CSV file with result
# ...

Step 3. 依需求給予參數

根據前面提到的需求,我們現在要做的 task 是對輸入的 CSV 檔案的資料做檢查,所以期望在輸入 rake 指令時能夠給予參數,以本次示範而言,這個參數就是要輸入的 CSV 檔案的路徑。

# inspect_registration_date.rake
desc "Inspect user registration date and export CSV file with result"
task :inspect_registration_date, [:file] => :environment do |task, args|
  # You can use args from here
end

使用 $ rake -T 查看指令的變化已為 rake inspect_registration_date[file],後面多了 [file] 作為指令接收參數。

在 Rails 專案中執行 Rake 不可或缺的 environment task

我們可以看到在這個 task 中有先決條件 :environment,複習一下,若 task 具先決條件,則以 rake 執行 $ rake inspect_registration_date[file] 前會先執行 $ rake environment,但使用 $ rake -T 又找不到它,這並不代表 environment task 不存在,那這個 environment task 又是什麼呢?

environment task 是讓其他 tasks 能夠以 :environment 作為先決條件,在其他 tasks 中重現整個 Rails 環境,而這提供我們能夠執行 $ rake RAILS_ENV = staging db:migrate 這樣的操作。

用翻譯蒟蒻轉成人話就是,environment task 提供給我們可以在 Rakefile 操作 Model 的權利,像是 User.find_by(email: 'test@gmail.com') 這樣的操作,若沒有加入先決條件 :environment 而執行 $ rake inspect_registration_date['FILE_PATH'],則會得到錯誤訊息 NameError: uninitialized constant User

示範錯誤行為,欲使用 Model 操作但無設定環境先決條件。
desc "Inspect user registration date and export CSV file with result"
task :inspect_registration_date, [:file] do |task, args|
  User.find_by(email: 'test@gmail.com')
end
$rake inspect_registration_date['/tmp/sample.csv']
rake aborted!
NameError: uninitialized constant User
/Users/mac/Documents/chao-chao-wu/5xruby/export_report_with_rake_sample/lib/tasks/inspect_registration_date.rake:3:in `block in <top (required)>'
Tasks: TOP => inspect_registration_date
(See full trace by running task with --trace)

因此,在 Rails 專案中,若要使用 Model 相關操作,就必須要設定先決條件 :environment

Step 4. 將任務需求寫在 block 裡

依照前提的需求說明,我們會在這個 task 做以下三件事:

  • 輸入 CSV 檔案
  • 檢查 CSV 資料並在 Result 欄位加入判斷結果
  • 輸出帶有結果的 CSV 檔案

接下來我們要將這三項要做的事情編寫成程式碼寫進 block 裡,當我們輸入 rake 指令,呼叫 task inspect_registration_date 時,就會自動執行 block 裡的程式碼。

# inspect_registration_date.rake
desc "Inspect user registration date and export CSV file with result"
task :inspect_registration_date, [:file] => :environment do |task, args|
  # Step 1. read importing CSV data
  csv_contents = CSV.read(args[:file].pathmap, headers: true)

  # Step 2. inspect user registration date and fill in result column
  csv_contents.each do |row|
    email = row['Email']

    # activity period
    activity_start_date = DateTime.new(2019, 12, 24)
    activity_end_date = DateTime.new(2019, 12, 31, 23).end_of_hour

    user_registration_date = User.find_by(email: email).created_at
    next row['Result'] = "此 Email 非在期間內註冊" unless user_registration_date.between?(activity_start_date, activity_end_date)
    row['Result'] = "此 Email 註冊於期間內"
  end

  # Step.3 export CSV file with result
  file_name = File.basename(args[:file], '.csv')
  File.open("/tmp/#{file_name}_result.csv", 'w') do |file|
    file.write(csv_contents)
  end
end
  • 在 rake 檔案中沒有 return 可以使用,但可以使用相同效果的 next。以示範為例,用 next 搭配判斷條件,若條件不符,則會直接在 result 欄位填入「此 Email 非在期間內註冊」,並且程式會跳出 Step 2 的 block 不再往下執行。

Step 5. 準備輸入用的 CSV 檔案並執行 rake 任務

完成 rake 檔案後,請將事前準備好的 CSV 檔案放在 /tmp 資料夾下,接著在 Terminal 輸入一行指令 rake inspect_registration_date['/tmp/sample.csv'],當任務執行完畢後,開啟 /tmp/sample_result.csv 檔案,此檔案就包含活動部門期望的結果,趕緊把這份檔案寄給活動部門就可以囉!🎉

  • 關於輸入 CSV 的檔案位置,你也可以放在任何你想放的位置,只要記得更改程式中輸出的路徑為你想找到含結果的檔案位置,因為範例中輸出檔案路徑是放在 /tmp 資料夾下。

  • 若在執行 rake 時發生錯誤 NameError: uninitialized constant CSV,請先確認 config/application.rb 是否有 require 'csv' library 到專案中。

總結

當你完成本篇文章的範例後,可以想一下,若情境變成要在不同時間點,多次使用相同條件來判斷需求是否符合時,那寫完一隻 rake 後,每次都只需要用一行指令就可以完成任務囉!🎉

也建議將 Rake 搭配排程工具一起使用,在特定的時間執行 task,像是寄發信件、備份等等行為都可以用 Rake 編寫成一個 task,在特定的時間執行,就不用再人工處理囉!

坦若你想對 rake 檔案中的程式碼進行測試,可以將 task block 裡的程式碼改寫成一個 Service Object,之所以要用 Service Object 改寫的原因在於我們無法測試 rake 檔案中的程式碼,但若改寫成 Service Object 這樣的 PORO(Plain Old Ruby Object),就可以對這段程式碼編寫測試囉!

參考資料