Help us understand the problem. What is going on with this article?

crystal の active_record を試してみた

More than 5 years have passed since last update.

今回はcrystalでactive_recordパターンを実装したactive_recordを試してみようと思います。データベースには MySQL を使います。

このライブラリは active_record という名前ですが、rails の ActiveRecord をコピーするというゴールではなく、active record パターンを正しく crystal で実行することが目的とのことなので、使い勝手は rails の ActiveRecord とは大きく異なっています。個人的には名前がややこしいし検索しづらいので別名にしたほうが良かったのではという気がします。

インストール

shards を使います。

  activerecord:
    github: waterlink/active_record.cr
    branch: master
  mysql_adapter:
    github: waterlink/mysql_adapter.cr
    branch: master

を shards.yml に記述してから crystal deps でインストールできます。

設定方法

現時点では環境変数を通じて MySQL への接続情報を設定するようです。

export MYSQL_USER=crystal
export MYSQL_PASSWORD=XXXXX
export MYSQL_DATABASE=crystal_db

環境変数さえ指定しておけば勝手に接続されるようです。

モデル定義

rails ではデータベースからテーブル定義を動的に読みだしていましたが、現時点ではカラムと型を全て手動で設定してやる必要があります。

class User < ActiveRecord::Model
  adapter mysql
  table_name users # 省略可能

  primary id                 :: Int
  field name :: String
  field user_type_id   :: Int
  field paid :: Bool
end

mysqlを利用することをモデルごとに指定する必要があります。
カラムについては上記のように field column_name :: type というフォーマットで指定します。この type の部分は

alias SqlType = String|Time|Int32|Int64|Float64|Nil|Date|Bool

で定義されているいずれかを指定しますが、なぜか Date を指定しても実行時にエラーに成ってしまいました。コンパイル時に型チェックをしているのに型に関する実行時エラーがでるのは少し残念ですが、ベストプラクティスが蓄積されて改善されていく範囲でしょう。

クエリ

次はクエリを見ていきます。

find by id

User.get(123)

where

上記のクエリはwhereを使って以下のようにもかけます。

User.where({"id" => 123})

ただしこの戻り値は配列なので厳密には異なるクエリです。また、現時点のバージョンでは where を実行した時点で SQL が実行されて即座に配列が戻ります。したがって where をつなげることはできません。

より複雑な where

上記のクエリは = で値を比較する場合ですが、不等号や ! は別の書き方をします。
例えば id が 100 未満のレコードを取得するクエリは以下のように書けます。

User.where(criteria("id") < 100)

ただしこのコードを実行するには以下を include しておく必要があります。

include ActiveRecord::CriteriaHelper

さて、この where の中身はどのように処理されているのかが気になりますね。ASTの解析が必要になりそうなので macro を駆使しているのかな、と推測できます。が、実はこの実装には macro も AST も使っていません。

crystal では ruby と同様 > などの比較演算子もオーバーライドすることができます。
なので criteria("id") が Criteria クラスのインスタンスを返しそのインスタンスの<メソッドが呼ばれてクエリを組み立てるという動きになっています。
つまり、

User.where(criteria("id").<(100))

こういうことです。なので常に criteria が左に来る必要があります。

User.where(100 > criteria("id"))

とは当然書けません。

複数の条件

複数の条件を指定するときは & ないしは and が使えます。例えば上記の > は Criteria クラスのメソッドですが、このメソッドは Query クラスのインスタンスを戻します。この Query クラスには and, '&' や or | などのメソッドが定義されているのでこれを使って Query を更に組み立てることができます。

例えば範囲指定は以下のようにかけます。

User.where((criteria("id") < 100) & (criteria("id") > 50))

残念なことは各 criteria を ( )で囲まないと syntax error になってしまうことです。& でも and でも同じ結果でした。上記の例で言うと & が Query ではなく 100 に対するメソッド呼び出しとして解釈されるためです。

not

Query の否定も自然にかけます。not または ! が使えます。

User.where(!(criteria("id") == 50))

ここでも同様に ( ) は必須です。not を使う場合は以下のように書きます。

User.where((criteria("id") == 50).not)

limit, order, group_by

未実装です

速度

さて気になる速度です。

数百万行のレコードが存在するテーブルから id < 10000 という条件で約10,000レコードをロードするamethyst コードを書いて wrk で測定してみました。

 ./wrk -c1 -t1 -d30s "http://localhost:3011/commitment/search?size=10000"
Running 30s test @ http://localhost:3011/commitment/search?size=10000
  1 threads and 1 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    78.29ms    4.07ms 110.72ms   95.04%
    Req/Sec    12.77      4.48    20.00     72.33%
  383 requests in 30.02s, 32.54KB read
Requests/sec:     12.76
Transfer/sec:      1.08KB

平均 78msです。うち30msは MySQL のレスポンス待ちに使われているので crystal 側の処理は 50ms ということになります。今回は action の中の処理が重たいので並列度を上げてもメリットが得られません。

(追記)以下の測定に誤りがあったので数字を修正しました。
一方 rails で同様のことをすると平均で 230ms かかりました。MySQLにかかる時間を無視するとcrystal の active_record のほうが 4倍程度速いということになります。かなり困惑する数字がでてしまいました。基本性能としては2桁くらいcrystalのほうが速いはずなのですが、たったの4倍です。DBクエリにかかる時間を省いての計算です。DBを含むと3倍程度にしかなりません。これは何か失敗している可能性があるので引き続き調査をしたいと思います。

また、メモリサイズについては amethyst が約70MB, rails が約230MBとなりました。ruby よりもオブジェクトのデータサイズはコンパクトなようです。これはお得感ありますね。

(さらに追記)
active_record のコードをいじりながら試してみましたが、model を10,000件 new するのに 20ms かかっているようです。10,000件あるといっても、ネイティブバイナリとしてはこれはちょっと遅い気がしますね。(rails が頑張っているというべきなのかも?)

試しに今度はロードした10,000件のデータをそのままCSVにして出力する様にしてみたところ、
amethyst: 87ms
rails: 876ms
と大きく差が開きました。DBを含んでも10倍差、DBを除くと14倍の性能差になります。CSVを生成する処理のみで比較すると100倍ほどの差になります。これなら納得感あります。

CSVを作成するような計算処理なら圧倒的に速いものの、active_record のインスタンスを作成する処理が現時点ではどうやら rails なみに遅いようです。初期化部分のソースを追いましたが目立って遅い感じのする箇所はなかったのですが。

引き続き調査はしてみたいと思いますが、実用的なアプリケーションではcrystalのほうが10倍位は高速という認識でいても良さそうです。

mmj
ウェブシステム開発やサービスを運営している京都の会社です。Kotlin, TypeScript, Reactを中心とした開発体制への移行をしています。 その前はRuby on Rails を中心に開発をしていました。
https://www.mmj.ne.jp
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away