Toyokumo Tech Blog

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

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サーバーとして必要な機能を足していきます。


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

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

Clojureで作るAPI テストできるようにする

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

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

tech.toyokumo.co.jp

現在開発しているプロジェクトにはテスト対象自体がまださほどありませんが、先にテストを書ける環境を整えておくことは今後の開発の健全性のために重要です。 そこでこの記事では、テストを実行できる環境を整えていきます。

最初のテスト

テスティングライブラリには公式のclojure.testを使っていきます。

かんたんに設定を読み込む関数のテストを書いてみます。

;; ./test/cljapi/config_test.clj
(ns cljapi.config-test
  (:require
   [cljapi.config :as sut]
   [clojure.test :refer [deftest testing is]]))

(deftest read-config-test
  (testing "usable profiles"
    (is (map? (sut/read-config :dev)))
    (is (map? (sut/read-config :test)))
    (is (map? (sut/read-config :prod)))
    (is (thrown? AssertionError (sut/read-config :hoge))))

  (testing "profile included"
    (is (= :dev
           (:profile (sut/read-config :dev))))
    (is (= :test
           (:profile (sut/read-config :test))))
    (is (= :prod
           (:profile (sut/read-config :prod))))))

deftest で1つのテストを定義します。一般的にはテストしたい関数名に -test をつけた名前をつけます。 is がclojure.testで中心となるアサーションを行うマクロです。 testing はドキュメンテーションのためのもので、エラー時にどこでエラーが起こっているのかを把握するために書いておくと便利です。

テストはどの開発環境でもエディタ上から実行できます。 テストを書くときはショートカットを使って実行しながらテストを書いていけるようにすべきです。 以下に各エディタごとにテストの実行方法へのリンクを用意しました。参照して実行してみてください。

開発環境自体のセットアップ方法は以下のまとめ等から開発環境の構築方法を参照してください。

tech.toyokumo.co.jp

テストランナー

ローカル環境はもちろんCI上でも全てのテストをまとめて実行できる環境を作ります。 そのためのテストランナーとしてkaochaを使います。

github.com

deps.edn:test aliasを追加します。

;; ./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"}}
 :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}}}

次にkaocha公式のおすすめに従って、テストを実行するスクリプトを追加します。

$ mkdir -p bin
$ echo '#!/usr/bin/env sh' > bin/kaocha
$ echo 'clojure -M:test "$@"' >> bin/kaocha
$ chmod +x bin/kaocha

最後にkaochaの設定を追加します。 tests.edn に設定を記述します。 最初は公式ドキュメントのサンプルをそのまま利用します。

;; ./tests.edn
#kaocha/v1
{:tests [{;; Every suite must have an :id
          :id :unit

          ;; Directories containing files under test. This is used to
          ;; watch for changes, and when doing code coverage analysis
          ;; through Cloverage. These directories are *not* automatically
          ;; added to the classpath.
          :source-paths ["src"]

          ;; Directories containing tests. These will automatically be
          ;; added to the classpath when running this suite.
          :test-paths ["test"]

          ;; Regex strings to determine whether a namespace contains
          ;; tests. (use strings, not actual regexes, due to a limitation of Aero)
          :ns-patterns ["-test$"]}]

 :plugins [:kaocha.plugin/print-invocations
           :kaocha.plugin/profiling]

 ;; Colorize output (use ANSI escape sequences).
 :color?      true

 ;; Watch the file system for changes and re-run. You can change this here to be
 ;; on by default, then disable it when necessary with `--no-watch`.
 :watch?      false

 ;; Specifiy the reporter function that generates output. Must be a namespaced
 ;; symbol, or a vector of symbols. The symbols must refer to vars, which Kaocha
 ;; will make sure are loaded. When providing a vector of symbols, or pointing
 ;; at a var containing a vector, then kaocha will call all referenced functions
 ;; for reporting.
 :reporter    kaocha.report/documentation

 ;; Enable/disable output capturing.
 :capture-output? true

 ;; Plugin specific configuration. Show the 10 slowest tests of each type, rather
 ;; than only 3.
 :kaocha.plugin.profiling/count 10}

ここまでできたら実際に実行してみます。

$ bin/kaocha
--- unit (clojure.test) ---------------------------
cljapi.config-tets
  read-config-test
    usable profiles
    profile included

1 tests, 7 assertions, 0 failures.

Top 1 slowest kaocha.type/clojure.test (0.02938 seconds, 98.0% of total time)
  unit
    0.02938 seconds average (0.02938 seconds / 1 tests)

Top 1 slowest kaocha.type/ns (0.02196 seconds, 73.3% of total time)
  cljapi.config-test
    0.02196 seconds average (0.02196 seconds / 1 tests)

Top 1 slowest kaocha.type/var (0.02085 seconds, 69.6% of total time)
  cljapi.config-test/read-config-test
    0.02085 seconds cljapi/config_test.clj:6

