Toyokumo Tech Blog

トヨクモ株式会社の開発本部のブログです。

Clojureを静的型付け言語にする

こんにちは、開発本部の松尾です。

Clojureは標準ライブラリに使いやすい道具が充実していて、可読性の高い抽象的なコードが書きやすく、Javaとの相互運用のお陰で使える資産も多いため実用的で、良い言語だと思っています。

しかし、おこがましくも1点だけ改善出来るところがあると思っています。それは、「静的型解析が出来ない(弱い)」という点です。

Pythonの型ヒント、Ruby3の静的型チェッカーの例に見られるように、静的型解析によるメリットは、パフォーマンスという観点を抜いても、バグを減らして開発効率を上げる観点から、もはや見過ごすことが出来ないのが時代の潮流だと感じています。特に中規模〜大規模の業務でのコードになってくると、正確さやドキュメント製の観点から重要度が増してくるかと思います。

ということで、静的型解析を出来るようにしていきたいと思いますが、なぜやる必要があるのか。既に他の手段は無いかについて次の項でお話します。

本題を見たい方は読み飛ばしてください。

なぜやる必要があるのか、代替手段の認識と反省

私が認識している中で、Clojureに型という概念を導入する方法は4つあります。

1. メタデータのタグ

^string, ^bytes などのように、シンボルの前につけているやつです。 しかし、Javaとの相互運用の際に最適化に使われるという目的が大きく、バグをへらす為の静的解析に使うにはあまりにも表現力が低いです。例えば、ある形を持ったマップの配列、といったClojureのオブジェクトの型を表現することが出来ません。

2. schema.core

これはトヨクモでも採用しているライブラリです。プリミティブな型やマップの型はもちろん、それらの配列や、Javaの型も扱うことが出来るので表現力は十分です。解析しきれない時は Anyを指定するか、単純に型を付けないかを選べば良く、書いた分だけ見返りが得られて、使っていない部分の開発を妨げることもありません。とても現実的だと思います。しかし、型があっているかどうかは実行時にしか分かりません。主に関数の入力と出力の際に検証をしているので、出せるエラーメッセージには限界があります。また、コードにおける原因と結果が遠いときは問題箇所を見つけづらいという問題意識があります。 また、全てのコードパスを確認するためには、多くのテストを書く必要があります。

3. core.typed Clojureのコードを静的解析することが出来ます。

しかし、以下のリンク先では、CircleCIが実際に導入してから2年後、実用を通して分かった問題点が挙げられています。これは重要参考文献だと思います。 circleci.com

簡単にまとめると以下の通りです。

  1. 解析が遅い
  2. 型を解析しきれないことがあり、その際に型チェックを無視するか、複雑な型アノテーションをつけるかを選ばなくてはならない
  3. サードパーティーコードには型がない。

後発のtypedclojureも出ていますが、開発が非常に盛んというわけではなく、基本的な方針は変わっていないようです。 また、より広く使われているschema.coreを採用している企業にとって、移行コストがかなり高くつくことが予想されます。

4. malli + clj-kondo

これは今回やろうとしていることに近いかと思います。 スキーマとバリデーションの機能を提供するmalliというライブラリが、clojureのリンタである clj-kondoと連携することによって、実行前にスキーマを静的に解析した結果を知ることが出来るようです。

github.com

特に大きな欠点はなさそうですが、強いて言えば、他のスキーマライブラリを使っているプロジェクトにおいては移行コストが高く付きそうです。

結論

ということで、既存でも型を解析するツールチェインは存在していますが、トヨクモでも利用していて、比較的広く使われているschema.coreのスキーマを静的解析する、ということには意義がありそうです。

大まかな方針として、既にあるものから漸進的に解析出来る情報を増やしていけば、既存コードに影響を与えることなく、静的解析で得られる情報を増やしていけるのではないか、という算段です。

サードパーティーコードに型がないという問題点は致し方がないと思いますが、他の例を見てみて、解析に必要な情報が足りないときにエラーを出したり、明示的に無視をするのを要求するのではなく、Any(なんでも許容する)という寛容な方に倒し、間違っている確証が得られる箇所のみをエラーとするベストエフォート方式が良いのではないかと思いました。

とはいえ、型が分からないものをそのままにしておくと、Anyが伝播していって全体的に型付けが弱くなってしまったり、Anyの場合にバグを検出することが出来ないという問題があります。 この問題に関しては、型アノテーションで補足をすることで解決出来るかと思います。静的解析が出来ない場合はschema.coreの実行時解析が役に立ちそうです。

速度についてですが、例えば、HTTPのパース、JSONのパースを始めとする何度も実行されるコードはやはり高速であればあるほど良く、そうある努力をすべきだと思っています。 コードの解析もそれに含まれるのではないかと思います。

今回は開発言語にRustを用います。 解析の性質上、取りうる型を網羅的に検査する為に、Rustの強い型付けや代数データ型は非常に有用です。また、解析の速度におけるオーバヘッドにならず、特に意識しなくてもそこそこ高速なことを期待しています。

読み進める前に

この記事は、Clojureを静的型付け言語にした際の機能や実装方法の概要を紹介するものですが、あまり実装の詳細を書いていくと理解しづらくなってしまうため、実装の代わりに「どのようなデータ構造を扱っているのか」という側面で解説していきます。 また、実装の詳細が気になる方の為に、合わせて実際のコードのリンクを近くに貼っておきます。

1. Clojureのパーサーを作る。

解析の準備の為に、Clojureのソースコードの文字列から、Clojureの構文を表すデータ構造に変換する必要があります。文字列からそのようなデータ構造に変換する機構のことをパーサーと呼びます。(大雑把な説明)

Rust製Clojureパーサーは無い気がしたので、まずはClojureのパーサーを作ります。 本題ではありませんので実装方法は割愛しますが、例えば、以下のソースコードを次のように変換出来るように実装します。

(s/defn add-one :- s/Str ;; サンプルコード
  [a :- s/Int]
  (+ a 1))
List(
  Symbol {ns: Some("s"), name: "defn"},
  Symbol {ns: None, name: "add-one"},
  Keyword { ns: None, name: "-"},
  Symbol {ns: Some("s"), name: "Str"},
  Vector (
     Symbol {ns: None, name: "a"},
     Keyword {ns: None, name: "-"},
     Symbol {ns: "s", name: "Int"}
  )
  List (
     Symbol {ns: None, name: "+"},
     Symbol {ns: None, name: "a"},
     IntegerLiteral(1)
  )
)

このとき、コードに影響を及ぼさないコメントや空白などの情報は削ぎ落とされています。 このようなデータ構造を、言語処理系の用語で「抽象構文木」と呼びます。

英語で AST(abstract syntax tree)と略すことが多いので、以降はASTという言葉を使います。

詳細を見たい方は以下のファイル内の型を見るとよりイメージが湧くかもしれません。実際は、位置の情報なども含まれています。

github.com

2. ASTを解析しやすいデータ構造に変換する

先程パースしたデータはあくまでリストの中にシンボルなどの下位のASTが入っているものに過ぎません。 つまり、関数ということが分かっているのではなく、「ただのリスト」に過ぎないのです。

他のLisp以外の言語は構文の種類がより多く、ASTの時点で意味合いが具体的に決まっていることが多いため、文字列からパースした段階でその情報を得られることが多いですが、Clojure(Lisp)は構文がリスト、ベクタ、リテラルぐらいしかなく、自由度が非常に高いので、ここから更に意味を抽出していきます。

Clojureの内部構造としては、def, defn, if, let などの特殊形式はマクロとして定義されていますが、これを展開して解析しようとするとキリが無いので、ある程度決め打ちでこちらが分かる範囲のデータに持ち込むというわけです。

先程のASTから以下のようなデータを生成します。

Function (
   decl: FunctionDecl,
   name: "add-one",
   arguments: [
      (Binding::Simple("a"), Some(Scalar("Int"))) // Int型
   ]
   return_type: Some(Scalar("Str")),
   body: [
     Call {
       func_expr: Symbol {ns: None, name: "+"},
       args: [
          SymbolRef {ns: None, name: "a"]
          IntegerLiteral(1)
       ]
     }
   ]
)

例えば、defnから始まるリストが、Function、それに続くシンボルが名前、その後のベクタが引数、 + から始まるリストが Call、というように、defnマクロが持つ意味に従って再解釈されています。

他にも特殊形式はあると思います。例えば、ifやwhenなどがありますが、これらもそれぞれのマクロが持つ意味に従って解釈していきます。

詳細を見たい方は以下のファイル内の型を見るとよりイメージが湧くかもしれません。

github.com

3. データを用いて型を解析していく

さて、ここまで作成してきたデータにより、型を解析するための準備は揃っています。 後は以下のことを出来るようにするだけです

  • 関数、変数、スキーマの型解決
  • 型シンボルを比較可能な形式に解決
  • 型が他の型に代入できるかのロジックの実装
  • 関数呼び出し、定義、代入、関数の戻り値などに関して、型が定義されたものに一致するかを検査する

関数、変数、スキーマの型解決について 簡単な実装方法を紹介します。 変数の参照先の解決という問題は、「スコープ」という概念と、あるスコープの中で参照出来る変数というデータを表現することによって解決することが出来ます。

例えば以下のような 関数定義があったとします

