0

Mẹo Khi import dữ liệu

GIỚI THIỆU

Gần đây mình vừa có cơ hội làm một task về import dữ liệu khách hàng vào hệ thống. Có lẽ đây là một chủ đề quá quen thuộc với tất cả mọi người, Nhưng để làm cho nó hoàn chỉnh thì không phải là việc đơn giản. Trong phần mình đã thực hiện thì mình đã rút ra được vài điều mà mình thấy hay hay. Nên hôm nay mình sẽ giới thiệu nó lại với mọi người

Triển khai

Chuẩn bị

Cài đặt gem "roo" để đọc file dữ liệu. gem này hỗ trợ chúng ta đọc các file có định dạng như: xslx, csv, xls ... Chi tiết về gem này các bạn có thể tham khảo thêm tại đây.

Mở file

def open_spreadsheet file
      case File.extname file.original_filename
      when ".csv" then Roo::CSV.new file.path, csv_options: {encoding: Encoding::ISO_8859_1}
      when ".xls" then Roo::Excel.new file.path
      when ".xlsx" then Roo::Excelx.new file.path
      end
end

Cách mở file như các bạn thấy ở trên. Nó khá là dể dàng nên mình không giải thích gì thêm ngoại trừ có một lưu ý là đối với file csv, khi mở file này các bạn cần truyền thêm 1 option là encoding: Encoding::ISO_8859_1 để encoding dữ liệu nhé.

Đọc file

Thiết lập

Gia sử mình có 1 bảng clients để lưu khách hàng. bảng này có các các thuộc tính đơn giản như full_name, address . Mỗi thuộc tính sẽ là 1 cột trong trong file excel, nên mình có cài đặt như sau:

mapping:
    datas:
        full_name: 1
        address: 2

Mapping dữ liệu


def mapping row 
    mapping_datas = Settings.mapping.datas
   {
        full_name: row[mapping_datas.full_name],
        address: row[mapping_datas.address]
    }
end

Import dữ liệu

def import file
    unless spreadsheet = open_spreadsheet(file)
        return {success: false, message: "không thể đọc file"}
    end

     clients = ActiveRecord::Base.transaction do
          (2..spreadsheet.last_row).map do |i|
                @current_index = i
                row = spreadsheet.row i
                Client.create! mapping(row)
          end
    end

    {success: true, clients: clients}
rescue ActiveRecord::RecordInvalid => invalid
        message = invalid.record.errors.full_messages.first
        {success: false, message: message, error_at: @current_index}
end

Logic ở trên có khá đơn giản. Phần tiếp theo mình sẽ cải tiến nó nhé.

Nâng cao

Phản hồi lỗi một lần

Các bạn chú ý logic xử lý lỗi ở trên. Khi có 1 lỗi nào đó xãy ra thì ngay lập tức dừng import dữ liệu và rollback dữ liệu đã import trước đó và phản hồi lỗi cho người dùng. Giả sử Khi chúng ta import một lượng dữ liệu lớn. Có hàng ngàn lỗi, thì ở đây chúng ta phải import hàng ngàn lần với mỗi lần server sẽ phản hồi 1 lỗi cho chúng ta sửa. Điều này rất bất tiên nên chúng ta cần đọc qua tất cả dữ liệu, tìm tất cả lỗi và trả về 1 lần cho người dùng.

def add_error error
    @errors.push index: current_index, detail: error
end

private
attr_reader :current_index, :errors

Giải thích: Method trên dùng để lưu lại tất cả lỗi đang có trong file dữ liệu. Mỗi khi phát hiện một lỗi chúng ta sẽ gọi method này để lưu lại.

def import file
    unless spreadsheet = open_spreadsheet(file)
        return {success: false, message: "không thể đọc file"}
    end
        
    @errors = []
    clients = ActiveRecord::Base.transaction do
        (2..spreadsheet.last_row).map do |i|
            @current_index = i
            begin
                row = spreadsheet.row i
                Client.create! mapping(row)
            rescue ActiveRecord::RecordInvalid => error
                add_error error
            end
        end
    end

   explain_error if errors.present?

   {success: true}
end

Giải thích: Mỗi khi tạo client mình cần rescue lỗi lại để transaction tiếp tục chạy đến khi đọc hết dữ liệu. Sau khi đọc hết dữ liệu chúng ta kiễm tra nếu có bất kì lỗi nào xãy ra khi import thì chúng ta sẽ gọi explain_error để phản lỗi, nếu không thì phản hồi thành công.

Phản hồi lỗi chi tiết

