RailsのCSVのところをRustで処理したら早かった

## 背景
RailsでCSVを返す時、10万データくらいのものを普通に処理するとメモリを食い潰したり、処理が遅かったりする。
そこで解決策を探していたら、[RustでRailsの代わりにCSVを作る](http://qiita.com/aoyagikouhei/items/4100867427e58a6d5a33)という素晴らしい記事に出会った。
この記事はPostgresやNginxを使ってたが、私はMySQLを使っており、Railsのアプリケーション内で完結したかったので、参考にしながら試験的に作って見た。

## お断り
Railsをすでに理解していることを前提に進めます。
初心者の方へ環境構築周りを最後の方におまけとして書いておきますので参考にしてください。
筆者はRust初心者ですのでアドバイスを頂けるととても助かります。

## 環境
- Ruby 2.3.3
- Rust 1.14.0
- Rails 5.0.1

Gemfileは基本デフォルト

```ruby: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クレートを使ったバージョンは最後の方にある追記を参考にしてください)

```Cargo.toml
[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ファイルは以下のようにする。

```2017__create_csv_tests.rb
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ファイルを以下のようにする。

```db/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でランダムな文字列を生成する方法](https://www.xmisao.com/2014/02/15/how-to-generate-a-random-string-in-ruby.html)を参考にしました。

最後にDBの接続設定を修正する。

```config/database.yml
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万件のデータを入れ込むのでかなり時間がかかる
```

### ルーティングからコントローラの設定
ルーティングの設定は

```ruby:config/routes.rb
Rails.application.routes.draw do
  root to: 'csv#index'
  get :ruby, to: 'csv#ruby'
  get :rust, to: 'csv#rust'
end
```

次にコントローラの設定をする。

```ruby:app/controllers/csv_controller.rb
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](https://github.com/ffi/ffi)へ)。

`libcsv.dylib` これはOSによって異なると思う。これに関してはRust側の説明の時に触れる。

### Viewの設定
CSV周りの機能などはコントローラにもたせたのでほとんどやることはない。

```html+erb:app/views/csv/index.html.erb
<%= 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を次のように修正する。

```rust/Cargo.toml
[package]
ここは特にいじらない

[dependencies]
mysql = "9.0.1"
libc = "0.2.0"

[lib]
name = "csv"
crate-type = ["dylib"]
```

次に `rust/src/lib.rs` を次のようにする。

```rust: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関数について

> 
```rust
let pool = mysql::Pool::new("mysql://root@localhost:3306/csv_test_development").unwrap();
```

これはDBへの接続を行う。環境によってはパスワードが必要になったりなどあると思うのでnew以下を個人の設定に合わせて修正する必要がある。

>
```rust
let select_all = ...
```

ここは公式ドキュメントをほぼ真似ている。
詳しくは http://blackbeam.org/doc/mysql/index.html

> // CSVの作成 以下

こちらはCSVのフォーマットに合わして文字列を生成している。

>
```rust
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で読み込む。

>
```rb
    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` として、サーバを立ち上げる。

![スクリーンショット 2017-01-30 13.47.46.png](https://qiita-image-store.s3.amazonaws.com/0/101588/4fd85e74-8ac1-3e57-4f15-f7008c35bfe4.png "スクリーンショット 2017-01-30 13.47.46.png")

このような画面が出てくるので後はリンクを押す。
無事CSVがダウンロードできたらRailsを立ち上げたターミナルに行くと何秒かかったかを見る事ができる。

```
------------------------------------------------------------
処理にかかった時間
10.226519
------------------------------------------------------------
```

```
------------------------------------------------------------
処理にかかった時間
146.68222
------------------------------------------------------------
```

上がRustの処理で下がRubyの処理。


## まとめ
Rubyで50万レコードでのCSV処理をした時メモリをバカ食いしていたので大きいデータに対しては極力Rubyを避けたほうがいいかもしれません。
今回は実験的に作ってみましたが、本格的に作れば一部の処理をRustに任せるのはありだと思いました。

## おまけ(Railsの環境構築)
まず、rbenvなどでruby2.3.3を入れます。
次に `gem install bundle` とします。

お好きなディレクトリで

```ruby:Gemfile
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:app/controllers/csv_controller.rb
    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ライブラリドキュメント](https://docs.ruby-lang.org/ja/latest/library/csv.html)に書いてある

> 不正な CSV データを与えたくない。あるフィールドが不正であることが確定す るのはファイルを全て読み込んだ後です。これは多くの時間やメモリを消費し ます。

ここの部分の処理のためメモリや処理時間を消費していたみたいです。

## 追記2017/02/02 14:00
### ご指摘いただいたCSVクレートを用いての処理ver
> Rails側の変更点はありません。

Cargo.tomlに追記する必要があるので以下のように設定します。 

```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` の修正をします。

```rust: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クレートを使うために

```rust
extern crate csv;
extern crate rustc_serialize;
```

とファイルの先頭に書き、構造体の上に `#[derive(RustcEncodable)]` をつけます。
あとは [CSVクレートの公式ドキュメント](http://burntsushi.net/rustdoc/csv/struct.Writer.html) を参考にしながらCSVの作成部分を変更しました。

修正が完了しましたら `rust` ディレクトリで `$ cargo build --release` として Rails サーバを立ち上げるだけです。

こちらでCSVを落としたところ

```
------------------------------------------------------------
処理にかかった時間
10.199653
------------------------------------------------------------
```

となりました。

今回の測定法がCPUの状態などに計測時間が依存していることを考慮しても十分に早くCSV処理が出来ていると考えられます。
Railsでボトルネックになりやすいビジネスロジック部分をRustに任せてみると良さそうかもしれません。