2018

3

17

Railsで大規模CSVデータをドラッグ&ドロップでプレビューしてDB登録してやんよ!!!

スポンサードリンク


要件シチュエーション

  • 1ファイルあたり4000行近いデータとやたらカラム項目の多いCSVファイル(文字コードはShift-JIS)が対象
  • プレビューにはフィルタリングかけて確認が必要なデータのみをプレビューさせたい
  • 登録ボタンをクリックでCSVファイルをアップロード
  • モデル内にファイルのユニークキーと一致がなければインサート、既存キーであれば指定カラムのみアップデート
  • 大量の処理なのでバルクインサート&アップデート処理が必要
  • 非エンジニア向けに登録オペレーションはとにかく簡単にしたい

仕様&使ったもの

  • ruby 2.3
  • Rails 4.2
  • DBはMySQLなので’mysql2′(gem)
  • ‘activerecord-import'(gem)

ビュー側概要

  1. 1.ビュー側にドラッグ&ドロップエリアを設置
  2. 2.エリア内にCSVファイルを投げるとAjax(非同期)通信でファイルデータ内容をサーバー側でフィルタリングして正規化データをレスポンスを受け取る
  3. 3.受け取ったレコードをtableタグに形成して出力
  4. 4.「登録ボタン」クリックでCSVファイルを普通にPOST通信でアップロード
  5. 5.モデルに更新処理 → 完了アクション

view.html.erb

<%= form_tag('/sabun_regist_complete', :action => 'complete', :method => 'post', :multipart => true, :id => 'csv_form') do %>
    <div id="drop_zone">
        <div class="drag-drop-inside">
            <p class="drag-drop-info">ここに差分CSVファイル(Shift_JIS)をドロップしてください</p>
            <%= file_field_tag :file_input %>
            <% if @file.present? %>
            <p id="drop_file">選択中のファイル[<span><%= @file.original_filename %></span>]</p>
            <% else %>
            <p id="drop_file">選択中のファイル[<span>未登録</span>]</p>
            <% end %>
            <%= submit_tag "このファイルを登録", class: 'btn btn-primary' %>
        </div>
    </div>
<% end %>

<table id="csv_data">
    <thead>
        <tr>
            <th>番号</th>
            <th>項目1</th>
            <th>項目2</th>
            <th>項目3</th>
            <th>項目4</th>
            <th>項目5</th>
        </tr>
    </thead>
    <tbody><!-- ここにCSVデータ出力 --></tbody>
</table>

スクリプトは面倒なら上記ビューに直接記述でも。

script.js

$(function(){
    // 要素もろもろ初期設定
    let $table = $("#csv_data");
    let $table_body = $table.children('tbody');
    let $fixed = $("#data_result");
    let fileArea = $('drop_zone');

    // CSVデータレコード出力タグ生成
    function funcAppendTag(item){
        let appendTag = '';
        appendTag += '<tr class="record" data-member="' + item[0] + '">';
        appendTag +=  '<td>' + item[0] + '</td>';
        appendTag +=  '<td>' + item[1] + '</td>';
        appendTag +=  '<td>' + item[2] + '</td>';
        appendTag +=  '<td>' + item[3] + '</td>';
        // 値が空だと"null"で返ってくるので文字列的に空にしたりする
        if(item[4] == null){
            appendTag +=  '<td></td>';
        }else{
            appendTag +=  '<td>' + item[4] + '</td>';
        }
        appendTag +=  '</tr>';
        return appendTag;
    }

    // CSVファイルのドロップ処理
    fileArea.addEventListener('dragover', function(evt){
        evt.preventDefault();
        fileArea.classList.add('dragover');
    });
    fileArea.addEventListener('dragleave', function(evt){
        evt.preventDefault();
        fileArea.classList.remove('dragover');
    });
    fileArea.addEventListener('drop', function(evt){
        evt.preventDefault();
        // table内を一旦空にする
        $table_body.empty();
        var files = evt.dataTransfer.files;
        // CSVファイル以外は許可しない
        if (files[0]['type'] != "application/vnd.ms-excel") {
            alert("CSVファイルではありません");
        }else{
            // ファイルを一時的にアップロードしてデータを出力
            var formData = new FormData($("#csv_form").get(0));
            $.ajax({
                url  : "/data_result", // 次のアクション先を指定
                type : "POST",
                data : formData,  // フォーム内のデータ
                cache       : false,
                contentType : false,
                processData : false,  // これないと多分rails側で弾かれる
                dataType    : "json" // レスポンスはJSON形式で受け取る
            }).done(function(data, textStatus, jqXHR){
                // レスポンスが無かったりデータ破損を念のため警告
                if (!Object.keys(data).length) {
                    alert("CSVファイルがShift_JIS形式でない、または壊れている可能性があります。")
                }else{
                    // csvレコードをtableタグ出力
                    $.each(data, function(i, item) {
                        appendTag = funcAppendTag(item);
                        $table_body.append(appendTag);
                    });
                }
                console.log(data);
                open_file.text(name);
            }).fail(function(jqXHR, textStatus, errorThrown){
                alert("CSVファイルの取得に失敗しました");
            });
        }
    });
});