(s/defn say-hello [to :- s/Str]
   (println to)

このとき、[to :- s/Str] の後の (println to) の部分が関数のボディにあたりますが、この中では、to という変数を参照出来るようになっていて、s/Str型を持っているということも分かるはずです。 この情報を defn に対応するデータを読み込んだ際にマップに保存していきます。

スコープはネストされていくものなので、深さによって変数が指す値が変わることがあります。そのため、マップの可変長配列 というデータ型で、関数や変数、スキーマを解決するためのデータを表現することが出来ます。

以下に実際のコードの関数の解析の例を載せておきます。

pub fn analyze_function(errors: Errors, context: Context, func: &Function) {
    let func_ty = get_func_type(context.clone(), &func.decl);
    context
        .borrow_mut()
        .variable_scopes
        .last_mut()
        .unwrap()
        .insert(func.decl.name.clone(), func_ty);
    variable_scope!(context, {
        for (arg_binding, opt_arg_ty) in &func.decl.arguments {
            match &arg_binding.value {
                semantic_parser::semantic_ast::Binding::Simple(name) => {
                    let arg_ty = if let Some(arg_ty) = opt_arg_ty {
                        context.borrow().resolve_type(&arg_ty).clone()
                    } else {
                        ResolvedType::Unknown
                    };
                    context
                        .borrow_mut()
                        .variable_scopes
                        .last_mut()
                        .unwrap()
                        .insert(name.clone(), arg_ty);
                }
                semantic_parser::semantic_ast::Binding::Complex { keys, alias } => todo!(),
            }
        }
        for expr in &func.exprs {
            analyze_expression(errors, context.clone(), expr);
        }
    });
}

このような関数を書いていきます。特にClojureは ifもwhenも様々なものが「値」なので、値の解析については再帰的に解析することになります。 その際も let forなど変数を束縛するマクロがあれば、スコープを追加して値を設定していきます。そして、それらの値も解析が終わる際にスコープをpop(削除)します。

この実装により変数などの型が解決出来るので、例えば、関数呼び出しの際に、以下のように型が間違っているかどうかが判定出来るようになります。

(say-helllo ;; s/Strを受け取る関数であることが分かっている
  1) ;; リテラルはそのまま s/Intであることが分かるので、これはエラーとなる

実際の実装のリンクも貼っておきます。

github.com

github.com

エラーを標準出力する

以上で紹介した実装方法によって、型が間違っている箇所をエラーとして蓄積していきます。 あとは集まったエラーを標準出力に出力するだけです。以下のようなイメージです。

for error in errors {
   println!("{}:{}:{}: {}", file_path, error.location.line, error.location.col, error.message)
}

エディタで表示する

標準出力だけでも有用かもしれませんが、普段の開発に組み込むことを考えれば、エディタ上でエラーを表示出来るのが望ましいです。 今回は弊社で広く使われている IntelliJ での方法を紹介します。

まず、IntelliJのプラグインであるFileWatcherを導入します。

設定 > ツール > FileWatchersを開き、リストに設定を追加します。

肝はこの部分です。この部分を標準出力の形式に合わせるだけで、保存時に出た標準出力をパースして、該当位置に波線が引かれるようになります。

ホバーすると、$MESSAGE$ の内容のツールチップを表示してくれます。

最後に

今回は、Clojureのパーサーから書いた為に結構な実装量になりましたが、これでClojureの静的解析をするための基礎が出来ました。まだ初歩的なものだしバグも多いかもしれませんが、このプログラムを漸進的に成長させて、実際に業務で使えるところまで持っていきたいという所存でいます。

他にも

  • 他のファイルやライブラリの読み込み、namespaceの解決
  • 標準ライブラリへの組み込み型付け
  • サードパーティーコードへの型付けの提供

などやることが多く考えられます。 もし、Clojureの静的解析に興味を持っていただけた方は、ご意見、感想を頂けると励みになります。 最後まで読んで頂きありがとうございます。


トヨクモでは一緒に働いてくれる技術が好きなエンジニアを募集しております。

採用に関する情報を公開しております。 気になった方はこちらからご応募ください。

Babashka pods から使う clj-kondo

開発本部の飯塚です。

この記事は Clojure Advent Calendar 2022 5日目の穴埋めに向けた記事です。

今回は clj-kondo を Babashka pods として利用する方法を簡単に解説したいと思います。

用語の説明

clj-kondo

clj-kondo とは何か、どう使うのかについては以下の記事でまとめているので もし知らなければ先にこちらを参照することをおすすめします。

tech.toyokumo.co.jp

Babashka

Babashka は Clojure のインタプリタです。 GraalVM を使ってネイティブイメージとして動くため起動がとても速く Clojure の機能のほとんどがそのまま使えるためスクリプトとしての用途などで広く利用されています。

Babashka pods

Babashka pods とは Babashka にて Clojure ライブラリとして使える「プログラム」です。

あえて強調した通り「プログラム」なので Clojure で書かれている必要は勿論ありません。 Go で作られたものもあれば C# で作られたものもあります。

Babashka pods として動くプログラムは nREPL をベースとしたプロトコルで Babashka とやりとりし動作します。 このプロトコルでは nREPL 同様に Bencode を使っていて、メッセージのやりとりは標準入出力で行われます。 なので基本的には Bencode さえ扱えればどの言語でも Babashka pods として動くプログラムは作れます。

参考までに拙作の Dad でも Babashka pods に対応しているので、その部分へのリンクだけ貼っておきます。 https://github.com/liquidz/dad/blob/main/src/dad/pod.clj

Babashka pods としての clj-kondo

前置きが長くなりましたが、clj-kondo は Babashka と同じ Borkdude 氏によるものなので当然 Babashka pods としても動きます。

一応 clj-kondo 自体が clojars にデプロイされているのでライブラリとしても勿論利用できはするのですが、 Babashka pods として利用することで手軽に便利スクリプトが作れるのでその利点と方法が紹介できればと思います。

今回はあるプロジェクト配下で private にできそうな public var を検出するスクリプト を例に説明します。

準備

まずは準備です。例えば foo.clj のようなファイルを用意してみましょう。 ファイルの保存先は任意のプロジェクトルート直下を想定しています。

(ns foo
  (:require
   [babashka.pods :as pods]))

;; Babashka pods として clj-kondo を読み込む
;; clj-kondo コマンドへのパスが通っている必要あり
;; もし clj-kondo コマンドを持っていない場合は Pod registry も利用可能
;; https://github.com/babashka/pod-registry
(pods/load-pod "clj-kondo")

;; 読み込んだ pod で提供されている ns を require
(require '[pod.borkdude.clj-kondo :as clj-kondo])

これだけで Babashka pods として clj-kondo を使う準備は完了です。 clojars のライブラリから使う場合は Leiningen や Clojure CLI を使って project.clj なり deps.edn なりから用意する必要がありますが、 Babashka pods から使う場合はファイル1つだけなのでかなり手軽であることがわかると思います。

エラーが無いかは bb コマンドを使って bb foo.clj のように実行しても確認はできますが、 いくら Babashka の起動が速いとは言っても非効率なので Clojure 開発環境から Babashka の REPL に接続することをおすすめします。 そうすることでフォーム単位での評価ができ、 REPL 駆動でスクリプトを書くことが可能になります。

なお最近の Clojure 開発環境であれば大抵は Babashka に対応しているはずなので、 どうやって Babashka の REPL に接続するのかは各開発環境のドキュメントを参照してください。 例えば拙作の vim-iced では IcedInstantConnect コマンドでREPLの起動と接続が可能です。

なおもし Clojure の開発環境がない場合は以下のまとめ記事を参考にすると良いでしょう。

tech.toyokumo.co.jp

プロジェクトの解析

次に実際に clj-kondo を使って解析データを取得してみましょう。

:lint で指定しているディレクトリは検出したいプロジェクトに応じて変更してください。 今回説明するスクリプトの中で一番時間がかかるのがここの clj-kondo による解析処理なので、例えば analysis-data として束縛しておけばその後の解析データを使った処理でデータ構造の確認や情報の抽出が楽になります。

(def analysis-data
  (-> {:lint ["src"]
       :config {:output {:analysis true}}}
      (clj-kondo/run!)
      (:analysis)))

public な var の抽出

解析データが取得できたら次は public な var の抽出です。

今回は :var-definitions という var の定義情報を利用します。 解析データに他にどのようなものが含まれるのかの詳細は clj-kondo のドキュメントを参照してください。

(def public-vars
  (->> (:var-definitions analysis-data)
       ;; public なものだけにしたいので private は除外
       (remove :private)
       ;; 必要な情報(ns名, var名)だけにする
       (map #(select-keys % [:ns :name]))
       (distinct)))

結果の出力

これで最後です。

「privateにできそう」というのは言いかえると「varが定義されているns以外で使われていない」ということです。 それをそのまま条件として書き出して、該当する var を出力します。

var の利用状況は解析データの :var-usages にあります。

(doseq [v public-vars]
  (let [;; public な var を使っている箇所を抜き出す
        usages (filter #(= (:name v) (:name %)) (:var-usages analysis-data))]
    ;; var の利用元の ns 名がすべて定義されている ns と一致するならば、それは「privateにできそうな var」
    (when (every? #(= (:ns v) (:from %)) usages)
      (println (format "%s/%s" (:ns v) (:name v))))))

これで public だけど private にできそうな var が表示できました。

勿論ライブラリ等のコードで意図的に public にしているものも表示されてしまうとは思いますが、 例えば public-vars の抽出時に特定の var は除外するみたいなことは自由にできるので、自身のプロジェクトに応じてカスタマイズすれば使えるスクリプトになるかと思います。

これ以外にも例えば以下のようなことも可能です。

  • テストコードの無い public var を表示する
  • ns のエイリアスとして他ファイルと異なるエイリアスを使っている箇所を表示する

今回紹介したスクリプトの全体は以下の Gist に保存してありますので、とりあえず試してみたい方はこちらからコピーしてください。

gist.github.com

最後に

clj-kondo の解析データはコマンドからも取得可能で、それをパイプしてワンライナーであれこれする方法もありますが、 試行錯誤のしづらさが個人的にネックでした。

それと比べて以上のようなことが1ファイルで、かつ REPL 駆動で書けるのは嬉しい人も多いのではないでしょうか?

clj-kondo は解析データからはいろいろな情報が取得可能なので、この記事内で紹介したこと以外でも便利な使い道があるはずです。

リンターであるという認識が強いのか解析データを利用する方面での記事はあまり多くない印象なので、これを機にこんなこともできて便利だよ!という記事が増えたら良いなと思います。


トヨクモでは一緒に働いてくれる技術が好きなエンジニアを募集しております。

採用に関する情報を公開しております。 気になった方はこちらからご応募ください。

Thanks OSS Award というサイトを公開しました

Thanks OSSチーム チームリーダーの飯塚です。

タイトルの通りですがこの度、Thanks OSS Award というサイトをついに公開できたのでその紹介です。

Thanks OSS Award とは

Thanks OSS Award とは私達の製品開発で使用しているライブラリ/ツールなどにおいて、 年度単位で選出した開発者の方々にその開発の継続を金銭的に支援しましょうという活動です。

oss.toyokumo.co.jp

上記サイトに詳細は書かれていますが、この活動のポイントは「継続」です。 新規機能の追加や問い合わせサポートではなく、今までやっていた開発を今後も同じように継続してもらうことを目的とした支援になっています。

よって見返りを求めるための支援でないことは予めご承知おきください。

私自身、業務外にてOSSを開発している身ですが、様々な要因で継続することの大変さを実感しています。 特に子どもが生まれたことにより自由時間が大きく減ったことで、アウトプットの速度は大きく下がりました。 それでも朝早くに起きて、子どもが寝ている時間を開発に当てるなどして継続できているのは「楽しい」という気持ちだけではなく、 スポンサーとして応援してくれている方々の存在がモチベーション維持に少なからず影響しているためだと感じています。

OSSは私達の製品を形作る重要なピース。 そのピースを作ってくれている方々のモチベーション維持に少しでも貢献できればというのが OSSを使わせてもらっている私達の願いです。

究極の目標

活動の背景/目的、また実際の選考結果などはサイトの方にまとめてありますのでそちらを参照していただきたいですが、 2022年度の支援金額だけこちらでも紹介させてもらうと $63,150 になりました。

1ドル144円換算だと 9,093,600 円です。

決して安い金額ではありませんが、複数の開発者の方々に支援する金額としてはこれだけで生活できるレベルではありません。 そう、OSS開発者がその開発だけで生活できる未来を築けたらというのが私達が考える究極の目標です。

その目標には私達だけではたどり着けないかもしれません。 すでにOSS含め多方に支援されている企業もいますが、OSSを利用していてそこに支援している企業というのはまだ少ないのではないでしょうか?

多くの企業がそれぞれお世話になっているOSS開発者の方々を支援することでこの未来は近づいてくると思います。 そのときにこの Thanks OSS Award という活動が支援するきっかけの1つにでもなると嬉しいです。

活動裏話

元々OSSへの支援は以下のブログ記事で書いてあるとおり2020年から行ってきました。

tech.toyokumo.co.jp

その頃からの私達のOSSへの思いは変わっていませんが、「今後の予定」に書いていた通り支援対象は拡大したいという考えはありました。 その考えのもと Thanks OSS Award は今年04月頃から動き始めました。

支援したい方の選出は普段お世話になっている気持ちがあったのでそこまで難航しなかったのですが、 支援方法の確保で少し手間がかかったので裏話として紹介します。

GitHub Sponsors が有効でない

支援方法としては2020年から実際に行ってきた GitHub Sponsors をそのまま採用しました。 ただ支援した方の中には GitHub Sponsors が有効でない方もいたので、その方には直接コンタクトをとって有効にできないかという相談をさせてもらいました。

私自身 GitHub Sponsors を有効にしている身としては地味に面倒な申請があったことから(日本のみ?)、有効に難色を示されるかもと思っていたのですが 何回かのやりとりの上で色よい返事をもらえた方がいたことは純粋に嬉しかったです。

なお GitHub Sponsors がまだサポートしていない国であるために有効にできないという方がいたのも事実です。 その方は残念ながら今年度の対象からは除外となってしまったのですが、 将来 GitHub Sponsors がサポートした暁には改めて支援対象として追加したいと考えています。

GitHub Sponsors の任意金額の開放

GitHub Sponsors は支援のTier(コース)を決める必要があります。 なので一番単純なのは「$N/月」、「$M/月」などのコースから選んで支援する形となるのですが、 今回支援金額はこちらで決めたかったので、このような設定の方にも任意の金額が指定できるように設定変更を直接相談させてもらいました。

コンタクトをとる手段を探す手間は同様ですが、どう相談したらいいものかがとても悩ましかったです。 というのも「$Nくらい支援したいので設定変更してください」と見知らぬ人から急に連絡がきても怪しいだけだからです。

とはいえ相談したい内容はそのとおりなので、所属だけでなく個人のアカウントなども紹介した上で 極力怪しさを払拭して(したつもりになって)やりとりをすることで設定変更していただけたのかなと思っています。

最後に

動き始めてから約半年、ようやく形になりましたが、最初に書いたとおりサイト公開が目的ではなく私達が使っているライブラリ/ツールなどの開発継続の支援が目的です。 つまり Thanks OSS Award はまだ始まったばかりです。

来年度以降も継続して行われる活動であるので、これらを通してOSS開発者の方々のモチベーション維持に貢献できればと願っています。

oss.toyokumo.co.jp

シンプルな例で理解するWASMの基礎の基礎

こんにちは。開発本部の松尾です。 今回は、趣味でWebAssemblyをターゲットにしたコンパイラを作った知見を元に、WebAssemblyについての解説記事を書きました。

WASMとは?

WebAssemblyの略で、Webプラットフォーム上で実行出来るように設計された、命令セット及び言語のことです。 Webにおいて広く使われている JavaScriptが、文字列からパースされて動的に実行されているのに対して、 WASMは、機械語に近い状態にコンパイルされたコードをバイナリ形式で配信することが出来る為、多くのケースにおいて、JavaSciptよりも高速に動くように設計されています。

WASMの用途やメリット

WASMの機能はJavaScriptに比べてかなり限定されており、基本的にWASMのみでは一般的なCPUがサポートしている計算を行うことしか出来ません。例えば、ファイルアクセスや通信などのIO機能に直接アクセスすることは出来ません。

WASMには、

  • JS以外の様々な言語をWASMにコンパイルしてブラウザ上で実行出来る
  • (多くの場合)高速に動作する

という性質がある為、

  • C, C++等JavaScript以外の言語をコンパイルして、既存のコードをWebプラットフォーム上で使う
  • 高速な処理が必要となるサービス体験をブラウザ上で実現し提供する

というユースケースが主になっています。

今回やること

非常に単純な WASMをテキスト形式で書き、実際にブラウザで実行することで、短時間でWASMの実態を理解し、身近に感じられるようになることを目指します。

テキスト形式について

WASMは時に、ブラウザで動く「言語」と説明されることがありますが、WASMには、バイナリ形式とテキスト形式両方が定義されており、そのうちテキスト形式は、WAT (WebAssembly Text Format) と呼ばれています。

バイナリ形式はご想像の通り、テキストでは無いバイナリデータの塊ですが、 テキスト形式は以下のような、LispのようにS式で構造化された言語で、意味的にはバイナリ形式とほぼ対応しています。

以下は引数の階乗を計算する WATです。

(module
  (func $fac (export "fac") (param f64) (result f64)
    local.get 0
    f64.const 1
    f64.lt
    if (result f64)
      f64.const 1
    else
      local.get 0
      local.get 0
      f64.const 1
      f64.sub
      call $fac
      f64.mul
    end))

見た目はLispに似ているかもしれませんが、CPUの命令セットを大抵のアーキテクチャに当てはまるように抽象化した命令をサポートするよう設計されている為、内容は低レイヤーで、アセンブリ言語に近いものになっています。

ただし、多くのアセンブリ言語と異なりメモリの読み書きの最小単位は、スタックというものに抽象化されています。スタックを用いた計算モデルのことをスタックマシンと呼びます。

スタックマシンの詳しい説明はリンク先に譲り、ここでは簡単な例で説明したいと思います。

例えば、1 + 1 を計算するプログラムは以下のようになります。

i32.const 1
i32.const 1
i32.add

これを関数にすると、WATは以下のようになります

(func $add (result i32)
  i32.const 1
  i32.const 1
  i32.add)

これだけです。

WATでは、(result i32) のように戻り値を宣言します。 WASMで扱える値は基本的には以下の4つのみです。

i32, i64, f32, f64

例えば、i32は 32ビットの整数を表します。

これに対して、 i32.const などの定数の宣言や、 i32.add などの演算が定義されている命令セットがWASMの実態です。

引数を2つ受け取り、それらを足して返す関数は以下の通りです。

(func $add (param i32 i32) (result i32)
  local.get 0
  local.get 1
  i32.add)

なんとなく、感覚で分かってきたのではないでしょうか。

では、これを実際に動かすことを考えていきましょう。 WASMでの最小の実行可能な単位は、moduleです。 モジュールの定義自体は非常に単純で、例えば空のモジュールは以下のように定義されます。

(module)

拍子抜けするほど簡単ですが、このモジュールの中に、関数を入れれば実行出来るようになります。

(module
  (func $add (param i32 i32) (result i32)
    local.get 0
    local.get 1
    i32.add))

ただし、WASMを外の実行環境(今回の場合ブラウザ上のJS)から呼べるようにするためには、export で明示的に外向けの名前を宣言する必要があります。

(module
  (func $add (export "add") (param i32 i32) (result i32)
    local.get 0
    local.get 1
    i32.add))

これで、本当にブラウザから直接実行出来るWASMに対応したWATが出来ました。

WATは意味的にWASMのバイナリ形式に対応しているだけで、ブラウザが直接解釈して実行するものではないので、これをコンパイルすることになります。*1

今回は、手早く試すために、wat2wasm demo を用いて実行してみましょう。

画像のように、左上にWATのコードを書き、左下にJavaScriptを書きます。

wat2wasm demo

JavaScript は以下のようになります。

const wasmInstance =
    new WebAssembly.Instance(wasmModule, {});
const { add } = wasmInstance.exports;

console.log(add(1,2))

裏側で、上のWATをコンパイルして、インスタンスにしたものを wasmModule に束縛するというマジックがありますが、実際のJavaScriptのコードも大体同じようなものになります。

詳しくは、MDNを参照してみてください。

developer.mozilla.org

さて、このデモはリアルタイムでコンパイルされるので、WATとJSを正しく書き終えた時点で右側に結果が現れているはずです。 右上は、対応するWASM(バイナリ形式) 右下が実行結果の出力です。

以上が、最も単純なWASMのチュートリアルでした。

Real World WASM

WASMが実際にどのようなものであるか、非常にシンプルながら、お分かりいただけたかと思います。

もちろん実際にアプリケーションで使われるWASMはもっと複雑で大きなものになりますが、上で紹介したような、CPUレベルに近い演算内容をブラウザが実行出来ることにより、以下のように、JSだけでは現実的ではなかった様々なことを実現することが出来るようになります。

  • 音声、画像などのメディアの処理
  • 時間のかかるアルゴリズムを短縮
  • メモリレイアウトを意識した最適化
  • 言語のコンパイラをブラウザ上で実行

Webの世界でも、ネイティブアプリケーションと遜色無いアプリケーションが作れるかもしれないと考えると夢が広がりますね。お読みいただきありがとうございました。


トヨクモでは一緒に働いてくれる技術が好きなエンジニアを募集しております。

採用に関する情報を公開しております。 気になった方はこちらからご応募ください。

*1:他の言語が「WASMに対応する」ということは、基本的に、この形式に対応したバイナリをコンパイルのターゲットとして出力出来る、ということを意味しています。

Clojureで作るAPI RESTful APIを追加する

[連載]Clojureで作るAPIの12記事目です。

前回の記事はこちらです。

tech.toyokumo.co.jp

前回記事ではRing Middlewareを追加してAPIとしての体裁を整えました。 今回はRESTful APIとして振る舞うために足りない仕組みの整備をしていきます。

RESTful APIに必要な残りの要素

実際のところこれまでの実装によって、ルーティングやJSONのエンコード/デコードなどAPIとしては十分な機能を備えるようになってきました。

その上でRESTful APIとして不足している機能としてはリクエストの

  • パスパラメーター(URLに含まれるパラメーター)
  • クエリパラメーター
  • リクエストボディ

の制御があります。以降ではこの3つをまとめてリクエストパラメーターと呼ぶことにします。

これらのリクエストパラメーターを制御する上で必要なことは2種類あります。

  1. APIが要求する型の形式(契約)を満たしているか
  2. データの重複排除など、データの内容が受け付けられるものか

これら2つは性質の異なるものであるため分けて考えた方がいいというのが筆者の私見です。

1つ目はクライアントのコードが満たすべき領域で開発上の問題であるため、 エラーレスポンスの受け取り手は開発者でありエンドユーザーではないはずです。 そのためある程度画一的かつリクエスト形式の修正に役立つ情報があれば良いはずです。

2つ目はユーザーの自由な入力を想定するものに対して、リクエスト形式は正しいもののビジネスロジックに依存してエラーとなっているものです。 APIのクライアントはこのエラーレスポンスを使ってユーザーに対してエラーの内容を伝えます。 従ってエラーの内容は一定以上詳細でかつ「次にどうすればいいのか」がわかるものであるべきです。

パスパラメーターが1番に該当し、クエリパラメーターとリクエストボディが2番に該当すると考えます。

以降ではこれら2つの検証と制御ができるようにしていきます。

パスパラメーターの制御

reititのcoercion機能を使います。

reititでは型を記述するための外部ライブラリとして複数の選択肢がありますが、 この連載では上記のreititのドキュメントでも使われており、 弊社でも使用しているprismatic/schemaを使用します。

何らかの会員管理をしているアプリケーションだとしてその人のページがあるとしましょう。 そんなパスを次のように定義してみます。

;; src/cljapi/router.clj
(ns cljapi.router
  (:require
   [camel-snake-kebab.core :as csk]
   [cljapi.handler :as h]
   [clojure.core.memoize :as memo]
   [muuntaja.core :as muu]
   [muuntaja.middleware :as muu.middleware]
   [reitit.coercion.schema]
   [reitit.ring :as ring]
   [reitit.ring.coercion :as rrc]
   [ring.middleware.defaults :as m.defautls]
   [schema.core :as s]))

;;; (略)

(def router
  (ring/router
   [["/health" {:name ::health
                :handler h/handler}]
    ["/api" {:middleware [[m.defautls/wrap-defaults ring-defaults-config]
                          [muu.middleware/wrap-format muuntaja-config]
                          muu.middleware/wrap-params
                          ;; middlewareを2つ追加
                          rrc/coerce-exceptions-middleware
                          rrc/coerce-request-middleware]
             ;; /api以下では型定義に基づいて変換するようにするための設定
             :coercion reitit.coercion.schema/coercion}
     ["/hello" {:name ::hello
                :handler h/handler}]
     ["/goodbye" {:name ::goodbye
                  :handler h/handler}]
     ;; :idで/accountの後にくるのがパスパラメーターであることを示している
     ["/account/:id" {:name ::account-by-id
                      ;; :idはInteger(java.lang.Long)であることを宣言
                      :parameters {:path {:id s/Int}}
                      ;; PUTとDELETEがあると定義
                      :put {:handler h/handler}
                      :delete {:handler h/handler}}]]]))

追加したrouteに対応するHandlerを用意します。

;; src/cljapi/handler/api/account.clj
(ns cljapi.handler.api.account
  (:require
   [cljapi.handler :as h]
   [cljapi.router :as r]
   [ring.util.http-response :as res]))

(defmethod h/handler [::r/account-by-id :put]
  [req]
  (res/ok {:method :put
           :path-params (:path-params req)}))

(defmethod h/handler [::r/account-by-id :delete]
  [req]
  (res/ok {:method :delete
           :path-params (:path-params req)}))

新しくHandlerを作ったら忘れずにrequireしておきます。

;; src/cljapi/component/handler.clj
(ns cljapi.component.handler
  (:require
   [cljapi.handler.api.account] ; 追加
   [cljapi.handler.api.greeting]
   [cljapi.handler.health]
   [cljapi.router :as router]
   [com.stuartsierra.component :as component]
   [reitit.ring :as ring]
   [ring.logger :as m.logger]
   [ring.middleware.lint :as m.lint]
   [ring.middleware.reload :as m.reload]
   [ring.middleware.stacktrace :as m.stacktrace]))

;;; (略)

ここまでかけたら再評価するかREPLを再起動して (go) した後でリクエストを投げて確認してみます。 レスポンスのJSONをみやすくするためにjqを使用しています。

$ curl -X PUT http://localhost:8000/api/account/123 |jq .
{
  "method": "put",
  "pathParams": {
    "id": "123"
  }
}

$ curl -X DELETE http://localhost:8000/api/account/123 |jq .
{
  "method": "delete",
  "pathParams": {
    "id": "123"
  }
}

$ curl -X PUT http://localhost:8000/api/account/abc |jq .
{
  "schema": {
    "id": "Int",
    "Keyword": "Any"
  },
  "errors": {
    "id": "(not (integer? \"abc\"))"
  },
  "type": "reitit.coercion/request-coercion",
  "coercion": "schema",
  "value": {
    "id": "abc"
  },
  "in": [
    "request",
    "path-params"
  ]
}

失敗時にはエラーの内容が返されており、リクエストのどこが間違っているのかわかります。

バリデーション

次にバリデーションをできるようにしていきます。 そのためにstructを使います。

github.com

まずライブラリを追加します。

;; deps.edn
{:paths ["src" "resources"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}
        info.sunng/ring-jetty9-adapter {:mvn/version "0.17.6" :exclusions [org.slf4j/slf4j-api]}
        org.clojure/tools.logging {:mvn/version "1.2.4"}
        spootnik/unilog {:mvn/version "0.7.30"}
        com.stuartsierra/component {:mvn/version "1.1.0"}
        aero/aero {:mvn/version "1.1.6"}
        metosin/reitit {:mvn/version "0.5.18"}
        metosin/ring-http-response {:mvn/version "0.9.3"}
        ring/ring-devel {:mvn/version "1.9.5"}
        ring-logger/ring-logger {:mvn/version "1.1.1"}
        ring/ring-defaults {:mvn/version "0.3.3"}
        metosin/muuntaja {:mvn/version "0.6.8"}
        camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.3"}
        org.clojure/core.memoize {:mvn/version "1.0.257"}
        ;; 追加
        funcool/struct {:mvn/version "1.4.0"}}
 :aliases {:dev {:extra-paths ["dev"]}
           :test {:extra-deps {lambdaisland/kaocha {:mvn/version "1.68.1059"}}
                  :main-opts ["-m" "kaocha.runner"]}
           :build {:deps {io.github.clojure/tools.build {:git/tag "v0.8.2" :git/sha "ba1a2bf"}}
                   :ns-default build}}}

本項で使うためのルート定義を追加します。

;; src/cljapi/router.clj
(ns cljapi.router
    (:require
     [camel-snake-kebab.core :as csk]
     [cljapi.handler :as h]
     [clojure.core.memoize :as memo]
     [muuntaja.core :as muu]
     [muuntaja.middleware :as muu.middleware]
     [reitit.coercion.schema]
     [reitit.ring :as ring]
     [reitit.ring.coercion :as rrc]
     [ring.middleware.defaults :as m.defautls]
     [schema.core :as s]))

;;; (略)

(def router
  (ring/router
   [["/health" {:name ::health
                :handler h/handler}]
    ["/api" {:middleware [[m.defautls/wrap-defaults ring-defaults-config]
                          [muu.middleware/wrap-format muuntaja-config]
                          muu.middleware/wrap-params
                          rrc/coerce-exceptions-middleware
                          rrc/coerce-request-middleware]
             :coercion reitit.coercion.schema/coercion}
     ["/hello" {:name ::hello
                :handler h/handler}]
     ["/goodbye" {:name ::goodbye
                  :handler h/handler}]
     ;; 追加
     ["/account" {:name ::account
                  :get {:handler h/handler}
                  :post {:handler h/handler}}]
     ["/account/:id" {:name ::account-by-id
                      :parameters {:path {:id s/Int}}
                      :put {:handler h/handler}
                      :delete {:handler h/handler}}]]]))

まずstructのドキュメントを参考にしながらプロジェクト独自のバリデーターを作成します。 これはエラーメッセージのカスタマイズなどをするためにも必要になります。

;; src/cljapi/validator.clj
(ns cljapi.validator
    (:require
     [clojure.string :as str]
     [struct.core :as st]))

(def required
  "structのrequiredに加えて、文字列の場合に空白のみの文字列を許容しないvalidator"
  {:message "必須入力項目です"
   :optional false
   :validate #(if (string? %)
                (boolean (seq (str/trim %)))
                (some? %))})

(def string
  [st/string
   :message "文字列である必要があります"
   :coerce str/trim])

(def string-shorter-than-256
  [st/max-count 255 :message "255文字以内で入力してください"])

(def email
  [st/email :message "メールアドレスの形式が正しくありません"])

これらを使ってバリデーション関数を実装します。

;; src/cljapi/validator.clj
(ns cljapi.validation.account
  (:require
   [cljapi.validator :as v]
   [struct.core :as st]))

(def ^:private get-account-schema
  {:search [v/string v/string-shorter-than-256]})

(def ^:private post-account-schema
  {:name [v/required v/string v/string-shorter-than-256]
   :email [v/required v/string v/email]})

(defn validate-get-account
  [{:keys [:params]}]
  (st/validate params get-account-schema {:strip true}))

(defn validate-post-account
  [{:keys [:params]}]
  (st/validate params post-account-schema {:strip true}))

バリデーションの対象にはクエリパラメーターとリクエストボディがあるのに :params だけを見ているのは、 すでに設定済みのRing middleware によって2つが :params にマージされるためです。

テストを書きながら使用例を見ていきます。

;; test/cljapi/validation/account_test.clj
(ns cljapi.validation.account-test
  (:require
   [cljapi.validation.account :refer [validate-get-account validate-post-account]]
   [clojure.string :as str]
   [clojure.test :refer [deftest testing is]]))

(deftest validate-get-account-test
  (testing "正常系"
    (is (= [nil
            {:search ""}]
           (validate-get-account {:params {:search ""}})))
    (is (= [nil
            {:search "foo"}]
           (validate-get-account {:params {:search "foo"}})))
    (is (= [nil
            {:search (str/join (repeat 255 "a"))}]
           (validate-get-account {:params {:search (str/join (repeat 255 "a"))}}))))
  (testing "異常系"
    (is (= [{:search "文字列である必要があります"}
            {}]
           (validate-get-account {:params {:search 123}})))
    (is (= [{:search "255文字以内で入力してください"}
            {}]
           (validate-get-account {:params {:search (str/join (repeat 256 "a"))}})))))

(deftest validate-post-account-test
  (testing "正常系"
    (is (= [nil
            {:name "トヨクモ"
             :email "hoge@example.com"}]
           (validate-post-account {:params {:name "トヨクモ"
                                            :email "hoge@example.com"}})))
    (is (= [nil
            {:name (str/join (repeat 255 "a"))
             :email "hoge@example.com"}]
           (validate-post-account {:params {:name (str/join (repeat 255 "a"))
                                            :email "hoge@example.com"}})))
    (is (= [nil
            {:name "トヨクモ"
             :email "hoge@example.com"}]
           (validate-post-account {:params {:name "トヨクモ"
                                            :email "hoge@example.com"
                                            :foo "bar"}}))
        "余計なキーは除外される"))
  (testing "異常系"
    (is (= [{:email "必須入力項目です"
             :name "必須入力項目です"}
            {}]
           (validate-post-account {:params {}})))
    (is (= [{:email "文字列である必要があります"
             :name "文字列である必要があります"}
            {}]
           (validate-post-account {:params {:name 1
                                            :email 2}})))
    (is (= [{:name "255文字以内で入力してください"
             :email "メールアドレスの形式が正しくありません"}
            {}]
           (validate-post-account {:params {:name (str/join (repeat 256 "a"))
                                            :email "hoge"}})))))

これを使ってHandlerを実装しましょう。

;; src/cljapi/validation/account.clj
(ns cljapi.handler.api.account
  (:require
   [cljapi.handler :as h]
   [cljapi.router :as r]
   [cljapi.validation.account :as v.account]
   [ring.util.http-response :as res]))

(defmethod h/handler [::r/account :get]
  [req]
  (let [[error values] (v.account/validate-get-account req)]
    (if error
      (res/bad-request error)
      (res/ok {:validated-values values}))))

(defmethod h/handler [::r/account :post]
  [req]
  (let [[error values] (v.account/validate-post-account req)]
    (if error
      (res/bad-request error)
      (res/ok {:validated-values values}))))

;; (略)

ここまでかけたら再評価するかREPLを再起動して (go) した後でリクエストを投げて確認してみます。

$ curl -X GET -i 'http://localhost:8000/api/account?search=abc'
HTTP/1.1 200 OK
Content-Type: application/json;charset=utf-8
Content-Length: 36
Server: Jetty(10.0.9)

{"validatedValues":{"search":"abc"}}

$ curl -X GET -i 'http://localhost:8000/api/account?search=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab'
HTTP/1.1 400 Bad Request
Content-Type: application/json;charset=utf-8
Content-Length: 55
Server: Jetty(10.0.9)

{"search":"255文字以内で入力してください"}

$ curl -X POST -i -H "Content-Type: application/json" -d '{"name" : "トヨクモ" , "email" : "foo@example.com"}' 'http://localhost:8000/api/account'
HTTP/1.1 200 OK
Content-Type: application/json;charset=utf-8
Content-Length: 69
Server: Jetty(10.0.9)

{"validatedValues":{"name":"トヨクモ","email":"foo@example.com"}}

$ curl -X POST -i -H "Content-Type: application/json" -d '{"name" : "" , "email" : "foo@example"}' 'http://localhost:8000/api/account'
HTTP/1.1 400 Bad Request
Content-Type: application/json;charset=utf-8
Content-Length: 103
Server: Jetty(10.0.9)

{"name":"必須入力項目です","email":"メールアドレスの形式が正しくありません"}

おわりに

ここまででリクエストパラメーターの制御をAPIサーバーに追加することができました。 これで十分RESTful APIサーバーとして機能を追加していくことができる準備が整いました。

コードはGitHubのリポジトリに上げてあります。 12_RESTfulAPIを追加するというタグからご覧ください。

次は開発環境にデータベースを追加して、DBを使った開発をしていくための準備します。


トヨクモでは一緒に働いてくれる技術が好きなエンジニアを募集しております。

採用に関する情報を公開しております。 気になった方はこちらからご応募ください。

Clojureで作るAPI RingMiddlewareを追加してAPIらしくする

[連載]Clojureで作るAPIの11記事目です。

前回の記事はこちらです。

tech.toyokumo.co.jp

前回記事ではルーターを追加して設定しました。 今回はその上にRing Middlewareを追加してAPIとしての体裁を整えていきます。

なお、Ring Middlewareについては次の記事で詳細に解説しています。 以降では次の記事をご理解いただいた前提で進めていきますので、まだの方は目を通しておいてください。

tech.toyokumo.co.jp

APIリクエストをロギングする

まずはリクエストとレスポンスをロギングできるようにします。そのためにRing-loggerを使います。

github.com

;; ./deps.edn
{:paths ["src" "resources"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}
        info.sunng/ring-jetty9-adapter {:mvn/version "0.17.6" :exclusions [org.slf4j/slf4j-api]}
        org.clojure/tools.logging {:mvn/version "1.2.4"}
        spootnik/unilog {:mvn/version "0.7.30"}
        com.stuartsierra/component {:mvn/version "1.1.0"}
        aero/aero {:mvn/version "1.1.6"}
        metosin/reitit {:mvn/version "0.5.18"}
        metosin/ring-http-response {:mvn/version "0.9.3"}
        ring/ring-devel {:mvn/version "1.9.5"}
        ;; 追加
        ring-logger/ring-logger {:mvn/version "1.1.1"}}
 :aliases {:dev {:extra-paths ["dev"]}
           :test {:extra-deps {lambdaisland/kaocha {:mvn/version "1.68.1059"}}
                  :main-opts ["-m" "kaocha.runner"]}
           :build {:deps {io.github.clojure/tools.build {:git/tag "v0.8.2" :git/sha "ba1a2bf"}}
                   :ns-default build}}}

Handlerを修正してRing-loggerを適用します。 今回はリクエストとレスポンスのログはパスに依らず毎回出力されて欲しいのでトップレベルのMiddlewareとして追加しています。

;; ./src/cljapi/component/handler.clj
(ns cljapi.component.handler
  (:require
   [cljapi.handler.api.greeting]
   [cljapi.handler.health]
   [cljapi.router :as router]
   [com.stuartsierra.component :as component]
   [reitit.ring :as ring]
   [ring.logger :as m.logger]
   [ring.middleware.lint :as m.lint]
   [ring.middleware.reload :as m.reload]
   [ring.middleware.stacktrace :as m.stacktrace]))

(def ^:private dev-middlewares
  "開発時だけ有効化する"
  [[m.reload/wrap-reload {:dirs ["src"]
                          :reload-compile-errors? true}]
   m.lint/wrap-lint
   [m.stacktrace/wrap-stacktrace {:color? true}]])

(defn- build-handler
  [profile]
  (let [common-middlewares [m.logger/wrap-with-logger]
        middlewares (if (= profile :prod)
                      common-middlewares
                      ;; 開発用のMiddlewareは先に適用する
                      (apply conj dev-middlewares common-middlewares))]
    (ring/ring-handler
     router/router
     nil
     {:middleware middlewares})))

(defrecord Handler [handler profile]
  component/Lifecycle
  (start [this]
    (assoc this :handler (build-handler profile)))
  (stop [this]
    (assoc this :handler nil)))

REPLを立ち上げてgoを評価してcurl http://localhost:8000/api/helloを実行すると次のようなログが出ていることが確認できます。

INFO [2022-07-25 18:01:45,496] qtp1942383383-47 - ring.logger {:request-method :get, :uri "/api/hello", :server-name "localhost", :ring.logger/type :starting}
DEBUG [2022-07-25 18:01:45,496] qtp1942383383-47 - ring.logger {:request-method :get, :uri "/api/hello", :server-name "localhost", :ring.logger/type :params, :params nil}
INFO [2022-07-25 18:01:45,497] qtp1942383383-47 - ring.logger {:request-method :get, :uri "/api/hello", :server-name "localhost", :ring.logger/type :finish, :status 200, :ring.logger/ms 1}

定番のMiddlewareをまとめて入れる

Ring-Defaultsを使って定番のMiddlewareをまとめて入れます。

github.com

;; ./deps.edn
{:paths ["src" "resources"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}
        info.sunng/ring-jetty9-adapter {:mvn/version "0.17.6" :exclusions [org.slf4j/slf4j-api]}
        org.clojure/tools.logging {:mvn/version "1.2.4"}
        spootnik/unilog {:mvn/version "0.7.30"}
        com.stuartsierra/component {:mvn/version "1.1.0"}
        aero/aero {:mvn/version "1.1.6"}
        metosin/reitit {:mvn/version "0.5.18"}
        metosin/ring-http-response {:mvn/version "0.9.3"}
        ring/ring-devel {:mvn/version "1.9.5"}
        ring-logger/ring-logger {:mvn/version "1.1.1"}
        ;; 追加
        ring/ring-defaults {:mvn/version "0.3.3"}}
 :aliases {:dev {:extra-paths ["dev"]}
           :test {:extra-deps {lambdaisland/kaocha {:mvn/version "1.68.1059"}}
                  :main-opts ["-m" "kaocha.runner"]}
           :build {:deps {io.github.clojure/tools.build {:git/tag "v0.8.2" :git/sha "ba1a2bf"}}
                   :ns-default build}}}

Ring-Defaultsで入る機能はAPIとしての動作にだけ働けばいいので /api 下で機能するように適用します。 そのためにrouterを編集します。

;; ./src/cljapi/router.clj
(ns cljapi.router
  (:require
   [cljapi.handler :as h]
   [reitit.ring :as ring]
   [ring.middleware.defaults :as m.defautls]))

(def ^:private ring-defaults-config
  (-> m.defautls/api-defaults
      ;; ロードバランサーの後ろで動いていると想定して、
      ;; X-Forwarded-For と X-Forwarded-Proto に対応させる
      (assoc :proxy true)))

(def router
  (ring/router
   [["/health" {:name ::health
                :handler h/handler}]
    ["/api" {:middleware [[m.defautls/wrap-defaults ring-defaults-config]]}
     ["/hello" {:name ::hello
                :handler h/handler}]
     ["/goodbye" {:name ::goodbye
                  :handler h/handler}]]]))

コメントでも書きましたが、多くの場合ロードバランサーの下で動かすことになると思うので、 X-Forwarded-ForヘッダX-Forwarded-Protoヘッダ に対応させています。

JSONの入出力に対応する

/api/helloのHandlerを次のように変更してリクエストしてみます。

;; ./src/cljapi/handler/api/greeting.clj
(ns cljapi.handler.api.greeting
  (:require
   [cljapi.handler :as h]
   [cljapi.router :as r]
   [ring.util.http-response :as res]))

(defmethod h/handler [::r/hello :get]
  [_]
  (res/ok {:greeting "Hello cljapi!!"}))

(defmethod h/handler [::r/goodbye :get]
  [_]
  (res/ok {:greeting "Goodbye!"}))
$ curl http://localhost:8000/api/hello -H "accept: application/json"
{
"servlet":"org.eclipse.jetty.servlet.ServletHandler$Default404Servlet-43135ea4",
"message":"Ring lint error: specified :body must a String, ISeq, File, or InputStream, but {:greeting "Hello cljapi!!"} was not",
"url":"/api/hello",
"status":"500"
}

エラーが返ってきます。

Handlerからはレスポンスマップとして {:status 200 :headers {} :body {:greeting "Hello cljapi!!"}} を返しています。 このbodyを自動的にJSONにしてくれる機能はまだないので、acceptヘッダに応じてJSONを返すことができるようにするMiddlewareを適用します。

そのためにMuuntajaを使います。

github.com

;; ./deps.edn
{:paths ["src" "resources"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}
        info.sunng/ring-jetty9-adapter {:mvn/version "0.17.6" :exclusions [org.slf4j/slf4j-api]}
        org.clojure/tools.logging {:mvn/version "1.2.4"}
        spootnik/unilog {:mvn/version "0.7.30"}
        com.stuartsierra/component {:mvn/version "1.1.0"}
        aero/aero {:mvn/version "1.1.6"}
        metosin/reitit {:mvn/version "0.5.18"}
        metosin/ring-http-response {:mvn/version "0.9.3"}
        ring/ring-devel {:mvn/version "1.9.5"}
        ring-logger/ring-logger {:mvn/version "1.1.1"}
        ring/ring-defaults {:mvn/version "0.3.3"}
        ;; 追加
        metosin/muuntaja {:mvn/version "0.6.8"}
        camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.3"}
        org.clojure/core.memoize {:mvn/version "1.0.257"}}
 :aliases {:dev {:extra-paths ["dev"]}
           :test {:extra-deps {lambdaisland/kaocha {:mvn/version "1.68.1059"}}
                  :main-opts ["-m" "kaocha.runner"]}
           :build {:deps {io.github.clojure/tools.build {:git/tag "v0.8.2" :git/sha "ba1a2bf"}}
                   :ns-default build}}}

muuntajaに加えてcamel-snake-kebabcore.memoizeを加えています。

routerを変更してmunntajaを加えます。

;; ./src/cljapi/router.clj
(ns cljapi.router
  (:require
   [camel-snake-kebab.core :as csk]
   [cljapi.handler :as h]
   [clojure.core.memoize :as memo]
   [muuntaja.core :as muu]
   [muuntaja.middleware :as muu.middleware]
   [reitit.ring :as ring]
   [ring.middleware.defaults :as m.defautls]))

(def ^:private ring-defaults-config
  (-> m.defautls/api-defaults
      ;; ロードバランサーの後ろで動いていると想定して、
      ;; X-Forwarded-For と X-Forwarded-Proto に対応させる
      (assoc :proxy true)))

(def ^:private memoized->camelCaseString
  "実装上kebab-case keywordでやっているものをJSONにするときにcamelCaseにしたい。
   バリエーションはそれほどないはずなのでキャッシュする"
  (memo/lru csk/->camelCaseString {} :lru/threshold 1024))

(def ^:private muuntaja-config
  "https://cljdoc.org/d/metosin/muuntaja/0.6.8/doc/configuration"
  (-> muu/default-options
      ;; JSONにencodeする時にキーをcamelCaseにする
      (assoc-in [:formats "application/json" :encoder-opts]
                {:encode-key-fn memoized->camelCaseString})
      ;; JSON以外のacceptでリクエストされたときに返らないように制限する
      (update :formats #(select-keys % ["application/json"]))
      muu/create))

(def router
  (ring/router
   [["/health" {:name ::health
                :handler h/handler}]
    ["/api" {:middleware [[m.defautls/wrap-defaults ring-defaults-config]
                          [muu.middleware/wrap-format muuntaja-config]
                          muu.middleware/wrap-params]}
     ["/hello" {:name ::hello
                :handler h/handler}]
     ["/goodbye" {:name ::goodbye
                  :handler h/handler}]]]))

ClojureのMapのキーを書くときは :kebab-case-keyword と書きたいですが、 JSONのキーとしては "camelCase" であるのが望ましいです。 そこで :encode-key-fn としてcamel-snake-kebabの ->camelCaseString を使うようにします。 ただし、キーに使う種類のバリエーションはさほど多くないでしょうから、core.memoizeを使ってキャッシュするようにしています。

実行して確認してみます。評価して (user/go)を評価するのを忘れないでください。

$ curl http://localhost:8000/api/hello -H "accept: application/json"
{"greeting":"Hello cljapi!!"}

JSONが返ってきていることが確認できます。

おわりに

ここまででJSONを返すAPIサーバーとして動作させることができるようになりました。

コードはGitHubのリポジトリに上げてあります。 11_RingMiddlewareを追加してAPIらしくするというタグからご覧ください。

次はAPIを追加しながらRESTful APIとしての振る舞いを確認していきます。


トヨクモでは一緒に働いてくれる技術が好きなエンジニアを募集しております。

採用に関する情報を公開しております。 気になった方はこちらからご応募ください。

Clojureで作るAPI ルーターを追加する

[連載]Clojureで作るAPIの10記事目です。

前回の記事はこちらです。

tech.toyokumo.co.jp

これまでWebアプリケーションの開発をするための準備をしてきましたが、これからは実際にWebアプリケーションとしての機能を追加していきます。 今回の記事ではWebアプリケーションにルーターを追加していきます。

ルーターの必要性

今まで作ってきたアプリケーションはどのパスにアクセスしても同じレスポンスを返します。 ここにルーターを加えることでリクエストされたパスとHTTPメソッドによって異なった処理を呼び出すことができるようにします。

ルーターとRingの関係性についてはこちらの記事で詳しく解説しているので参照してください。

tech.toyokumo.co.jp

以降はこちらの内容をご理解いただいた前提で進めていきます。

reititを追加する

ルーターライブラリの選択肢は複数ありますが、今回は今最も有力な選択肢であるreititを使用します。

github.com

まずはdeps.ednに追加します。reititは複数のモジュールが含まれるので必要なものだけを選択して加えることもできますが、今回は簡単のためにまとめて追加します。

{:paths ["src" "resources"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}
        info.sunng/ring-jetty9-adapter {:mvn/version "0.17.6" :exclusions [org.slf4j/slf4j-api]}
        org.clojure/tools.logging {:mvn/version "1.2.4"}
        spootnik/unilog {:mvn/version "0.7.30"}
        com.stuartsierra/component {:mvn/version "1.1.0"}
        aero/aero {:mvn/version "1.1.6"}
        ;; 追加
        metosin/reitit {:mvn/version "0.5.18"}
        metosin/ring-http-response {:mvn/version "0.9.3"}}
 :aliases {:dev {:extra-paths ["dev"]}
           :test {:extra-deps {lambdaisland/kaocha {:mvn/version "1.68.1059"}}
                  :main-opts ["-m" "kaocha.runner"]}
           :build {:deps {io.github.clojure/tools.build {:git/tag "v0.8.2" :git/sha "ba1a2bf"}}
                   :ns-default build}}}

Handlerを書きやすくするためのライブラリとしてmetosin/ring-http-responseも加えています。 次項ですぐに使用します。

Handlerを追加する

試しにHandlerを3つ追加してみます。 発展していくことを考慮してnamespaceも分けておきます。

;; ./src/cljapi/handler/health.clj
(ns cljapi.handler.health
  (:require
   [ring.util.http-response :as res]))

(defn health
  "ヘルスチェックに対応するためのHandlerとして意図しています"
  [_]
  (res/ok "Application is runnig"))
;; ./src/cljapi/handler/api/greeting.clj
(ns cljapi.handler.api.greeting
  (:require
   [ring.util.http-response :as res]))

(defn hello [_]
  (res/ok "Hello world"))

(defn goodbye [_]
  (res/ok "Goodbye!"))

ring.util.http-responseRingのレスポンスマップを意図が分かりやすく生成するためのユーティリティを提供してくれるライブラリです。 (res/ok "hoge") を実行すると {:status 200 :headers {} :body "hoge"} が返ります。

Routerを定義する

前項で作成したHandlerを使ってRouterを定義します。

;; ./src/cljapi/router.clj
(ns cljapi.router
  (:require
   [cljapi.handler.api.greeting :as api.greeting]
   [cljapi.handler.health :as health]
   [reitit.ring :as ring]))

(def router
  (ring/router
   [["/health" health/health]
    ["/api"
     ["/hello" api.greeting/hello]
     ["/goodbye" api.greeting/goodbye]]]))

ring/router に与えているベクタがreititを利用したrouterの定義です。 構文は見たままで上記の範囲は理解しやすいと思います。 詳しい解説は次の公式ページを参照してください。

cljdoc.org

RouterからRing Handlerを作る

前項で作成したRouterからRing Handler作り、それをHandlerとしてWebサーバーに与えるようにします。

;; ./src/cljapi/component/handler.clj
(ns cljapi.component.handler
  (:require
   [cljapi.router :as router]
   [com.stuartsierra.component :as component]
   [reitit.ring :as ring]))

(defn- build-handler
  []
  (ring/ring-handler router/router))

(defrecord Handler [handler]
  component/Lifecycle
  (start [this]
    (assoc this :handler (build-handler)))
  (stop [this]
    (assoc this :handler nil)))

ここまで書けたらREPLを立ち上げて (user/go) としてcurlで試してみます。

$ curl http://localhost:8000/health
Application is runnig
$ curl http://localhost:8000/api/hello
Hello world
$ curl http://localhost:8000/api/goodbye
Goodbye!

定義したパスに応じて異なるHandlerが呼ばれ、異なるレスポンスが返ってきていることが確認できます。

開発生産性を引き上げる

ここまででルーターを使う根本の目的は達成できていますが、長く開発を続けていく上では十分ではありません。

試しに src/cljapi/handler/api/greeting.cljhello を次のようにして保存してみます。

(defn hello [_]
  (res/ok "Hello cljapi"))

その上で curl http://localhost:8000/api/hello を実行してもレスポンスは変わりません。 変更した関数を評価しても変わりません。

現状でHandlerの変更を反映するには、変更したHandlerとそれを使用しているRouterをいずれも評価した上で (user/go) をREPLで評価してWebサーバーを再起動する必要があります。 この状態では

  1. 変更した関数などの評価を忘れる
  2. 毎回goでSystemを再起動するのが手間がかかるかつ遅い

という問題があります。 開発生産性を引き上げるためにそれぞれの問題を解決します。

評価のし忘れを防ぐ

評価のし忘れを防ぐために、開発環境で立ち上がっているアプリケーションにアクセスがきた時に、変更があったnamespaceを再読み込みできるようにします。

ring-develにある wrap-reload というRing Middlewareがこの機能を提供してくれます。

Rinig MiddlewareについてはRing Middlewareとは何かをご参照ください。 本項はこの内容を理解していただいた前提で進めていきます。

ライブラリを依存関係に加えます。

;; ./deps.edn
{:paths ["src" "resources"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}
        info.sunng/ring-jetty9-adapter {:mvn/version "0.17.6" :exclusions [org.slf4j/slf4j-api]}
        org.clojure/tools.logging {:mvn/version "1.2.4"}
        spootnik/unilog {:mvn/version "0.7.30"}
        com.stuartsierra/component {:mvn/version "1.1.0"}
        aero/aero {:mvn/version "1.1.6"}
        metosin/reitit {:mvn/version "0.5.18"}
        metosin/ring-http-response {:mvn/version "0.9.3"}
        ;; 追加
        ring/ring-devel {:mvn/version "1.9.5"}}
 :aliases {:dev {:extra-paths ["dev"]}
           :test {:extra-deps {lambdaisland/kaocha {:mvn/version "1.68.1059"}}
                  :main-opts ["-m" "kaocha.runner"]}
           :build {:deps {io.github.clojure/tools.build {:git/tag "v0.8.2" :git/sha "ba1a2bf"}}
                   :ns-default build}}}

