今回は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倍位は高速という認識でいても良さそうです。