0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Rails初心者】RakeタスクでCSVインポートを30秒で完了させた

Last updated at Posted at 2025-11-01

はじめに

何を作ったか

現在未経験からエンジニアに転職するためポートフォリオを作成しています。

本記事では、青空文庫が公開している全作品データ(約18,000件)のCSVファイルをRailsアプリケーションのデータベースに取り込むRakeタスクを実装しました。

実装の過程で以下の課題に直面し、解決しました

  • 初回実装では5〜10分かかっていた処理を約30秒に高速化
  • CSVファイルのBOM(Byte Order Mark)問題
  • 何度実行しても安全なべき等性の確保

この記事で学べること

  • Rakeタスクの基本的な作成方法
  • バルクINSERTによる大量データの高速処理(30〜60倍の改善)
  • CSVファイルのBOM問題とその対処法
  • べき等なデータインポートの実装方法
  • 実際の開発で遭遇した問題のデバッグ手法

対象読者

  • Railsで大量データのインポート処理を実装したい方
  • Rakeタスクのな使い方を知りたい初心者
  • CSVインポートでパフォーマンス問題に直面している方

環境

  • Ruby 3.2.2
  • Rails 7.2.2
  • MySQL 8.0.40
  • Docker

やりたいこと

  • 青空文庫の全作品データ(約18,000件)をDBに格納
  • Rakeタスクで自動化
  • 何度実行しても安全(べき等性)

実装の流れ

1. テーブル設計

📌 CSVファイルの取得先
青空文庫の全作品リストCSVは以下に公開されています
新しい作品が公開されるごとに、こちらも更新されるようです
https://www.aozora.gr.jp/index_pages/person_all.html

青空文庫の全作品リストCSVには、作品情報・著者情報・底本情報など約60項目が含まれています。
今回は以下の6項目を抽出してDBに保存します

データ例:「ウェストミンスター寺院」

DB項目 CSV項目
aozora_book_id 作品ID 059898
title 作品名 ウェストミンスター寺院
author 姓・名 ワシントン・アーヴィング
published_date 公開日 2020-04-03
aozora_content_url テキストファイルURL https://www.aozora.gr.jp/cards/001257/files/59898_70731.html
aozora_card_url 図書カードURL https://www.aozora.gr.jp/cards/001257/card59898.html

マイグレーションファイル

class CreateAozoraBooks < ActiveRecord::Migration[7.2]
  def change
    create_table :aozora_books do |t|
      t.string :aozora_book_id, null: false
      t.string :title, null: false
      t.string :author, null: false
      t.string :aozora_content_url, null: false
      t.string :aozora_card_url
      t.date :published_date

      t.timestamps
    end
    add_index :aozora_books, :aozora_book_id, unique: true
    add_index :aozora_books, :title
    add_index :aozora_books, :author
  end
end

2. rakeファイル実装

rails/lib/tasks/aozora_books.rakeを作成し、コードを記述します
Rakeタスクはコマンドで必要なときだけ実行します
コマンド$ rails aozora_books:importで実行できます

インポート処理以外の大枠

require 'open-uri'
require 'zip'
require 'csv'

namespace :aozora_books do
  desc "青空文庫の全作品データ(約18,000件)をCSVからインポート"
  task import: :environment do
    url = "https://www.aozora.gr.jp/index_pages/list_person_all_extended_utf8.zip"
    
    puts "青空文庫CSVをダウンロード中..."
    
    begin
      # ZIPファイルをダウンロード
      URI.open(url) do |zip_file|
        Zip::File.open(zip_file) do |zip|
          # zip.glob('*.csv') = ZIP内の全CSVファイルを検索 / .first = 最初のCSVファイルを取得
          csv_entry = zip.glob('*.csv').first
          
          puts "CSVファイルを読み込み中..."
          csv_data = csv_entry.get_input_stream.read.force_encoding('UTF-8')
          
          # ここにインポート処理(後述)
        end
      end
    rescue => e
      puts "❌ エラー: #{e.message}"
      raise e
    end
  end