リクエスト時の自動的な再評価は開発時だけ有効化したいので、 profile をSystemから渡すことで開発時かどうかの判別ができるようにします。

;; ./src/cljapi/system.clj
(ns cljapi.system
  (:require
   [cljapi.component.a :as c.handler]
   [cljapi.component.server :as c.server]
   [cljapi.config :as config]
   [clojure.tools.logging :as log]
   [com.stuartsierra.component :as component]
   [unilog.config :as unilog]))

(defn- new-system [{:as config :keys [:profile]}]
  (component/system-map
   ;; config内のprofileをHandler Componentに渡すように変更している
   :handler (c.handler/map->Handler {:profile profile})
   :server (component/using
            (c.server/map->Jetty9Server (:server config))
            [:handler])))

(defn- init-logging! [config]
  (unilog/start-logging! (:logging config)))

(defn start [profile]
  (let [config (config/read-config profile)
        system (new-system config)
        _ (init-logging! config)
        _ (log/info "system is ready to start")
        started-system (component/start system)]
    (log/info "system is started")
    started-system))

(defn stop [system]
  (component/stop system))

後は開発時のHandlerに適用するだけです。 Ring Middlewareの適用方法は上記の記事で解説した通常通りの関数実行を行う方法でももちろんできますが、 今回はreititの提供するMiddlewareの適用方法を使います。

