新しいプログラミング言語を学ぶために、isuconのWebAppを実装したらいろいろと勉強になった
いろいろと得るものが多かったので、やったことと感想をまとめます。
長くなってしまったので、お時間ある時にどうぞ。
TL;DR
- Crystal言語(ja) で、isucon5-qualifier-standaloneのWebAppを実装
- 新しい言語の勉強をする際、isuconを題材にすると良さそう
- 実装するものが決まっているので余計なことは考えずコーディングに集中できる
- 参考にできる他言語の実装がすぐそばにある
- ライブラリのコードを読むことに抵抗がなくなった
- ライブラリのリポジトリにPRを送りたくなった
リポジトリ
Crystal言語 で、isucon5-qualifier-standaloneのWebAppを実装しました。
目次
発端
isuconの記事がTwitter上で流れていたとき、ふと「Crystalってどのくらい速いんだろう...?」と思ったことがきっかけです。
実際にやったことのピックアップ
実装を始めてから公開に至るまで、いくつかの壁を乗り越えてきました。その中でも特に印象に残っていることをピックアップして紹介します。これらの壁が、のちの自分の「自信」につながっていたりします。
自分は静的型付け言語を触るのはほぼ初めてだったので、どんな風に書けるのか楽しみでもありました。
ここからはCrystalの実装の話になるので、取り組みの感想をお聞きしたい方は「実際に行ってみて感じたこと」まで飛んでください。
では見ていきましょう。
DBライブラリからの返り値が壮大なUnion型になっていてつらい
DBライブラリは waterlink/crystal-mysql を使用しました。
ライブラリからクエリ結果を取得してあれこれするのですが、以下のエラーに遭遇します。
undefined method 'to_i' for Nil (compile-time type is (Array(Array(Bool | Float64 | Int32 | Int64 | MySQL::Types::Date | Slice(UInt8) | String | Time | Nil)) | Nil)) (did you mean 'to_s'?)
これはUnion型
と言って、その変数が「取り得る型」を示しています。user_idに対してto_i
メソッドを呼び出したところ、「Nilが入ってきた場合、to_iメソッドは定義されていない」というエラーになったのです。いやうん、わかる。わかるよ。
コンパイラが型を特定できるように記述すると、このエラーを避けることができます。Crystalではis_a?
などのメソッドで、型を特定できます。
if 変数.is_a?(...) | プログラミング言語 Crystal
if a.is_a?(String) # a は必ず String end if b.is_a?(Number) # b は必ず Number end
この知識を用いて実装したコードがこちら。
... query = "SELECT * FROM profiles WHERE user_id = :user_id" result = MySQL::Query.new(query, {"user_id" => user.id}).run(db) user_id, first_name, last_name, sex, birthday, pref, updated_at = result.first error_404 unless user_id.is_a?(Int32) error_404 unless first_name.is_a?(String) error_404 unless last_name.is_a?(String) error_404 unless sex.is_a?(String) error_404 unless birthday.is_a?(Time) error_404 unless pref.is_a?(String) error_404 unless updated_at.is_a?(Time) profile = Profile.new(user_id, first_name, last_name, sex, birthday, pref, updated_at) ...
つらい。
確かにこのunless文を抜けると、それぞれの型は特定されます。従ってこれは動作します。しかし、全てのSQL部分にこのようなコードがあるのはつらいですよね。
そこでふと思い出したのが、「PostgreSQLのライブラリは、返ってきた結果の型が特定されていたな...」 とうこと。PostgreSQL用の will/crystal-pg というライブラリがあります。READMEに typed-querying
という項目があるんですね。 exec
メソッドの第一引数にTupleで型を渡してやれば、その型に沿って結果が返ってきます。
will/crystal-pgのREADME.mdから引用
result = DB.exec({Int32, String}, "select id, email from users") result.fields #=> [PG::Result::Field, PG::Result::Field] result.rows #=> [{1, "will@example.com"}], …] result.to_hash #=> [{"field1" => value, …}, …]
まさにこれです。rows
を呼び出すとTupleの配列が返ってきて、各rowでは型が特定されています。で、「これは一体どうやっているんだろう」と興味を持ち始め、forkしていろいろ探り始めました。
exec
メソッドがどこで定義され、どのような処理を行っているのか。自分のわかる範囲を少しずつ広げていきます。疑問だったのが、「型を指定するTupleの要素数」が不定であること。要素数がわからないのですが、それをどうやって処理しているのか。いろいろ調べた結果、たどり着いたのがここです。
will/crystal-pg/ から引用 src/pg/result.cr#L48
macro generate_private_each(from, to) {% for n in (from..to) %} private def each(types : Tuple({% for i in (1...n) %}Class, {% end %} Class)) @raw_data.each do |row| yield ({ {% for j in (0...n) %} types[{{j}}].cast( decode( row[{{j}}], {{j}}) ), {% end %} }) end end {% end %} end
わけがわかりません。
macro文は初めて見ました。しかし、これも1行1行を見ていけば解きほぐせるはずです。細かく見ていくと、これはなんと「Tupleの要素が1個〜32個までの計32種類のメソッドを定義している」ということでした。ちょうどこんな感じです。
# Tupleの要素1個のメソッド private def each(types : Tuple(Class)) @raw_data.each do |row| yield({ types[0].cast(decode(row[0], 0))}) end end # Tupleの要素2個のメソッド private def each(types : Tuple(Class, Class)) @raw_data.each do |row| yield({ types[0].cast(decode(row[0], 0)), types[1].cast(decode(row[1], 1))}) end end ... # Tupleの要素32個のメソッド private def each(types : Tuple(Class, Class, ... Class)) # 32個並ぶ @raw_data.each do |row| yield({ types[0].cast(decode(row[0], 0)), types[1].cast(decode(row[1], 1)), ...}) # 32個並ぶ end end
なるほどー。
crystalでは、引数の型が違えば、メソッド名が同じものでも別のメソッドとして扱われます。なので、カラムの型指定のTupleは32要素まで可能ということですね(個数ごとにちゃんとそのメソッドが呼ばれる)。逆に言えば、33カラム以上が返ってくるクエリには使えないということです。これはドキュメントにも書いてある通りです。
ということで、型指定のカラクリがわかったところで、crystal-mysqlをラップしましょう。
src/isucon5q-crystal.cr#L58-L131
結果、大量のunless
が必要だった箇所は、とてもスッキリしました。(変数が多いのは仕方なし)
query = "SELECT * FROM profiles WHERE user_id = :user_id" result = @db.exec({Int32, String, String, String, Time, String, Time}, query, {"user_id" => user.id}) user_id, first_name, last_name, sex, birthday, pref, updated_at = result.first profile = Profile.new(user_id, first_name, last_name, sex, birthday, pref, updated_at)
この「型を特定して返す」という機能は非常に便利だったので、PRを整理して本家に送ろうと思います。
ともあれ、壁をひとつクリアしました。
マルチバイト文字がうまくinsertできなくてつらい
実装を進めていくと、以下のエラーに遭遇しました。
Unexpected byte 0x27 at position 36, malformed UTF-8 (InvalidByteSequenceError)
なにやら文字コード関連のにおいがします。日本語をinsertすると、最初の1文字しかinsertされていませんでした。crystal-mysqlのコードが怪しいと踏んで、1行1行しっかりと把握しつつ原因を突き止めます。すると、「変数の置換(クエリ文字列内の:hoge
の部分を変数の値で書き換える作業)」が原因でした。
クエリ内の変数を値に置換する場所で、以下のようなメソッドがありました。
waterlink/crystal-mysql から引用 src/mysql/query.cr#L44-L54
private def replace(s, name, value) result = "" len = name.size p0 = 0 while p1 = s.index(name, p0) result += s[p0...p1] result += value p0 = p1 + len end result + s[p0..-1] end
クエリ文字列s
の中のname
の部分をvalue
に変換しています。この時の処理が「バイト単位」なんですね。なので、UTF-8のマルチバイト文字が壊れてしまった。結果、whileを抜けてしまい、十分な置換が行われなかったということです。
このメソッドは結局「文字列の置換」を行っているわけで、gsub
を使えば解決するだろうと思いました。そして、以下の修正をしたところ期待通りの動作をしました。
private def replace(s, name, value) - result = "" - len = name.size - p0 = 0 - while p1 = s.index(name, p0) - result += s[p0...p1] - result += value - p0 = p1 + len - end - result + s[p0..-1] + s.gsub(/#{name}/, value) end
めでたしめでたし。
diffは以下にまとまっています。
これは、forkした at-grandpa/crystal-mysql では修正済みであり、今回のリポジトリではこれを使用しています。
diffはできているので、こちらも本家にPRを送ろうと思います。
日本語が文字化けしていてつらい
次に対応した不具合は、「select結果の日本語が全て『?????』になる」というものでした。いろいろ整理すると、
- DBに入っているデータは正しい(他の言語の実装で問題なく動いていたため)
- つまり、crystal側に問題がある
- mysqlのライブラリからの返り値が既に「???」になっていたので、ライブラリ側の問題である
ということがわかりました。そこで、どこまでが正常でどこからが異常かを徹底的に探りました。そして行き着いた先は、Cバインディングされたlibmysqlの関数だったのです。
CバインディングとはCrystalの機能のひとつで、C言語の関数とCrystalのメソッドを対応付けて、Crystal内から呼び出せるようにするものです。mysqlには C言語のAPI がありますが、crystal-mysqlはそれらをCバインディングして使用していました。
問題がC言語側にあるとわかった今、何らかの文字コード関連を設定していなければならないのでは?と思いました。そして、mysqlのC言語API を見てみると、
mysql_set_character_set() 現在の接続のデフォルトの文字セットを設定します
というものがありました。これはもしやと思い、Cバインディングのコードを書き、Crystal側で mysql_set_character_set()
を呼び出せるようにし、引数にutf8
を設定したところ、問題なく日本語表示をすることができました。「接続時の文字コード」が原因だったのですね。
これらのdiffは、以下にまとまっています。
こちらも整理してPRを送ろうと思います。
その他
その他、細々といろいろやったのですが、詳細はまた別記事にでもしようかと思います。
実際に行ってみて感じたこと
新しい言語を学ぶ際に、isuconを題材にすると手を動かすまでのハードルが下がる
「あの言語を勉強したい」「あのフレームワークを試したい」といっても、まず何を作ればよいかを考えないとコードすら書けません。目的が「言語の勉強」であれば、制作物の仕様などを考えることは余計な時間かなと思います。サンプルならすぐに試せるかもしれませんが、実際にWebAppなどを制作することに比べると経験値は劣ると思います。
isuconの題材を実装することのメリットとしては、以下が考えられます。
- 実装するものが決まっているので余計なことは考えずコーディングに集中できる
- 参考にできる他言語の実装がすぐそばにある
- 動くことを確認しながら進められる
- ちょうどよい規模感
- ベンチマークが取れるので、追求しがいがある
余計なことを考えずにすぐに手を動かせるのが強みですし、かつ参考実装が近くにあるので躓きも少なくて済みます。言語の勉強に関しては、良い題材ではないでしょうか。
得るものが多かった
実際に行ってみて、下記のことを感じました。
- 英語の公式ドキュメントが怖くなくなった
- ライブラリのコードを追うことが怖くなくなった
- 他のライブラリにも興味を持ち、いろいろ探すようになった
- 使っているライブラリにPRを送りたくなった
特にライブラリのコードを追うことが怖くなくなったのは大きく、そのおかげでいろいろなライブラリを探し回るようになりましたし、PRも送りたくなりしました。これは今までとは違う感覚で、ものすごくわくわくしています。
今後
今後やろうとしていることは以下です。
- isucon5q-crystal がやっとスタート地点に立ったのでチューニングしてみる
- 他の様々なライブラリを試してみる
- 発端が「Crystalのパフォーマンスはどのくらいなんだろう?」という疑問だったので、その疑問を晴らしたいです
- isucon6-qualifier-standaloneが公開済みなのでCrystalで実装してみる
- 他の言語でisuconの題材を実装してみる
- Elixirとか興味あります
- ある程度慣れてきたら、何か作って公開する
最後に
今回は isuconの題材をCrystalで実装しましたが、これもisuconを運営してくださる方々や、練習環境を公開してくださる方々のおかげです。この場をお借りして、お礼を申し上げます。
なかなか手を動かせなかった自分も、この取り組みではすごく手を動かせました。毎日、あーだこーだ言いながら最短30分くらいはコードを触っていたと思います。これを期に、どんどんソーシャルコーディングの海を進んでいこうと思います。
Crystalに興味がある方は、アドベントカレンダーやってますのでどうぞー!