Makefileに追加

最後に繰り返し実行できるようにMakefileに追加します。 今後CI上で実行することを考慮して1つでもテストが失敗したらその時点でテストを終了するようにオプションをつけておきます。

.PHONY: format
format:
    cljstyle check

.PHONY: lint
lint:
    clj-kondo --lint src

.PHONY: static-check
static-check: format lint

.PHONY: clean
clean:
    rm -fr target/

# 追加
.PHONY: test
test:
    bin/kaocha --fail-fast

.PHONY: build
build: clean
    clojure -T:build uber

make test を実行して確認しておいてください。

おわりに

ここまでで1つ単体テストを書き、それをテストランナーを使ってテストを実行できることが確認できました。

コードはGitHubのリポジトリに上げてあります。 09_テストできるようにするというタグからご覧ください。

次はWebサーバーにRouterを追加し、複数のパスに対して異なるレスポンスを返すことができるようにしていきます。


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

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

Clojureで作るAPI ロギングできるようにする

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

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

tech.toyokumo.co.jp

この記事ではロギングをできるようにしていきます。

Javaのロギングの歴史的経緯と現在

Clojureアプリケーションは多くの部分でJavaのライブラリに依存していることから、ClojureアプリケーションのロギングにJavaのロギングの歴史的経緯が関わってきます。

Javaのロギングの歴史的経緯は複雑です。この項ではそれを必要最低限理解するための情報を提供したいと思います。 この項を飛ばして進むことも可能です。興味のある方はお付き合いいただければと思います。

まずJavaのロギングライブラリの構造について理解するためには デザインパターン (ソフトウェア) - Wikipedia の1つである Facadeパターン について理解する必要があります。

リンク先を読んでいただけたけると、Facadeパターンとは実装の詳細を隠蔽し、簡単に使えるインターフェースを用意することだと理解いただけると思います。 理解いただけたという前提で本題のJavaのロギングライブラリの解説に移っていきますが、全てをこの記事に書き切ることはできないので、良い記事を2つ紹介します。

まずこちらの記事の図を見ながらイメージを掴んでいただければと思います。

qiita.com

次にこちらのスライドで登場するライブラリの名前をなんとなくでも頭に入れてください。

www.slideshare.net

これらの記事からわかることをまとめます。

  • よく名前を見るJavaのロギングライブラリにはFacadeと実装がある
  • 今のデファクトスタンダードのFacadeはCommons LoggingではなくSLF4J
  • SLF4Jはさまざまなログ出力実装へのbinding(2.0.0からはproviderと言われる)も同時に提供しており、それらのbindingはログ出力実装をSLF4Jを使用しているように変換し、SLF4J+自分が設定したログ出力実装の組み合わせに適合させてくれる
  • LogbackはSLF4Jと一緒に使うログ出力実装としてSLF4Jの作者が作ったもので、SLF4JとLogbackの組み合わせは広く使われている。SLF4Jの公式にも次の記述がある
    • Logback's ch.qos.logback.classic.Logger class is a direct implementation of SLF4J's org.slf4j.Logger interface. Thus, using SLF4J in conjunction with logback involves strictly zero memory and computational overhead.

簡単ですがここまで理解できれば、実際にログ出力をするための知識としては十分です。

unilogを使って設定する

この連載では上記で説明したように最もスタンダードな組み合わせだと思われるSLF4JとLogbackの組み合わせを使っていきます。

Logbackはクラスパス上の logback.xml というXMLファイルで設定ファイルを記述します。 しかしXMLファイルの記述はちょっと大変です。 また各ログ実装のproviderを1つずつ調べて依存関係に入れていくのも手間です。

そこでClojureのデータとしてLogbackの設定を書けるようにしてくれ、また各ログ実装のproviderをまとめて依存関係に入れてくれるClojureライブラリであるunilogを使います。

github.com

いつもなら deps.edn に依存の記述を追加するところですが、実はunilogはすでにClojureで作るAPI Web サーバーを立ち上げる - Toyokumo Tech Blogで追加していました。

ですのでClojureのロギングのFacadeである clojure.tools.logging だけを新たに依存に加えます。

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"}}
 :aliases {:dev {:extra-paths ["dev"]}
           :build {:deps {io.github.clojure/tools.build {:git/tag "v0.8.2" :git/sha "ba1a2bf"}}
                   :ns-default build}}}

つまり、clojure.tools.logging -> SLF4J -> Logbackという図式を作ります。

ログ出力の設定を config.edn に記載します。

;; ./resources/config.edn
{:logging {:level #profile {:dev :debug
                            :default :info}
           :console #profile {:prod {:encoder :json}
                              :default true}
           :overrides {"org.eclipse.jetty" :info}}

 :server {:opts {:host "localhost"
                 :port #long #profile {:default 5000
                                       :dev 8000}
                 :join? false}}}

