Introduction of Ruby CSV

內容是Web Tech Topic #9 分享所做的整理


什麼是 CSV

CSV 全名為 Comma-Separated Values,就是以“逗點”分隔的純文本,是一種通用的、相對簡單的文件格式。範例:

Tony,27,22000,Hi
David,30,50000,Hello

你可以在 Microsoft 的 Excel 中打開這個檔案,就會發現到這些被逗點分隔的資料整齊地排列在每個欄位內,事實上,你也可以在 Excel 中編輯資料表,最後存檔的時候選擇用 csv 的格式輸出。


常見的格式問題

1. 每個欄位都會以字串的方式儲存
2. 如果要存放的資料內容有逗點,用雙引號(’’)保護,可以避免被電腦誤認是分隔符號。
3. 如果要存放的資料內容有雙引號,那…就再用一個雙引號保護它。

範例:

Tony,27,22000,”Hi, my name is Tony”
David,30,50000,”say “”Hello””, my friend.”

常見的使用時機

輸出報表: 這裡指的報表,泛指為非 CRUD 的操作。通常為給定特定的條件所查詢尋到的多筆資料。例如:熱門文章,商城中符合搜尋條件限制的商品清單等等。
資料庫備份: 資料庫在搬移的時候,通常可以拆成兩個部分,資料庫欄位格式 (schema) 的sql檔與留存資料內容的 csv 檔,所以基本上 csv 就是份資料表,每一行可以被視作一筆的資料。


Ruby 如何讀取 CSV

Ruby 本身有內建的 library 可以讀取,只需要宣告 require “csv” 就可以了,是不是相當方便呢?

讀取的方式可以分為兩種,一次性讀取或是分行讀取。一次性讀取會將整份 csv 檔案寫入記憶體,而分行讀取則是讀取單行資料進記憶體處理,先進先出。所以如果要讀取的 csv 檔案非常大,或是記憶體容量有限的情形下,就應該要避免使用一次性讀取而改用分行讀取,避免爆炸。

Ruby 會將整份 csv 當作一個陣列 (array),第一行就是這個陣列的第一個元素,以此類推直到最後一行資料。而 Ruby 又會將每一行資料當作一個陣列,也就是每筆資料的第一個欄位就是第一個元素,以此類推直到最後一個欄位。由此可知,整份 csv 就是個二維陣列 (two-dimensional array)。

另外,內建的 parse method 可以將字串 (string) 轉成 csv。

require ‘csv’
# 一次性讀取
csv = CSV.read(“workers.csv”)
=> [[“Tony”, “27”, “22000”, “Hi, my name is Tony”],
[“David”, “30”, “50000”, “say \”Hello\”, my friend.”]]
# 分行讀取
CSV.foreach(“workers.csv”) do |row|
puts row.inspect
end
=>[“Tony”, “27”, “22000”, “Hi, my name is Tony”]
=>[“David”, “30”, “50000”, “say \”Hello\”, my friend.”]
# 利用parse,將字串轉成csv
string = “sky,30,50000,\”Time to run!\”\nabner,26,50000,\”amazing!\””
parsed_csv = CSV.parse(string)
=> [[“sky”, “30”, “100000”, “Time to run!”],
[“abner”, “26”, “100000”, “amazing!”]]

常見的options

col_sep: string

使用 column separation option 可以讓我們處理非”逗號”分隔的資料表,用自行定義的符號作為分隔符號。

csv = CSV.read(“workers.ssv”, col_sep: “;”)

headers: boolean

使用 headers option 可以讓 ruby 將第一筆資料視為欄位的標題。另外用法在接下來繼續介紹。

csv = CSV.read(“workers.csv”, headers: true)

常見的操作

那我們就來練習操作一下吧,下方的 workers.csv 記錄了每個員工的姓名、年齡跟月薪。範例:

Tony,27,22000
Danny,30,50000
Bobby,31,44000
Willy,22,55000

練習一:算出平均月薪。

require "csv"
workers = CSV.read("workers.csv")
count = workers.size
total = workers.inject(0) {|sum, worker| sum += worker[2].to_i}
average_salary = total/count

練習二:幫大家調薪30%,輸出成另一張資料表。

require "csv"
new_csv = CSV.open("new.csv", "w")
CSV.foreach("workers.csv") do |worker|
worker[2].to_i * 1.3
new_csv << worker
end

經過簡單的兩個練習,相信大家已經發現其實讀取後就是在操作陣列而已。


讀取欄位標題差很多

現在我們來觀察一下,讀取欄位標題狀態的 csv 有什麼不同。範例:

name,age,salary
Tony,27,22000
Danny,30,50000

在沒有讀取欄位標題狀態之下,Ruby 會將整份文件變成二維陣列,而欄位標題視為第一筆資料。範例:

require 'csv'
csv_without_headers = CSV.read("workers.csv")
p csv_without_headers.class
=> Array
p csv_without_headers[0]
=> ["name", "age", "salary"]

在有讀取欄位標題狀態之下,Ruby 會將第一筆資料變成欄位標題,而整份文件變成一個 CSV::Table 的類別物件。

require 'csv'
csv_with_headers = CSV.read("workers.csv", headers: true)
p csv_with_headers.class
=> CSV::Table
p csv_with_headers[0]
=> #<CSV::Row "name":"Tony" "age":"27" "salary":"22000">

甚至在分行讀取的操作中,每一行的資料也不再是陣列,而是 CSV::Row。而欄位標題已經不再被視為是資料的一部分。

require 'csv'
CSV.foreach("workers.csv", headers: true) do |row|
p row.class
end
=> CSV::Row
=> CSV::Row

變成 CSV 有什麼特別的嗎?難道操作陣列不好嗎?

Array 是一個有順序性的資料結構,可以按照已知順序的 index 取得該值,對比之下,Hash 就是沒有順序性的容器,所以依靠自己定義的 key 去存取 value。Ruby 的 CSV 有點類似陣列跟雜湊 (hash) 的混合體,CSV::Table仍然保持了整份資料表中一筆筆資料的順序性,但也可以由 CSV::Row 操作自行定義的欄位標題取得該欄位的內容,有點類似 array of hashes。

練習三:取得第一個員工的姓名與第二個員工的年齡。

require "csv"
csv = CSV.read("workers.csv", headers:true)
p csv[0]["name"]
=> "Tony"
p csv[1]["age"]
=> "30"

練習四:刪去第一個員工的資料與整份資料表的salary欄位。

require "csv"
csv = CSV.read("workers.csv", headers:true)
csv.delete(0)
p csv
=>name,age,salary
Danny,30,50000
csv.delete("salary")
p csv
=>name,age
Tony,27
Danny,30

練習五:選出年齡大於28的員工。

require "csv"
workers = CSV.read("workers.csv", headers: true)
old_workers = workers.select do |worker|
worker["age"].to_i > 28
end`
p old_workers.inspect
=>[#<CSV::Row "name":"Danny" "age":"30" "salary":"50000">]

常見的Gems

  • smarter_csv: 可以直接處理 Mongoid 或 ActiveRecord 的資料,支援Resque 或 Sidekiq 等非同步處理。
  • csv_shaper: 提供了漂亮的 DSL,可以像用 Jbuilder 方式在 view 中寫 csv,如此就可以避免把組合 csv 的邏輯寫在model 或是 controller中。