背景
RailsでCSVを返す時、10万データくらいのものを普通に処理するとメモリを食い潰したり、処理が遅かったりする。
そこで解決策を探していたら、RustでRailsの代わりにCSVを作るという素晴らしい記事に出会った。
この記事はPostgresやNginxを使ってたが、私はMySQLを使っており、Railsのアプリケーション内で完結したかったので、参考にしながら試験的に作って見た。
お断り
Railsをすでに理解していることを前提に進めます。
初心者の方へ環境構築周りを最後の方におまけとして書いておきますので参考にしてください。
筆者はRust初心者ですのでアドバイスを頂けるととても助かります。
環境
- Ruby 2.3.3
- Rust 1.14.0
- Rails 5.0.1
Gemfileは基本デフォルト
source 'https://rubygems.org'
git_source(:github) do |repo_name|
repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?("/")
"https://github.com/#{repo_name}.git"
end
gem 'rails', '~> 5.0.1'
gem 'mysql2', '>= 0.3.18', '< 0.5'
gem 'puma', '~> 3.0'
gem 'sass-rails', '~> 5.0'
gem 'uglifier', '>= 1.3.0'
gem 'coffee-rails', '~> 4.2'
gem 'jquery-rails'
gem 'turbolinks', '~> 5'
gem 'jbuilder', '~> 2.5'
gem 'ffi'
group :development, :test do
gem 'byebug', platform: :mri
end
group :development do
gem 'web-console', '>= 3.3.0'
gem 'listen', '~> 3.0.5'
gem 'spring'
gem 'spring-watcher-listen', '~> 2.0.0'
end
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]
cargoの設定は以下の通り(CSVクレートを使ったバージョンは最後の方にある追記を参考にしてください)
[dependencies]
mysql = "9.0.1"
libc = "0.2.0"
[lib]
name = "csv"
crate-type = ["dylib"]
Rails側の設定
bundle exec rails new . -BTf -d mysql
でnewした時の設定とする。
データの作成
bundle exec rails g model csv_tests
でModelを作成。
migrationファイルは以下のようにする。
class CreateCsvTests < ActiveRecord::Migration[5.0]
def change
create_table :csv_tests do |t|
t.string :name
t.string :hoge
t.string :foo
t.string :hogefoo
t.string :hogehoge
t.string :foofoo
t.string :namehoge
t.string :namefoo
t.string :namehogefoo
t.string :namehogehoge
t.string :namefoofoo
t.timestamps
end
end
end
データを入れるため。seeds.rbファイルを以下のようにする。
500_000.times do |i|
t = CsvTest.new
t.name = (1...100).map{ (65 + rand(26)).chr }.join
t.hoge = (1...100).map{ (65 + rand(26)).chr }.join
t.foo = (1...100).map{ (65 + rand(26)).chr }.join
t.hogefoo = (1...100).map{ (65 + rand(26)).chr }.join
t.hogehoge = (1...100).map{ (65 + rand(26)).chr }.join
t.foofoo = (1...100).map{ (65 + rand(26)).chr }.join
t.namehoge = (1...100).map{ (65 + rand(26)).chr }.join
t.namefoo = (1...100).map{ (65 + rand(26)).chr }.join
t.namehogefoo = (1...100).map{ (65 + rand(26)).chr }.join
t.namehogehoge = (1...100).map{ (65 + rand(26)).chr }.join
t.namefoofoo = (1...100).map{ (65 + rand(26)).chr }.join
t.save!
if(i % 1000 == 0)
puts "#{i}個保存完了"
end
end
ランダムな文字列を作る際、Rubyでランダムな文字列を生成する方法を参考にしました。
最後にDBの接続設定を修正する。
default: &default
adapter: mysql2
encoding: utf8
pool: 5
username: root
password:
socket: /tmp/mysql.sock
development:
<<: *default
database: csv_test_development
ここまで設定したら以下のコマンドを実行する。
$ bundle exec rails db:create
$ bundle exec rails db:migrate
$ bundle exec rails db:seed
# seedは50万件のデータを入れ込むのでかなり時間がかかる
ルーティングからコントローラの設定
ルーティングの設定は
Rails.application.routes.draw do
root to: 'csv#index'
get :ruby, to: 'csv#ruby'
get :rust, to: 'csv#rust'
end
次にコントローラの設定をする。
require 'csv'
require 'ffi'
class CsvController < ApplicationController
def index
end
def ruby
start_time = Time.now
ruby_csv = CSV.generate do |csv|
data_column = CsvTest.column_names
data_column.delete("created_at")
data_column.delete("updated_at")
csv << data_column
CsvTest.all.each do |data|
csv << data.attributes.values_at(*data_column)
end
end
puts '------------------------------------------------------------'
puts '処理にかかった時間'
puts Time.now - start_time
puts '------------------------------------------------------------'
respond_to do |format|
format.csv { send_data ruby_csv }
end
end
module CSVmaker
extend FFI::Library
ffi_lib Rails.root + 'rust/target/release/libcsv.dylib'
attach_function :make_csv, [], :string
end
def rust
start_time = Time.now
rust_csv = CSVmaker.make_csv
puts '------------------------------------------------------------'
puts '処理にかかった時間'
puts Time.now - start_time
puts '------------------------------------------------------------'
respond_to do |format|
format.csv { send_data rust_csv }
end
end
end
コントローラ内でrequireやmoduleを定義するのはナンセンスだが、実験で作っているので今回はこのようにした。
今回のような単純な測定だとCPU状態とかに処理時間が依存したりするのであまり良くはないが、処理時間の差はかなりの差になると思ったので単純な測定にしている。
module CSVmaker
は後々作るRustの関数を使うものだと考えてくれれば良い(詳しくはRuby ffiへ)。
libcsv.dylib
これはOSによって異なると思う。これに関してはRust側の説明の時に触れる。
Viewの設定
CSV周りの機能などはコントローラにもたせたのでほとんどやることはない。
<%= link_to 'csv download(ver: ruby)', ruby_path(format: :csv) %>
<%= link_to 'csv download(ver: rust)', rust_path(format: :csv) %>
これでRails側の設定は完了。
Rust側
Railsのルートディレクトリで cargo new rust
とする。
そして生成されたディレクトのcargo.tomlを次のように修正する。
[package]
ここは特にいじらない
[dependencies]
mysql = "9.0.1"
libc = "0.2.0"
[lib]
name = "csv"
crate-type = ["dylib"]
次に rust/src/lib.rs
を次のようにする。
extern crate mysql;
extern crate libc;
use libc::*;
use std::ffi::{CString};
pub struct CsvTest {
id: i32,
name: String,
hoge: String,
foo: String,
hogefoo: String,
hogehoge: String,
foofoo: String,
namehoge: String,
namefoo: String,
namehogefoo: String,
namehogehoge: String,
namefoofoo: String,
}
#[no_mangle]
pub extern fn make_csv() -> *const c_char {
// DBの接続
let pool = mysql::Pool::new("mysql://root@localhost:3306/csv_test_development").unwrap();
// SELECTクエリ
let select_all: Vec<CsvTest> =
pool.prep_exec("SELECT id, name, hoge, foo, hogefoo, hogehoge, foofoo, namehoge, namefoo, namehogefoo, namehogehoge, namefoofoo from csv_tests", ())
.map(|result| {
result.map(|x| x.unwrap()).map(|row| {
let (id, name, hoge, foo, hogefoo, hogehoge, foofoo, namehoge, namefoo, namehogefoo, namehogehoge, namefoofoo) = mysql::from_row(row);
CsvTest {
id: id,
name: name,
hoge: hoge,
foo: foo,
hogefoo: hogefoo,
hogehoge: hogehoge,
foofoo: foofoo,
namehoge: namehoge,
namefoo: namefoo,
namehogefoo: namehogefoo,
namehogehoge: namehogehoge,
namefoofoo: namefoofoo,
}
}).collect()
}).unwrap();
// CSVの作成
let mut rust_csv: String;
rust_csv = format!("\"id\",\"name\",\"hoge\",\"foo\",\"hogefoo\",\"hogehoge\",\"foofoo\",\"namehoge\",\"namefoo\",\"namehogefoo\",\"namehogehoge\",\"namefoofoo\"\n");
for i in &select_all {
let record: String = format!("\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\"\n",
i.id.to_string(),
i.name,
i.hoge,
i.foo,
i.hogefoo,
i.hogehoge,
i.foofoo,
i.namehoge,
i.namefoo,
i.namehogefoo,
i.namehogehoge,
i.namefoofoo
);
rust_csv.push_str(&record);
}
CString::new(rust_csv).unwrap().into_raw()
}
structは構造体なので説明はRustドキュメントにお願いする。
make_csv関数について
let pool = mysql::Pool::new("mysql://root@localhost:3306/csv_test_development").unwrap();
これはDBへの接続を行う。環境によってはパスワードが必要になったりなどあると思うのでnew以下を個人の設定に合わせて修正する必要がある。
let select_all = ...
ここは公式ドキュメントをほぼ真似ている。
詳しくは http://blackbeam.org/doc/mysql/index.html
// CSVの作成 以下
こちらはCSVのフォーマットに合わして文字列を生成している。
CString::new(rust_csv).unwrap().into_raw()
こちらはCの文字列に修正してRubyに渡すためのものである。
pub extern fn make_csv() -> *const c_char
これの返り値の型をstringにするとRubyで呼び出した時セグメントフォルトとなる。
build
rustディレクトリで cargo build --release
とする。
完了したら ls target/release
として libcsv.dylib
のようなファイルがあるか確認をする。
ただし、これはOSによって拡張子が違ったりすると思う。
Macではlibcsv.dylibだった。
これをさきほどRailsのコントローラ内に定義してmoduleで読み込む。
module CSVmaker
extend FFI::Library
ffi_lib Rails.root + 'rust/target/release/libcsv.dylib'
attach_function :make_csv, [], :string
end
こうすることでRustの関数をRubyで使用することができる。
実験
以上で設定は完了したので bundle exec rails s
として、サーバを立ち上げる。
このような画面が出てくるので後はリンクを押す。
無事CSVがダウンロードできたらRailsを立ち上げたターミナルに行くと何秒かかったかを見る事ができる。
------------------------------------------------------------
処理にかかった時間
10.226519
------------------------------------------------------------
------------------------------------------------------------
処理にかかった時間
146.68222
------------------------------------------------------------
上がRustの処理で下がRubyの処理。
まとめ
Rubyで50万レコードでのCSV処理をした時メモリをバカ食いしていたので大きいデータに対しては極力Rubyを避けたほうがいいかもしれません。
今回は実験的に作ってみましたが、本格的に作れば一部の処理をRustに任せるのはありだと思いました。
おまけ(Railsの環境構築)
まず、rbenvなどでruby2.3.3を入れます。
次に gem install bundle
とします。
お好きなディレクトリで
source 'https://rubygems.org'
gem 'rails', '~> 5.0.1'
を作ります。
bundle install --path vendor/bundle
とします。
pathを指定した際、gitignoreで vendor/bundle
としてからGitに入れましょう。
bundle exec rails new . -BTf -d mysql
とすれば今回使う環境の出来上がりです。
bundleは
gem install
とは異なるのでrails s
などのようなコマンドを打つ時は頭にbundle exec
とする必要があります。
追記2017/01/30 19:00
CSVのところをRubyでは標準ライブラリを使っていたが、Rustでは独自に処理した形となっているのでRubyの方をRustに合わせてみた。
ruby_csv = "\"id\",\"name\",\"hoge\",\"foo\",\"hogefoo\",\"hogehoge\",\"foofoo\",\"namehoge\",\"namefoo\",\"namehogefoo\",\"namehogehoge\",\"namefoofoo\"\n"
CsvTest.all.each do |data|
ruby_csv << "\"#{data.id}\",\"#{data.name}\",\"#{data.hoge}\",\"#{data.foo}\",\"#{data.hogefoo}\",\"#{data.hogehoge}\",\"#{data.foofoo}\",\"#{data.namehoge}\",\"#{data.namefoo}\",\"#{data.namehogefoo}\",\"#{data.namehogehoge}\",\"#{data.namefoofoo}\"\n"
end
CSV.generate
の部分を上記のように置き換えました。
こちらで実験したところ
------------------------------------------------------------
処理にかかった時間
73.224735
------------------------------------------------------------
となりました。
公式CSVライブラリドキュメントに書いてある
不正な CSV データを与えたくない。あるフィールドが不正であることが確定す るのはファイルを全て読み込んだ後です。これは多くの時間やメモリを消費し ます。
ここの部分の処理のためメモリや処理時間を消費していたみたいです。
追記2017/02/02 14:00
ご指摘いただいたCSVクレートを用いての処理ver
Rails側の変更点はありません。
Cargo.tomlに追記する必要があるので以下のように設定します。
[dependencies]
mysql = "9.0.1"
libc = "0.2.0"
csv = "0.14"
rustc-serialize = "0.3.22"
[lib]
name = "csv"
crate-type = ["dylib"]
次に rust/src/lib.rs
の修正をします。
extern crate mysql;
extern crate libc;
extern crate csv;
extern crate rustc_serialize;
use libc::*;
use std::ffi::{CString};
#[derive(RustcEncodable)]
pub struct CsvTest {
id: i32,
name: String,
hoge: String,
foo: String,
hogefoo: String,
hogehoge: String,
foofoo: String,
namehoge: String,
namefoo: String,
namehogefoo: String,
namehogehoge: String,
namefoofoo: String,
}
#[no_mangle]
pub extern fn make_csv() -> *const c_char {
// DBの接続
let pool = mysql::Pool::new("mysql://root@localhost:3306/csv_test_development").unwrap();
// SELECT
let select_all: Vec<CsvTest> =
pool.prep_exec("SELECT id, name, hoge, foo, hogefoo, hogehoge, foofoo, namehoge, namefoo, namehogefoo, namehogehoge, namefoofoo from csv_tests", ())
.map(|result| {
result.map(|x| x.unwrap()).map(|row| {
let (id, name, hoge, foo, hogefoo, hogehoge, foofoo, namehoge, namefoo, namehogefoo, namehogehoge, namefoofoo) = mysql::from_row(row);
CsvTest {
id: id,
name: name,
hoge: hoge,
foo: foo,
hogefoo: hogefoo,
hogehoge: hogehoge,
foofoo: foofoo,
namehoge: namehoge,
namefoo: namefoo,
namehogefoo: namehogefoo,
namehogehoge: namehogehoge,
namefoofoo: namefoofoo,
}
}).collect()
}).unwrap();
// CSVの作成
let mut rust_csv = csv::Writer::from_memory();
let header = ("id", "name", "hoge", "foo", "hogefoo", "hogehoge", "foofoo", "namehoge", "namefoo", "namehogefoo", "namehogehoge", "namefoofoo");
rust_csv.encode(header);
for i in &select_all {
rust_csv.encode(i);
}
CString::new(rust_csv.as_string()).unwrap().into_raw()
}
主な変更点はCSVの作成のところです。
CSVクレートを使うために
extern crate csv;
extern crate rustc_serialize;
とファイルの先頭に書き、構造体の上に #[derive(RustcEncodable)]
をつけます。
あとは CSVクレートの公式ドキュメント を参考にしながらCSVの作成部分を変更しました。
修正が完了しましたら rust
ディレクトリで $ cargo build --release
として Rails サーバを立ち上げるだけです。
こちらでCSVを落としたところ
------------------------------------------------------------
処理にかかった時間
10.199653
------------------------------------------------------------
となりました。
今回の測定法がCPUの状態などに計測時間が依存していることを考慮しても十分に早くCSV処理が出来ていると考えられます。
Railsでボトルネックになりやすいビジネスロジック部分をRustに任せてみると良さそうかもしれません。