2017年12月20日水曜日

clojureのductでcrudアプリを作る方法


この記事は Clojure Advent Calendar 2017 20日目の記事です。

背景

clojureとはLisp系プログラミング言語の一種です。
ductとは、clojureで作られたwebアプリケーションフレームワークです。
そのductを使って、ユーザー情報をcrud操作 (create, read, update, destroy)できるwebアプリを作ってみました。

ところどころ詰まったので、方法を共有します。

全体像

  1. 動作環境
  2. ductアプリを作成
  3. DBを接続
  4. ductアプリを起動して、exampleページを確認
  5. migration
  6. boundary
  7. validation
  8. handler
  9. routing
  10. 動作確認
  11. まとめ
  12. 参考

動作環境

PC

OSとしてubuntuをインストールしたPCを利用しました。

バージョン情報はこちらです。
$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description: Ubuntu 17.10
Release: 17.10
Codename: artful

$ uname -a
Linux asuki-ThinkPad-S1-Yoga 4.13.0-19-generic #22-Ubuntu SMP Mon Dec 4 11:58:07 UTC 2017 x86_64 x86_64 x86_64 GNU/Linux

Java

Javaとはプログラミング言語の一種です。
この言語を動かすためのJVMという仕組みを利用して、clojureは動きます。

下記のコマンドで、open-jdk-1.8をインストールしました。
sudo apt install openjdk-8-jre

バージョン情報はこちらです。
$ java -version
openjdk version "1.8.0_151"
OpenJDK Runtime Environment (build 1.8.0_151-8u151-b12-0ubuntu0.17.10.2-b12)
OpenJDK 64-Bit Server VM (build 25.151-b12, mixed mode)

leiningen

leiningenとは、clojureのライブラリをとりまとめてくれるプログラムです。
この記事では、このプログラムを通して、clojureを起動します。

下記のコマンドでインストールしました。
wget https://raw.githubusercontent.com/technomancy/leiningen/stable/bin/lein
chmod a+x lein
sudo mv lein /usr/local/bin

バージョン情報はこちらです。
(leinコマンドの初回実行時は、関連プログラムをダウンロードするため時間がかかります。)
$ lein -v
Leiningen 2.8.1 on Java 1.8.0_151 OpenJDK 64-Bit Server VM

PostgreSQL

PostgreSQLとは、データベースの一種です。
アプリで扱う情報を保存するのに使います。

下記のコマンドでインストールしました。
sudo apt install postgresql-contrib

バージョン情報はこちらです。
$ psql --version
psql (PostgreSQL) 9.6.6

ductアプリを作成

「lein duct プロジェクト名 オプション」のようなコマンドでductのプロジェクトを作れます。
設定可能なオプションはductのドキュメントにリストアップされています。

今回は下記のコマンドで、ataraxyというルーティングライブラリを利用し、exampleページを持ち、PostgreSQLと連携する、「duct-crud-practice」というプロジェクトを「gitprojects」ディレクトリの中につくりました。
mkdir ~/gitprojects
cd ~/gitprojects
lein new duct duct-crud-practice +ataraxy +example +postgres

DBを接続

プロジェクトを作ったのでexampleページを確認したいところですが、DBを接続していない状況でductアプリを起動しようとすると、下記のエラーが出て起動できません。
PSQLException サーバはパスワード・ベースの認証を要求しましたが、いかなるパスワードも提供されませんでした。  org.postgresql.core.v3.ConnectionFactoryImpl.doAuthentication (ConnectionFactoryImpl.java:487)

そのため、サンプルページ確認の前にDBとの接続を行います。

まず、PostgreSQLの設定をするために、potgresユーザーになります。
sudo -i -u postgres

下記のコマンドを実行して、ductで利用する、パスワードありのユーザーを作成します。
入力求められるので、今回はユーザ名を「clojure_user」、パスワードを「secret」、特別な権限を持たない一般ユーザーを作成しました。(権限があっても無くてもアプリは動きます。)
createuser --interactive -P

下記のコマンドで、アプリに使うDBを「duct_crud_practice」という名前で作成しました。
createdb duct_crud_practice

設定ができたら、postgresユーザーからログアウトします。
exit


作ったユーザーとDBの情報を「dev/resources/dev.edn」に記述します。
~/gitprojects/duct/duct-crud-practice/dev/resources/dev.edn
{:database-url "jdbc:postgresql://localhost/duct_crud_practice?user=clojure_user&password=secret"}

期待通りにDBと接続できれば、次の手順で説明するexampleページが確認できるはずです。

ductアプリを起動して、exampleページを確認

