【Rails 6】単一のコントローラー、ビューで複数モデルに対応した汎用性高めのCSVインポート機能を作る

## 概要

先日、Rails でとあるサービスを作っている際に「〇〇 と △△ と □□ 〜 の CSV インポート機能を作ってくれ」といった要望を一度にたくさん受けたのですが、単にモデルの数だけ同じようなコードがダラダラと増えるのも嫌だなぁと思い何か良さげな書き方が無いか探していたところ、そこそこ汎用性の高そうなコードができたので紹介します。

![csv-importer (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/86fcfd44-822a-15fa-b0f4-60020b13577a.gif)

**主な内容**

- 1つのコントローラー、ビューで複数モデルの CSV インポートに対応
- データ挿入前にプレビュー画面を挟み、取り込み予定データの内容を確認可能
- CSVファイルの中身に問題があった場合、何行目のどこが悪いかを表示

全体的に "Dry" かつコンパクトに、たとえ後に対象のモデルが増えたとしてもなるべく少量の追記で対応できるように心がけました。

## 仕様

- Ruby 3
- Rails 6
- PostgreSQL 13
- Bootstrap 5
- Docker

今回は Dockerfile を書くところから始めていきます。

## 下準備

まず最初に下準備から。

### 各種ディレクトリ & ファイルを作成

```
$ mkdir rails-multi-csv-importer && cd rails-multi-csv-importer
$ touch Dockerfile docker-compose.yml entrypoint.sh Gemfile Gemfile.lock
```

```dockerfile:./Dockerfile
FROM ruby:3.0

RUN curl https://deb.nodesource.com/setup_14.x | bash

RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
    && echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list

RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs yarn

ENV APP_PATH /myapp

RUN mkdir $APP_PATH
WORKDIR $APP_PATH

COPY Gemfile $APP_PATH/Gemfile
COPY Gemfile.lock $APP_PATH/Gemfile.lock
RUN bundle install

COPY . $APP_PATH

COPY entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
EXPOSE 3000

CMD ["rails", "server", "-b", "0.0.0.0"]
```

```yml:./docker-compose.yml
version: "3"
services:
  db:
    image: postgres:13-alpine
    environment:
      POSTGRES_USER: root
      POSTGRES_PASSWORD: password
    volumes:
      - psgl_data:/var/lib/postgresql/data
    ports:
      - 5432:5432
  web:
    build:
      context: .
      dockerfile: Dockerfile
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/myapp
      - ./vendor/bundle:/myapp/vendor/bundle
    environment:
      TZ: Asia/Tokyo
      RAILS_ENV: development
    ports:
      - "3000:3000"
    depends_on:
      - db
volumes:
  psgl_data:
```


```sh:./entrypoint.sh
#!/bin/bash
set -e

# Remove a potentially pre-existing server.pid for Rails.
rm -f /myapp/tmp/pids/server.pid

# Then exec the container's main process (what's set as CMD in the Dockerfile).
exec "$@"
```

```ruby:./Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem "rails", "~> 6"
```

```ruby:./Gemfile.lock
# 空欄でOK
```

### rails new

おなじみのコマンドでアプリの雛型を作ります。

```
$ docker-compose run web rails new . --force --no-deps -d postgresql --skip-test
```

### database.ymlを編集

デフォルトの状態だとデータベースとの接続ができないので「database.yml」の一部を書き換えます。

```yml:./config/database.yml
default: &default
  adapter: postgresql
  encoding: unicode
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password: password # デフォルトだと空欄になっているはずなので変更
  host: db # デフォルトだとlocalhostになっているはずなので変更

development:
  <<: *default
  database: myapp_development

test:
  <<: *default
  database: myapp_test

production:
  <<: *default
  database: <%= ENV["DATABASE_NAME"] %>
  username: <%= ENV["DATABASE_USERNAME"] %>
  password: <%= ENV["DATABASE_PASSWORD"] %>
```

### コンテナを起動 & データベースを作成

```
$ docker-compose build
$ docker-compose up -d
$ docker-compose run web bundle exec rails db:create
```

### 動作確認（localhost:3000 にアクセス）

![スクリーンショット 2022-01-03 21.41.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/6d3c7749-4e15-01c9-046e-9bb02ac964a6.png)


[localhost:3000](http://localhost:3000/) にアクセスしてウェルカムページが表示されればOKです。

### slim を導入

個人的にテンプレートエンジンは erb よりも slim の方が好みなので変更します。

```rb:Gemfile
gem "slim-rails"
gem "html2slim"
```

```
$ docker-compose build
```

既存のビューファイルを slim に書き換え。

```
$ docker-compose run web bundle exec erb2slim app/views app/views
$ docker-compose run web bundle exec erb2slim app/views app/views -d
```




### Bootstrap を導入

見た目を整えるために Bootstrap を使用します。

#### yarn install

必要なライブラリをインストール。

```
$ docker-compose run web yarn add bootstrap @popperjs/core
```

#### app/views/layouts/application.html

```diff_ruby:./app/views/layouts/application.html
- = stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload'
+ = stylesheet_pack_tag 'application', media: 'all', 'data-turbolinks-track': 'reload'
```

「app/views/layouts/application.html」内の9行目あたりに記述されている「stylesheet_link_tag」を「stylesheet_pack_tag」に書き換えます。

#### app/javascript/stylesheets/application.scss

```
$ mkdir app/javascript/stylesheets
$ touch app/javascript/stylesheets/application.scss
```

「app/javascript/stylesheets/」以下に「application.scss」を作成し、次の１行を追記してください。

```rb:./app/javascript/stylesheets/application.scss
@import "~bootstrap/scss/bootstrap";
```

#### app/javascript/packs/application.js

「app/javascript/packs/application.js」内に次の2行を追記。

```javascript:./app/javascript/packs/application.js
import "bootstrap";
import "../stylesheets/application";
```

これで Bootstrap の設定は完了です。

Bootstrapは v5 から JQuery に依存しなくなったので以前よりも楽に導入できるようになりましたね。

## 本実装

下準備ができたところで、いよいよ本格的な実装に入ります。

### 各種 gem をインストール

```rb:Gemfile
gem "roo"
gem "activerecord-import"
gem "rails-i18n"
```

```
$ docker-compose build
```

- [roo](https://github.com/roo-rb/roo)
    - CSV ファイルを読み取るための gem
- [activerecord-import](https://github.com/zdennis/activerecord-import)
    - バルクインサートを行うための gem
- [rails-i18n](https://github.com/svenfuchs/rails-i18n)
    - 日本語化対応のためのgem


※ Rails 6 からは標準でバルクインサート機能が備わっているみたいですが、個人的に activerecord-import が使い慣れているので今回はこちらを使用しました。

### 各種モデルを作成

今回は

- Author（作者）
- Book（書籍）

というシンプルなデータ構造のモデルを2つ用意します。

```
$ docker-compose run web rails g model Author name:string email:string birthdate:date
$ docker-compose run web rails g model Book title:string price:integer published_date:date
$ docker-compose run web rails db:migrate
```

バリデーションもそれっぽく設定しておきましょう。

```rb:app/models/author.rb
class Author < ApplicationRecord
  validates :name, presence: true, uniqueness: true
  validates :email, presence: true, uniqueness: true
  validates :birthdate, presence: true
end
```

```rb:app/models/book.rb
class Book < ApplicationRecord
  validates :title, presence: true, uniqueness: true
  validates :price, presence: true
  validates :published_date, presence: true
end
```

### 日本語化対応

「config/application.rb」内に以下を内容を記述し、デフォルトのタイムゾーンや言語を日本に変更してください。


```rb:./config/application.rb
module Myapp
  class Application < Rails::Application
    # ...
    
    # 追記
    config.time_zone = "Tokyo"
    config.active_record.default_timezone = :local
    config.i18n.default_locale = :ja
    config.i18n.load_path += Dir[Rails.root.join("config", "locales", "**", "*.{rb,yml}").to_s]
    
    # ...
  end
end
```

また、「config/locales/」以下にja.ymlを作成します。

```
$ touch config/locales/ja.yml
```

```yml:./config/locales/ja.yml
ja:
  activerecord:
    models:
      author: 作者
      book: 書籍
    attributes:
      id: ID
      created_at: 作成日時
      updated_at: 更新日時
      author:
        name: 名前
        email: メールアドレス
        birthdate: 生年月日
      book:
        title: 作品名
        price: 価格
        published_date: 出版日
```


### CsvImporter クラスを作成

CSVインポート用のクラスを「lib/」以下に作成します。

```rb:./lib/csv_importer.rb
class CsvImporter
  class << self
    def run(file, model_name)
      # モデルによってヘッダーを設定
      headers = set_headers(model_name)

      results = [] # バリデーションチェックに通過した（= DBに取り込み可能な）データを格納する配列
      errors = []  # CSVファイル読み込み中に生じたエラーを格納する配列

      CSV.foreach(file.path, headers: true, skip_blanks: true).with_index(2) do |row, index|
        # ヘッダーの項目に従って各値を切り出し
        row_hash = row.to_hash.slice(*headers.keys)
        attributes = row_hash.transform_keys(&headers.method(:[]))

        # バリデーションチェック（無効な値が入っていた場合はエラーを発生させる）
        instance = model_name.constantize.new(attributes)

        if instance.valid?
          results << attributes
        else
          # 何行目でエラーが生じたのかも記録
          errors.push({ row_number: index, messages: instance.errors.full_messages })
        end

      rescue StandardError => e
        errors.push({ row_number: index, messages: [e] })
        return [], errors
      end

      [results, errors]
    end

    private
      
      def set_headers(model_name)
        headers = {}
        
        # locales ファイルに登録した値を key/value として使用
        I18n.t("activerecord.attributes.#{model_name.underscore}").each do |k, v|
          headers.store(v, k.to_s)
        end

        headers
      end
  end
end
```

モデルやヘッダーといった情報を引数で受け取る事でなるべく汎用性高く使い回せるようにしました。

なお、「lib/」以下のファイルを読み込むための設定を忘れずに「config/application.rb」内に記述しておいてください。

```rb:./config/application.rb
module Myapp
  class Application < Rails::Application
    # ...
    
    # lib 以下のファイルを読み込むように
    config.autoload_paths += %W[#{config.root}/lib]
    
    # ...
  end
end
```

### コントローラーを作成

```
$ docker-compose run web rails g controller csv
```

```rb:./app/controllers/csv_controller.rb
class CsvController < ApplicationController
  # フラッシュメッセージを Bootstrap 対応させるための設定
  add_flash_types :success, :info, :warning, :danger
  
  def index; end

  def create
    model_name = params[:model_name]
    
    # results は JSON 形式の配列で渡ってくるので parse が必要
    results = params[:results].map { |row_json| JSON.parse(row_json) }

    instances = results.map do |attributes|
      model_name.constantize.new(attributes)
    end

    # バルクインサート
    ActiveRecord::Base.transaction do
      model_name.constantize.import!(instances)
    end

    redirect_to csv_index_path, success: set_flash_message(model_name)
  end

  def import
    file = params[:file]
    @model_name = params[:model_name]

    # CsvImporter.run() の返り値は Hash 形式の配列
    @results, @errors = CsvImporter.run(file, @model_name)

    respond_to do |format|
      if @errors.present?
        format.js { render :error }
      else
        format.js { render :success }
      end
    end
  end

  private

    # モデルによって異なるフラッシュメッセージを設定
    def set_flash_message(model_name)
      "#{t("activerecord.models.#{model_name.underscore}")}のCSVインポートに成功しました"
    end
end
```

params の受け渡しや locales ファイルを活用する事で、少ない記述で複数モデルに対応可能となっています。

また、import アクションの結果は　Ajax　による非同期通信でスムーズな画面表示をさせるようにしました。

### ビューを作成

```
$ touch app/views/csv/index.html.slim
$ touch app/views/csv/success.js.erb app/views/csv/error.js.erb
$ touch app/views/csv/_results.html.slim app/views/csv/_error_messages.html.slim
```

```slim:./app/views/csv/index.html.slim
// フラッシュメッセージ
- flash.each do |type, message|
  div class="alert alert-#{type} alert-dismissible rounded-0 fade show" role="alert" 
    button.btn-close aria-label="Close" data-bs-dismiss="alert" type="button" 
    h5.m-0
      = message

.container.p-3
  .row.mb-2
    .col-md-12
      .d-flex.align-items-center.mb-2
        h1.m-0.text-dark CSVインポート
        .ms-auto
          button.btn.btn-primary data-bs-target="#fileUploadModal" data-bs-toggle="modal" type="button"
            | ファイルアップロード
  
  .row
    .col-lg-12
      .card.card-primary.card-outline
        .card-body
          #outputs
            | ここにアップロードした結果が表示されます

// モーダル画面
#fileUploadModal.modal.fade aria-hidden="true" aria-labelledby="fileUploadModalLabel" tabindex="-1" 
  .modal-dialog
    .modal-content
      .modal-header
        h5#fileUploadModalLabel.modal-title
          | ファイルアップロード
        button.btn-close id="modalClose" aria-label="Close" data-bs-dismiss="modal" type="button"

      = form_with url: import_csv_index_path, local: false do |f|
        .modal-body
          table.table.table-borderless
            tr
              td = f.select :model_name, I18n.t("activerecord.models").map { |k, v| [v, k.to_s.camelize] }, { value: nil, selected: false, prompt: "対象のデータを選択してください" }, class: "form-select"
            tr
              td = f.file_field :file, accept: ".csv", class: "form-control"

        .modal-footer
          button.btn.btn-secondary data-bs-dismiss="modal" type="button"  閉じる
          = f.submit "送信", class: "btn btn-success"
```


```slim:./app/views/csv/success.js.erb
document.getElementById("outputs").innerHTML = "<%= j(render 'results', results: @results, model_name: @model_name) %>";
document.getElementById("modalClose").click();
```

```slim:./app/views/csv/error.js.erb
document.getElementById("outputs").innerHTML = "<%= j(render 'error_messages', errors: @errors) %>"
document.getElementById("modalClose").click();
```


```slim:./app/views/csv/_results.html.slim
table.table
  thead
    tr
      th.border-top-0 scope="col"
      
      - results[0].each do |k, _|
        th.border-top-0 scope="col"
          = t("activerecord.attributes.#{model_name.underscore}.#{k}")
    
    // データの件数をカウントするために each.with_index で回す
    - results.each.with_index(1) do |row_hash, index|
      tr
        td = index
        
        - row_hash.each do |k, _|
          td = row_hash[k]

// 取り込み予定のデータを JSON 形式の配列で create アクションに渡す
= form_with url: csv_index_path, local: true do |f|
  - results.map { |row_hash| row_hash.to_json }.each do |row_json|
    = f.hidden_field :results, multiple: true, value: row_json
    = f.hidden_field :model_name, value: model_name
  
  .mt-3.text-end
    = link_to "リセット", csv_index_path, class: "btn btn-secondary"
    = f.submit "登録", class: "btn btn-success ms-2"
```

```slim:./app/views/csv/_error_messages.html.slim
p CSVファイルに問題があるため、インポートに失敗しました

// エラーが生じた行数と具体的な内容を出力
- @errors.each do |error|
  - error[:messages].each do |message|
    li = error[:row_number] ? "#{error[:row_number]}行目：#{message}" : message

.mt-3.text-right
  = link_to "リセット", csv_index_path, class: "btn btn-secondary"
```

### ルーティングを設定

最後にルーティングを設定しましょう。

```rb:./config/routes.rb
Rails.application.routes.draw do
  resources :csv, only: %i[index create] do
    collection do
      post :import
    end
  end
end
```

## 動作確認

[localhost:3000/csv](http://localhost:3000/csv) にアクセスして次のようになっていれば無事完成です。

![qCxqje72THYSmSV1641241811_1641241820.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/ff287fde-285d-9c53-3eda-b8a63dd9da62.png)


```
$ touch authors.csv books.csv
```

```:./authors.csv
名前,メールアドレス,生年月日
夏目漱石,natsume_soseki@example.com,1867-02-09
芥川龍之介,akutagawa_ryunosuke@example.com,1892-03-01
太宰治,dazai_osamu@example.com,1909-06-19
```

```:./books.csv
作品名,価格,出版日
吾輩は猫である,1870,1905-10-06
羅生門,3300,1917-05-01
人間失格,1375,1948-07-25
```

適当な CSV ファイルを作成して試しにアップロードしてみましょう。

![スクリーンショット 2022-01-04 5.31.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/40a0f497-9fae-4759-fed7-d73b3bf876ca.png)

取り込みたい対象のデータと、それに対応した CSV ファイルを選択します。

![スクリーンショット 2022-01-04 5.39.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/ec47b176-ae10-a055-14bb-0123c048428e.png)

特に CSV ファイルの中身に問題が無ければインポートに成功するはず。

```rb
Author Create Many (1.8ms)  INSERT INTO "authors" ("id","name","email","birthdate","created_at","updated_at") VALUES (nextval('public.authors_id_seq'),'夏目漱石','natsume_soseki@example.com','1867-02-09','2022-01-04 05:32:45.496784','2022-01-04 05:32:45.496784'),(nextval('public.authors_id_seq'),'芥川龍之介','akutagawa_ryunosuke@example.com','1892-03-01','2022-01-04 05:32:45.496784','2022-01-04 05:32:45.496784'),(nextval('public.authors_id_seq'),'太宰治','dazai_osamu@example.com','1909-06-19','2022-01-04 05:32:45.496784','2022-01-04 05:32:45.496784') RETURNING "id"
```

ちゃんとバルクインサートされていますね。

![スクリーンショット 2022-01-04 5.35.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/f4ffa99b-8245-f764-2b36-16772288cbff.png)

逆に CSV ファイルの中身に問題がある場合は何行目のどこが悪いのかといった具体的なメッセージが表示されるので、それに従いファイルを修正してください。

## あとがき

以上、1つのコントローラー、ビューで複数モデルに対応した汎用性の高いCSVインポート機能を作ってみました。

少しでも参考になれば幸いです。もし他に良い方法があるよという方はコメントでご指摘ください。

https://github.com/kazama1209/rails-multi-csv-importer