Hãy tưởng tượng file excel chúng ta có hàng ngàn dòng, và mỗi dòng có hàng ngàn cột, nhiệm vụ của chúng ta bây giờ là sẽ phản hồi lỗi cụ thể đã xãy ra ở đâu. Tại cột nào của dòng nào trong file excel. Tại phần đọc dữ liệu chúng ta đã ánh xạ dữ liệu từ các cột trong file excel đến các thuộc tính của client. Bây giờ chúng ta sẽ làm ngược lại, chúng ta sẽ ánh xạ các lỗi từ các thuộc tính của client đến các cột của file excel. Đầu tiên chúng ta cần cài đặt như sau:

    mapping
        cells:
            full_name: A
            address: B
def explain_error
    mapping_cells = Settings.mapping.cells

    details = errors.map do |error|
         record = error[:detail].record
         record.errors.keys.map do |attribute|
             cell = mapping_cells[attribute]
            {
                  row: error[:index],
                  cell: cell,
                  message: record.errors.full_messages_for(attribute).first
            }
          end
    end

    details.flatten.sort{|x, y| sort_error x, y}
end

Giải thích: Chúng ta sẽ có 1 vòng lặp cha lặp qua các client bị lỗi và một vòng lặp con lặp qua các thuộc tính bị lỗi của một client. từ tên thuộc tính chúng ta sẽ lấy được tên cột trên file excel dựa vào cài đặt chúng ta đã tạo. Tiếp theo chúng ta sẽ tiến hành sắp xếp các lỗi theo thứ tự từ trên trái qua phải, từ trên xuống dưới của các cell trong file excel.

def sort_error x_sortable, y_sortable
    result = x_sortable[:row] <=> y_sortable[:row]
    return result if result != 0

    settings = Settings.sorting
    x_cell = x_sortable[:cell][0..2].squish.rjust 2, "0"
    y_cell = y_sortable[:cell][0..2].squish.rjust 2, "0"
    x_cell <=> y_cell
end

Nested lỗi

Bài toán

Giả sử client có thêm 1 thuộc tính là job_id, thuộc tính này tham chiếu đến 1 bảng master data trong hệ thống. Yêu cầu đặt ra là nếu dữ liệu trong file excel khớp với 1 một trong những dữ liệu đã có trong master data thì lấy nó ra, còn nếu không thì tạo mới.

Giải quyết

def mapping_job content
      return if content.blank?
          
      job = Job.find_by(name: content)
      job || Job.create!(name: content)
end

Cải tiến

Chú ý ở đây thay vì chạy vào database tìm xem nội dung này có khớp trong master data không, thì mình sẽ lấy tất cả dữ liệu trong master data và cache lại vào một biến instance. sau đó chúng ta sẽ tìm trong biến này. Mục đích để tránh mỗi row dữ liệu lại phải chọc vào database kiễm tra.

def mapping_job content
      return if content.blank?
       @jobs ||= Job.all.each_with_object({}) do |job, jobs|
            jobs[job.name] = job
      end
      @jobs[content] ||= Job.create!(name: content)
end

Lưu ý: Trường hợp tạo mới thêm chúng ta cũng cần thêm nó vào biến instance nhé. Dùng ||= thay vì || để làm điều này. Có 1 vấn để ở đây là khi chúng ta tạo job thì lỗi sẽ nằm trên Job. Lúc này tại method explain_error chúng ta sẽ khó khăn để phản hồi lỗi về người dùng vì lỗi đang nằm trên 2 model khác nhau là job và client. Vì vậy chúng ta thay đổi như sau:

def raise_error attribute, error, options = {}
    client = Client.new
    client.errors.add attribute, error, options
    add_error ActiveRecord::RecordInvalid.new client
end

Giải thích: Chúng ta sẽ giả mạo rằng có 1 một lỗi tại thuộc tính job_id trên client thay vì lỗi trên model Job.

def mapping_job content
      return if content.blank?
       @jobs ||= Job.all.each_with_object({}) do |job, jobs|
            jobs[job.name] = job
      end
      @jobs[content] ||= Job.create!(name: content)
rescue ActiveRecord::RecordInvalid
    raise_error :job_id, :too_long, count: 255
end

Kết luận

Trên đây mình vừa giới thiệu các bạn một vài mẹo khi import dữ liệu. Nếu các bạn thấy hay thì hãy share, like và comment bên dưới video. Nhớ đăng kí kênh và nhấn vào chuông để nhận thông báo sớm nhất khi có video mới nhé. Đùa thôi 😄


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.