UZABASE Tech Blog

〜迷ったら挑戦する道を選ぶ〜 株式会社ユーザベースの技術チームブログです。

【Clojure】Ductで始めるWebAPI開発

こんにちは!!SPEEDA開発チームの岡村です!!

私たちの開発チームでは、先日チームメンバの野口が書いたこちらの記事に書かれているように、チームメンバーの入れ替えが頻繁に行われます。

かく言う私も一ヶ月前に行われたチームシャッフルで、ClojureでDuctを使って開発を行うチームに移動しました。(Clojureほぼ未経験)

私がClojure初心者だからこそ、これからClojureとDuctを始めて見ようかなと思っている方に対し、お伝えできることがあると思い、今回はDuctでWebAPIを作成する方法をご紹介しようと思います。

Ductとは

Ductはライフサイクル管理を行うライブラリ、Integrantをベースとしたサーバサイドのフレームワークであり、アプリケーション構造は、Integrantのコンフィグレーションマップによって定義されます。

DuctとIntegrantの関係に関しては、弊社の中村がこちらの記事でも一度ご紹介しています。

作成物

CRUDの題材として定番な、TODO管理を下記リクエストで行えるAPIを作成していきます。

リクエストメソッド パス 内容
GET /todos 一覧を取得する
GET /todos/:id 指定したデータを取得する
POST /todos データを登録する
PUT /todos/:id 指定したデータを更新する
DELETE /todos/:id 指定したデータを削除する

プロジェクト作成

DuctプロジェクトはLeiningenを使って作成することができ、プロファイルを指定すると、
それに合わせプロジェクト構成や設定ファイルを自動で生成してくれます。

$ lein new duct todo +api +ataraxy +postgres

今回はAPIを作成し、ルーティングライブラリにAtaraxy、TodoデータをPostgresで管理するため、
+api +ataraxy +postgresと指定しました。

Todoを管理するデータベースも作成しておきましょう。
今回はDockerコンテナで作成しました。

docker run --name todo-db -p 5432:5432 -e POSTGRES_DB=todo-db -e POSTGRES_USER=username01 -e POSTGRES_PASSWORD=password01 -d postgres:11.5 

システムの起動

DuctはREPLを利用して開発を進めていくため、先ほど作成したプロジェクトに移動し、REPLを起動しましょう!

$ lein repl
nREPL server started on port 50077 on host 127.0.0.1 - nrepl://127.0.0.1:50077
REPL-y 0.4.3, nREPL 0.6.0
Clojure 1.10.0
OpenJDK 64-Bit Server VM 11.0.2+9
    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

REPLが起動したら、(dev)と入力し開発環境をロードします。

user=> (dev)
:loaded

その後、(go)とコマンドを入力するとシステムを起動することができるのですが、
DBへの接続情報をまだ記述していないため、下記の様なエラーが発生し、起動することができません。

dev=> (go)
:duct.server.http.jetty/stopping-server
Execution error (PSQLException) at org.postgresql.core.v3.ConnectionFactoryImpl/doAuthentication (ConnectionFactoryImpl.java:534).
サーバはパスワード・ベースの認証を要求しましたが、パスワードが渡されませんでした。
dev=> 

システムを起動させるため、DB接続情報を記述しましょう!
/todo/dev/resources配下にある、dev.ednファイルを以下の様に変更します。
このファイルはファイル名の通り、開発環境での設定を記述するファイルです。

{:duct.database/sql
 {:connection-uri "jdbc:postgresql://localhost:5432/todo-db?user=username01&password=password01"}
 }

Ductではednファイルにコンフィグレーションマップを書いていくことで、
アプリケーションの構造を表現することができます。

ここではduct.database/sqlというコンポーネントの初期化時に、データベースの接続情報が渡される用に定義しています。

書き換えた後に、再度システムを起動させてみましょう。

dev=> (go)
:duct.server.http.jetty/starting-server {:port 3000}
:initiated

上記の様に表示されていれば、システムが起動されています。 curlでリクエストを送ってみましょう。

$ curl http://localhost:3000
{"error":"not-found"}

errorが帰ってきますが、起動していることがわかります。

マイグレーション

ductのmoduleでマイグレーションツールとして提供されている、
migrator.ragtime を利用して、システムの起動時にテーブルの作成と、テストデータの投入が行われるようにしましょう。

先ほど編集したdev.ednを下記のように編集します。