ビューのソースは必要最低限。
レスポンスが返ってくるまでは登録ボタンを隠したり、
処理時間がかかるのでローディングのアニメーションやら、
出力データが長いので「tablesorter」プラグインなんかも入れて
ユーザビリティに配慮してあげると非エンジニア勢も喜んでくれるハズ

コントローラー側概要

  1. 1.基本的にビュー側の初期アクションでは非同期として受け取ったPOSTデータ(CSVファイル)をJSON形式にして返してあげるだけ。
  2. 2.もし特定条件に一致する行データのみを抽出した場合はこっちで加工してあげないとJS側では4000行レベルはかなりキツイ
  3. 3.クライアント側で確認したビューデータを通常POSTさせたら実際にファイルを受け取ってモデルへ登録処理
  4. 4.複数のINSERTをする場合、パフォーマンスが倍以上違うので(というより終わらな過ぎてタイムアウトで弾かれる)のでバルクインサートを使う。railsではバルクインサートさせるには「activerecord-import」というgemが必要みたいなのでインストールして処理
  5. 5.それでも終わらない場合があるのでTimeoutメソッドを使ってある程度のタイムアウト処理をコントロール

csv_drop_controller.rb

class CsvDropController < InternalController

    require 'csv'
    require 'active_support'
    require 'active_record'
    require 'activerecord-import'
    require 'timeout'
    protect_from_forgery :except => [:index]
    protect_from_forgery with: :exception

    # 入力画面アクション
    def index
        if params[:file_input].present?
            @file = params[:file_input]
            # 「カラムn列目が"1"のレコードのみを取得」なのど条件抽出
            @csv_data = CSV.read(@file.path, encoding: "SJIS:UTF-8").select{|row| row[n] == "1"}
            # 抽出インスタンスをJSON形式に変換して非同期レスポンス
            respond_to do |f|
                logger.debug f
                f.json { render json: @csv_data.to_json(:include => [:data]) }
            end
        end
    end

    # 完了画面アクション
    def complete
        begin
            # クライアント側で対応してればいらないかも
            if params[:file_input].blank?
                @error_message = "ファイルが選択されていません"
                raise
            elsif File.extname(params[:file_input].original_filename) != ".csv"
                @error_message = "CSVを読み込んでください";
                raise
            else
                @file = params[:file_input]
                # ビュー側の条件抽出を改めて
                @csv_data = CSV.read(@file.path, encoding: "SJIS:UTF-8").select{|row| row[n] == "1"}

                begin
                    # 4000行くらいアップデートかけるとかなり時間かかってrails側で弾かれるんで10~13秒くらいでいったん諦める
                    Timeout.timeout(13) do
                        datas = []
                        # モデルのカラムを指定
                        columns = [:id, :column01, :column02, :column03, :column04, :column05]
                        @csv_data.each do |row|
                            # ID以外でユニークカラムの既存判定などする場合
                            if Model.exists?(corporation_number: row[1])
                                # 該当レコードが既にあったら一部フィールドのみを更新対象に設定
                                existing = Model.where(column01: row[1])
                                logger.debug existing
                                existing.each do |data|
                                    data.column02= row[6]
                                    data.column03= row[8]
                                    datas << users
                                end
                            else
                                # 新規の場合はCSVそれぞのフィールドを指定して連結
                                datas << Model.new(:column01=> row[1], :column02=> row[6], :column03=> row[8], :column04=> row[10], :column05=> row[12])
                            end
                        end
                        # datasに連結した配列を一気にバルクインサート(アップデートは上書きしたいカラムをそれぞれ指定)
                        Model.import datas, on_duplicate_key_update: [:column02, :column03]
                    end

                rescue => e
                    logger.error e
                    @error_message = "CSVのデータ大きすぎるためファイル内容を分割して登録してください";
                    raise
                end
            end
        rescue => e
            # 例外処理の失敗うんぬん
            logger.error e
            if @error_message.blank?
                @error_message = e
            end
            render action: :index
        end
    end
end


トップへ