;; ./src/cljapi/component/handler.clj
(ns cljapi.component.handler
  (:require
   [cljapi.router :as router]
   [com.stuartsierra.component :as component]
   [reitit.ring :as ring]
   [ring.middleware.lint :as m.lint]
   [ring.middleware.reload :as m.reload]
   [ring.middleware.stacktrace :as m.stacktrace]))

(defn- build-handler
  [profile]
  ;; ring/ring-handlerの引数を変更しています
  ;; 第1引数はこれまで通りrouter
  ;; 第2引数はdefalut-handlerですがこれは今のところはnilにしておきます
  ;; 第3引数のマップにmiddlewareを複数加えています
  (ring/ring-handler
   router/router
   nil
   {:middleware (if (= profile :prod)
                  []
                  ;; 開発時に適用するRing Middlewareです。
                  ;; 今回はwrap-reloadだけでなく、ring-develの提供する他の開発用のMiddlewareもついでに適用しています。
                  [;; 指定したディレクトリ以下の変更を検知してリロードさせるMiddleware
                   ;; オプションはデフォルトそのままをあえて可視化のために書いています
                   [m.reload/wrap-reload {:dirs ["src"]
                                          :reload-compile-errors? true}]
                   ;; リクエストマップとレスポンスマップがRingの仕様を満たしているかをチェックするMiddleware
                   m.lint/wrap-lint
                   ;; 例外をわかりやすく表示してくれるMiddleware
                   [m.stacktrace/wrap-stacktrace {:color? true}]])}))