end

コードの解説

説明
require 'zip' rubyzip gem(Gemfileに追加必要)
task import: :environment Railsの環境を読み込む(モデルが使える)
URI.open(url) URLから直接ダウンロード
Zip::File.open ZIPを解凍
csv_entry.get_input_stream.read ZIP内のCSVファイルを読めるように開き、中身を全部読み込む

インポート処理内部

  ActiveRecord::Base.transaction do
    puts "既存データを削除中..."
    AozoraBook.delete_all
    
    books_data = []
    count = 0
    skipped_count = 0
    line_number = 0

    # CSV.parse が自動的にループする / CSVファイルに含まれる約18,000作品全てに対し実行される
    # 「row」内に1つ1つの作品データが格納される     
    CSV.parse(csv_data, headers: true) do |row|
      line_number += 1
      
      begin
        # 作品IDがない作品はスキップ
        if row['作品ID'].blank?
          puts "⚠️ 行#{line_number}: 作品IDが空です"
          puts "   行の内容: #{row.to_h.inspect}"
          skipped_count += 1
          next
        end
        
        # 本文URLがない作品はスキップ
        if row['XHTML/HTMLファイルURL'].blank?
          skipped_count += 1
          next
        end

        # データを配列に追加(DBには保存しない)
        # データのマッピング:CSVの列名とDBのカラム名を対応付けている
        books_data << {
          aozora_book_id: row['作品ID'],
          title: row['作品名'] || '',  # nil対策
          author: "#{row['姓'] || ''} #{row['名'] || ''}".strip,
          aozora_content_url: row['XHTML/HTMLファイルURL'],
          aozora_card_url: row['図書カードURL'],
          published_date: row['公開日'].presence,
          created_at: Time.current,
          updated_at: Time.current
        }

        count += 1

        # 配列books_dataに1000件追加された時DBにINSERTする
        if books_data.size >= 1000
          # insert_all: 一括挿入(MySQLで動作)
          AozoraBook.insert_all(books_data)
          puts "#{count}件処理完了"
          books_data = []  # 配列をクリア
        end
        
      rescue StandardError => e
        skipped_count += 1
        puts "⚠️ 行#{line_number}でエラー:"
        puts "   作品ID: #{row['作品ID'].inspect}"
        puts "   エラー: #{e.class}: #{e.message}"
        puts "   行の内容(最初の200文字): #{row.to_h.inspect[0..200]}"
        next
      end
    end

    # 残りの1000件以下のデータをINSERT
    if books_data.any?
      AozoraBook.insert_all(books_data)
    end

    # ターミナルに進行状況を表示
    puts "━━━━━━━━━━━━━━━━━━━━━━━━━━"
    puts "✅ インポート完了!"
    puts "   処理件数: #{count}件"
    puts "   スキップ: #{skipped_count}件"
    puts "   DB登録: #{AozoraBook.count}件"
    puts "━━━━━━━━━━━━━━━━━━━━━━━━━━"
  end  # ← トランザクション終了

コードの解説

  • 途中でエラーが発生し中断された場合、その途中までのデータのみMySQLに保存されるのを防ぐためにActiveRecord::Base.transaction do ~ endを使用します

  • AozoraBook.delete_allをDB操作の最初に行うことで、べき等性を確保しています

  • 18,000件を1件ずつINSERTすると非常に遅い(5分~10分)ため、1000件ごとに配列[books_data]へ格納した後、まとめてINSERTします(約30秒)

  • 残りの作品リストデータが1000件以下になった場合、insert_allが発火しないために個別に記述します

3. 躓いたエラー:BOM問題

「2. 実装」で作成したRakeタスクを実行

$ docker-compose exec rails rake aozora_books:import

順調に進んでいると思ったらエラー発生...

青空文庫CSVをダウンロード中...
CSVファイルを読み込み中...
既存データを削除中...
警告: スキップしました - 作品ID: , エラー: Column 'aozora_book_id' cannot be null
警告: スキップしました - 作品ID: , エラー: Column 'aozora_book_id' cannot be null
警告: スキップしました - 作品ID: , エラー: Column 'aozora_book_id' cannot be null
...
(延々と続く)

