Giới thiệu Gem roo và roo-xls, áp dụng qua thực tế.
Bài đăng này đã không được cập nhật trong 3 năm
- Import data từ file
.csv
,.xls
, .... và import vào database có vẻ là 1 chức năng đã khá quen thuộc, và cũng vì vậy mà mỗiweb framework
đều có 1 vài thư viện hỗ trợ việc đọc các filespreadsheet
rất tốt. Nhưng các thư viện này sẽ cung cấp các chức năng cơ bản, và để chúng hoạt động phù hợp với yêu cầu của dự án cụ thể, chúng ta thường xây dựng lại các lớp cơ sở riêng. - Bài viết này mình sẽ giới thiệu đến các bạn 1
gem
hỗ trợ rất tốt cho việc đọc data từ filespreadsheet
choRuby
làroo
- Và cũng để ghi lại cách mà chính mình đã sử dụng nó cho dự hiện tại để ruốt lại kinh nghiệm lần sau. Mong nhận được sự góp ý.
1. Installation
$ gem install roo
Hoặc
gem "roo"
gem "roo-xls"
- Ở đây mình có 2 gem là
roo
vàroo-xls
. roo-xls
được tách ra từroo
để riêng biệt cho việc đọc file.xls
. Vì dự án hiện tại của mình cần đọc cả file.xls
và.xlsx
- Một vài function được hỗ tự trừ
roo
mình sẽ liệt kê dưới đây:
2. Usage
Opening a spreadsheet
require 'roo'
xlsx = Roo::Spreadsheet.open('./new_prices.xlsx')
xlsx = Roo::Excelx.new("./new_prices.xlsx")
# Use the extension option if the extension is ambiguous.
xlsx = Roo::Spreadsheet.open('./rails_temp_upload', extension: :xlsx)
xlsx.info
# => Returns basic info about the spreadsheet file
Roo::Spreadsheet.open
can accept both paths and File
instances.
Working with sheets
ods.sheets
# => ['Info', 'Sheet 2', 'Sheet 3'] # an Array of sheet names in the workbook
ods.sheet('Info').row(1)
ods.sheet(0).row(1)
# Set the last sheet as the default sheet.
ods.default_sheet = ods.sheets.last
ods.default_sheet = ods.sheets[2]
ods.default_sheet = 'Sheet 3'
# Iterate through each sheet
ods.each_with_pagename do |name, sheet|
p sheet.row(1)
end
Accessing rows and columns
Roo uses Excel's numbering for rows, columns and cells, so 1
is the first index, not 0
as it is in an Array
sheet.row(1)
# returns the first row of the spreadsheet.
sheet.column(1)
# returns the first column of the spreadsheet.
Almost all methods have an optional argument sheet
. If this parameter is omitted, the default_sheet will be used.
sheet.first_row(sheet.sheets[0])
# => 1 # the number of the first row
sheet.last_row
# => 42 # the number of the last row
sheet.first_column
# => 1 # the number of the first column
sheet.last_column
# => 10 # the number of the last column
Accessing cells
You can access the top-left cell in the following ways
sheet.cell(1,1)
sheet.cell('A',1)
sheet.cell(1,'A')
sheet.a1
# Access the second sheet's top-left cell.
sheet.cell(1,'A',sheet.sheets[1])
Querying a spreadsheet
Use each
to iterate over each row.
If each is given a hash with the names of some columns, then each will generate a hash with the columns supplied for each row.
sheet.each(id: 'ID', name: 'FULL_NAME') do |hash|
puts hash.inspect
# => { id: 1, name: 'John Smith' }
end
Use sheet.parse
to return an array of rows. Column names can be a String
or a Regexp
.
sheet.parse(id: /UPC|SKU/, qty: /ATS*\sATP\s*QTY\z/)
# => [{:id => 727880013358, :qty => 12}, ...]
Use the :header_search
option to locate the header row and assign the header names.
sheet.parse(header_search: [/UPC*SKU/,/ATS*\sATP\s*QTY\z/])
Use the :clean
option to strip out control characters and surrounding white space.
sheet.parse(clean: true)
Exporting spreadsheets
Roo has the ability to export sheets using the following formats. It
will only export the default_sheet
.
sheet.to_csv
sheet.to_matrix
sheet.to_xml
sheet.to_yaml
Excel (xlsx and xlsm) Support
Stream rows from an Excelx spreadsheet.
xlsx = Roo::Excelx.new("./test_data/test_small.xlsx")
xlsx.each_row_streaming do |row|
puts row.inspect # Array of Excelx::Cell objects
end
By default blank cells will be excluded from the array. To keep them, use the option pad_cells = true. (They will be set to nil in the array)
xlsx.each_row_streaming(pad_cells: true) do |row|
puts row.inspect # Array of Excelx::Cell objects
end
To stream only some of the rows, you can use the max_rows
and offset
options.
xlsx.each_row_streaming(offset: 1) do |row| # Will exclude first (inevitably header) row
puts row.inspect # Array of Excelx::Cell objects
end
xlsx.each_row_streaming(max_rows: 3) do |row| # Will yield 4 rows (it's automatically incremented by 1) after the supplied offset.
puts row.inspect # Array of Excelx::Cell objects
end
Iterate over each row
xlsx.each_row do |row|
...
end
Roo::Excelx
also provides these helpful methods.
xlsx.excelx_type(3, 'C')
# => :numeric_or_formula
xlsx.cell(3, 'C')
# => 600000383.0
xlsx.excelx_value(row,col)
# => '600000383'
xlsx.formatted_value(row,col)
# => '0600000383'
Roo::Excelx
can access celltype, comments, font information, formulas, hyperlinks and labels.
xlsx.comment(1,1, ods.sheets[-1])
xlsx.font(1,1).bold?
xlsx.formula('A', 2)
OpenOffice / LibreOffice Support
Roo::OpenOffice has support for encrypted OpenOffice spreadsheets.
# Load an encrypted OpenOffice Spreadsheet
ods = Roo::OpenOffice.new("myspreadsheet.ods", password: "password")
Roo::OpenOffice
can access celltype, comments, font information, formulas and labels.
ods.celltype
# => :percentage
ods.comment(1,1, ods.sheets[-1])
ods.font(1,1).italic?
# => false
ods.formula('A', 2)
CSV Support
# Load a CSV file
csv = Roo::CSV.new("mycsv.csv")
Because Roo uses the standard CSV library, you can use options available to that library to parse csv files. You can pass options using the csv_options
key.
For instance, you can load tab-delimited files (.tsv
), and you can use a particular encoding when opening the file.
# Load a tab-delimited csv
csv = Roo::CSV.new("mytsv.tsv", csv_options: {col_sep: "\t"})
# Load a csv with an explicit encoding
csv = Roo::CSV.new("mycsv.csv", csv_options: {encoding: Encoding::ISO_8859_1})
source: README.md
3. Apply
Vấn đề:
- Có 6 template file
spreadsheet
bao gồm 2 extendtion:.xls
và.xlsx
- Mỗi 1 file excel sẽ có nhiều
sheet
và mỗisheet
có Mình sẽ có 3 loại data:- List users
- List managers
- 1 block data
group
- Yêu cầu:
- Tìm
group
trong database vớigroup_code
củagroup
. - Với mỗi record
manager
: tạo hoặc updatemanager
và addmanager
vào danh sách quản lý củagroup
. Nếu có chứa thông tinclass_name
thì tạo thêm 1user
với data củamanager
và add vào danh sáchstudents
củaclass
và là 1students
thuộcgroup
- Với mỗi record
users
: thì tạo 1user
thuộcclass
vàgroup
. - Verify: Nếu data chỉ định
user
sẽ bắt đầu học 1class
với cùng 1 ngàystart_date
thì báo errors. - List manager sẽ có tọa độ sẽ bắt đầu từ cell đầu tiên nằm phí dưới, cùng column với cell có content là
MANAGERS
có content là 1number
và kết thúc khi gặp cell có content làSTUDENTS
- List user sẽ có tọa độ sẽ bắt đầu từ cell đầu tiên nằm phí dưới, cùng column với cell có content là
STUDENTS
có content là 1number
và kết thúc ở row cuối cùng. - block
group
sẽ là nằm ở 1 column có các cell liên tiếp là ["Class code", "Class name", "Class info", "Teacher name"]
- Tìm
ví dụ:
Class code | CODE_CLASS_1 | |||||||
Class name | HÓA | |||||||
Class info | DẠY TIÊU HÓA | |||||||
Teacher name | BERBERIN | |||||||
MANAGERS |
||||||||
name | class name | |||||||
1 | ||||||||
2 | ||||||||
3 | ||||||||
STUDENTS | ||||||||
1 | ||||||||
2 | ||||||||
3 | ||||||||
Thực hiện:
- Mỗi 1 file excel tất nhiên sẽ có cách detect riêng, mình sẽ không nói về phần này ở đây vì nó khá đơn giản và cũng như không liên quan đến
roo
mình đang muốn giới thiệu. - Vì nhận thấy việc xử lý data của mình đều dự trên từng sheet
- Mình sẽ xây dựng 1 thư viện tại
lib/spreadsheet_reader
- Mình nhận thấy mình sẽ có 2 cách đọc data:
-
- là theo table.
-
- Theo block.
-
=> Mình sẽ xây dựng 2 class cho mỗi cách đọc data này
- Mình xây dựng 1
super
class như sau:
# lib/spreadsheet_reader/sheet_reader.rb
class SpreadsheetReader::SheetReader
include SpreadsheetReader::Finder
include SpreadsheetReader::Validator
attr_reader :sheet
def initialize sheet, args = {}
validate sheet, args[:validate] if args[:validate].present?
@sheet = sheet
end
def read!
raise NotImplementedError
end
def cell *args
@sheet.cell(*args)
end
def column *args
@sheet.column(*args)
end
def row *args
@sheet.row(*args)
end
private
def validate sheet, expected_format
expected_format.each do |key, value|
raise SpreadsheetReader::Errors::InvalidFormat unless public_send "#{key}?", value, sheet
end
end
end
Vì nhận thấy việc 2 việc cơ bản mình phải làm đó là:
-
- Tìm kiếm địa chỉ của các cell có content, hay 1 block gì đó.
-
- Đảm bảo sheet đang đọc đúng với format.
=> Mình xây dựng 2 modul
Finder
vàvalidate
để thực hiện 2 công việc này. Mình muốn việc handle error 1 các cụ thể sẽ do 1 service thuộc application đảm nhiệm nên ở đây mình sẽraise
error để tách biệtlib
của mình với application. cònlib
chỉ là đọc data theo yêu cầu của sẻvice
- Đảm bảo sheet đang đọc đúng với format.
=> Mình xây dựng 2 modul
Class erros:
# lib/spreadsheet_reader/errors/base.rb
class SpreadsheetReader::Errors::Base < StandardError
def initialize message = self.class.name.underscore.gsub(%r{\/}, " ")
super message
end
end
# lib/spreadsheet_reader/errors/invalid_format.rb
class SpreadsheetReader::Errors::InvalidFormat < SpreadsheetReader::Errors::Base
end
finder:
# lib/spreadsheet_reader/finder.rb
module SpreadsheetReader
module Finder
def coordinate_of_content content, sheet = @sheet
(0...sheet.last_row).each do |row_index|
column_index = sheet.row(row_index).index content
return {row: row_index, column: column_index} if column_index
end
nil
end
def coordinate_of_column_block block, sheet = @sheet
(0...sheet.last_column).each do |column_index|
column = sheet.column column_index
row_index = column.index block.first
return {row: row_index, column: column_index} if array_contains_block? column, block, row_index
end
nil
end
def coordinate_of_row_block block, sheet = @sheet
(0...sheet.last_column).each do |row_index|
row = sheet.column column_index
column_index = row.index block.first
return {row: row_index, column: column_index} if array_contains_block? row, block, column_index
end
nil
end
def row_index_of content, column_index, sheet = @sheet
sheet.column(column_index).index content
end
def column_index_of content, row_index, sheet = @sheet
sheet.row(row_index).index content
end
private
def array_contains_block? array, block, index = nil
index ||= array.index block.first
return false unless index
block == array[index...(index + block.length)]
end
end
end
Validator:
# lib/spreadsheet_reader/validator.rb
module SpreadsheetReader
module Validator
include SpreadsheetReader::Finder
def has_cell_with_content? content, sheet = @sheet
coordinate_of_content(content.to_s, sheet).present?
end
def has_cells_with_contents? contents, sheet = @sheet
contents.is_a?(Array) && contents.all?{|content| coordinate_of_content content, sheet}
end
def has_column_with_block? block, sheet = @sheet
coordinate_of_column_block(block, sheet).present?
end
def has_data_at? coordinate, sheet = @sheet
sheet.cell(coordinate[:row], coordinate[:column]).present?
end
end
end
Hầu như mình đã hoàng thành những phần cơ bản mà mình cần, bây h sẽ là 2 class chính
# /lib/spreadsheet_reader/data_table_reader.rb
class SpreadsheetReader::DataTableReader < SpreadsheetReader::Base
def read! formater
start_at = table_start_at formater[:start_at]
end_at = table_end_at formater[:end_at]
raise SpreadsheetReader::Errors::ValidationError unless start_at && end_at
map_data_table start_at[:row], end_at[:row], formater[:header], start_at[:column]
end
private
def map_data_table start_at_row, end_at_row, header_formater, start_at_column = 0
(start_at_row..end_at_row).reduce([]) do |data, row_index|
row = @sheet.row row_index
next data unless row[start_at_column].is_a? Numeric
data.push header_formater.new(*row.slice!(start_at_column + 1, header_formater.new.length))
end
end
def table_start_at start_with
start_with[:index] || coordinate_of_content(start_with[:label])
end
def table_end_at end_with
return {row: @sheet.last_row} unless end_with
coordinate_of_content end_with[:label]
end
end
# /lib/spreadsheet_reader/data_block_reader.rb
class SpreadsheetReader::DataBlockReader < SpreadsheetReader::Base
def read! formater
start_at = coordinate_of_column_block formater[:block_label]
raise SpreadsheetReader::Errors::ValidationError unless start_at
@sheet.column(start_at[:column] + 1)[start_at[:row]...(start_at[:row] + formater[:block_label].length)]
end
end
Mình đã xây dựng được tần base cho việc đọc data từ từng sheet. Việc còn lại là nhận file và verify data. Mình sẽ nói cụ thể về cách mình xây dựng tiếp 1 service để phục vụ verify data cũng như import. Vì phần này cần model và logic hơi nhiều. nên mình đã tổ chức model tại repository . Các bạn có thể đọc qua. mình sẽ update các triển khai cụ thể sau.
All rights reserved