{:duct.database/sql
 {:connection-uri "jdbc:postgresql://localhost:5432/todo-db?user=username01&password=password01"}

 :duct.migrator/ragtime
 {:migrations [#ig/ref :todo.migration/create-todos]}

 [:duct.migrator.ragtime/sql :todo.migration/create-todos]
 {:up ["CREATE TABLE todos (id SERIAL  PRIMARY KEY, title TEXT)"
       "INSERT INTO todos (title) values('test1')"
       "INSERT INTO todos (title) values('test2')"]
  :down ["DROP TABLE todos"]}
 }

システムを再起動させてみましょう。
REPLで(reset)と入力すると、再起動することができます。

dev=> (reset)
:reloading ()
:duct.migrator.ragtime/applying :todo.migration/create-todos#2cd2c3f9
:resumed

これでデータベースにテーブルが作成され、データが投入されました。

一覧取得処理の実装

まずはTodo一覧を取得する処理を実装していきましょう。
/todo/resources/todo/に存在する、config.ednを下記のように編集します。

{:duct.profile/base
 {:duct.core/project-ns todo

  :duct.router/ataraxy
  {:routes
   {[:get "/todos"] [:todo.handler.todos/list]}
   }

  ;; Handlers
  :todo.handler.todos/list {}

  }

 :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
 {}}

:duct.router/ataraxyコンポーネントに、 {[:get "/todos"] [:todo.handler.todos/list]}という記述を追加しました。
これは、/todosにGETリクエストが来た時、:todo.handler.todos/listコンポーネントで処理をするということを表しています。

:todo.handler.todos/list {}という記述も追加しました。これは:todo.handler.todos/listコンポーネントの初期化時に空のマップを渡すということを示しています。

ハンドラーの実装

続いてハンドラーを作成していきます。

/todo/src/todo/handlerに、todos.cljというファイルを作成し、以下を記述します。

(ns todo.handler.todos
  (:require [ataraxy.response :as response]
            [integrant.core :as ig]))

(defmethod ig/init-key ::list [_ _]
  (fn [_]
    [::response/ok {:message "OK!!!"}]))

Integrantでマルチメソッドとして定義されている、ig/init-keyを実装することにより、コンポーネントを作成することができます。

::listというのは、todo.handler.todos/listと同じ意味になります。
これにより、先ほどconfig.ednで書いた設定と処理を紐づけることができます。

戻り値は、Ataraxyを利用しているのでベクタで記述することができます。
一先ずリクエストが来たら :message "OK!!!"というマップを返すようにしました。

一旦システムを再起動し、実際にリクエストをしてみましょう!

dev> (reset)
:reloading (todo.handler.todos)
;;=> :resumed
$ curl  http://localhost:3000/todos
{"message":"OK!!!"}

レスポンスが帰ってくることを確認できました!

それでは、実際にデータベースからTodoリストを取得し、返すように実装を変えていきます。

/todo/resources/todo/config.ednを下記のように変更します。

{:duct.profile/base
 {:duct.core/project-ns todo

  :duct.router/ataraxy
  {:routes
   {[:get "/todos"] [:todo.handler.todos/list]}
   }

  ;;Handlers
  :todo.handler.todos/list {: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
 {}}

:todo.handler.todos/list {}と記述していた箇所を、
:todo.handler.todos/list {:db #ig/ref :duct.database/sql}に変更しました。

#ig/ref関数を利用し、:todo.handler.todos/listコンポーネントは、:duct.database/sqlコンポーネントに依存しているということを示しています。

:duct.database/sqlは、最初にdev.ednで記述したコンポーネントのことです。

これにより、:todo.handler.todos/listコンポーネントの初期化時に、
初期化済の:duct.database/sqlコンポーネントを受け取ります。

次に先ほど作成した、todos.cljを以下のように変更します。

(ns todo.handler.todos
  (:require [ataraxy.response :as response]
            [integrant.core :as ig]
            [todo.boundary.todos :as todos]))

(defmethod ig/init-key ::list [_ {:keys [db]}]
  (fn [_]
    (let [todos (todos/get-todos db)]
      [::response/ok todos])))

第二引数に、先ほどconfig.ednで定義したデータベースの接続情報を受け取り、
後で作成する、データベース接続を行うBoundaryにDB接続情報を渡して結果を返すようにしました。

init-keyは、第一引数にコンポーネントのキー(:todo.handler.todos/list)が、

第二引数にはコンフィグマップのコンポーネントに対応する以下のようなバリューが渡ってきます。

{:db #duct.database.sql.Boundary
 {:spec
  {:datasource #object[net.ttddyy.dsproxy.support.ProxyDataSource 0x11e3a8d net.ttddyy.dsproxy.support.ProxyDataSource@11e3a8d]}}}

バリューは:todo.handler.todos/list {:db #ig/ref :duct.database/sql}{:db #ig/ref :duct.database/sql}のことです。
#ig/refと記述しているため、初期化された:duct.database/sqlコンポーネントが渡ってきています。

データアクセス層の実装

続いて、実際にデータベースへの接続を行う層として、Boundaryを作成していきましょう!

/todo/src/todo/boundaryにtodos.cljというファイルを作成し、以下を記述します。

(ns todo.boundary.todos
  (:require
   [clojure.java.jdbc :as jdbc]))

(defprotocol Todos
  (get-todos [db]))

(extend-protocol Todos
  duct.database.sql.Boundary

  (get-todos [{:keys [spec]}]
    (jdbc/query spec ["SELECT * FROM todos"]))
  )

DBへの接続情報を受け取り、clojure.java.jdbcを利用してデータベースにアクセスしています。

システムを再起動し、リクエストを送ってみましょう!

curl http://localhost:3000/todos
[{"id":1,"title":"test1"},{"id":2,"title":"test2"}]

データベースから値を取得することができました!

登録処理の実装

次にデータベースへの登録処理を実装していきましょう。
流れは一覧取得処理を実装した時とほとんど同じです。

config.ednに/todosにPOSTでリクエストが送られてきた時の定義を記述します。

{:duct.profile/base
 {:duct.core/project-ns todo

  :duct.router/ataraxy
  {:routes
   {[:get "/todos"] [:todo.handler.todos/list]
    [:post "/todos" {body :body-params}] [:todo.handler.todos/create body]}
   }

  :todo.handler.todos/list {:db #ig/ref :duct.database/sql}
  :todo.handler.todos/create {: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
 {}}

先ほどと違うところは、HTTPリクエストからパラメータを取得しているというところです。
HTTPリクエストの情報はマップで渡ってきており、そこから登録に必要な:body-paramsの値を取り出しています。

ハンドラーの実装

次にhandlerを下記のように変更します。

(ns todo.handler.todos
  (:require [ataraxy.response :as response]
            [integrant.core :as ig]
            [todo.boundary.todos :as todos]))

(defmethod ig/init-key ::list [_ {:keys [db]}]
  (fn [_]
    (let [todos (todos/get-todos db)]
      [::response/ok todos])))

(defmethod ig/init-key ::create [_ {:keys [db]}]
  (fn [{[_ params] :ataraxy/result}]
    (let [result (todos/create-todo db params)
          id (:id (first result))]
      [::response/created (str "/todos/" id)])))

無名関数の引数が一覧検索の時と異なっています。
この無名関数の引数には下記のようなHTTPリクエスト情報のマップが渡ってきます。

{:ssl-client-cert nil, 
 :protocol HTTP/1.1, 
 :remote-addr 0:0:0:0:0:0:0:1, 
 :params {}, 
 :body-params {:title test3}, 
 :route-params {}, 
 :headers {user-agent curl/7.54.0, host localhost:3000, accept */*, content-length 18, content-type application/json}, 
 :server-port 3000, 
 :muuntaja/request #FormatAndCharset{:format application/json, :charset utf-8, :raw-format application/json}, 
 :ataraxy/result [:todo.handler.todos/create {:title test3}], 
 :content-length 18, :form-params {}, 
 :query-params {}, 
 :content-type application/json, 
 :character-encoding UTF-8, 
 :uri /todoss 
 :server-name localhost, 
 :query-string nil, 
 :muuntaja/response #FormatAndCharset{:format application/json, :charset utf-8, :raw-format */*}, :body #object[org.eclipse.jetty.server.HttpInputOverHTTP 0x74981d49 HttpInputOverHTTP@74981d49], 
:scheme :http, 
:request-method :post}

そのため:ataraxy/resultを分配束縛させています。

データアクセス層の実装

続いてBoundaryを編集しましょう!

(ns todo.boundary.todos
  (:require
   [clojure.java.jdbc :as jdbc]))

(defprotocol Todos
  (get-todos [db])
  (create-todo [db params]))

(extend-protocol Todos
  duct.database.sql.Boundary

  (get-todos [{:keys [spec]}]
    (jdbc/query spec ["SELECT * FROM todos"]))

  (create-todo [{:keys [spec]} params]
    (jdbc/insert! spec :todos {:title (:title params)}))

  )

jdbc/queryを使っても、もちろん実装できるのですが、jdbc/insert!を利用してみました。

これで登録処理の実装は完了です!

システムを再起動してリクエストを送ってみましょう!

curl -i -X POST http://localhost:3000/todos -d '{"title": "test3"}'  --header "Content-Type: application/json" 
HTTP/1.1 201 Created
Date: Thu, 29 Aug 2019 05:14:01 GMT
Location: http://localhost:3000/todos/3
Content-Type: application/octet-stream
Content-Length: 0
Server: Jetty(9.2.21.v20170120)

登録処理を行うことができました!

更新、削除、一件取得処理の実装

更新、削除、一件取得処理もこれまでと同様にして実装することができます!
そのため説明は割愛させていただきますが、最終的に実装は下記のようになります。

todo/resources/todo/config.edn

{:duct.profile/base
 {:duct.core/project-ns todo

  :duct.router/ataraxy
  {:routes
   {[:get "/todos"] [:todo.handler.todos/list]
    [:get "/todos/" id] [:todo.handler.todos/fetch ^int id]
    [:post "/todos" {body :body-params}] [:todo.handler.todos/create body]
    [:put "/todos/" id {body :body-params}] [:todo.handler.todos/update ^int id body]
    [:delete "/todos/" id] [:todo.handler.todos/delete ^int id]
    }}

  ;; Handler
  :todo.handler.todos/list
  {:db #ig/ref :duct.database/sql}
  :todo.handler.todos/fetch
  {:db #ig/ref :duct.database/sql}
  :todo.handler.todos/create
  {:db #ig/ref :duct.database/sql}
  :todo.handler.todos/update
  {:db #ig/ref :duct.database/sql}
  :todo.handler.todos/delete
  {: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
 {}}

todo/src/todo/handler/todos.clj

(ns todo.handler.todos
  (:require [ataraxy.response :as response]
            [integrant.core :as ig]
            [todo.boundary.todos :as todos]))

(defmethod ig/init-key ::list [_ {:keys [db]}]
  (fn [_]
    (let [todos (todos/get-todos db)]
      [::response/ok todos])))

(defmethod ig/init-key ::fetch [_ {:keys [db]}]
  (fn [{[_ id] :ataraxy/result}]
    (let [todo (todos/fetch-todo db id)]
      [::response/ok todo])))

(defmethod ig/init-key ::create [_ {:keys [db]}]
  (fn [{[_ params] :ataraxy/result}]
    (let [result (todos/create-todo db params)
          id (:id (first result))]
      [::response/created (str "/todos/" id)])))

(defmethod ig/init-key ::update [_ {:keys [db]}]
  (fn [{[_ id params] :ataraxy/result}]
    (todos/update-todo db id params)
    [::response/no-content]))

(defmethod ig/init-key ::delete [_ {:keys [db]}]
  (fn [{[_ id] :ataraxy/result}]
    (todos/delete-todo db id)
    [::response/no-content]))

todo/src/todo/boundary/todos.clj

(ns todo.boundary.todos
  (:require
   [clojure.java.jdbc :as jdbc]))

(defprotocol Todos
  (get-todos [db])
  (fetch-todo [db id])
  (create-todo [db params])
  (update-todo [db id params])
  (delete-todo [db id]))

(extend-protocol Todos
  duct.database.sql.Boundary

  (get-todos [{:keys [spec]}]
    (jdbc/query spec ["SELECT * FROM todos"]))

  (fetch-todo [{:keys [spec]} id]
    (jdbc/query spec [(format "SELECT * FROM todos WHERE id = '%s'" id)]))

  (create-todo [{:keys [spec]} params]
    (jdbc/insert! spec :todos {:title (:title params)}))

  (update-todo [{:keys [spec]} id params]
    (jdbc/update! spec :todos {:title (:title params)} ["id = ?" id]))

  (delete-todo [{:keys [spec]} id]
    (jdbc/delete! spec :todos ["id = ?" id]))

  )

まとめ

Integrantの「アプリケーションの構造と実装を、明確に分けて開発を行っていける」という機能を利用して作成されたDuctは、ednファイルで各コンポーネント間の依存関係を明示でき、「何が何に依存しているのかednファイルを見ればわかる」といったとこや、提供されているモジュールを利用することにより、実装を手軽に行えるということに魅力を感じました。