shiba-hiro’s 備忘録

技術ブログの皮を被ったただの備忘録

Crystal(Kemal)でWeb APIを実装する

仕事でがっつり使う機会があり、実験と学習を兼ねて。
簡素なCRUDを実装した成果物は、githubにアップしてあります。

github.com

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をインストールして利用してみます。

kemalcr.com

軽量で高速な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には、すでにたくさんのライブラリの選択肢があります。
下記のページから、用途に沿ったものを選定することができます。

GitHub - veelenga/awesome-crystal: A collection of awesome Crystal libraries, tools, frameworks and software

ちなみに、RailsそっくりなAmberというフレームワークも存在します。
Quick Startなんてほとんど同じです。

amberframework.org

なお、今回、ORMにはCrectoを利用しています。
Webフレームワークと同様、既に幾つかの選択肢が出ていますが、その中ではcommit数の多いもので、使い勝手も良好です。
associationsなども組むことができます。

www.crecto.com

所感

私にとっては初めての型推論の言語で、特にnilチェックはやりすぎなんじゃないかと思うほどの指摘をコンパイラから受けつつの実装でした。
ただ、最初こそ戸惑ったものの、慣れればかなり快適に実装ができるなぁという印象です。
何よりコンパイル後のnative codeが高速に動作するのが気持ちよく、スケール「アウト」が求められたりマイクロサービスとして軽量なAPIを立ち上げたりといった分散型のシステムとは、比較的相性がよいように思えます。

リポジトリにはDockerfileやテストケースもアップしています。
運用面が気になる方はそちらもご参照ください。

GitHub - shiba-hiro/note-api-by-crystal: note-api-by-crystal is a sample web api project implemented by Crystal