(defrecord Handler [handler profile]
  component/Lifecycle
  (start [this]
    (assoc this :handler (build-handler profile)))
  (stop [this]
    (assoc this :handler nil)))

今回使用しているreititのMiddlewareの適用方法は、通常の関数実行で適用した時と視覚的に順序が逆であることに注意が必要です。

関数実行でスレッディングマクロを使って適用した場合は次のようになりますが、

(-> handler
    (hatena-middleware)  ; 前処理3番目、後処理1番目
    (debug-middleware)   ; 前処理2番目、後処理2番目
    (no-mean-middleware) ; 前処理1番目、後処理3番目
    )

reititの提供する適用方法を利用すると次のようになります。

{:middleware [hatena-middleware   ; 前処理1番目、後処理3番目
              debug-middleware    ; 前処理2番目、後処理2番目
              no-mean-middleware  ; 前処理3番目、後処理1番目
              ]}

本項では自動で再評価するやり方について書いてきましたが、そもそもどの開発環境にもすべてのnamespaceを一括で再読み込みするコマンドがあります。 ですので評価のし忘れは開発に慣れてくればコマンド1回で解決する問題ではあります。

自動で評価してしまうということは、評価する前後での変化を見たいなどの柔軟性を失ってしまうということでもありますから、 慣れてきたらオフにするか選択できるようにするなどの対応を入れることも選択肢の1つだと思います。

