圧倒亭グランパのブログ

30年後の自分にもわかるように書くブログ

新しいプログラミング言語を学ぶために、isuconのWebAppを実装したらいろいろと勉強になった

いろいろと得るものが多かったので、やったことと感想をまとめます。

長くなってしまったので、お時間ある時にどうぞ。  

TL;DR

  • Crystal言語(ja) で、isucon5-qualifier-standaloneのWebAppを実装
  • 新しい言語の勉強をする際、isuconを題材にすると良さそう
    • 実装するものが決まっているので余計なことは考えずコーディングに集中できる
    • 参考にできる他言語の実装がすぐそばにある
  • ライブラリのコードを読むことに抵抗がなくなった
  • ライブラリのリポジトリにPRを送りたくなった  

リポジトリ

Crystal言語 で、isucon5-qualifier-standaloneのWebAppを実装しました。

github.com

 

目次

発端

isuconの記事がTwitter上で流れていたとき、ふと「Crystalってどのくらい速いんだろう...?」と思ったことがきっかけです。

 

実際にやったことのピックアップ

実装を始めてから公開に至るまで、いくつかの壁を乗り越えてきました。その中でも特に印象に残っていることをピックアップして紹介します。これらの壁が、のちの自分の「自信」につながっていたりします。

自分は静的型付け言語を触るのはほぼ初めてだったので、どんな風に書けるのか楽しみでもありました。

ここからはCrystalの実装の話になるので、取り組みの感想をお聞きしたい方は「実際に行ってみて感じたこと」まで飛んでください。

では見ていきましょう。

 

DBライブラリからの返り値が壮大なUnion型になっていてつらい

DBライブラリは waterlink/crystal-mysql を使用しました。

github.com

ライブラリからクエリ結果を取得してあれこれするのですが、以下のエラーに遭遇します。

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で型を渡してやれば、その型に沿って結果が返ってきます。

github.com

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を使えば解決するだろうと思いました。そして、以下の修正をしたところ期待通りの動作をしました。

query.crのdiff

     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は以下にまとまっています。

github.com

これは、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 バインディング | プログラミング言語 Crystal

問題がC言語側にあるとわかった今、何らかの文字コード関連を設定していなければならないのでは?と思いました。そして、mysqlのC言語API を見てみると、

mysql_set_character_set()    現在の接続のデフォルトの文字セットを設定します

というものがありました。これはもしやと思い、Cバインディングのコードを書き、Crystal側で mysql_set_character_set()を呼び出せるようにし、引数にutf8 を設定したところ、問題なく日本語表示をすることができました。「接続時の文字コード」が原因だったのですね。

これらのdiffは、以下にまとまっています。

github.com

こちらも整理してPRを送ろうと思います。

その他

その他、細々といろいろやったのですが、詳細はまた別記事にでもしようかと思います。

 

実際に行ってみて感じたこと

新しい言語を学ぶ際に、isuconを題材にすると手を動かすまでのハードルが下がる

「あの言語を勉強したい」「あのフレームワークを試したい」といっても、まず何を作ればよいかを考えないとコードすら書けません。目的が「言語の勉強」であれば、制作物の仕様などを考えることは余計な時間かなと思います。サンプルならすぐに試せるかもしれませんが、実際にWebAppなどを制作することに比べると経験値は劣ると思います。

isuconの題材を実装することのメリットとしては、以下が考えられます。

  • 実装するものが決まっているので余計なことは考えずコーディングに集中できる
  • 参考にできる他言語の実装がすぐそばにある
  • 動くことを確認しながら進められる
  • ちょうどよい規模感
  • ベンチマークが取れるので、追求しがいがある

余計なことを考えずにすぐに手を動かせるのが強みですし、かつ参考実装が近くにあるので躓きも少なくて済みます。言語の勉強に関しては、良い題材ではないでしょうか。

得るものが多かった

実際に行ってみて、下記のことを感じました。

  • 英語の公式ドキュメントが怖くなくなった
  • ライブラリのコードを追うことが怖くなくなった
  • 他のライブラリにも興味を持ち、いろいろ探すようになった
  • 使っているライブラリにPRを送りたくなった

特にライブラリのコードを追うことが怖くなくなったのは大きく、そのおかげでいろいろなライブラリを探し回るようになりましたし、PRも送りたくなりしました。これは今までとは違う感覚で、ものすごくわくわくしています。

今後

今後やろうとしていることは以下です。

  • isucon5q-crystal がやっとスタート地点に立ったのでチューニングしてみる
    • 他の様々なライブラリを試してみる
    • 発端が「Crystalのパフォーマンスはどのくらいなんだろう?」という疑問だったので、その疑問を晴らしたいです
  • isucon6-qualifier-standaloneが公開済みなのでCrystalで実装してみる
  • 他の言語でisuconの題材を実装してみる
    • Elixirとか興味あります
  • ある程度慣れてきたら、何か作って公開する

最後に

今回は isuconの題材をCrystalで実装しましたが、これもisuconを運営してくださる方々や、練習環境を公開してくださる方々のおかげです。この場をお借りして、お礼を申し上げます。

なかなか手を動かせなかった自分も、この取り組みではすごく手を動かせました。毎日、あーだこーだ言いながら最短30分くらいはコードを触っていたと思います。これを期に、どんどんソーシャルコーディングの海を進んでいこうと思います。

 

Crystalに興味がある方は、アドベントカレンダーやってますのでどうぞー!

qiita.com