全件がスキップされてしまいました
エラーメッセージを見ると

作品ID:の後が空
aozora_book_idnullになっている

つまりrow['作品ID']nullを返しているようです。
しかし、CSVファイルを目視で確認すると確実に作品IDデータは入っています

作品ID,作品名,作品名読み,...
"059898","ウェストミンスター寺院","ウェストミンスターじいん",...
"056078","駅伝馬車","えきでんばしゃ",...

エラーの原因

このエラーの原因はCSVファイルの先頭(最初のヘッダー名の前)にBOMが含まれていたのが原因です。
これがCSVファイルの先頭に配置されたため、データ取得時に問題が発生してしまいました

BOMとはByte Order Markの略で、Unicodeテキストファイルの先頭に挿入される特殊なバイトのシーケンスです。
主に、ファイルのエンコーディングと文字の並び順(バイトオーダー)を示すために使用されます。
UTF-8形式のファイルにBOMが含まれると、ファイルを開くときにテキストエディターなどのソフトウェアが正しくエンコードを解釈できるようになります。

エラーへの対応

DB操作前に、BOM削除の処理を追加することで解決しました

  puts "CSVファイルを読み込み中..."
  csv_data = csv_entry.get_input_stream.read.force_encoding('UTF-8')
  csv_data = csv_data.sub("\uFEFF", '')  # ← BOM削除を追加!

  ActiveRecord::Base.transaction do

エラー対処も含めた完全版コード
require 'open-uri'
require 'zip'
require 'csv'

namespace :aozora_books do
  desc "青空文庫の全作品データ(約18,000件)をCSVからインポート"
  task import: :environment do
    url = "https://www.aozora.gr.jp/index_pages/list_person_all_extended_utf8.zip"
    
    puts "青空文庫CSVをダウンロード中..."
    
    begin
      URI.open(url) do |zip_file|
        Zip::File.open(zip_file) do |zip|
          # zip.glob('*.csv') = ZIP内の全CSVファイルを検索 / .first = 最初のCSVファイルを取得
          csv_entry = zip.glob('*.csv').first
          
          puts "CSVファイルを読み込み中..."
          # ZIP内のCSVファイルを読めるように開き、中身を全部読み込む
          csv_data = csv_entry.get_input_stream.read.force_encoding('UTF-8')
          csv_data = csv_data.sub("\uFEFF", '')  # ← BOM削除を追加!

          # トランザクション開始(DB操作のみをトランザクション内で実行)
          ActiveRecord::Base.transaction do

            # すでに一度DBに作品リストが登録済みの場合、エラーになるため実行前に全削除
            puts "既存データを削除中..."
            AozoraBook.delete_all
            
            books_data = []
            count = 0
            skipped_count = 0
            line_number = 0

            # CSV.parse が自動的にループする / CSVファイルに含まれる18,000作品全てに対し実行される
            # 「row」内に1つ1つの作品データが格納されている     
            CSV.parse(csv_data, headers: true) do |row|
              line_number += 1
              
              begin
                # 作品IDまたは本文URLがない作品はスキップ
                if row['作品ID'].blank?
                  puts "⚠️ 行#{line_number}: 作品IDが空です"
                  puts "   行の内容: #{row.to_h.inspect}"
                  skipped_count += 1
                  next
                end
                
                # 本文URLがない作品はスキップ
                if row['XHTML/HTMLファイルURL'].blank?
                  skipped_count += 1
                  next
                end

                # データを配列に追加(DBには保存しない)
                # データのマッピング:CSVの列名とDBのカラム名を対応付けている
                books_data << {
                  aozora_book_id: row['作品ID'],
                  title: row['作品名'] || '',  # nil対策
                  author: "#{row['姓'] || ''} #{row['名'] || ''}".strip,
                  aozora_content_url: row['XHTML/HTMLファイルURL'],
                  aozora_card_url: row['図書カードURL'],
                  published_date: row['公開日'].presence,
                  created_at: Time.current,
                  updated_at: Time.current
                }

                count += 1

                # 配列books_dataに1000件追加された時DBにINSERTする
                if books_data.size >= 1000
                  # insert_all: 一括挿入(MySQLで動作)
                  AozoraBook.insert_all(books_data)
                  puts "#{count}件処理完了"
                  books_data = []  # 配列をクリア
                end
                
              rescue StandardError => e
                skipped_count += 1
                puts "⚠️ 行#{line_number}でエラー:"
                puts "   作品ID: #{row['作品ID'].inspect}"
                puts "   エラー: #{e.class}: #{e.message}"
                puts "   行の内容(最初の200文字): #{row.to_h.inspect[0..200]}"
                next
              end
            end

            # 残りの1000件以下のデータをINSERT
            if books_data.any?
              AozoraBook.insert_all(books_data)
            end
            
            puts "━━━━━━━━━━━━━━━━━━━━━━━━━━"
            puts "✅ インポート完了!"
            puts "   処理件数: #{count}件"
            puts "   スキップ: #{skipped_count}件"
            puts "   DB登録: #{AozoraBook.count}件"
            puts "━━━━━━━━━━━━━━━━━━━━━━━━━━"
          end  # ← トランザクション終了
        end
      end
      
    rescue => e
      puts "❌ エラーが発生しました: #{e.message}"
      puts "   バックトレース:"
      puts e.backtrace.first(5)
      raise e
    end
  end