プロジェクトを作ったら、replという、コマンドを実行できるやり取りプログラムを立ち上げて、その中でコマンドを実行すことでductアプリを起動できます。
cd ~/gitprojects/duct-crud-practice
lein repl
(dev)
(go)

起動に成功すると、下記のように3000番ポートで接続できるサーバーが立ち上がった旨のメッセージが出ます。


この状態で htttp://localhost:3000/example にアクセスすると、サンプルページを確認できます。


注意すべきなのは、 http://localhost:3000 にアクセスしても、何も表示されないことです。
自分の場合、起動しているのにパスが間違っているために起動していないと勘違いしてしまい、時間を取られました。

migration

webアプリにおけるmigratoinとは、DBの初期設定を意味します。

ductの場合、「resources/duct_crud_practice/config.edn」に下記のような記述を追加すると、起動時にuserテーブルを構築してくれます。
今回のアプリでは、名前とメールアドレスを持ったユーザー情報を扱います。
~/gitprojects/duct/duct-crud-practice/resources/duct_crud_practice/config.edn
 {:migrations [#ig/ref :duct-crud-practice/users]}

 [:duct.migrator.ragtime/sql :duct-crud-practice/users]
 {:up ["CREATE TABLE users (
          id    SERIAL PRIMARY KEY,
          name  varchar(255),
          email varchar(255)
        );"]
  :down ["DROP TABLE users;"]}

boundary

boundaryとは、DBのデータの扱い方を記述する部分です。
crud操作に必要となる、一覧情報取得(get-users)、個別情報取得(get-user)、新規作成(create-user)、更新(update-user)、削除(delete-user)を「src/duct_crud_practice/boundary/users.clj」に記述します。
src/duct_crud_practice/boundary/users.clj
(ns duct-crud-practice.boundary.users
  (:require [clojure.java.jdbc :as jdbc]
            [clojure.core :refer [format]]
            [duct.database.sql]))

(defprotocol Users
  (get-users [db])
  (get-user [db id])
  (create-user [db params])
  (update-user [db id params])
  (delete-user [db id]))

(extend-protocol Users
  duct.database.sql.Boundary
  (get-users [{:keys [spec]}]
    (jdbc/query spec ["SELECT * FROM users;"]))
  (get-user [{:keys [spec]} id]
    (jdbc/query spec [(format "SELECT * FROM users WHERE id = '%s';" id)]))
  (create-user [{:keys [spec]} params]
    (jdbc/query spec [(format "INSERT INTO users (name, email) VALUES ('%s', '%s') RETURNING id;"
                              (:name params)
                              (:email params))]))
  (update-user [{:keys [spec]} id params]
    (jdbc/query spec [(format "UPDATE users SET name = '%s', email = '%s' WHERE id = '%s' RETURNING id;"
                              (:name params)
                              (:email params)
                              id)]))
  (delete-user [{:keys [spec]} id]
    (jdbc/query spec [(format "DELETE FROM users WHERE id = '%s' RETURNING *;" id)])))

validation

specphraseを組み合わせて、バリデーションエラーの表示を実現しました。
specとはclojureでデータ構造をチェックするためのプログラムです。
phraseとは、specのチェックでエラーになったときのメッセージを設定できるプログラムです。

ちなみに、「specはプログラマ向けの仕組みであって、バリデーションエラーを出すために利用するのはふさわしくない」という意見もあるので、そう思う方はこちのページ(ユーザー入力のバリデーションにstructを利用する)で紹介されているstructを使うと良いかもしれません。

ともあれ、この記事ではspec + phraseでのバリデーション実装方法を共有します。

specは標準で入っていますが、phraseを利用するためには設定の追加が必要です。
project.cljのdependenciesにphraseを追加します。(参考コミット
~/gitprojects/duct/duct-crud-practice/project.clj
  :dependencies [..
                 [phrase "0.1-alpha2"]]


src/duct_crud_practice/spec/user.clj」にuserのバリデーションを記述しました。
(ns duct-crud-practice.spec.user
  (:require [clojure.spec.alpha :as s]
            [phrase.alpha :refer [phrase defphraser phrase-first]]))

(def email-regex #"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$")
(defn email-format? [email] (re-matches email-regex email))

(s/def ::email
  (s/and not-empty string? email-format?))

(defphraser not-empty
  {:via [::email]}
  [_ _]
  "Please input email.")

(defphraser string?
  {:via [::email]}
  [_ _]
  "Please input email as string.")

(defphraser email-format?
  {:via [::email]}
  [_ _ _]
  "Please intput as email format.")

(s/def ::name not-empty)

(defphraser not-empty
  {:via [::name]}
  [_ _]
  "Please input name.")

(s/def ::user (s/keys :req [::name ::email]))

(defn user->spec-user [user]
  {::name (:name user)
   ::email (:email user)})

(defn spec-user->user [spec-user]
  {:name (::name spec-user)
   :email (::email spec-user)})

(defn valid? [user]
  (s/valid? ::user (user->spec-user user)))

(defn error-message [user]
  (phrase-first {} ::user (user->spec-user user)))

(defn error-messages [user]
  (->>
   (for [problem (some->> (user->spec-user user)
                          (s/explain-data ::user)
                          (::s/problems))]
     (let [message (phrase {} problem)
           path (first (:path problem))]
       [path message]))
   (into {})
   spec-user->user))

データが正しいか判別する[「valid?」と、エラーメッセージをマップで返す「error-message」をhandlerで利用しています。

handler

handlerとは、どのように処理を行うか記述する部分です。
ちょっと長いですが、コードを貼り付けて、軽く解説します。
~/gitprojects/duct/duct-crud-practice/src/duct_crud_practice/handler/example.clj
(ns duct-crud-practice.handler.example
  (:use [hiccup.core])
  (:require [ataraxy.core :as ataraxy]
            [ataraxy.response :as response]
            [clojure.string :as str]
            [duct-crud-practice.boundary.users :as db.users]
            [duct-crud-practice.spec.user :as s.user]
            [integrant.core :as ig]))

(defmethod ig/init-key :duct-crud-practice.handler/example [_ options]
  (fn [{[_] :ataraxy/result}]
    [::response/ok (html [:span "This is an example handler"])]))

(defn error-message-line [error]
  [:div {:style "background: #fcc; margin-bottom: 5px;"} error])

(defn user-form [action method user & {:keys [error-messages]}]
  [:form {:action action :method "post"}
   [:input {:name "_method" :value method :type "hidden"}]
   [:div
    [:label {:for "name"} "name"]
    [:input {:name "name" :value (:name user)}]
    (if-let [error (:name error-messages)]
      (error-message-line error))]
   [:div
    [:label {:for "email"} "email"]
    [:input {:name "email" :value (:email user)}]
    (if-let [error (:email error-messages)]
      (error-message-line error))]
   [:button {:type "submit"} "Submit"]])

(defn show-users-view [users]
  (html [:div
         [:div "Users"]
         [:a {:href "/users/new"} "Add user"]
         [:table
          [:thead
           [:tr
            [:th "id"]
            [:th "name"]]]
          [:tbody
           (for [user users]
             [:tr
              [:td [:a {:href (str "/users/" (:id user))} (:id user)]]
              [:td (:name user)]])]]]))

(defn show-user-view [user]
  (html [:div "User"
         (pr-str user)
         [:div
          [:a {:href (str "/users/" (:id user) "/edit")} "edit"]
          [:form {:action (str "/users/" (:id user) "/delete") :method "post"}
           [:button {:type "submit"} "delete"]]]]))

(defn new-user-view [user error-messages]
  (html [:div "New User"
         (user-form "/users/" "post" user
                    :error-messages error-messages)]))

(defn edit-user-view [user-id user error-messages]
  (html [:div "Edit User"
         (user-form (str "/users/" user-id "/update") "put" user
                    :error-messages error-messages)]))

(defmethod ig/init-key :duct-crud-practice.handler/user-new [_ options]
  (fn [{[_] :ataraxy/result}]
    [::response/ok (new-user-view nil nil)]))

(defmethod ig/init-key :duct-crud-practice.handler/user-index [_ {:keys [db]}]
  (fn [{[_] :ataraxy/result}]
    (let [users (db.users/get-users db)]
      [::response/ok (show-users-view users)])))

(defmethod ig/init-key :duct-crud-practice.handler/user-create [_ {:keys [db]}]
  (fn [{[_ params] :ataraxy/result}]
    (if (s.user/valid? params)
      (let [user (first (db.users/create-user db params))]
        [::response/found (str "/users/" (:id user))])
      [::response/ok (new-user-view params (s.user/error-messages params))])))

(defmethod ig/init-key :duct-crud-practice.handler/user-show [_ {:keys [db]}]
  (fn [{[_ id] :ataraxy/result}]
    (let [user (first (db.users/get-user db id))]
      [::response/ok (show-user-view user)])))

(defmethod ig/init-key :duct-crud-practice.handler/user-edit [_ {:keys [db]}]
  (fn [{[_ id] :ataraxy/result}]
    (let [user (first (db.users/get-user db id))]
      [::response/ok (edit-user-view id user nil)])))

(defmethod ig/init-key :duct-crud-practice.handler/user-update [_ {:keys [db]}]
  (fn [{[_ id params] :ataraxy/result}]
    (if (s.user/valid? params)
      (let [user (first (db.users/update-user db id params))]
        [::response/found (str "/users/" (:id user))])
      [::response/ok (edit-user-view id params (s.user/error-messages params))])))

(defmethod ig/init-key :duct-crud-practice.handler/user-destroy [_ {:keys [db]}]
  (fn [{[_ id] :ataraxy/result}]
    (let [user (first (db.users/delete-user db id))]
      [::response/found "/users/"])))

前半はhiccup(ductの作者が作ったhtml作成プログラム)を利用してviewを記述しています。
(-viewという関数がそれです。)
後半は値に応じてviewを出したり、リダイレクトするハンドラを定義しています。
(「defmethod ig/init-key」で始まる記述がそれです。)

routing

どのurlにリクエストがあったら、どのhandlerを利用するかを設定します。
~/gitprojects/duct/duct-crud-practice/resources/duct_crud_practice/config.edn
 :duct.module/ataraxy
 {[:get "/example"] [:example]
  ["/users/"] {[:get ""] [:user-index]
               [:get "new"] [:user-new]
               [:post {body :params}] [:user-create body]
               [:get id] [:user-show id]
               [:get id "/edit"] [:user-edit id]
               [:post id "/update" {body :params}] [:user-update id body]
               [:post id "/delete"] [:user-destroy id]}}

 :duct-crud-practice.handler/example
 {:db #ig/ref :duct.database/sql}

 :duct-crud-practice.handler/user-index
 {:db #ig/ref :duct.database/sql}

 :duct-crud-practice.handler/user-new
 {:db #ig/ref :duct.database/sql}

 :duct-crud-practice.handler/user-create
 {:db #ig/ref :duct.database/sql}

 :duct-crud-practice.handler/user-show
 {:db #ig/ref :duct.database/sql}

 :duct-crud-practice.handler/user-edit
 {:db #ig/ref :duct.database/sql}

 :duct-crud-practice.handler/user-update
 {:db #ig/ref :duct.database/sql}

 :duct-crud-practice.handler/user-destroy
 {:db #ig/ref :duct.database/sql}

動作確認

exampleページを見たときと同様に、ductアプリを起動します。
cd ~/gitprojects/duct-crud-practice
lein repl
(dev)
(go)

アプリを起動済みの場合は、下記のコマンドでプログラムを読み込めます。
(reset)

replを使わずに起動したい場合は。「dev/resources/dev.edn」に記述したDBのurlを環境変数として渡しながらlein runを実行すると、アプリを動かせます。
DATABASE_URL="jdbc:postgresql://localhost/duct_crud_practice?user=clojure_user&password=secret" lein run

http://localhost:3000/users/ にアクセスすると、userのcrud操作ができます。
(注意すべきなのは、パスが「/users/」であることです。url最後の「/」が無い「/users」では、ページが表示されません。)


まとめ

ductでcrudアプリを実装できました。

良かったこと
  • 気になっていたductを使って、シンプルなwebアプリを作れた
  • specとphraseを組み合わせて、バリデーションを実現できた
  • 今まで自分からは使ったことがなかった、PostgreSQLの初期設定方法が分かった

気になること
  • specのpathの扱いがよく分かってないので、複雑なモデルだとバリデーションが動かなくなりそう
  • このままだとsql injectionされる
    (userのnameに「_', '_'); DELETE FROM users WHERE id IS NOT NULL; INSERT INTO users (name, email) VALUES ('badman」とか入力されると、userテーブルを消されます)

気になることはありますが、自分が想定していたように動くアプリができて良かったです。
何かの参考になれば嬉しいです。

参考

今回の記事の試行錯誤に使ったプロジェクトです。
asukiaaa/duct-crud-practice

全体像の参考にしました。
duct + integramt: Advancing Duct
duct + compojure-api: Building services with Duct and compojure-api

PostgreSQL設定の参考にしました
How To Install and Use PostgreSQL on Ubuntu 14.04
How To Use Roles and Manage Grant Permissions in PostgreSQL on a VPS
How to configure postgresql for the first time?
Building a Database-Backed Clojure Web Application

ataraxyでのリダイレクト設定の参考にしました
ataraxy/src/ataraxy/response.clj
ataraxy/test/ataraxy/response_test.clj

変更履歴

2017.12.20
twitterを通して指摘を頂きまして、確認できた内容をそれぞれ記事に反映しました。
アドバイスありがとうございます。

0 件のコメント :