Systemの再起動を減らす

Webサーバーは起動時のHandlerを参照しているため、今のままでは評価したものを反映するにはWebサーバーを再起動する必要があります。

この問題を解決するために、マルチメソッドを使います。

マルチメソッドは動的ポリモーフィズムを実現するための機能の1つです。 defmulti の定義に使った関数の戻り値がdispatchするための値となり、 defmethod で定義した値と一致した defmethod が呼び出されます。 以下に簡単な例を示します。

(defmulti currency
  "この下に定義している関数の戻り値がdispatchを実現するための関数となる。
   以下の例ではマップmのキー :type のバリューでdispatchしようとしている。"
  (fn [m] (get m :type)))

;; Clojureのキーワードは関数としても使えるから次のようにも書ける
;; (defmulti currency :type)

(defmethod currency :yen
  [{:keys [:amount]}]
  (str "¥" amount))

(defmethod currency :dollar
  [{:keys [:amount]}]
  (str "$" amount))

(defmethod currency :euro
  [{:keys [:amount]}]
  (str "€" amount))

;; :default はマッチするものがない時に使われます
(defmethod currency :default
  [_]
  "unknown")

(currency {:type :yen
           :amount 500})
;; => "¥500"

(currency {:type :dollar
           :amount 500})
;; => "$500"

