Export file CSV dung lượng lớn
Bài đăng này đã không được cập nhật trong 7 năm
Ruby on Rails hỗ trợ tốt việc xuất file CSV, đặc biệt là với http streaming. Tuy nhiên, có 2 vấn đề khi xuất file CSV với dữ liệu lớn:
- Tốn thời gian
- Tốn bộ nhớ (nếu một dòng của file CSV chứa nhiều model)
Giải pháp cho cả hai vấn đề này là xuất CSV trong database và Rails chỉ nhận response. Trong ví dụ này, tôi sử dụng Postgresql để nhập và xuất CSV.
Source code tại đây.
Controller
class CsvExportController < ApplicationController
def index
respond_to do |format|
format.csv { render_csv }
end
end
private
def selected_items
# implementation omited
end
def csv_lines
Items::CsvExport.new(selected_items)
end
def render_csv
set_file_headers
set_streaming_headers
response.status = 200
self.response_body = csv_lines
end
def set_file_headers
headers['Content-Type'] = 'text/csv; charset=UTF-16LE'
headers['Content-disposition'] = 'attachment;'
headers['Content-disposition'] += " filename=\"#{file_name}.csv\""
end
def set_streaming_headers
headers['X-Accel-Buffering'] = 'no'
headers["Cache-Control"] ||= "no-cache"
headers.delete("Content-Length")
end
def file_name
'a_big_export'
end
end
Controller nhận response của phương thức csv_lines
là một object Enumerable
interface. Controller sẽ lặp lại object đó trong từng hàng.
Exporter
module Items
class CsvExport
include CsvBase::CsvBase
def initialize(items)
@items = items
end
private
attr_reader :items
def header
CSV.generate_line(['a column', 'another column'], col_sep: "\t").to_s
end
def export_columns
['items.a_column', 'related_class.another_column']
end
def export_sql
items.select(export_columns)
end
end
end
Class này xác định làm thế nào để có dữ liệu export. Nó nhận một object ActiveRecord
relation để xây dựng một truy vấn sql export. Công việc chính thực hiện bởi module CsvBase :: CsvBase
.
Cần lưu ý là việc dựng class exporter tuỳ thuộc vào ứng dụng, trong ví dụ này chỉ được cung cấp như một khuôn mẫu giúp bạn định hình về việc triển khai trong thực tế.
CsvBase
Module CsvBase :: CsvBase
xây dựng trong một class và sử dụng các phương thức được định nghĩa trên class đó. Phương thức each
cung cấp dữ liệu dạng enumerator và là ứng dụng độc lập. Để đơn giản, giả định rằng file CSV sẽ được mở bằng Excel.
module CsvBase
module CsvBase
BOM = "\377\376".force_encoding('UTF-16LE')
include Enumerable
def each
yield bom
yield encoded(header)
generate_csv do |row|
yield encoded(row)
end
end
def header
''
end
def bom
::CsvBase::CsvBase::BOM
end
private
def encoded(string)
string.encode('UTF-16LE', undef: :replace)
end
# WARNING: This will most likely NOT work on jruby!!!
def generate_csv
conn = ActiveRecord::Base.connection.raw_connection
conn.copy_data(export_csv_query) do
while row = conn.get_copy_data
yield row.force_encoding('UTF-8')
end
end
end
def export_csv_query
%Q{copy (#{export_sql}) to stdout with (FORMAT CSV, DELIMITER '\t', HEADER FALSE, ENCODING 'UTF-8');}
end
end
end
Vì module CsvBase :: CsvBase
include module Enumerable
nên mỗi phương thức của nó sẽ kết thúc khi được gọi bởi controller. Lần lượt các xử lý như sau:
- Thêm 1 BOM (byte order mark), thành phần quan trọng để mở CSV bằng Excel trên Window. Nó có thể được override trong class CsvExport nếu không cần thiết hoặc cần thêm gì đó khác.
- Tiếp đến là một tiêu đề được mã hóa.
- Cuối cùng, phương thức
generate_csv
tạo dữ liệu cho file.
Phương thức generate_csv
rất thú vị. Nó sử dụng một API level thấp của postgres connector. Trình kết nối nhận được một lệnh (lệnh sao chép) với một truy vấn sql chọn tất cả các row trong database. Khi row được trả về từ Postgres là một chuỗi csv, Ruby sẽ mã hóa chuỗi và thông qua.
Việc tải xuống giờ đây sẽ nhanh chóng và dùng một lượng bộ nhớ không đổi khi generate output.
Để so sánh, hãy tạo một exporter mới sử dụng find_each
chuẩn.
module Items
class CsvExport
include Csv::CsvBase
def initialize(items)
@items = items
end
private
attr_reader :items
def header
CSV.generate_line(['a column', 'another column'], col_sep: "\t").to_s
end
def generate_csv
items.find_each do |item|
CSV.generate_line([item.a_column, item.association.another_column], col_sep: "\t").to_s
end
end
end
end
Xuất 500k bản ghi:
Dùng find_each
- Thời gian: ~ 450 giây
- Bộ nhớ: ~ 1.8GB
Dùng raw_connection
- Thời gian: ~ 90 giây
- Bộ nhớ: ~ 400MB
Kết luận
Có thể thấy được tốc độ tăng 5x speed/memory trong khi hành vi không đổi. Tuy nhiên giải pháp này không được khuyến khích bởi một vài lưu ý sau:
- Phụ thuộc vào postgres do sử dụng các kết nối đặc thù. Tuy không phải là vấn đề trong tất cả các ứng dụng, nhưng cần xem xét.
- Môi trường non-MRI của
raw_connection
rất có thể sẽ lỗi trên JRuby. - Khi yêu cầu xuất file phức tạp hơn, cần phải thay đổi nhiều code - trong ví dụ thì nội dung của CSV rất đơn giản, thực sự là vấn đề cho các nhà phát triển.
Do đó, cách này sử dụng để có cái nhìn cơ bản nhất và chỉ sử dụng khi xử lý bằng Rails thực sự khó khăn.
Nguồn: http://michalolah.com/blog/ruby/rails/sql/csv/export/exporting-milions-of-rows-via-csv/
All rights reserved