Export file CSV dung lượng lớn
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. ...
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: " ").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 = "377376".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 ' ', 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: " ").to_s end def generate_csv items.find_each do |item| CSV.generate_line([item.a_column, item.association.another_column], col_sep: " ").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/