Git Repo
ソースコードと原文が入ったレポジトリ
https://github.com/MokkeMeguru/clj-web-dev-ja/tree/main/chap3
シリーズ
- Clojure x ClojureScript で深める Web 開発 (0)
- Clojure x ClojureScript で深める Web 開発 (1) Duct x Clean Architecture
- Clojure x ClojureScript で深める Web 開発 (2) 環境の構築
- Clojure x ClojureScript で深める Web 開発 (3) API 作成入門
- Clojure x ClojureScript で深める Web 開発 (4) Auth
- Clojure x ClojureScript で深める Web 開発 (5) API 開発 トランザクション添え
- Clojure x ClojureScript で深める Web 開発 (6) クライアントサイドと re-frame
本編
開発の流れの確認
ここまでで、REPL と integrant 、CLI を組み合わせて開発を行う準備が整いました。 以降からサーバ API 開発を行っていくわけですが、一旦ここで開発の流れ (諸説あり) を確認します。
-
環境変数を設定する。
profiles.clj
を編集するか、export
を用いて、環境変数を設定します。特に DB の接続先を変えたり、 GCP のシークレットトークンを用いたりする際には、この工程が必須になります。 -
REPL を立ち上げる。
lein repl
より (dev profile) 環境を立ち上げます。各エディタとこの REPL を連携することで、書いたコードの評価ができるようになります。 -
機能を書く。
-
integrant の機能を書く。
integrant のドキュメント に従って記述します。
-
integrant 外の機能を書く。
Clean Architecture の様式に従って、domain、usecase などを記述します。
-
config を更新する。
config.edn
を更新します。更新したら、dev.clj
で書いた(restart)
を評価して反映します。 なお、config.edn
を更新する必要がなくとも、何らかの機能を更新した場合には(restart)
を評価する必要があります。
基本的には 3, 4 を繰り返すことで開発を進めていきます。
HTTP サーバを建てる
API は HTTP サーバに生えるので、HTTP サーバを建てます。 HTTP サーバの機能は、出力口にあたるので、Clean Architecture 的には infrastructure
に含まれる部分と考えられます。
また今回は API サーバなので、ルーティングについても考える必要があります。 HTTP の port=3000
番ポートを開けて待ち受けることと、ルーティングすることは別なので、それぞれ別の機能とみなして実装を行っていきます。
依存関係を考えてまずはルーティング部分から記述します。 ルーティングは reitit
(https://github.com/metosin/reitit) というライブラリを利用します。 2021 年のところルーティングにおいて、他ライブラリと比較して速度の面で有利であること、良質なドキュメントがあることのためです。
ルータのコード (picture-gallery.infrastructure.router.core)
(ns picture-gallery.infrastructure.router.core
(:require
[reitit.ring :as ring]
[reitit.core :as reitit]
[reitit.coercion.spec]
[reitit.swagger :as swagger]
[reitit.swagger-ui :as swagger-ui]
[reitit.ring.coercion :as coercion]
[reitit.ring.middleware.muuntaja :as muuntaja]
[reitit.ring.middleware.exception :as exception]
[reitit.ring.middleware.multipart :as multipart]
[reitit.ring.middleware.parameters :as parameters]
[reitit.ring.middleware.dev :as dev]
[reitit.ring.spec :as spec]
[spec-tools.spell :as spell]
[muuntaja.core :as m]
[clojure.java.io :as io]
[ring.logger :refer [wrap-with-logger]]
[integrant.core :as ig]
[taoensso.timbre :as timbre]
[reitit.dev.pretty :as pretty]))
(defn app [env]
(ring/ring-handler
(ring/router
[;; 後述する swagger のためのエンドポイント
["/swagger.json"
{:get {:no-doc true
:swagger {:info {:title "picture-gallery-api"}
:securityDefinitions
{:Bearer
{:type "apiKey"
:in "header"
:name "Authorization"}}
:basePath "/"}
:handler (swagger/create-swagger-handler)}}]
;; "/api" 以下に機能を追加していく。
["/api"]]
;; 細かい設定
{:exception pretty/exception
:data {:coercion reitit.coercion.spec/coercion
:muuntaja m/instance
:middleware
[;; swagger feature
swagger/swagger-feature
;; query-params & form-params
parameters/parameters-middleware
;; content-negotiation
muuntaja/format-negotiate-middleware
;; encoding response body
muuntaja/format-response-middleware
;; exception handling
exception/exception-middleware
;; decoding request body
muuntaja/format-request-middleware
;; coercing response bodys
coercion/coerce-response-middleware
;; coercing request parameters
coercion/coerce-request-middleware
;; multipart
multipart/multipart-middleware]}})
(ring/routes
(swagger-ui/create-swagger-ui-handler {:path "/api"})
(ring/create-default-handler))
{:middleware [wrap-with-logger]}))
(defmethod ig/init-key ::router [_ {:keys [env]}]
(timbre/info "router got: env" env)
(app env))
;; halt-key! は何もしないので 省略できる
次に HTTP サーバ。同じく reitit
ライブラリを参考に書きます。 reitit
は Clojure ライブラリの中でもかなり開発・運用環境が良いので、API 開発時のルーティング (また、後に出てくる SPA クライアント側のルーティングにも) おすすめです。
(ns picture-gallery.infrastructure.server
(:require [taoensso.timbre :as timbre]
[ring.adapter.jetty :as jetty]
[integrant.core :as ig]))
(defmethod ig/init-key ::server [_ {:keys [env router port]}]
(timbre/info "server is running in port" port)
(timbre/info "router is " router)
(jetty/run-jetty router {:port port :join? false}))
;; server は 常駐アプリケーションなので、停止を強制させる必要がある。
;; 引数に与えられている server は init-key にある (jetty/run-jetty ...) で返される server
(defmethod ig/halt-key! ::server [_ server]
(.stop server))
config を更新します。
{:picture-gallery.infrastructure.env/env {}
:picture-gallery.infrastructure.router.core/router {:env #ig/ref :picture-gallery.infrastructure.env/env}
:picture-gallery.infrastructure.server/server {:env #ig/ref :picture-gallery.infrastructure.env/env
:router #ig/ref :picture-gallery.infrastructure.router.core/router
:port 3000}}
REPL で (restart)
を評価してみます。
(dev)=> (restart)
loading environment via environ
running in dev
log-level :info
orchestra instrument is active
2021-03-13T14:41:44.588Z b343f5d53e9e INFO [picture-gallery.infrastructure.router.core:72] - router got: env {:running "dev", :log-level :info}
2021-03-13T14:41:44.592Z b343f5d53e9e INFO [picture-gallery.infrastructure.server:7] - server is running in port 3000
2021-03-13T14:41:44.592Z b343f5d53e9e INFO [picture-gallery.infrastructure.server:8] - router is clojure.lang.AFunction$1@22b20c19
;; => :initiated
(dev)=>
サーバが立ち上がったのを確認することができました。
Test API の作成
今回は Test API として ping - pong API を作ってみることにします。
ping - pong API とは /ping へリクエストを投げると “pong” という文字が返ってくる API です。 DB への接続もないので、非常にシンプルに作ることができます。
更に今後の開発のために、 Swagger と呼ばれる API の仕様記述のためのツールを使ってブラウザ上で ping - pong API をテストできるようにします。
Swagger のある生活
サーバとクライアントの接続部分の情報共有をどのように行うのか。サーバ・クライアントアプリケーション (サービス) を開発する際にこの議題がしばしば挙がります。 (※ Rails / Django のようなサーバとクライアントを一つのプログラムで完結させるものを除く)
一般にサーバとクライアントは JSON を始めとする何らかのフォーマットにエンコードされたデータをやり取りし、それらを仕様として各プログラムは認識 / デコードします。
近年では、Swagger (OpenAPI) というツールがこの仕様共有のために注目されています。 Swagger は API のエンドポイントとそのエンドポイントで通信する際のデータ仕様をブラウザを用いて確認できるツールで、また、Swagger を Web クライアントとしてサーバとデータのやり取りをテストすることができます。
本ガイドでは、Swagger を サーバ側のコードから自動生成する ことで、Swagger の利用を行っていきます。
ping - pong フローの確認
今回扱う ping - pong API のフローを確認します。今回は練習のため comment という optional な 値を導入しました。
client server
| +--------------------+ |
| --- | /ping | --> |
| | 'ping-message | |
| +--------------------+ |
| |
| +----------<success>-+ |
| <-- | 'pong-message | --- |
| +--------------------+ |
~ ~
| +----------<failure>-+ |
| <-- | 'error-message | --- |
| +--------------------+ |
-
’ping-message (query)
{:ping "ping" :comment "<optional string>"}
-
’pong-message (response body)
{:pong "pong" :comment "<optional string>"}
domain の作成
domain (entities) は取り扱うデータの仕様です。文字長のような制約も含めた仕様を記述していきます。
(golang や Java の String, Integer の代わりです。)
(ns picture-gallery.domain.sample
(:require [clojure.spec.alpha :as s]))
(def max-comment-length 1024)
(s/def ::ping (s/and string? (partial = "ping")))
(s/def ::pong (s/and string? (partial = "pong")))
(s/def ::not-exist-comment nil?)
(s/def ::exist-comment (s/and string? #(< (count %) max-comment-length)))
(s/def ::comment (s/or :not-exist-comment ::not-exist-comment
:exist-comment ::exist-comment))
REPL で動作確認をしてみましょう。
(s/valid? ::ping "ping") ;; => true
(s/valid? ::ping 0) ;; => false
(s/valid? ::ping "pong") ;; => false
動作確認を元に、テストを書いておきます。
(ns picture-gallery.domain.sample-test
(:require [picture-gallery.domain.sample :as sut]
[picture-gallery.utils.string :as pg-string]
[clojure.spec.alpha :as s]
[clojure.test :as t]))
(t/deftest ping
(t/is (not (s/valid? ::sut/ping "pong")))
(t/is (not (s/valid? ::sut/ping 0)))
(t/is (s/valid? ::sut/ping "ping")))
(t/deftest pong
(t/is (not (s/valid? ::sut/pong "ping")))
(t/is (not (s/valid? ::sut/pong 0)))
(t/is (s/valid? ::sut/pong "pong")))
(t/deftest _comment
(t/is (s/valid? ::sut/comment nil))
(t/is (s/valid? ::sut/comment "hello"))
(t/is (not (s/valid? ::sut/comment (pg-string/rand-str 2048)))))
なお、動作確認、テストの段階で仕様に漏れがあれば、修正を施しましょう。
ルーティングを設定し、 Swagger を生やす
API でどのようなデータをやり取りするのかがわかったところで、早速エンドポイントを作っていきます。
まずは条件を無視して、サンプルのデータをやり取りするだけのエンドポイントを作ります。
-
ルータ
(ns picture-gallery.infrastructure.router.sample (:require [picture-gallery.domain.openapi.sample :as sample-openapi] [picture-gallery.interface.controller.api.sample.ping-post :as ping-post-controller] [picture-gallery.usecase.sample.ping-pong :as ping-pong-usecase] [picture-gallery.interface.presenter.api.ping-post :as ping-post-presenter] [picture-gallery.utils.error :refer [err->>]])) ;; handlers (defn ping-post-handler [input-data] (ping-post-presenter/->http ;; 出力データにフォーマットする (err->> input-data ;; 入力データを ping-post-controller/http-> ;; フォーマットして ping-pong-usecase/ping-pong ;; 処理する ))) ;; router (defn sample-router [] ["/samples" ["/ping" {:swagger {:tags ["sample_API"]} :post {:summary "ping - pong" :parameters {:query ::sample-openapi/post-ping-query-parameters} :responses {200 {:body ::sample-openapi/post-ping-response}} :handler ping-post-handler}}]])
-
ハンドラの内部
Clean Architecture 的には入力データの処理を controller、ロジックを usecase、出力を presenter で行うので、それに従って書いていきます。(先にルータですべてのコードを書いて、後で分割していく方が楽かもしれません。)
-
controller
controller のコード
(ns picture-gallery.interface.controller.api.sample.ping-post (:require [clojure.spec.alpha :as s] [picture-gallery.domain.sample :as sample-domain] [picture-gallery.domain.error :as error-domain] [picture-gallery.domain.openapi.sample :as sample-openapi])) (s/def ::query ::sample-openapi/post-ping-query-parameters) (s/def ::parameters (s/keys :req-un [::query])) (s/def ::http-input-data (s/keys :req-un [::parameters])) (s/fdef http-> :args (s/cat :input-data ::http-input-data) :ret (s/or :success (s/tuple ::sample-domain/ping-pong-input nil?) :failure (s/tuple nil? ::error-domain/error))) (defn http-> " http request -> usecase input model " [input-data] (let [{:keys [parameters]} input-data {:keys [query]} parameters {:keys [ping comment]} query input-model (cond-> {:ping (:ping query)} comment (assoc :comment comment)) conformed-input-model (s/conform ::sample-domain/ping-pong-input input-model)] (if (not= ::s/invalid conformed-input-model) [conformed-input-model nil] [nil (error-domain/input-data-is-invalid (s/explain-str ::sample-domain/ping-pong-input input-model))])))
-
usecase
(ns picture-gallery.usecase.sample.ping-pong (:require [clojure.spec.alpha :as s] [picture-gallery.domain.sample :as sample-domain] [picture-gallery.domain.error :as error-domain] [orchestra.spec.test :as st])) (s/fdef ping-pong :args (s/cat :input-model ::sample-domain/ping-pong-input) :ret (s/or :success (s/cat :ping-pong-output ::sample-domain/ping-pong-output :error nil?) :failure (s/cat :ping-pong-output nil? :error ::error-domain/error))) ;; ここを後で編集する (ロジックが空) (defn ping-pong [input-model] (let [{:keys [ping comment]} input-model output-model {:pong "pong"}] [output-model nil]))
-
presenter
presenter のコード(ns picture-gallery.interface.presenter.api.ping-post (:require [clojure.spec.alpha :as s] [picture-gallery.domain.sample :as sample-domain] [picture-gallery.domain.openapi.sample :as sample-openapi] [picture-gallery.domain.openapi.base :as base-openapi] [picture-gallery.domain.error :as error-domain] [orchestra.spec.test :as st])) (s/def ::body ::sample-openapi/post-ping-response) (s/def ::http-output-data (s/keys :req-un [::base-openapi/status ::body])) (s/fdef ->http :args (s/cat :arg (s/or :success (s/tuple ::sample-domain/ping-pong-output nil?) :failure (s/tuple nil? ::error-domain/error))) :ret (s/or :success ::http-output-data :failure ::error-domain/error)) (defn ->http " usecase output model -> http response " [[output-data error]] (if (nil? error) {:status 200 :body output-data} error))
最後に、大元のルータに接続します。
(defn app []
(ring/ring-handler
(ring/router
[["/swagger.json"
{:get {:no-doc true
:swagger {:info {:title "picture-gallery-api"}
:securityDefinitions
{:Bearer
{:type "apiKey"
:in "header"
:name "Authorization"}}
:basePath "/"}
:handler (swagger/create-swagger-handler)}}]
["/api"
(sample-router/sample-router) ;; "/api" 以下に生やしていく
]]
;; ...
)))
すべて記述するとともに、 実装した関数を評価したら 4.2 、 (restart)
より環境を更新します。 なお、実装した関数を評価していないと、正しく動作しません。
Swagger にアクセスして確かめてみましょう。ブラウザの localhost:3000/api
より Swagger の画面にアクセスできます。
swagger で API を試す
試しに ping に “ping” を入力して 実行すると、
{ "pong": "pong" }
と返ってきます。これは期待通りですね。
ping に “hello” を入力すると、以下のようなエラーが返ってきます。これは、controller (3.4) で s/conform
した際に、 domain の ping の仕様 (:picture-gallery.domain/sample/ping
) に違反しているためです。
{
"code": 1,
"message": "input data is invalid: \"hello\" - failed: (partial = \"ping\") in: [:ping] at: [:ping] spec: :picture-gallery.domain.sample/ping\n"
}
次に、 ping に “ping”、 comment に “hello world” を入力してみましょう。 comment がレスポンスに含まれていません。これは仕様とは違いますね。入力や出力の変換部分ではなく、usecase の未実装が原因です (※本来はテストでカバーできている場面ですが、Swagger に慣れてもらうために、意図的にテストを飛ばしています)。
{ "pong": "pong" }
usecase (3.4) を見ると、 comment を output モデルに追加していないことがわかるので、これを追加します。 つまり以下のように修正します。
(defn ping-pong [input-model]
(let [{:keys [ping comment]} input-model
output-model (cond-> {:pong "pong"}
;; もし comment があれば、 assoc 関数を用いて comment を追加する
comment (assoc :comment comment))]
[output-model nil]))
修正を反映して、再度 swagger で試してみましょう。
{
"pong": "pong",
"comment": "hello world"
}
期待する結果を得ることができました。
付録 & 捕捉
logging 機能の設定
log の設定機能を追加します。 timbre というライブラリを呼び出すだけなのでほとんどコードはないです。
(ns picture-gallery.infrastructure.logger
(:require [taoensso.timbre :as timbre]
[integrant.core :as ig]))
(defmethod ig/init-key ::logger [_ {:keys [env]}]
(println "set logger with log-level" (:log-level env))
(timbre/set-level! (:log-level env))
{})
環境変数からログの出力レベルを得たいので、config に :env
の依存関係を追加します。
{:picture-gallery.infrastructure.env/env {}
:picture-gallery.infrastructure.logger/logger {:env #ig/ref :picture-gallery.infrastructure.env/env}}
コードの全評価
Emacs の開発環境である、 Cider では、 cider-eval-all-files
というコマンドで、指定したディレクトリ以下の今まで記述したコードのすべてを評価することができます。
また、VSCode の Calva では、コードを保存すると評価するオプション Eval On Save
が存在します。