(currency {:type :euro
           :amount 500})
;; => "€500"

(currency {:type :pound
           :amount 500})
;; => "unknown"

Routerからはマルチメソッドの定義(抽象)にだけ依存するようにし、実装には依存しないようにします。 そうすることでHandlerの実装が変わってもWebサーバーの再起動が不要になります。

まずマルチメソッドの定義を用意します。

;; ./src/cljapi/handler.clj
(ns cljapi.handler
  (:require
   [ring.util.http-response :as res]))

(defmulti handler
  "引数はリクエストマップ
   reititで作られたHandler(reitit.ring/ring-handlerで作ったHandler)を通すと
   リクエストマップには :reitit.core/match というキーにマッチしたrouteのdataが含まれる。
   そこからマッチしたrouteの名前を取り出す。
   またRingの仕様により :request-method にHTTP Methodが含まれる。
   この2つを組み合わせて [route-name, request-method] という2つの情報でHandlerの実装とマッチさせる。"
  (fn [req]
    [(get-in req [:reitit.core/match :data :name])
     (get req :request-method)]))

(defmethod handler :default
  [_]
  (res/not-found "not found"))

Routerの定義をこのマルチメソッドに依存するように書き換えます。

;; ./src/cljapi/router.clj
(ns cljapi.router
  (:require
   ;; 具体的なhandlerへの依存が消えている
   [cljapi.handler :as h]
   [reitit.ring :as ring]))

