ClojureScript & Golangでチャットアプリを作ってみた

ClojureScript & Golangでチャットアプリを作ってみた

こんにちは、Pairsの開発をしているインターン生の竹内です!

まだ入社して一ヶ月とペーペーな僕ですが、今まで2年ほどClojureを使ったWebアプリケーションの開発をしていたり、今はGo言語を書くことが多いと言うこともあって、最近はGo言語とClojureが気になって朝も起きられない毎日です!

そんな訳で、Go言語については大先輩の方々が素晴らしい記事を書いてくださっているので、今回は初めてClojureScriptに触れる方向けにClojureScriptを使ってSPA(Single Page Application)を開発する方法について書こうと思います:)

ClojureScriptとは?

ClojureScriptとは、JVM上で動作する言語であるClojureをJavaScriptに翻訳するコンパイラの事を指します。

つまり”ClojureScriptとは?”と言うのは殆ど”Clojureとは?”と同じ意味なので、少しClojureについて説明します。

Clojure

Clojureは、JVM仮想マシン上で動作するLISP系の言語です。

並行処理に強く、Immutable(不変)なデータ構造を持つことや、コードを対話的に実行してテスト出来るREPLによる開発が可能な言語である事が知られています。

また、2013にはGo言語よりGoroutineやChannelを持ち込んだライブラリのcore.asyncが公開され、大きな反響を呼びました。

…などなど、特徴を挙げていくとキリが無いのですが、まずは難しい説明は抜きにしてアプリケーションを作る準備を始めましょう!

ClojureScriptの前準備

Clojure/ClojureScriptを使った開発では、Leiningenと呼ばれるビルドツールを使います。

LeiningenはClojure/ClojureScriptのコンパイルは勿論、開発プロジェクトから外部ライブラリまで包括して管理してくれるClojureの開発に欠かせないツールなので、まずはこちらをインストールしましょう!

