Link Search Menu Expand Document

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 這個活動。

image

解說:

  1. 和匯出 CSV 一樣,Ruby 內建了 CSV 庫可以解析 CSV,所以第一行先 require 'csv'
  2. CSV.foreach 會打開這個 CSV 檔案跑循環,每筆資料就是一行 row,那一行的第一列是 row[0]、第二列是 row[1]。只要依序塞給 event.registrations.new 即可。
  3. CSV 中的票種是字串,但是轉進我們的資料庫中需要轉換成 Ticket model,因此這裡寫成 tickets.find{ |t| t.name == row[0] } 用票種名稱去找是哪一個物件。
  4. 時間也是一樣,透過 Time.parse 轉成時間物件
  5. 因為匯入會一次匯入非常多筆,我們希望不管每筆資料 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 進行解析循環。

image

image

注意本來的 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 把匯入檔案存下來紀錄過程

上一節的方式比較簡略,我們並沒有把上傳的檔案存下來,而是直接讀取檔案內容進行處理。

如果這是一個面向終端用戶的功能,會需要實作的更完整,例如:

  1. 把上傳的檔案先存下來,先給用戶預覽字段順序是否正確、有多少數據要匯入、哪些數據有問題
  2. 確認後,才開始匯入資料庫
  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>

image

執行 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>

這樣就完工啦。

image


Copyright © 2010-2022 Wen-Tien Chang All Rights Reserved.