25. 數據匯入
25-1 解析 CSV 檔案 (Rake)
我們在實戰應用「數據匯出」實作過匯出 CSV 格式,一個有良心的平臺會實作匯出功能,方便用戶進行分析或資料轉移。或是用 Microsoft Excel 或 Apple Numbers 試算表,可以點選另存新檔(Save As…)為 CSV UTF-8 格式來做數據匯出。
CSV 逗號分隔值 格式除了容易匯出,也是最容易匯入處理的格式,這一章會用 CSV 舉例,不需要額外裝 gem 就能處理。
假設我們拿到這樣的 CSV 檔案,接下來要如果匯入資料庫呢?總不能一筆一筆輸入太慢了,當然要用寫程序的方式來做匯入。
如果匯入是程序員的一次性的任務,我們可以不需要實作 Web UI,只需要一個 rake 任務可以執行就好了。
請下載這一個 CSV: registrations.csv 放在專案 tmp
目錄下(按右鍵另存新檔,可以用編輯器打開確認是CSV格式)
這有 1000 筆報名資料,其中有 2 筆故意缺少了 E-mail。
編輯 lib/tasks/dev.rake
,讓我們新增一個 import_registration_csv_file
任務
+ require 'csv'
namespace :dev do
+ task :import_registration_csv_file => :environment do
+ event = Event.find_by_friendly_id("fullstack-meetup")
+ tickets = event.tickets
+
+ success = 0
+ failed_records = []
+
+ CSV.foreach("#{Rails.root}/tmp/registrations.csv") do |row|
+ registration = event.registrations.new( :status => "confirmed",
+ :ticket => tickets.find{ |t| t.name == row[0] },
+ :name => row[1],
+ :email => row[2],
+ :cellphone => row[3],
+ :website => row[4],
+ :bio => row[5],
+ :created_at => Time.parse(row[6]) )
+
+ if registration.save
+ success += 1
+ else
+ failed_records << [row, registration]
+ end
+ end
+
+ puts "總共匯入 #{success} 筆,失敗 #{failed_records.size} 筆"
+
+ failed_records.each do |record|
+ puts "#{record[0]} ---> #{record[1].errors.full_messages}"
+ end
+
+ end
執行 rake dev:import_registration_csv_file
就會執行了匯入的操作到 fullstack-meetup
這個活動。
解說:
- 和匯出 CSV 一樣,Ruby 內建了 CSV 庫可以解析 CSV,所以第一行先
require 'csv'
CSV.foreach
會打開這個 CSV 檔案跑循環,每筆資料就是一行row
,那一行的第一列是row[0]
、第二列是row[1]
。只要依序塞給event.registrations.new
即可。- CSV 中的票種是字串,但是轉進我們的資料庫中需要轉換成 Ticket model,因此這裡寫成
tickets.find{ |t| t.name == row[0] }
用票種名稱去找是哪一個物件。 - 時間也是一樣,透過
Time.parse
轉成時間物件 - 因為匯入會一次匯入非常多筆,我們希望不管每筆資料 save 成功或失敗,都能跑完全部資料,最後印出一個總結:告訴我們總共幾筆成功,總共幾筆失敗,是哪些筆失敗又是什麽原因。
25-2 解析 CSV 檔案 (Web UI 接口)
需要 Web UI 可以上傳檔案的話,讓我們來實作一下:
請編輯 config/routes.rb
,新增一個 import 路由
namespace :admin do
# 略
resources :events do
- resources :registrations, :controller => "event_registrations"
+ resources :registrations, :controller => "event_registrations" do
+ collection do
+ post :import
+ end
+ end
編輯 app/views/admin/event_registrations/index.html.erb
加上檔案上傳的輸入框:
<%= link_to "匯出 CSV", admin_event_registrations_path(:format => :csv), :class => "btn btn-default" %>
<%= link_to "匯出 Excel", admin_event_registrations_path(:format => :xlsx), :class => "btn btn-default" %>
</p>
+
+ <hr>
+
+ <%= form_tag import_admin_event_registrations_path(@event), :multipart => true do %>
+ <p><%= file_field_tag "csv_file" %></p>
+ <p><%= submit_tag "匯入CSV", :class => "btn btn-danger" %></p>
+ <% end %>
編輯 app/controllers/admin/event_registrations_controller.rb
新增一個 import action,內容基本上跟 rake 的版本差不多
+ def import
+ csv_string = params[:csv_file].read.force_encoding('utf-8')
+
+ tickets = @event.tickets
+
+ success = 0
+ failed_records = []
+
+ CSV.parse(csv_string) do |row|
+ registration = @event.registrations.new( :status => "confirmed",
+ :ticket => tickets.find{ |t| t.name == row[0] },
+ :name => row[1],
+ :email => row[2],
+ :cellphone => row[3],
+ :website => row[4],
+ :bio => row[5],
+ :created_at => Time.parse(row[6]) )
+
+ if registration.save
+ success += 1
+ else
+ failed_records << [row, registration]
+ Rails.logger.info("#{row} ----> #{registration.errors.full_messages}")
+ end
+ end
+
+ flash[:notice] = "總共匯入 #{success} 筆,失敗 #{failed_records.size} 筆"
+ redirect_to admin_event_registrations_path(@event)
+ end
+
protected
其中 csv_string
就是從上傳的檔案中讀取內容,接著用 CSV.parse
進行解析循環。
注意本來的 admin layout 中的 flash 樣式有點問題,請修改 app/views/layouts/admin.html.erb
修正一下:
- <div class="container">
+ <div class="container" style="padding-top: 60px">
- <p class="notice"><%= notice %></p>
- <p class="alert"><%= alert %></p>
+ <% if notice %>
+ <p class="notice alert-success"><%= notice %></p>
+ <% end %>
+ <% if alert %>
+ <p class="alert alert-danger"><%= alert %></p>
+ <% end %>
25-3 把匯入檔案存下來紀錄過程
上一節的方式比較簡略,我們並沒有把上傳的檔案存下來,而是直接讀取檔案內容進行處理。
如果這是一個面向終端用戶的功能,會需要實作的更完整,例如:
- 把上傳的檔案先存下來,先給用戶預覽欄位順序是否正確、有多少數據要匯入、哪些數據有問題
- 確認後,才開始匯入資料庫
- 可以瀏覽過往的匯入歷史紀錄
接下來我們示範如何新增一個 Model 把檔案存下來處理,以及新增一個 RegistrationImports controller 顯示匯入的歷史紀錄。
執行 rails g model registration_import
,這個 Model 會存下上傳的 CSV 檔案,並記錄匯入的結果。
編輯 db/migrate/2017XXXXXX4512_create_registration_imports.rb
class CreateRegistrationImports < ActiveRecord::Migration[5.0]
def change
create_table :registration_imports do |t|
+ t.string :status
+ t.string :csv_file
+ t.integer :event_id, :index => true
+ t.integer :user_id
+ t.integer :total_count
+ t.integer :success_count
+ t.text :error_messages
t.timestamps
end
end
end
執行 rake db:migrate
執行 rails g uploader registration_import_csv
編輯 app/models/registration_import.rb
,其中的 process!
方法就是要執行的匯入操作。
+ require 'csv'
class RegistrationImport < ApplicationRecord
+ mount_uploader :csv_file, RegistrationImportCsvUploader
+
+ validates_presence_of :csv_file
+
+ belongs_to :event
+ belongs_to :user
+
+ serialize :error_messages, JSON
+
+ def process!
+ csv_string = self.csv_file.read.force_encoding('utf-8')
+ tickets = self.event.tickets
+
+ success = 0
+ failed_records = []
+
+ CSV.parse(csv_string) do |row|
+ registration = self.event.registrations.new( :status => "confirmed",
+ :ticket => tickets.find{ |t| t.name == row[0] },
+ :name => row[1],
+ :email => row[2],
+ :cellphone => row[3],
+ :website => row[4],
+ :bio => row[5],
+ :created_at => Time.parse(row[6]) )
+
+ if registration.save
+ success += 1
+ else
+ failed_records << [row, registration.errors.full_messages]
+ end
+ end
+
+ self.status = "imported"
+ self.success_count = success
+ self.total_count = success + failed_records.size
+ self.error_messages = failed_records
+
+ self.save!
+ end
end
編輯 app/models/event.rb
has_many :registrations, :dependent => :destroy
+ has_many :registration_imports, :dependent => :destroy
編輯 config/routes.rb
namespace :admin do
# (略)
resources :events do
+ resources :registration_imports
編輯 app/views/admin/event_registrations/index.html.erb
加上一個按鈕
<p class="text-right">
<%= link_to "New Registration", new_admin_event_registration_path(@event), :class => "btn btn-primary" %>
+ <%= link_to "Import Registration", admin_event_registration_imports_path(@event), :class => "btn btn-primary" %>
</p>
執行 rails g controller admin::registration_imports
編輯 app/controllers/admin/registration_imports_controller.rb
- class Admin::RegistrationImportsController < ApplicationController
+ class Admin::RegistrationImportsController < AdminController
+ before_action :require_editor!
+ before_action :find_event
+
+ def index
+ @imports = @event.registration_imports.order("id DESC")
+ end
+
+ def create
+ @import = @event.registration_imports.new(registration_import_params)
+ @import.status = "pending"
+ @import.user = current_user
+
+ if @import.save
+ @import.process!
+ flash[:notice] = "匯入完成"
+ end
+
+ redirect_to admin_event_registration_imports_path(@event)
+ end
+
+ protected
+
+ def find_event
+ @event = Event.find_by_friendly_id!(params[:event_id])
+ end
+
+ def registration_import_params
+ params.require(:registration_import).permit(:csv_file)
+ end
end
其中在 @import.save
之後,隨即呼叫 process!
開始匯入。
新增 app/views/admin/registration_imports/index.html.erb
顯示檔案上傳的輸入框,以及歷史匯入紀錄。
<h1><%= @event.name %> / Registrations Import</h1>
<%= form_for [:admin, @event, RegistrationImport.new] do |f| %>
<div class="form-group">
<%= f.label :csv_file %>
<%= f.file_field :csv_file, :required => true, :class => "form-control" %>
</div>
<div class="form-group">
<%= f.submit "送出", :class => "btn btn-primary" %>
</div>
<% end %>
<table class="table">
<tr>
<th>ID</th>
<th>狀態</th>
<th>CSV檔案</th>
<th>總筆數</th>
<th>匯入成功筆數</th>
<th>錯誤訊息</th>
</tr>
<% @imports.each do |import| %>
<tr>
<td><%= import.id %></td>
<td><%= import.status %></td>
<td><%= link_to import.csv_file.url, import.csv_file.url %></td>
<td><%= import.total_count %></td>
<td><%= import.success_count %></td>
<td>
<ul>
<% Array(import.error_messages).each do |e| %>
<li><%= e[0] %> ----> <strong><%= e[1] %></strong></li>
<% end %>
</ul>
</td>
</tr>
<% end %>
</table>
這樣就完工啦。