概要
先日、Rails でとあるサービスを作っている際に「〇〇 と △△ と □□ 〜 の CSV インポート機能を作ってくれ」といった要望を一度にたくさん受けたのですが、単にモデルの数だけ同じようなコードがダラダラと増えるのも嫌だなぁと思い何か良さげな書き方が無いか探していたところ、そこそこ汎用性の高そうなコードができたので紹介します。
主な内容
- 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
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"]
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:
#!/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 "$@"
# frozen_string_literal: true
source "https://rubygems.org"
git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
gem "rails", "~> 6"
# 空欄でOK
rails new
おなじみのコマンドでアプリの雛型を作ります。
$ docker-compose run web rails new . --force --no-deps -d postgresql --skip-test
database.ymlを編集
デフォルトの状態だとデータベースとの接続ができないので「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 にアクセス)
localhost:3000 にアクセスしてウェルカムページが表示されればOKです。
slim を導入
個人的にテンプレートエンジンは erb よりも slim の方が好みなので変更します。
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
- = 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」を作成し、次の1行を追記してください。
@import "~bootstrap/scss/bootstrap";
app/javascript/packs/application.js
「app/javascript/packs/application.js」内に次の2行を追記。
import "bootstrap";
import "../stylesheets/application";
これで Bootstrap の設定は完了です。
Bootstrapは v5 から JQuery に依存しなくなったので以前よりも楽に導入できるようになりましたね。
本実装
下準備ができたところで、いよいよ本格的な実装に入ります。
各種 gem をインストール
gem "roo"
gem "activerecord-import"
gem "rails-i18n"
$ docker-compose build
-
roo
- CSV ファイルを読み取るための gem
-
activerecord-import
- バルクインサートを行うための gem
-
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
バリデーションもそれっぽく設定しておきましょう。
class Author < ApplicationRecord
validates :name, presence: true, uniqueness: true
validates :email, presence: true, uniqueness: true
validates :birthdate, presence: true
end
class Book < ApplicationRecord
validates :title, presence: true, uniqueness: true
validates :price, presence: true
validates :published_date, presence: true
end
日本語化対応
「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
ja:
activerecord:
models:
author: 作者
book: 書籍
attributes:
id: ID
created_at: 作成日時
updated_at: 更新日時
author:
name: 名前
email: メールアドレス
birthdate: 生年月日
book:
title: 作品名
price: 価格
published_date: 出版日
CsvImporter クラスを作成
CSVインポート用のクラスを「lib/」以下に作成します。
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」内に記述しておいてください。
module Myapp
class Application < Rails::Application
# ...
# lib 以下のファイルを読み込むように
config.autoload_paths += %W[#{config.root}/lib]
# ...
end
end
コントローラーを作成
$ docker-compose run web rails g controller csv
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
// フラッシュメッセージ
- 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"
document.getElementById("outputs").innerHTML = "<%= j(render 'results', results: @results, model_name: @model_name) %>";
document.getElementById("modalClose").click();
document.getElementById("outputs").innerHTML = "<%= j(render 'error_messages', errors: @errors) %>"
document.getElementById("modalClose").click();
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"
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"
ルーティングを設定
最後にルーティングを設定しましょう。
Rails.application.routes.draw do
resources :csv, only: %i[index create] do
collection do
post :import
end
end
end
動作確認
localhost:3000/csv にアクセスして次のようになっていれば無事完成です。
$ touch authors.csv books.csv
名前,メールアドレス,生年月日
夏目漱石,natsume_soseki@example.com,1867-02-09
芥川龍之介,akutagawa_ryunosuke@example.com,1892-03-01
太宰治,dazai_osamu@example.com,1909-06-19
作品名,価格,出版日
吾輩は猫である,1870,1905-10-06
羅生門,3300,1917-05-01
人間失格,1375,1948-07-25
適当な CSV ファイルを作成して試しにアップロードしてみましょう。
取り込みたい対象のデータと、それに対応した CSV ファイルを選択します。
特に CSV ファイルの中身に問題が無ければインポートに成功するはず。
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"
ちゃんとバルクインサートされていますね。
逆に CSV ファイルの中身に問題がある場合は何行目のどこが悪いのかといった具体的なメッセージが表示されるので、それに従いファイルを修正してください。
あとがき
以上、1つのコントローラー、ビューで複数モデルに対応した汎用性の高いCSVインポート機能を作ってみました。
少しでも参考になれば幸いです。もし他に良い方法があるよという方はコメントでご指摘ください。