12/08/2018, 15:59

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/

0