こんなタイトルになってますが,なにも「Crystalコンパイラのソースコードを頭から順に読みましょう」ということではなく(それはそれで勉強にはなると思いますが),プログラミング中に何か困ったことがあったらCrystalコンパイラや標準添付ライブラリのソースコードがお役に立ちますよ,というお話です。
ご存知の通り Crystal はセルフホスティングな言語です。つまり,Crystal コンパイラ自身や標準添付ライブラリなどがCrystal言語を使って作成されており,そのソースコードは GitHub( crystal-lang/cystal)上で全て公開されています。
ということは,コンパイラ自体を作ってる凄い人たちが,様々な用途向けに書いた大量のサンプルコードが読み放題,ということです。
ドキュメントやAPIリファレンスだけではよく分からないマクロたライブラリの実用例がわんさと転がっているわけですから,これは活用しない手はありません。
プログラミング言語自身のソースコードというとスーパーハッカーの皆さんが見るものなんでは,と少々敷居が高く感じる方もいるかもしれませんが,私個人はむしろ初〜中級者にこそ積極的に読む価値があると感じています。
というわけで,個人的な crystal-lang/cystal でソースコードを調べたら解決した事例をご紹介します。
事例1: CrystalでTLSを使う
arcage/crystal-email に STARTTLS コマンドを実装するには,Crystal から TLS 通信を行う必要がありました。
APIリファレンスを漁ってみると, Openssl::SSL::Socket::Client なるクラスが存在していて,なんとなくそれを使えばなんとかなりそうな気配はするものの,Openssl::SSL::Socket::Client のAPIリファレンスにはサンプルコードが全くないため,どうすれば実際に使用できるのかがうまくイメージできませんでした。
こんな時,いきなり Openssl::SSL::Socket::Client 自体のソースを読むこともできますが,仮に「どのような処理が行われるか」を理解できたとしても,それが「どのように使えば良いか」と直結するとは限りません。ですので,標準添付ライブラリの中で Openssl::SSL::Socket::Client が使われている箇所を探します。
例えば,HTTP::Client は HTTPS 接続ができるんだから,そこでも Openssl::SSL::Socket::Client を使っているんではないか,と考えて src/http/client.cr ファイル内を「Openssl::SSL::Socket::Client.new」で検索してみたところ,サーバとの接続用ソケットを取得するプライベートメソッド private def socket
の定義の中で
tls_socket = OpenSSL::SSL::Socket::Client.new(socket, context: tls, sync_close: true, hostname: @host)
という記述を見つけました。
ローカル変数 socket
は,その行のすぐ上でサーバへの接続用 TCPSocket として生成されており,context
引数として渡されている tls
は,遡っていくと一般的な使い方では initailize
メソッド中で OpenSSL::SSL::Context::Client.new
で初期化されていることがわかりました。
Openssl::SSL::Socket::Client.new
の定義を見ると,context
引数が省略された場合のデフォルト値も OpenSSL::SSL::Context::Client.new
なので,特に問題がなければこれは省略してしまっても良さげですね。
その他,接続先サーバのポート番号は必要ないけれど,相手先のホスト名(hostname
引数)は明示的に指定する必要がありそう,などといった事が見えてきます。
また,private def socket
の定義を見ると,https を利用する場合には Openssl::SSL::Socket::Client オブジェクトを返し,そうでなければ TCPSocket オブジェクトがそのまま返されています。それ以外の場所では,socket
メソッドの返り値が Openssl::SSL::Socket::Client だろうが TCPSocket だろうがお構い無しに,同じように読み書きしています。
ということは,Openssl::SSL::Socket::Client は TCPSocket と同じように読み書きできる,つまり,生成時に渡された IO オブジェクトに暗号化のラッパをかますような動きをするっぽい。であれば,sync_close
引数は Openssl::SSL::Socket::Client を閉じた際に,内部の IO も閉じてくれるんだろうな,と当たりをつけて,Openssl::SSL::Socket::Client の(正確にはその親クラスの Openssl::SSL::Socket の)実装を追いかけてみると,確かにそのように動いているようです。
そんなこんなで,最終的に STARTTLS コマンドに対する220番応答を受け取ったら,それまでサーバとやり取りしていた @socket
(TCPSocket)を,自身を引数として生成した Openssl::SSL::Socket::Client オブジェクトに置き換え,以降はそちらを読み書きする形で STARTTLS 対応が実現できました。
事例2:似たような処理のメソッドを一度に定義する
arcage/crystal-patliteでは,赤/黄/緑3色の信号灯のステータスを制御できますが,そのためのメソッドは赤灯のステータスに関するものだけでも7つ必要です。
status.red # 赤灯の状態を取得する
status.red_on # 赤灯を常時点灯させる
status.red_flash # 赤灯を点滅させる
status.red_off # 赤灯を消灯させる
status.red_on? # 赤灯が常時点灯状態であれば true を返す
status.red_flash? # 赤灯が点滅状態であれば true を返す
status.red_off? # 赤灯が消灯状態であれば true を返す
これを黄灯,緑灯についても同じだけ定義しなければなりません。
それぞれのメソッドは,変数名や定数名が各色に対応したものになるくらいでほぼ同じ処理ですが,いちいちコピペするのも手間ですし,後で修正しようとした際も3色分それぞれ書き換える必要が出てきます。
制御対象は全く違いますが,似たようなインタフェースを持っている標準添付ライブラリ Colorize のソースを見てみると,
{% for name in COLORS %}
def {{name.id}}
@fore = FORE_{{name.upcase.id}}
self
end
def on_{{name.id}}
@back = BACK_{{name.upcase.id}}
self
end
{% end %}
というマクロで,色名の配列 COLORS
から色情報を取り出して,各色についての処理を一度に定義していることが分かりました。
{{name.id}}
で変数 name
に納められた色名がそのままコード中に展開され,{{name.upcase.id}}
で大文字変換された色名がコード中に展開されるようです。
この name
は,なんとなく Crystal の String 型のようにも見えますが,イロイロ調べてみると,String 型ではなく,マクロ専用の Crystal::Macros::StringLiteral 型の変数で,Crystal::Macros::StringLiteral にも,Crystal の String 型ほどではないものの,いくつかの文字列操作メソッドが用意されているようです。
また,name.id
は,Crystal::Macros::MacroId 型を返すメソッドなのですが,なぜこのような処理が必要かというと,Crystal::Macros::StringLiteral 型は文字列そのものではなく構文上の文字列のリテラル表現を表す型で,その値には文字列を括るダブルクォート("
)までが含まれているためのようです。例えば,name
が "red"
の時に def {{name}}
とコード中に展開すると,def "red"
になってしまうのですが,name.id
によってリテラルそのもの("red"
)ではなくそのリテラルのコンテンツ(red
)を取り出すことができる,ということみたいですね。
この辺のマクロの挙動は,まだ完全に理解できていないのですが,それでもその恩恵を受けることでコードがすっきりして保守性を向上させることができそうです。