Importing records from CSV and Excel in Rails 5
Đối với lập trình viên Rails thì khi chạy thử các chức năng của chương trình họ thường seed dữ liệu qua file seeds.rb. Tuy nhiên đối với người sử dụng chương trình, đặc biệt đối với các chương trình quản lý (ở đây mình lấy ví dụ là hệ thống quản lý học viên), họ phải nhập dữ liệu thực từ một danh ...
Đối với lập trình viên Rails thì khi chạy thử các chức năng của chương trình họ thường seed dữ liệu qua file seeds.rb. Tuy nhiên đối với người sử dụng chương trình, đặc biệt đối với các chương trình quản lý (ở đây mình lấy ví dụ là hệ thống quản lý học viên), họ phải nhập dữ liệu thực từ một danh sách có sẵn vào database. Trong rất nhiều hệ thống hiện nay, người dùng có thể thực hiện chức năng này bằng cách import dữ liệu từ file CSV hoặc Excel. Đây là cách làm phổ biến và đơn giản đổi với người sử dụng. Còn đối với vai trò là developer, làm cách nào để chúng ta có thể implement tác vụ này một cách dễ dàng và hiệu quả? Mình xin được giới thiệu một hướng giải quyết như sau. Hãy cùng bắt đầu nhé.
Mình sẽ tạo một app quản lý học viên để làm ví dụ cho việc import dữ liệu của học viên đăng ký.
rails new sms
Ta sẽ dựng một model students đơn giản với các thuộc tính như sau
rails g model Student name email score:integer
Migrate dữ liệu
rails db:migrate
Tiếpruby theo ta tạo controller tương ứng StudentController
rails g controller Students index
Trong đó index liệt kê ra tất cả các học viên trong hệ thống
class StudentsController < ApplicationController def index @students = Student.all end end
Khởi tạo view cho trang index (chứa danh mục học viên). Để cho đơn giản thì mình chỉ tạo view cho phép hiển thị ra danh sách dưới dạng bảng.
<%= flash[:notice] %> <table> <thead> <tr> <th>STT</th> <th>Name</th> <th>Email</th> <th>Score</th> </tr> </thead> <tbody> <% @students.each_with_index do |student, index| %> <tr> <td><%= index %></td> <td><%= student.name %></td> <td><%= student.email %></td> <td><%= student.score %></td> </tr> <% end %> </tbody> </table>
Bây giờ ta đến với phần chính. Để import được dữ liệu CSV rất đơn giản, ta có thể sử dụng một class có sẵn trong Ruby là csv . Tuy nhiên ở đây mình cố gắng đưa ra cách import một file bất kỳ trong các dạng file .csv | .xlsm | .xlsx | .ods. Để làm được điều đó ta sử dụng gem 'roo'. Gem roo hỗ trợ thao tác truy xuất file cho các dạng bảng tính phổ biến như csv, excel, open office, libre office.
Cài đặt
Bạn có thể cài đặt với RubyGems:
gem install roo
hoặc thêm vào Gemfile như sau:
gem "roo"
Sau đó chạy bundle install
Áp dụng
Ta viết một class method cho model Student để thực hiện thao tác import
class Student < ApplicationRecord class << self def import_file file # file có thể ở dạng file hoặc là path của file đều được xử lý chính xác bởi method open spreadsheet = Roo::Spreadsheet.open file # lấy cột header (column name) header = spreadsheet.row 1 (2..spreadsheet.last_row).each do |i| # lấy ra bản ghi và biến đổi thành hash để có thể tạo record tương ứng row = [header, spreadsheet.row(i)].transpose.to_h create! row end end end end
Nếu soi vào trong code của Roo::Spreadsheet.open bạn có thể thấy nó tạo một đối tượng từ một trong 4 loại extension (csv, xlsm, xlsx hay ods). Tức là bạn có thể handle bất cứ file nào chỉ bằng một dòng code duy nhất. Tiếp đó việc lấy ra dữ liệu để tạo ra record là hoàn toàn dễ dàng. Tuy nhiên trong những dòng code trên có một vấn đề. Mình sẽ đưa ra vấn đề và giải pháp ở phần cuối bài.
Điều tiếp theo cần làm là thêm vào giao diện một nút import, tương ứng với một phương thức import trong controller. Trong view students/index.html.erb ta thêm
<div> <h4>Import data</h4> <%= form_tag import_students_path, multipart: true do %> <%= file_field_tag :file %> <%= submit_tag "Import" %> <% end %> </div>
Về phía students_controler ta thêm phương thức import
class StudentsController < ApplicationController def index @students = Student.all end def import Student.import_file params[:file] redirect_to root_url, notice: "Data imported" end end
Giờ hãy sửa lại một chút phần route
Rails.application.routes.draw do root to: 'students#index' resources :students do collection {post :import} end end
Tốt rồi, giờ ta có thể chạy server và thử xem hoạt động của chức năng này ra sao
rails s
À đừng quên tạo sẵn một file .csv hay .xlsx hoặc .ods có dạng như này. Hãy đảm bảo rằng columne names chính là tên thuộc tính của model bạn cần import (ở đây là name, email, score). Trong trường hợp nó có định dạng khác hoặc có chứa các cột khác, bạn sẽ phải tùy chỉnh code phía trên một chút.
Sau khi chạy server
Hãy chọn lấy một file
Đây là kết quả
Hãy thử với những loại file khác bạn sẽ thấy nó cũng hoạt động chính xác. Nếu muốn nó hoạt động đúng như yêu cầu của bạn thì bạn chỉ cần tùy chỉnh một chút phần xử lý code bên model thôi.
Quay trở lại vấn đề mình đã nói, đoạn code ở phía trên có chút vấn đề
(2..spreadsheet.last_row).each do |i| # lấy ra bản ghi và biến đổi thành hash để có thể tạo record tương ứng row = [header, spreadsheet.row(i)].transpose.to_h create! row end
Mỗi khi bạn create một bản ghi với ActiveRecord, một câu lệnh truy vấn INSERT được tạo ra và gửi tới database. Giả sử ta import 10 file, mỗi file khoảng 500 bản ghi tức là khoảng 5000 câu lệnh truy vấn riêng biệt tới database và database sẽ phải phân tích 5000 câu lệnh một cách riêng biệt, mở và đóng bảng students 5000 lần để ghi dữ liệu, ngoài ra nó còn phải thực hiện các thao tác cập nhật index với mỗi sự thay đổi dữ liệu. Điều đó đặt gánh nặng tài nguyên và thời gian xử lý không hề nhỏ lên database. Rất may mắn cho chúng ta là có nhiều cách khác nhau để giải quyết vấn đề này, mình xin giới thiệu một cách đơn giản, đó là sử dụng gem activerecord-import. Gem này giúp chúng ta hỗ trợ khả năng insert dữ liệu hàng loạt trong ActiveRecord, đặc biệt giúp loại bỏ N+1 insert problem . Cách sử dụng như sau
Cài đặt gem activerecord-import
Bạn có thể cài đặt activerecord-import với RubyGems:
gem install activerecord-import
hoặc thêm vào Gemfile như sau:
gem "activerecord-import"
Sau đó chạy bundle install
Tối ưu với phương thức import
Thay vì sử dụng create!, hãy xây dựng các instance của Student trong memory và pass chúng vào method .import của thư viện activerecord-import:
def import_file file spreadsheet = Roo::Spreadsheet.open file header = spreadsheet.row(1) students = [] (2..spreadsheet.last_row).each do |i| row = [header, spreadsheet.row(i)].transpose.to_h student = new row students << student end import! students end
Đây là câu truy vấn duy nhất được thực hiện tới database, từ đây bạn có thể hiểu tác dụng của nó trong việc tối ưu việc import dữ liệu
Thậm chí ở đây activerecord-import còn cung cấp cho bạn một cách còn nhanh hơn, đó là gán luôn columns name và dãy các bản ghi vào và phương thức import tự động làm phần còn lại. Bạn không cần phải qua bước khởi tạo object để lưu record nữa.
def import_file file spreadsheet = Roo::Spreadsheet.open file header = spreadsheet.row(1) rows = [] (2..spreadsheet.last_row).each do |i| rows << spreadsheet.row(i) end import! header, rows end
Rất nhanh và gọn phải không nào?