:logging というキーのバリューに設定を定義してあります。

  • :level ログレベルです。開発中はdebugレベル以上、それ以外ではinfoレベル以上で出力されるようにしています。
  • :console 標準出力に出す設定です。本番ではJSONで出力し、それ以外ではデフォルトのフォーマットで出力されるようにしています。
  • :overrides ログレベルを特定のパッケージに対して設定しています。 org.eclipse.jetty はWebサーバーですが、開発時にdebugレベルだとログが多すぎるので抑制しています。

Systemと統合する

それではSystemと統合してログ出力を足していきましょう。

;; ./src/cljapi/sytem.clj
(ns cljapi.system
  (:require
   [cljapi.component.handler :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 [config]
  (component/system-map
   :handler (c.handler/map->Handler {})
   :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))

init-logging! でunilogを使ってロギングの初期化をしています。 これを start に組み込むことで開発時と本番環境のいずれにおいても初期化がされるようになります。

REPLを立ち上げて (go) を評価することで実際にログが出力されることを確かめてみましょう。

INFO [2022-07-01 15:49:00,546] nREPL-session-f03ea922-17bf-447e-987c-43ddca2b2d9d - cljapi.system system is ready to start
INFO [2022-07-01 15:49:00,582] nREPL-session-f03ea922-17bf-447e-987c-43ddca2b2d9d - org.eclipse.jetty.server.Server jetty-10.0.9; built: 2022-03-30T16:46:32.527Z; git: a9eaf8d5d73369acf610ce88f850c0d56c4b1113; jvm 11.0.15+9-LTS
INFO [2022-07-01 15:49:00,650] nREPL-session-f03ea922-17bf-447e-987c-43ddca2b2d9d - org.eclipse.jetty.server.handler.ContextHandler Started o.e.j.s.ServletContextHandler@27fdbafe{/,null,AVAILABLE}
INFO [2022-07-01 15:49:00,657] nREPL-session-f03ea922-17bf-447e-987c-43ddca2b2d9d - org.eclipse.jetty.server.AbstractConnector Started ServerConnector@1eb67498{HTTP/1.1, (http/1.1)}{localhost:8000}
INFO [2022-07-01 15:49:00,666] nREPL-session-f03ea922-17bf-447e-987c-43ddca2b2d9d - org.eclipse.jetty.server.Server Started Server@4b8e5772{STARTING}[10.0.9,sto=0] @6911ms
INFO [2022-07-01 15:49:00,666] nREPL-session-f03ea922-17bf-447e-987c-43ddca2b2d9d - cljapi.system system is started

Jettyが出すログと共に自分で記述したログが同じフォーマットで出力されていることが確認できます。

次にビルドしてJARを実行したときのログを見てみます。

$ make build
$ java -jar target/cljapi.jar
{"@timestamp":"2022-07-01T15:19:41.566+09:00","@version":"1","message":"system is ready to start","logger_name":"cljapi.system","thread_name":"main","level":"INFO","level_value":20000}
{"@timestamp":"2022-07-01T15:19:41.608+09:00","@version":"1","message":"jetty-10.0.9; built: 2022-03-30T16:46:32.527Z; git: a9eaf8d5d73369acf610ce88f850c0d56c4b1113; jvm 11.0.15+9-LTS","logger_name":"org.eclipse.jetty.server.Server","thread_name":"main","level":"INFO","level_value":20000}
{"@timestamp":"2022-07-01T15:19:41.672+09:00","@version":"1","message":"Started o.e.j.s.ServletContextHandler@76e3b45b{/,null,AVAILABLE}","logger_name":"org.eclipse.jetty.server.handler.ContextHandler","thread_name":"main","level":"INFO","level_value":20000}
{"@timestamp":"2022-07-01T15:19:41.678+09:00","@version":"1","message":"Started ServerConnector@5d3b58ca{HTTP/1.1, (http/1.1)}{localhost:5000}","logger_name":"org.eclipse.jetty.server.AbstractConnector","thread_name":"main","level":"INFO","level_value":20000}
{"@timestamp":"2022-07-01T15:19:41.687+09:00","@version":"1","message":"Started Server@7f2d31af{STARTING}[10.0.9,sto=0] @902ms","logger_name":"org.eclipse.jetty.server.Server","thread_name":"main","level":"INFO","level_value":20000}
{"@timestamp":"2022-07-01T15:19:41.687+09:00","@version":"1","message":"system is started","logger_name":"cljapi.system","thread_name":"main","level":"INFO","level_value":20000}

設定通り、JSONで出力されていることが確認できます。

おわりに

ここまでで、ロギングのライブラリと設定を適切に行い、実際にログ出力できていることが確認できました。

コードはGitHubのリポジトリに上げてあります。 08_ロギングできるようにするというタグからご覧ください。

次はテストの設定を行い、簡単なテストを書いてみます。


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

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