(def router
  (ring/router
   [["/health" {:name ::health
                :handler h/handler}]
    ["/api"
     ["/hello" {:name ::hello
                :handler h/handler}]
     ["/goodbye" {:name ::goodbye
                  :handler h/handler}]]]))

今までは ["/health" health/health] だったところが ["/health" {:name ::health :handler h/handler}] に変わっています。 これはreititのRoute Dataという仕様で、 マップを書くとこれがDataとしてリクエストマップに含まれるようになります。 defmulti handler の関数ではこれを利用してマッチしたHandlerの名前を取り出しているということです。

Handlerの実装も書き換えます。

;; ./src/cljapi/handler/health.clj
(ns cljapi.handler.health
  (:require
   [cljapi.handler :as h]
   [cljapi.router :as r]
   [ring.util.http-response :as res]))

(defmethod h/handler [::r/health :get]
  [_]
  (res/ok "Application is runnig"))
;; ./src/cljapi/handler/api/greeting.clj
(ns cljapi.handler.api.greeting
  (:require
   [cljapi.handler :as h]
   [cljapi.router :as r]
   [ring.util.http-response :as res]))

(defmethod h/handler [::r/hello :get]
  [_]
  (res/ok "Hello world"))

(defmethod h/handler [::r/goodbye :get]
  [_]
  (res/ok "Goodbye!"))

いずれも関数だったHandlerがマルチメソッドの実装(defmethod)に変わっています。 マッチさせるためのデータも [Route名のキーワード HTTPメソッド] となっていることがわかります。

最後に、この状態では defmethod しているHandlerの実装のnamespaceはどこからも読み込まれていないので、 Handler Componentからrequireしておきます。

(ns cljapi.component.handler
  (:require
   ;; ここにrequireを足している
   [cljapi.handler.api.greeting]
   [cljapi.handler.health]
   [cljapi.router :as router]
   [com.stuartsierra.component :as component]
   [reitit.ring :as ring]
   [ring.middleware.lint :as m.lint]
   [ring.middleware.reload :as m.reload]
   [ring.middleware.stacktrace :as m.stacktrace]))

(defn- build-handler
  [profile]
  (ring/ring-handler
   router/router
   nil
   {:middleware (if (= profile :prod)
                  []
                  [[m.reload/wrap-reload {:dirs ["src"]
                                          :reload-compile-errors? true}]
                   m.lint/wrap-lint
                   [m.stacktrace/wrap-stacktrace {:color? true}]])}))

(defrecord Handler [handler profile]
  component/Lifecycle
  (start [this]
    (assoc this :handler (build-handler profile)))
  (stop [this]
    (assoc this :handler nil)))

ここまで書けたらREPLを再起動して動作を確認してみましょう。

$ curl http://localhost:8000/health
Application is runnig
$ curl http://localhost:8000/api/hello
Hello world
$ curl http://localhost:8000/api/goodbye
Goodbye!

これまで通りのことは変わりなくできています。

次にHandlerを書き換えて保存してからもう一度curlを投げてみます。

;; ./src/cljapi/handler/api/greeting.clj
(ns cljapi.handler.api.greeting
  (:require
   [cljapi.handler :as h]
   [cljapi.router :as r]
   [ring.util.http-response :as res]))

(defmethod h/handler [::r/hello :get]
  [_]
  (res/ok "Hello cljapi!!")) ;; ここを変更

(defmethod h/handler [::r/goodbye :get]
  [_]
  (res/ok "Goodbye!"))
$ curl http://localhost:8000/api/hello
Hello cljapi!!

これで目的だった、「ファイルを変更して保存し、再度APIアクセスをしたら変更が反映される」状態を作ることができました。

おわりに

ここまででルーターをアプリケーションに適用し、かつ生産的に開発を続けていく準備もできました。

コードはGitHubのリポジトリに上げてあります。 10_ルーターを追加するというタグからご覧ください。

次はRing Middlewareを使ってより本格的なAPIサーバーとして必要な機能を足していきます。


トヨクモでは一緒に働いてくれる技術が好きなエンジニアを募集しております。

採用に関する情報を公開しております。 気になった方はこちらからご応募ください。