Crystal(Kemal)でWeb APIを実装する
仕事でがっつり使う機会があり、実験と学習を兼ねて。
簡素なCRUDを実装した成果物は、githubにアップしてあります。
Crystal Programming Languageとは
Ruby風の文法、開発効率の良さと、高速実行可能なExecutableへのコンパイルとの両立を目指した、オープンソースの言語です。
簡潔な記述が可能でありながらも、コンパイラによる型推論が、安全なプログラム作成をサポートしてくれます。
The Crystal Programming Language
以下でいくつか特徴を見てみます。
なお、以下のサンプルソースでファイル名を記載しているものは、全て上述のgitリポジトリからの引用です。
型推論の威力
Crystalは静的な型の概念がある言語です。
コンパイラは、明示的な型宣言がなくとも、値にマッチする型を見つけようとし、エラーがあればコンパイル時に検出します。
class_checker = "hello, world" puts typeof(class_checker) # String class_checker = 2018 puts typeof(class_checker) # Int32
とはいえ上記のようなシンプルな例ではあまりメリットを感じられません。
私がこの仕組みについて感動したのは、処理の分岐(エラーハンドリング含む)等を記述するシーンだったので、紹介します。
下記で出てくるhalt envは、Contextを終了する役割を果たす、Kemalというライブラリのマクロです。
ここでは、halt envが呼ばれると、その後のif以降の処理へは進まないことがポイントです。
# src/note-api/resource/note_resource.cr begin changeset = Repo.insert(note) rescue e log(e.to_s) halt env, status_code: 500, response: {message: "Internal server error."}.to_json end if changeset.errors.empty? . . .
注目すべき点は、changesetの宣言が事前に必要ないところです。
なぜならば、Repo.insertが成功した際に返る型が何かを(Repo.insertの定義ファイルから)コンパイラは分かっており、begin~end後の処理は、changesetの値が必ずRepo.insertの返り値になっていることを推論できるからです。
Javaっぽく書いたものと比べてみると・・・
ChangeSet changeset = null; try { changeset = noteRepository.insert(note); } catch(Exception e) { logger.error(e); return Response.Status.INTERNAL_SERVER_ERROR.buid(); } if (changeset != null && !changeset.hasError()) { . . .
changesetの宣言やnullチェックの点でコードの記述を減らせることがわかります。
(尤も、このケースではnoteRepository.insertの返り値がnullにならないことを、「ソースコードから人間が読み取れば」、nullチェックを省くこともできます)
それでいて、たとえば下記のようにエラーキャッチ後も処理を続行するようになっていると、Crystalのコンパイラは指摘をくれます。
begin changeset = Repo.insert(note) rescue e log(e.to_s) # comment out here and continue process # halt env, status_code: 500, response: {message: "Internal server error."}.to_json end if changeset.errors.empty? . . .
# try to compile with above inappropriate code $ crystal build src/note-api.cr . . . in src/note-api/resource/note_resource.cr:28: undefined method 'errors' for Nil (compile-time type is (Crecto::Changeset::Changeset(Model::Note) | Nil)) if changeset.errors.empty?
コンパイラによる推論が働かないと、こういったケースでは変数がnullになる可能性を見逃してしまう可能性があります。
一方Crystalでは、このような仕様のおかげで、(説明のために極端に言ってしまえば)「意味のある記述だけを」「安全に」行うことができます。
拡張性
Crystalは、非常に拡張性の高い言語です。
全てがオブジェクトであり、演算子(+, -など)もメソッドとして振る舞いが定義されています。
また、ひとつのクラスについて、いくつかのファイルに分割して記述することができます。
そのため、標準定義やライブラリ内の定義も、必要に応じて手軽に上書きすることができます。
もちろん、逆のことを言えば、どこで上書きがされるか分からない怖さもあります。
# src/note-api/repo.cr module Crecto::Adapters::Mysql private def self.instance_fields_and_values(query_hash : Hash) {fields: query_hash.keys, values: query_hash.values.map { |v| v.is_a?(Time) ? v.to_s("%Y-%m-%dT%T.%L") : v.as(DbValue) }} end end
この例では、Time型の値からSQLを記述する際に、ミリ秒まで含められるように変更を加えています。
マクロ
マクロを利用することで、煩雑なソースコードの記述を削減することができます。
コンパイル時に評価され、規則に応じたCrystalのソースコードが生成されます。
# src/note-api/filter.cr macro reject(env, status_code, response) {{env}}.response.status_code = {{status_code}} {{env}}.response.print {{response}} raise Kemal::Exceptions::CustomException.new({{env}}) end
上記の例は、あまりうまくメリットを表現したものではないかも知れませんが、いくつか典型的なユースケースは考えられます。
たとえば、ソースコードの階層化を目指すと必ず出てくる、オブジェクトAからBへのコンバート処理。
もとのオブジェクトから、同じ名前のフィールドを文字列にして別オブジェクトを返すだけのコンバーターなどは、あるあるかも知れません。
そして抽象化クラスなどをうまく作れない場合、オブジェクトの数だけこういった処理を記述しなければならないこともあります。
下記はイメージですが、マクロを用いて、field_namesからコードを生成させる仕組みにすれば、あらゆる型のオブジェクトで応用できます。
macro convert(original, created, field_names) {% for field_name in field_names %} {{created}}.{{field_name}} = {{original}}.{{field_name}}.to_s {% end %} end . . . created = Created.new convert original, created, [id, name]
あるいは、メソッド化することが適さないようなもの。
同じような処理が必要だけれど、かといってオブジェクトやサービスのメソッドにしてしまうと階層が壊れてしまう、、、
こういったケースでは、シンプルに記述するためのマクロを定義するだけで解決します。
ソースコードの管理や見通しを犠牲にすることなく、設計を保つことができます。
Kemalの始め方
Crystalの特徴がざっくりわかったところで、アプリケーションの実装に取り掛かります。
Crystalにはプロジェクト作成のためのコマンドが備わっているので、まずはこれを実行します。
$ crystal init app note-api-by-crystal $ cd note-api-by-crystal/ $ ls LICENSE README.md shard.yml spec src
ライブラリのインストール
続いて、WebフレームワークであるKemalをインストールして利用してみます。
軽量で高速なWebフレームワークであることをウリにしており、シンプルなAPIであればすぐに実装できます。
先ほど自動生成されたshard.ymlへ依存を記述することで、ライブラリをインストールすることができます。
# shard.yml dependencies: kemal: github: kemalcr/kemal branch: master
コマンドにはshardsを使います。
shards/README.md at master · crystal-lang/shards · GitHub
該当のライブラリが依存している他のライブラリも、併せてインストールしてくれます。
libディレクトリが作成され、その中にライブラリは格納されます。
$ shards install Fetching https://github.com/kemalcr/kemal.git Fetching https://github.com/luislavena/radix.git Fetching https://github.com/jeromegn/kilt.git Installing kemal (0.22.0 at master) Installing radix (0.3.8) Installing kilt (0.4.0) $ ls lib/ kemal kilt radix
実装
それぞれのWebAPIは下記のような実装になります。
特に難しいことはなく、HTTPメソッドとエンドポイントを記述して処理を囲むだけです。
# src/note-api/resource/note_resource.cr get "/v1/notes" do |env| begin Repo.all(Model::Note).map { |note| Interface::NoteResponse.new(note) }.to_json rescue e log(e.to_s) halt env, status_code: 500, response: {message: "Internal server error."}.to_json end end
また、HTTPメソッドに相当する個別の処理だけでなく、全てのリクエストへ影響するbefore_allなども用意されています。
これを利用することで、filterを実装することもできます。
ここでは、特定のリクエストヘッダーが含まれているかどうかをチェックしています。
# src/note-api/filter/header_filter.cr before_all do |env| next unless Kemal.config.env == "production" some_header = env.request.headers["some_header"]? if some_header.to_s.empty? reject env, status_code: 400, response: {message: "some_header is needed."}.to_json end end
メインになるクラスでKemal.runを実行させれば、サーバーが起動します。
# src/note-api.cr require "uuid" require "kemal" require "mysql" require "crecto" require "./note-api/*" Kemal.config.port = Config::PORT.to_i Kemal.run
その他
Crystalには、すでにたくさんのライブラリの選択肢があります。
下記のページから、用途に沿ったものを選定することができます。
ちなみに、RailsそっくりなAmberというフレームワークも存在します。
Quick Startなんてほとんど同じです。
なお、今回、ORMにはCrectoを利用しています。
Webフレームワークと同様、既に幾つかの選択肢が出ていますが、その中ではcommit数の多いもので、使い勝手も良好です。
associationsなども組むことができます。
所感
私にとっては初めての型推論の言語で、特にnilチェックはやりすぎなんじゃないかと思うほどの指摘をコンパイラから受けつつの実装でした。
ただ、最初こそ戸惑ったものの、慣れればかなり快適に実装ができるなぁという印象です。
何よりコンパイル後のnative codeが高速に動作するのが気持ちよく、スケール「アウト」が求められたりマイクロサービスとして軽量なAPIを立ち上げたりといった分散型のシステムとは、比較的相性がよいように思えます。
リポジトリにはDockerfileやテストケースもアップしています。
運用面が気になる方はそちらもご参照ください。