Webサービスを作る例。
いろんな人が散々やっているところではあるが、とりあえず練習で作っておけばいざというときに役に立つという、素振り練習的な意味で。
全体はこちら: https://github.com/t2ru/webexample
サーバ側
使用ライブラリ
- Immutant (JBoss)
- Liberator
- Compojure
- org.clojure/
- data.json
- java.jdbc
設計ポリシ
- ImmutantでJBossのインフラをなるべく活用する。
- Webサーバ
- トランザクション制御
- DBアクセスはclojure.java.jdbcを直で使う。
- 後でチューニングしやすいように、SQLの抽象化は使わない。
- XAトランザクションで囲う。(ただし、XAの信じすぎ良くない。)
- サンプルのDBはH2(ファイルDB)を使う。商用でJBossのコンテナに載せるときに、datasourceに変えてコネクションプールを使えば良い。
- WebサービスはLiberatorを使ってREST準拠にする。
- URLパスでのルーティングにはCompojureを使う。
- Compojureのハンドラ内に全部書いてしまう。
- API内で使っているSQLを全部把握できるため、非効率なSQL発行は一目瞭然。
- 認可情報やSQLなどを全て一覧できるようにすることで、目視のセキュリティチェックをやりやすくなる。
- SQLインジェクションやXSSの攻撃をされない設計
コード例
src/webexample/core.clj
(defroutes task-service
;; コンテナ系リソース
(GET "/task" {:keys [db]}
(resource
:allowed-methods [:get]
:available-media-types ["application/json"]
:handle-ok (list-tasks db)))
(POST "/task" {:keys [db body]}
(let [data (try (as-json body :key-fn keyword :eof-error? false)
(catch Exception e nil))
new-id (or (:newid (first (next-task-id db))) 0)]
(resource
:allowed-methods [:post]
:available-media-types ["application/json"]
:malformed? (nil? (:title data))
:handle-malformed (pr-str data)
:post! (fn [_] (new-task! db new-id (:title data)))
:handle-created {:id new-id})))
;; エレメント系リソース
(GET "/task/:id" [id :as {:keys [db]}]
(resource
:allowed-methods [:get]
:available-media-types ["application/json"]
:handle-ok (fn [_] (first (get-task db)))))
(PUT "/task/:id" [id :as {:keys [db body]}]
(let [data (try (as-json body :key-fn keyword :eof-error? false)
(catch Exception e nil))]
(resource
:allowed-methods [:put]
:available-media-types ["application/json"]
:malformed? (nil? (:title data))
:put! (fn [_] (update-task! db (:title data) id))
:new? false)))
(DELETE "/task/:id" [id :as {:keys [db]}]
(resource
:allowed-methods [:delete]
:available-media-types ["application/json"]
:delete! (fn [_] (delete-task! db id)))))
SQLはyesqlを使って外出し。
resource/data/task.sql
-- name: create-task-table!
CREATE TABLE IF NOT EXISTS task (
id INTEGER PRIMARY KEY,
title VARCHAR(255))
-- name: list-tasks
SELECT id, title FROM task ORDER BY id DESC
-- name: next-task-id
SELECT MAX(id)+1 AS newid FROM task
-- name: new-task!
INSERT INTO task (id, title) VALUES (:id, :title)
-- name: get-task
SELECT id, title FROM task WHERE id = :id
-- name: update-task!
UPDATE task SET title = :title WHERE id = :id
-- name: delete-task!
DELETE FROM task WHERE id = :id
設計のこころ
重要なものを明示し、一覧できるようにすること。そうすることで、性能問題やセキュリティ問題の発生を防ぐ。テストやセキュリティチェックツールに頼らず1、作る時にバグを排除できるようにする。 (Correctness by Construction)
クライアント側
使用ライブラリ
- Reagent (react.js)
- cljs-ajax
設計ポリシ
- DOM生成部分とハンドラ部分に分けて整理する。
- react.js (ClojureからはReagent) を使えば、細かいハンドラ部分をかなり省略できる。
- Webデザイナとの分業を視野に入れ、JavaScriptのDOM生成で全部作ってしまうのは避ける。(サンプルではやってない)
- レンダリングをなるべく一度で済ませるようにして、性能劣化を防ぐ。
- Reagentになるべく任せて自分でやらないほうがいい気がする。
- JQuery UIみたいなUI要素ごとの部品化(これもやってない)
コード例
aは、アプリケーション状態を保存するatom。
サーバへのCRUDとブラウザ内部の状態更新を行う。
ハンドラ部(src-cljs/webexample/core.cljs)
(defn list-tasks [a]
(GET "task"
{:handler #(reset! a %)
:error-handler #(js/alert %)}))
(defn add-task [a]
(let [elem (dom/get-element "newtask")
title (.-value elem)
f (fn [v response] (cons {"id" (get response "id") "title" title} v))]
(when-not (= title "")
(set! (.-value elem) "")
(POST "task"
{:params {:title title}
:format :json
:handler #(swap! a f %)
:error-handler #(js/alert %)})))
false)
(defn update-task [a id]
(let [elem (dom/get-element "newtitle")
new-title (.-value elem)
f (fn [v] (map (fn [{xid "id" xtitle "title" :as t}]
(if (= id xid) {"id" id "title" new-title} t)) v))]
(when-not (= new-title "")
(PUT (str "task/" id)
{:params {:title new-title}
:format :json
:handler #(swap! a f)
:error-handler #(js/alert %)})))
false)
(defn delete-task [a id]
(let [f (fn [v] (filter #(not= id (get % "id")) v))]
(DELETE (str "task/" id)
{:format :json
:handler #(swap! a f)
:error-handler #(js/alert %)})))
DOM生成部(src-cljs/webexample/core.cljs)
(defn task-app []
(let [task-list (atom nil)
editing (atom nil)]
(list-tasks task-list)
(fn []
[:div
[:input {:type :submit :value "Sync"
:on-click (fn []
(reset! editing nil)
(list-tasks task-list))}]
[:form {:on-submit #(add-task task-list)}
[:input#newtask {:type :text}]
[:input {:type :submit}]]
(->> (for [task @task-list
:let [{id "id" title "title"} task]]
[:li {:on-click #(reset! editing id)}
id " "
(if (= id @editing)
[:input#newtitle
{:type :text :defaultValue title
:on-blur
(fn []
(reset! editing nil)
(update-task task-list id))}]
[:span title " "
[:span {:on-click #(delete-task task-list id)} "[x]"]])])
(cons :ul)
vec)])))
(defn main-app []
[:div
[:h1 "list tasks"]
[(task-app)]])
(defn ^:export run []
(reagent/render-component [main-app] (.-body js/document)))
まだやってないこと
- 認証
- 入力のバリデーション
- 一覧のページング
- 国際化
- 見た目のデザイン
- ネット接続が不安定な環境への対応
- core.asyncが使えるかもしれない。主にクライアント側で。
- etc...
気が向いたら更新するかも。
-
テストいらないということではなく、バグ発見をテストに頼るべきでないという意味。 ↩