mkdir $HOME/bin && cd $HOME/bin
# Windowsの場合はwget https://raw.githubusercontent.com/technomancy/leiningen/stable/bin/lein.bat
wget https://raw.githubusercontent.com/technomancy/leiningen/stable/bin/lein
chmod +x lein
export PATH=$PATH:$HOME/bin
echo 'export PATH=$PATH:$HOME/bin' >> $HOME/.zshrc # bashを使用している場合は $HOME/.bashrc
※今回は例として$HOME/binディレクトリに配置していますが、好みに合わせて変更してください。
準備が出来たら、Leiningenが導入できているか確認する意味で、さっそくleinコマンドを叩いてみましょう。
Leiningen本体がダウンロードされ、Leiningenのヘルプが表示されれば導入完了です!続いて、早速初めてのプロジェクトを作ってみましょう:)
mkdir $HOME/my-clojure-projects && cd $HOME/my-clojure-projects
lein new figwheel my-first-project -- --om
Leiningenでプロジェクトの雛形を作るにはlein newコマンドを用いますが、
lein new <Leiningenプラグイン名> <プロジェクト名> <lein newに対するオプション> <プラグインに対するオプション>
としてLeiningenのプラグインを指定する事で、Leiningenがオンラインレポジトリから自動でプラグインを参照し、プラグインに登録されているテンプレートを使ってプロジェクトの雛形を作る事が出来ます。
今回はfigwheelと呼ばれるプラグインを使用する事でClojureScriptに必要な準備を全て自動で行ったのですが、後々触るクライアントサイドルーティングの為に少しだけ設定を弄ります。
Clojure/ClojureScriptのプロジェクトの設定はproject.cljに集約されているので、こちらを開きましょう。
my-first-project
├── README.md
├── dev
│ └── user.clj
├── project.clj <-- ココ!
├── resources
│ └── public
│ ├── css
│ │ └── style.css
│ └── index.html
└── src
└── my_first_project
└── core.cljs
ファイルを開いたら、以下のように[secretary "1.2.3"]を追記します。
:dependencies [[org.clojure/clojure "1.8.0"]
[org.clojure/clojurescript "1.8.51"]
[org.clojure/core.async "0.2.374"
:exclusions [org.clojure/tools.reader]]
[secretary "1.2.3"] ;; Changed!
[sablono "0.3.6"]
[org.omcljs/om "0.9.0"]]
無事に設定も終わった所で、早速ClojureScriptに触れてみましょう!
Hello, ClojureScript!
まずはlein figwheelを実行し、http://localhost:3449を開いてみてください。
この画像のように"Hello world!"の字が表示されていたら、ClojureScriptのコードは正常にコンパイルされています。
この調子で、実際にソースコードに手を入れてみましょう!
ClojureScriptのソースコードはsrcフォルダの中に配置されているので、まずはsrc/my_first_project/core.cljsを開いてみてください。
my-first-project
├── README.md
├── dev
│ └── user.clj
├── project.clj
├── resources
│ └── public
│ ├── css
│ │ └── style.css
│ └── index.html
└── src
└── my_first_project
└── core.cljs <-- ココ!
core.cljsをお気に入りのエディタ(もしくはIDE)で開けば、以下のようなコードとなっている筈です。
(ns my-first-project.core
(:require [om.core :as om :include-macros true]
[om.dom :as dom :include-macros true]))
(enable-console-print!)
(println "This text is printed from src/my-first-project/core.cljs. Go ahead and edit it and see reloading in action.")
;; define your app data so that it doesn't get over-written on reload
(defonce app-state (atom {:text "Hello world!"}))
(om/root
(fn [data owner]
(reify om/IRender
(render [_]
(dom/h1 nil (:text data)))))
app-state
{:target (. js/document (getElementById "app"))})
(defn on-js-reload []
;; optionally touch your app-state to force rerendering depending on
;; your application
;; (swap! app-state update-in [:__figwheel_counter] inc)
)
まずはブラウザに表示されていた"Hello, world!"を好きなものに変更しましょう。
変更すべき箇所を探してみると、恐らく以下のコードが見つかるかと思います。
(defonce app-state (atom {:text "Hello world!"}))
ここで、一度lein figwheelコマンドを実行したターミナルを見てみましょう。
lein figwheelコマンドがコードの入力を受け付けているのが確認出来ると思います。
lein figwheelコマンドは、先ほど登場した"figwheel"と呼ばれるプラグインを走らせていて、このプラグインがREPL(対話型実行環境)を提供しつつ、ソースコードの変更も検知して実行してくれています。
どちらを使ってもコードを実行する事は出来ますが、一旦ここではREPLの方を使って変更してみましょう。
"Hello world!"を変更するには、以下のコードをREPLに入力し、実行(Enterキーを入力)します。
(in-ns 'my-first-project.core) ;; 名前空間をmy-first-project.coreへ移動
(swap! app-state assoc :text "Hello Clojure!") ;; atomが保持している状態を変更
コードの実行完了と共にブラウザの表示が変化していれば、変更完了です!
※補足: ソースコードを変更した場合のコンパイル結果はブラウザ側にしか出ないので注意してください。
長くなりましたが、いよいよSPAの開発に取り掛かりましょう:)
つくるもの
最近、Go言語界隈ではIrisと呼ばれるWeb Application Frameworkが盛り上げっているようなので、今回はこちらをバックエンドに使い、簡単なチャットアプリケーションを作りたいと思います。
しかし、一から作るとなると大変な上にClojureScriptから話が逸れてしまうので、バックエンドの部分はIrisのサンプルをそのまま使用して行く形とします。
バックエンド
以下のコマンドでバックエンドの準備をします。
go get github.com/kataras/iris
go get github.com/iris-contrib/examples
go run github.com/iris-contrib/examples/websocket_1/*.go
バックエンドの準備はこれだけです。楽勝ですね!????
次はClojureScriptに戻り、Viewの実装をしていきます。
View
ClojureScriptの持つ有名なライブラリの一つが、React.jsのClojureScript向けラッパーOmです。
気が付いた方もいるかも知れませんが、先ほどブラウザに"Hello world!"を表示する為に既にOmを使用しているので、もう一度手を入れてチャットアプリのViewを作りましょう!
まず、以下の部分のコードを見て下さい。
(om/root
(fn [data owner]
(reify om/IRender
(render [_]
(dom/h1 nil (:text data)))))
app-state
{:target (. js/document (getElementById "app"))})
今のコードのままではComponentを変更出来ない上に、どこまでがComponentのコードなのか分かりづらいので、Componentを切り出します。
;; Component
(defn home-view [data owner]
(reify om/IRender
(render [_]
(dom/h1 nil (:text data)))))
(om/root 
chat-view
app-state
{:target (. js/document (getElementById "app"))})
続いて、チャット画面のComponentを追加します。
;; Appended Component
(defn chat-view [_ owner]
(reify
om/IInitState
(init-state [_]
{:messages []})
om/IRenderState
(render-state [_ state]
(dom/form nil
(dom/label #js{:for "message"}
"message: ")
(dom/input #js{:type "text" :id "message"})
(dom/br nil)
(dom/label #js{:for "send"}
"send message: ")
(dom/button #js{:id "send"}
"send")
(dom/ul nil
(map #(dom/li nil %) (:messages state)))))))
(defn home-view [data owner]
(reify om/IRender
(render [_]
(dom/h1 nil (:text data)))))
(om/root 
home-view
app-state
{:target (. js/document (getElementById "app"))})
ここで注目して欲しいのは、新たに追加されたchat-viewに含まれるom/IInitStateom/IRenderStateです。
これらはClojureのデータ構造の一つである"Protocol"と言うもので、以下のコードのようなメソッド名と引数のみを持った極めて他言語のInterfaceに近いデータ構造です。
(defprotocol IRobot
(walk [this]))
chat-viewはOmの持つProtocolからom/IInitStateom/IRenderStateを継承しており、Componentの中で状態を管理、即ちサーバーから受け取ったメッセージを保持する事が出来るようになっています。
最後に、ページに表示しているComponentをchat-viewへ変更してみましょう!
(om/root 
chat-view ;; <- Changed!!
app-state
{:target (. js/document (getElementById "app"))})
このように表示されればViewの完成です:)
続きまして、WebSocketを使ってサーバーとチャットメッセージを送受信するコードを実装して行きます。
サーバーとの連携
チャットメッセージの送受信を行うコードはcore.cljsではなく、ws.cljsに書いて行く事としましょう。
my-first-project
├── README.md
├── dev
│ └── user.clj
├── project.clj
├── resources
│ └── public
│ ├── css
│ │ └── style.css
│ └── index.html
└── src
└── my_first_project
├── core.cljs
└── ws.cljs <-- ココ!
ws.cljsを開いたら、WebSocket周りの処理を書いていきます。
(ns my-first-project.ws
(:require-macros [cljs.core.async.macros :refer [go][/go]])
(:require [cljs.core.async :refer [close! put! <! chan]]))
(def url "ws://localhost:8080/ws")
(def ws (js/WebSocket. url))
(defprotocol ILifeCycle
(start [this])
(stop [this]))
(defrecord WebSocket [receive]
ILifeCycle
;; WebSocketを起動
(start [this]
(let [receive (chan)]
;; メッセージ受信時の処理
(set! (. ws -onmessage) #(put! receive %))
;; 新しいClojureオブジェクトを返す
(assoc this :receive receive)))
;; WebSocketを終了
(stop [this]
(close! (:receive this))))
(defn open []
(-> (->WebSocket nil)
(start)))
(defn close 
[ws]
(stop ws))
(defn send 
[message]
(.send ws message))
名前空間の宣言部分は、他のライブラリから関数やマクロを持ち込むreferを使っている以外は殆どcore.cljsと同じなので良いとして、肝心のWebSocketの処理部分を見ていきましょう。
(defprotocol ILifeCycle
(start [this])
(stop [this]))
(defrecord WebSocket [receive]
ILifeCycle
;; WebSocketを起動
(start [this]
(let [receive (chan)]
;; メッセージ受信時の処理
(set! (. ws -onmessage) #(put! receive %))
;; 新しいClojureオブジェクトを返す
(assoc this :receive receive)))
;; WebSocketを終了
(stop [this]
(close! (:receive this))))
このコードでは、機能の起動と終了をstartstopで管理する事を示すILifeCycleProtocolと、ILifeCycleを実装するWebSocket-Recordを宣言しています。
また、Recordはクラスに似た性質を持ちますが、フィールドではなくイミュータブルなハッシュマップを持っていたり、継承が出来ない等の特徴を持つClojureのデータ構造で、クラスと同じようにコンストラクタを呼び出す事でRecordのインスタンスを得ることが出来ます。
今回のコードではopen関数を呼び出す事でWebSocket-Recordのインスタンスが作られ、後はsend関数とclose関数を扱うだけなので、一旦View側(core.cljs)へ戻って呼び出してみましょう!
まず、ws.cljsのコードを呼び出すために、(ns ...)ブロックを変更します。
(ns my-first-project.core
(:require-macros [cljs.core.async.macros :refer [go][/go]]) ;; Changed!
(:require [om.core :as om :include-macros true]
[om.dom :as dom :include-macros true]
[cljs.core.async :refer [close! put! <! chan]] ;; Changed!
[my-first-project.ws :as ws])) ;; Changed!
ws.cljsのコードを呼び出す準備が出来たら、ComponentからWebSocket-Recordを呼び出します。
(defn chat-view [_ owner] ;; Changed!
(reify
;; Changed!
om/IWillMount
(will-mount [_]
(go (loop []
;; サーバーからメッセージを受け取る
(let [e (<! (om/get-state owner [:websocket :receive]))
message (if e (. e -data))]
;; サーバーから受け取ったメッセージを追加
(om/update-state! owner :messages #(conj % message))
(when message
(recur))))))
;; Changed!
om/IWillUnmount
(will-unmount [_]
;; WebSocketの接続を終了させる
(ws/close (om/get-state owner :websocket)))
om/IInitState
(init-state [_]
{:messages []
:websocket (ws/open)})
om/IRenderState
(render-state [_ state]
(dom/div nil
(dom/label #js{:for "message"}
"message: ")
(dom/input #js{:type "text" :ref "message"})
(dom/br nil)
(dom/label #js{:for "send"}
"send message: ")
(dom/button #js{:id "send"
:onClick #(ws/send (. (om/get-node owner "message") -value))} ;; Changed!
"send")
(dom/ul nil
(map #(dom/li nil %) (:messages state)))))))
(defn home-view [data owner]
(reify om/IRender
(render [_]
(dom/h1 nil (:text data)))))
(om/root 
chat-view
;; WebSocketの接続を開始
app-state
{:target (. js/document (getElementById "app"))})
この変更では、WebSocket-Recordを管理するために、IWillMountIWillUnmountの2つを新たに実装しています。
IWillMountは、ComponentがHTMLのDOMとして作られたタイミングで呼び出されるwill-mountを持つので、このメソッドにおいてチャットメッセージを更新するGoroutineを走らせています。
一方のIWillUnmountは、ComponentがHTMLのDOMから削除されるタイミングに呼び出されるumount-unmountを持つので、こちらではWebSocketへの接続を終了する処理を追加しています。
これらによって、Component中でのws/sendやチャンネルによるメッセージの受信が可能になっております。
ここまで実装出来れば、チャット機能は完成です!
最後に、初めに作ったhome-viewと、チャット画面のchat-viewをルーティングによって切り替えられるようにして、更にSPAらしくしてみましょう!
クライアントサイドルーティング
Omの元となるReact.jsにはreact-routerがあるかと思いますが、Omにはルーティング機能が存在しないので、最初に追加したsecretaryを使用します。
なお、ルーティングは便宜上このままcore.cljsで行いますので、まずはcore.cljsにSecretaryをインポートしましょう。
(ns my-first-project.core
(:require-macros [cljs.core.async.macros :refer [go][/go]])
(:require [om.core :as om :include-macros true]
[om.dom :as dom :include-macros true]
[cljs.core.async :refer [close! put! <! chan]]
[secretary.core :as secretary :refer-macros [defroute]] ;; Changed!
[goog.events :as events] ;; Changed!
[goog.history.EventType :as EventType] ;; Changed!
[my-first-project.ws :as ws])
(:import goog.History)) ;; Changed!
準備ができた所で、Secretaryを使ってルーティングを定義していきます。
これからは描画するComponentをSecretaryが選ぶ事となるので、まずはComponentを描画している(om/root ...)節をコメントアウトしてからルーティングを定義します。
;; Comment out!!
;;(om/root
;; chat-view
;; app-state
;; {:target (. js/document (getElementById "app"))})
;; http://localhost:3449/以下のURLのprefixを指定
(secretary/set-config! :prefix "#") ;; Appended!
;; Appended!
(defroute chat-path "/chat" []
(om/root
chat-view
{}
{:target (. js/document (getElementById "app"))}))
;; Appended!
(defroute home-path "/" []
(om/root
home-view
app-state
{:target (. js/document (getElementById "app"))}))
;; URLの変更を検知し、ディスパッチを行う
(let [h (History.)]
(goog.events/listen h EventType/NAVIGATE #(secretary/dispatch! (.-token %)))
(doto h (.setEnabled true)))
このコードにより、"#/"をhome-viewへ、"#/chat"をchat-viewへルーティングしています。
せっかくルーティングを設定していても、指定のURLをいちいち手打ちで入力しなければならないのは面倒が過ぎるので、どちらのページにもアクセス出来るように少しViewを変更しましょう。
;; Appended!
(defn navigator [_ owner]
(reify
om/IRender
(render [_]
(dom/div nil
(dom/a #js{:href "#/"}
"Home")
(dom/a #js{:href "#/chat"}
"Chat")))))
(defn chat-view [_ owner]
(reify
om/IWillMount
(will-mount [_]
(go (loop []
;; サーバーからメッセージを受け取る
(let [e (<! (om/get-state owner [:websocket :receive]))
message (if e (. e -data))]
;; サーバーから受け取ったメッセージを追加
(om/update-state! owner :messages #(conj % message))
(println message)
(when message
(recur))))))
om/IWillUnmount
(will-unmount [_]
;; WebSocketの接続を終了させる
(ws/close (om/get-state owner :websocket)))
om/IInitState
(init-state [_]
{:messages []
:websocket (ws/open)})
om/IRenderState
(render-state [_ state]
(dom/div nil
(om/build navigator {}) ;; Changed!
(dom/label #js{:for "message"}
"message: ")
(dom/input #js{:type "text" :ref "message"})
(dom/br nil)
(dom/label #js{:for "send"}
"send message: ")
(dom/button #js{:id "send"
:onClick #(ws/send (. (om/get-node owner "message") -value))}
"send")
(dom/ul nil
(map #(dom/li nil %) (:messages state)))))))
(defn home-view [data owner]
(reify om/IRender
(render [_]
(dom/div nil
(om/build navigator {}) ;; Changed!
(dom/h1 nil (:text data))))))
このコードにより、home-viewchat-viewの上部に他ページへのリンクを追加し、1クリックで双方のページへ飛べるようになります。
ちなみに、home-viewchat-viewに追加した(build ...)節は、新しいnavigatorComponentをComponent内に組み込む為に使用しています。
以上のコードが動作すれば、チャットアプリケーションは完成です。ここまでの長丁場、お疲れ様でした!
総括
駆け足となりましたが、ClojureScriptでSPAを作り上げるまでの手順を順を追って解説致しました。
主にLisp特有の文法等の理由でなかなか採用されないClojure/ClojureScriptですが、figwheelとREPLを使った開発の快適さや、クライアントサイドでGoroutineを使用できる便利さ等を少しでも感じて頂けたのなら幸いです。
また、現在Omの後継であるom.nextのAlpha版が公開されておりますので、機会があればそちらについての記事も書きたいと思います!
それでは、良きClojureライフを!
※今回の手順を全て行ったコードをこちらに置きましたので、是非お役立てください!
参考文献
Credit
トップ画像を作成するにあたり、以下の画像を改変・使用しております。