end

4. 実行結果

BOM削除の処理を追加した後、Rakeタスクを実行したところ、正常に完了しました。

$ docker-compose exec rails rake aozora_books:import

青空文庫CSVをダウンロード中...
CSVファイルを読み込み中...
既存データを削除中...
1000件処理完了
2000件処理完了
...
17000件処理完了
━━━━━━━━━━━━━━━━━━━━━━━━━━
 インポート完了
   処理件数: 17,660
   スキップ: 217
   DB登録: 17,660
━━━━━━━━━━━━━━━━━━━━━━━━━━
実行時間: 約30秒

データが正しくDBに保存されているか、rails consoleで確認してみます。

Loading development environment (Rails 7.2.2.1)
[1] pry(main)> AozoraBook.count
  AozoraBook Count (4.0ms)  SELECT COUNT(*) FROM aozora_books
=> 17660
[2] pry(main)> AozoraBook.first
  AozoraBook Load (1.5ms)  SELECT aozora_books.* FROM aozora_books ORDER BY aozora_booksid ASC LIMIT 1
=> #<AozoraBook:0x00007f93c830c498
 id: 1,
 aozora_book_id: "059898",
 title: "ウェストミンスター寺院",
 author: "アーヴィング ワシントン",
 aozora_content_url: "https://www.aozora.gr.jp/cards/001257/files/59898_...",
 aozora_card_url: "https://www.aozora.gr.jp/cards/001257/card59898.ht...",
 published_date: "2020-04-03",
 created_at: "2025-10-31 05:17:16.692569000 +0000",
 updated_at: "2025-10-31 05:17:16.692607000 +0000">
[3] pry(main)> AozoraBook.where("author LIKE ?", "%夏目%").count
  AozoraBook Count (8.7ms)  SELECT COUNT(*) FROM aozora_books WHERE (author LIKE '%夏目%')
=> 113

データが正常にインポートされ、検索機能も問題なく動作していることが確認できました。
夏目漱石の作品が113件登録されていることも確認できています

終わりに

青空文庫の約18,000件のデータを30秒でインポートするRakeタスクを実装しました。

実装のポイント

  • バルクINSERTで30〜60倍高速化(5〜10分 → 30秒)
  • BOM削除でCSV読み込みエラーを解決
  • トランザクションでデータ整合性を保証
  • べき等性を確保し、何度実行しても安全

参考資料

最後まで読んでいただきありがとうございました!
同じような実装で困っている方の参考になれば幸いです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?