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/