Clojurianでラブライブ!ファン(海未🏹&曜⛵推し)のlagénorhynque (a.k.a. カマイルカ)です。
Opt Technologies所属で、普段は広告運用に関わる社内向けプロダクトをPHP, TypeScript, Clojureで開発しています。
最近までにClojureをプロダクトに導入したり、Haskell/Elmプロダクトの開発者を募集したりと、社内でもClojureやHaskellといった言語への注目度がこれまで以上に高まっています(改めて断言)。
そこで再び、今年11月から新たに始まったClojure勉強会clj-nakanoでの課題を題材に、ClojureとDuctフレームワークでWeb APIを開発してみました。
ちなみにclj-nakanoといえば、Clojure開発者Rich HickeyのClojure/conj 2017でのキーノートEffective Programs - 10 Years of Clojureを解説した日本語資料も先日一部で話題になっていたようです。
situated-program-challenge
clj-nakanoでは、Rich Hickeyのキーノートでも出てきた表現"situated program"というものについて探求するべく、situated-program-challengeという課題に取り組んでいます。
詳しくはリポジトリのREADMEに書かれていますが、所定のデータベースにアクセスするREST APIのサーバとクライアントを複数言語で実装し、典型的なWeb APIをいかに簡潔に実装できるか、仕様変更に対していかに柔軟に対応できるか、検証してみようというものです。
第2回clj-nakanoではClojureとScalaでの実装例が紹介されました。
- Clojure版 by @iku000888 さん: iku000888/situated-program-challenge at clj-solution
- Scala版 by @shinichy さん: shinichy/situated-program-challenge at version1
また先日、私自身もHaskell版を実装してみました。
ClojurianがHaskellでWeb API開発に入門してみた - Qiita
そこで今回は、↑の記事執筆時に間に合わなかったClojure (Duct)版の実装を簡単にご紹介します。
基本的なビジネスロジックはHaskell (Yesod)版と同様なパターンで実装しているので、両者を見比べてみると面白いかもしれません。
Clojure版の実装
仕様version1のClojure (Duct)版実装はこちら:
lagenorhynque/situated-program-challenge at clj-version1
国内のClojureコミュニティでも注目度の高い(と思われる) Ductは、アプリケーション状態管理ライブラリIntegrantを基礎とした、データ駆動(data-driven)/データ指向(data-oriented)な設計が特徴的なWebフレームワークです。
以前から気になりつつも本格的に試したことがなかったため、今後のプロダクト利用を見据えた技術検証も兼ねて、Web API開発に使ってみることにしました。
1. プロジェクトの生成
Getting Started · duct-framework/duct WikiやDuct作者のブログ記事Building Web Services with Ductなどを参考にプロジェクトを生成し、ローカル設定ファイルをセットアップする。
今回はWeb API向けの構成で、ルーティングにAtaraxy、DBとしてPostgreSQLを利用する想定なので、
$ lein new duct rest-server +api +ataraxy +postgres
$ cd rest-server
$ lein duct setup
2. DB接続設定 & システムの起動
docker-compose.ymlで利用するPostgreSQLのデータベース接続情報が与えられているので、Ductの設定ファイルに反映する。
今回はproduction環境設定(resources/rest_server/config.edn
)で直接 database-url
を指定し、development環境設定(dev/resources/dev.edn
)からはDB接続設定を削除することにした(ちなみに、環境変数 DATABASE_URL
で外から与えることもできる)。
{:duct.profile/base
{:duct.core/project-ns rest-server}
:duct.profile/dev #duct/include "dev"
:duct.profile/local #duct/include "local"
:duct.profile/prod {}
:duct.module/logging {}
:duct.module.web/api {}
:duct.module/sql
{:database-url "jdbc:postgresql://localhost:5432/meetup?user=meetup&password=password123"}}
{}
REPLからシステム(開発環境)を起動するには、
$ lein repl
Warning: implicit middleware found: lein-duct.plugin/middleware
Please declare all middleware in :middleware as implicit loading is deprecated.
nREPL server started on port 64722 on host 127.0.0.1 - nrepl://127.0.0.1:64722
REPL-y 0.4.3, nREPL 0.5.3
Clojure 1.10.0
Java HotSpot(TM) 64-Bit Server VM 11+28
Docs: (doc function-name-here)
(find-doc "part-of-name-here")
Source: (source function-name-here)
Javadoc: (javadoc java-object-or-class-here)
Exit: Control+D or (exit) or (quit)
Results: Stored in vars *1, *2, *3, an exception in *e
user=> (dev) ; 開発環境をロード
:loaded
dev=> (go) ; システムを起動
:duct.server.http.jetty/starting-server {:port 3000}
:initiated
dev=> (reset) ; システムをリフレッシュ(ソースコードの変更を反映)
:reloading (rest-server.util rest-server.boundary.db.core rest-server.boundary.db.group rest-server.boundary.db.member rest-server.boundary.db.venue rest-server.handler.member rest-server.boundary.db.meetup rest-server.handler.venue rest-server.handler.meetup rest-server.handler.group rest-server.main dev user)
:resumed
dev=>
コマンドラインからシステムを起動するには、
$ lein run
jarをビルドしてシステムを起動するには、
$ lein uberjar
$ java -jar target/rest-server-0.1.0-SNAPSHOT-standalone.jar
3. ルーティングの定義
ルーティングライブラリとして、Ductのモジュールとしても利用可能になっているAtaraxyを利用する。
AtaraxyではClojureのデータとしてルーティングの定義を表現し、リクエストデータ(Clojureのマップ)に対して必要に応じて分配束縛や型変換を行うことができる。
ただのデータなのでDuctでは設定ファイルに書くようになっている。
{:duct.profile/base
{:duct.core/project-ns rest-server
:rest-server.handler.meetup/list {:db #ig/ref :duct.database/sql}
:rest-server.handler.meetup/create {:db #ig/ref :duct.database/sql}
:rest-server.handler.meetup/fetch {:db #ig/ref :duct.database/sql}
:rest-server.handler.meetup/join {:db #ig/ref :duct.database/sql}
:rest-server.handler.member/list {:db #ig/ref :duct.database/sql}
:rest-server.handler.member/create {:db #ig/ref :duct.database/sql}
:rest-server.handler.member/fetch {:db #ig/ref :duct.database/sql}
:rest-server.handler.venue/list {:db #ig/ref :duct.database/sql}
:rest-server.handler.venue/create {:db #ig/ref :duct.database/sql}
:rest-server.handler.group/list {:db #ig/ref :duct.database/sql}
:rest-server.handler.group/create {:db #ig/ref :duct.database/sql}
:rest-server.handler.group/join {:db #ig/ref :duct.database/sql}}
:duct.profile/dev #duct/include "dev"
:duct.profile/local #duct/include "local"
:duct.profile/prod {}
:duct.module/logging {}
:duct.module.web/api {}
:duct.module/sql
{:database-url "jdbc:postgresql://localhost:5432/meetup?user=meetup&password=password123"}
:duct.module/ataraxy
{"/members"
{:get [:member/list]
[:post {member :body-params}] [:member/create member]
["/" member-id]
{:get [:member/fetch ^int member-id]
"/meetups"
{["/" meetup-id]
{:post [:meetup/join ^int member-id ^int meetup-id]}}
"/groups"
{["/" group-id]
{[:post {group-member :body-params}] [:group/join ^int member-id ^int group-id group-member]}}}}
"/groups"
{:get [:group/list]
[:post {group :body-params}] [:group/create group]
["/" group-id]
{"/meetups"
{:get [:meetup/list ^int group-id]
[:post {meetup :body-params}] [:meetup/create ^int group-id meetup]
["/" meetup-id]
{:get [:meetup/fetch ^int group-id ^int meetup-id]}}
"/venues"
{:get [:venue/list ^int group-id]
[:post {venue :body-params}] [:venue/create ^int group-id venue]}}}}}
例えば、 /members/{member-id}/meetups/{meetup-id}
への POST
は、キー :duct.module/ataraxy
に対するルーティング定義マップの
"/members"
-> ["/" member-id]
-> "/meetups"
-> ["/" meetup-id]
-> :post
というパスで表現し、その値 [:meetup/join ^int member-id ^int meetup-id]
でハンドラー関数のコンポーネントに対応付けている。
対応するハンドラー関数のコンポーネントはルーティング定義マップの下にある
:rest-server.handler.meetup/join {:db #ig/ref :duct.database/sql}
で定義している(これに紐付く関数はDBアクセスを行うため、Integrantで :duct.database/sql
に依存させている)。
4. ハンドラーの実装1
ルーティングを定義したら、対応するハンドラー関数を定義する。
Ductではアプリケーション状態管理ライブラリとしてIntegrantを利用しているため、ここでは↑で設定したハンドラー関数のコンポーネントの初期化処理をClojureのマルチメソッドによって実装する。
まずは仮実装として、個々のコンポーネントのキーに対するマルチメソッド integrant.core/init-key
でルーティング定義に対応する引数を受け取り、常にHTTPレスポンスOK (ステータスコード200)を返す関数として実装してみる。
(ns rest-server.handler.member
(:require [ataraxy.response :as response]
[integrant.core :as ig]))
(defmethod ig/init-key ::list [_ {:keys [db]}]
(fn [_] [::response/ok]))
(defmethod ig/init-key ::create [_ {:keys [db]}]
(fn [{[_ member] :ataraxy/result}]
[::response/ok]))
(defmethod ig/init-key ::fetch [_ {:keys [db]}]
(fn [{[_ member-id] :ataraxy/result}]
[::response/ok]))
他の名前空間に設定したハンドラー関数のコンポーネントも同様に実装する。
- rest-server/src/rest_server/handler/meetup.clj
- rest-server/src/rest_server/handler/group.clj
- rest-server/src/rest_server/handler/venue.clj
これでルーティング定義とハンドラー関数が正しく紐付き、REPLで (reset)
するとコンパイルが通ってRESTサーバが動作するようになる。
5. DBアクセス層の実装
DBのデータを参照/更新するためにDBアクセス層を実装する。
ここではclojure.java.jdbcにHoney SQLを適宜組み合わせて利用する。
複数レコードのSELECT、1レコードのSELECT、1レコードのINSERT、複数レコードのINSERTというよくあるパターンをユーティリティとしてまとめてみた。
また、DBのテーブルでは識別子がsnake_caseだが、Clojureのマップでは通常kebab-caseのキーワードを利用するので、DBアクセスの前後に適宜変換するようにしている。
(ns rest-server.boundary.db.core
(:require [clojure.java.jdbc :as jdbc]
[honeysql.core :as sql]
[rest-server.util :as util]))
(defn select [{db :spec} sql-map]
(->> sql-map
sql/format
(jdbc/query db)
util/transform-keys-to-kebab))
(defn select-one [{db :spec} sql-map]
(->> sql-map
sql/format
(jdbc/query db)
util/transform-keys-to-kebab
first))
(defn insert! [{db :spec} table row-map & {:keys [id-col]
:or {id-col :id}}]
(->> row-map
util/transform-keys-to-snake
(jdbc/insert! db table)
first
id-col))
(defn insert-multi! [{db :spec} table row-maps]
(->> row-maps
util/transform-keys-to-snake
(jdbc/insert-multi! db table)))
Ductでは、DBなど外部リソース/サービスとのやり取りをビジネスロジックと疎結合にするため、Clojureのプロトコルで外部リソース/サービスに対するインターフェースとして「境界」(boundary)を定義し、それに対して実装する仕組みが提供されている。
ここでは、DBのエンティティへの各種参照/更新アクセスをメソッドとしたプロトコルを定義し、↑で用意したユーティリティやHoney SQLを利用してDBアクセス操作を実装している。
(ns rest-server.boundary.db.member
(:require [duct.database.sql]
[honeysql.core :as sql]
[rest-server.boundary.db.core :as db]))
(defprotocol Members
(list-members [db])
(create-member [db member])
(fetch-member [db member-id])
(fetch-members [db member-ids]))
(extend-protocol Members
duct.database.sql.Boundary
(list-members [db]
(db/select db (sql/build :select :*
:from :members)))
(create-member [db member]
(db/insert! db :members member))
(fetch-member [db member-id]
(db/select-one db (sql/build :select :*
:from :members
:where [:= :id member-id])))
(fetch-members [db member-ids]
(db/select db (sql/build :select :*
:from :members
:where [:in :id member-ids]))))
SQLを書くのに利用しているHoney SQLはClojureのデータでSQLを表現するDSLで、(ここでは簡潔さのために honeysql.core/build
関数を利用するだけにとどまっているが)ただのデータなので非常に柔軟にクエリを組み立てることができる。
(ns rest-server.boundary.db.meetup
(:require [duct.database.sql]
[honeysql.core :as sql]
[rest-server.boundary.db.core :as db]))
(defprotocol Meetups
(list-meetups [db group-id])
(create-meetup [db meetup])
(fetch-meetup [db meetup-id])
(fetch-meetup-members [db meetup-id])
(create-meetup-member [db meetup-member]))
(extend-protocol Meetups
duct.database.sql.Boundary
(list-meetups [db group-id]
(db/select db (sql/build :select :*
:from :meetups
:where [:= :group_id group-id])))
(create-meetup [db meetup]
(db/insert! db :meetups meetup))
(fetch-meetup [db meetup-id]
(db/select-one db (sql/build :select :*
:from :meetups
:where [:= :id meetup-id])))
(fetch-meetup-members [db meetup-id]
(db/select db (sql/build :select :members.*
:from :members
:join [:meetups_members [:= :members.id :meetups_members.member_id]]
:where [:= :meetups_members.meetup_id meetup-id])))
(create-meetup-member [db meetup-member]
(db/insert! db :meetups_members meetup-member)))
JOIN
や WHERE
などもClojureのデータの組み合わせで簡潔に表現できる。
e.g.
(sql/build :select :members.*
:from :members
:join [:meetups_members [:= :members.id :meetups_members.member_id]]
:where [:= :meetups_members.meetup_id meetup-id])
その他のDBとのboundaryも同様に実装する。
6. ハンドラーの実装2
ここまでのボトムアップな下ごしらえで必要な材料はすべてそろったので、最後にハンドラー関数のビジネスロジックを実装する。
HTTPのリクエストもレスポンスもDBとの入出力データも、一貫してClojureの最も基本的な抽象たるマップ(associativeなもの)とシーケンス(sequentialなもの)の組み合わせにすぎないので、マップやシーケンスに対する豊富な関数や言語機能をフル活用して非常に簡潔に実装することができた。
(ns rest-server.handler.member
(:require [ataraxy.response :as response]
[clojure.set :as set]
[integrant.core :as ig]
[rest-server.boundary.db.member :as db.member]))
(defn member-with-id [member]
(set/rename-keys member {:id :member-id}))
(defmethod ig/init-key ::list [_ {:keys [db]}]
(fn [_] [::response/ok (map member-with-id
(db.member/list-members db))]))
(defmethod ig/init-key ::create [_ {:keys [db]}]
(fn [{[_ member] :ataraxy/result}]
(let [id (db.member/create-member db member)]
[::response/ok (-> member
(assoc :id id)
member-with-id)])))
(defmethod ig/init-key ::fetch [_ {:keys [db]}]
(fn [{[_ member-id] :ataraxy/result}]
(when-let [member (db.member/fetch-member db member-id)]
[::response/ok (member-with-id member)])))
(ns rest-server.handler.meetup
(:require [ataraxy.response :as response]
[integrant.core :as ig]
[rest-server.boundary.db.meetup :as db.meetup]
[rest-server.boundary.db.venue :as db.venue]
[rest-server.handler.member :as member]
[rest-server.handler.venue :as venue]
[rest-server.util :as util]))
(defn meetup-with-venue-and-members [{:keys [id title start-at end-at]} venue members]
{:event-id id
:title title
:start-at start-at
:end-at end-at
:venue (venue/venue-with-address venue)
:members (map member/member-with-id members)})
(defn fetch-meetup-detail [db {:keys [id venue-id] :as meetup}]
(let [venue (db.venue/fetch-venue db venue-id)
members (db.meetup/fetch-meetup-members db id)]
(meetup-with-venue-and-members meetup venue members)))
(defn get-meetup [db meetup-id]
(when-let [meetup (db.meetup/fetch-meetup db meetup-id)]
(fetch-meetup-detail db meetup)))
(defmethod ig/init-key ::list [_ {:keys [db]}]
(fn [{[_ group-id] :ataraxy/result}]
[::response/ok (map (partial fetch-meetup-detail db)
(db.meetup/list-meetups db group-id))]))
(defmethod ig/init-key ::create [_ {:keys [db]}]
(fn [{[_ group-id {:keys [start-at end-at] :as meetup}] :ataraxy/result}]
(let [meetup' (assoc meetup
:start-at (util/string->timestamp start-at)
:end-at (util/string->timestamp end-at)
:group-id group-id)
id (db.meetup/create-meetup db meetup')]
[::response/ok (-> meetup'
(assoc :id id)
((partial fetch-meetup-detail db)))])))
(defmethod ig/init-key ::fetch [_ {:keys [db]}]
(fn [{[_ _ meetup-id] :ataraxy/result}]
(when-let [meetup (get-meetup db meetup-id)]
[::response/ok meetup])))
(defmethod ig/init-key ::join [_ {:keys [db]}]
(fn [{[_ member-id meetup-id] :ataraxy/result}]
(db.meetup/create-meetup-member db {:meetup-id meetup-id
:member-id member-id})
[::response/ok (get-meetup db meetup-id)]))
その他のハンドラーも同様に実装してRESTサーバは完成!
まとめ
- ClojureのフレームワークDuctでデータによる宣言的な記述を主体としてシンプルにWeb APIを実装できた
- Duct, Integrant, Ataraxyのデータ駆動/データ指向な設計には可能性を感じる
- Luminusは十分実用的だしArachneにも期待しているけど、現状Ductが個人的にもかなり良い選択肢かも
- やはりClojureは超楽しい>ω</