Toyokumo Tech Blog

トヨクモ株式会社の開発本部のブログです。

ClojureのWeb開発でもっとも重要なRing Handlerについて理解する

こんにちは。開発本部の @makinoshi です。

トヨクモではメインの開発言語としてClojureを採用しています。 Clojureの文法は簡潔でデータ型も少なく、比較的覚えやすい言語だと思います。

一方でWeb開発においては、Symfony/LaravelRuby on Railsのような大きなフレームワークに乗るのではなく、 1つのことをうまくやるライブラリを組み合わせてアプリを作っていくという文化であるため、 それぞれを組み合わせていくための知識が必要になります。

その知識は一見すると膨大に思えるのですが、HTTPそのものの知識を除けば実は理解するのに必要な知識は少なく、RingのHandlerがどういうものなのか理解できれば、 ほぼ理解できたといって過言ではありません。

弊社では新卒で入社した方やアルバイトの学生さんなど、徐々にClojureを書く人が増えていることもあり、 この記事で基本的な部分を理解してもらえるように解説していこうと思います。

サンプルコードのリポジトリはこちらにあります。

Ringとは何か

By abstracting the details of HTTP into a simple, unified API, Ring allows web applications to be constructed of modular components that can be shared among a variety of applications, web servers, and web frameworks.

とあるように、HTTPのリクエスト・レスポンスを抽象化し、 仕様 を定めたものです。

抽象化層であるため、実際のサーバーの実装は他が担当します。 実装を提供するサーバーの例としては次のようなライブラリがあります。

これらのサーバーがRingが定めた仕様に沿ってHTTPリクエストを受け、HTTPレスポンスを返します。

弊社製品ではImmutantを使用しています。

Ring Handlerとは何か

Ring Handlerは単なる関数です。

ただし、Ringが定めた仕様に従ったリクエストマップを引数で受け取り、Ringが定めた仕様に従ったレスポンスマップを返す必要があります。 具体的には次のようなものです。

(defn handler [request]
  {:status 200
   :body "Hello world!"})

Handlerをどう使うか

Ring Handlerは決まった形のInput/Outputを提供する関数だということまでわかりました。

ではそれをどう使うかというと、上述したようにRingの仕様を実装しているサーバーの起動時に引数として渡すだけになります。 ring-jetty-adapter を使った例を示します。

(require 'ring.adapter.jetty)

(defn- handler [request]
  {:status 200
   :body "Hello world!"})

(ring.adapter.jetty/run-jetty handler {:host "localhost"
                                       :port 8080
                                       :join? false})

これをREPLで評価して、HTTPieでHTTPリクエストを投げてみます。

*この記事ではHTTPレスポンスを綺麗に表示させるためにHTTPieを使います。

$ http http://localhost:8080
HTTP/1.1 200 OK
Content-Length: 13
Date: Tue, 02 Jul 2019 08:50:31 GMT
Server: Jetty(9.4.12.v20180830)

Hello world!

きちんとリクエストを受け、レスポンスを返せていることが確認できました。

URLによって違った結果を返すにはどうすればいいのか

ここまでで好きな形のレスポンスを返すWebサーバーができていますが、当然ながらどのURLにリクエストしても同じ結果が返ってきます。

$ http http://localhost:8080/hoge
HTTP/1.1 200 OK
Content-Length: 13
Date: Tue, 02 Jul 2019 08:50:31 GMT
Server: Jetty(9.4.12.v20180830)

Hello world!

URLによって異なった結果を返せないと使い物にならないわけですが、そのためにはリクエストマップの中の:uriの値を使います。

(require 'ring.adapter.jetty)

(defn- handler [{:as req :keys [uri]}]
  (cond
    (= uri "/hello")
    {:status 200
     :body "Hello world!"}

    (= uri "/about")
    {:status 200
     :headers {"Content-Type" "text/plain; charset=utf-8"}
     :body "弊社についてご紹介します。"}

    :else
    {:status 404
     :headers {"Content-Type" "text/plain; charset=utf-8"}
     :body "お探しのページは見つかりませんでした。"}))

(ring.adapter.jetty/run-jetty handler {:host "localhost"
                                       :port 8080
                                       :join? false})
$ http http://localhost:8080/hello
HTTP/1.1 200 OK
Content-Length: 13
Date: Tue, 02 Jul 2019 09:05:05 GMT
Server: Jetty(9.4.12.v20180830)

Hello world!

$ http http://localhost:8080/about
HTTP/1.1 200 OK
Content-Length: 39
Content-Type: text/plain;charset=utf-8
Date: Tue, 02 Jul 2019 09:05:12 GMT
Server: Jetty(9.4.12.v20180830)

弊社についてご紹介します。

$ http http://localhost:8080/foo
HTTP/1.1 404 Not Found
Content-Length: 57
Content-Type: text/plain;charset=utf-8
Date: Tue, 02 Jul 2019 09:05:23 GMT
Server: Jetty(9.4.12.v20180830)

お探しのページは見つかりませんでした。

一応URLによって異なるレスポンスを返すことはできましたが、handlerにいちいち条件を足していくのはやってられないです。

この問題を解決するために、ルーティングライブラリを使います。 ルーティングライブラリには次のようなものがありますが、やっていることはどれも上記のようなことです。

ここでは弊社の製品でも使っているbidiを使った例を示します。

(require 'ring.adapter.jetty)
(require 'bidi.ring)

(defn- hello-handler [req]
  {:status 200
   :body "Hello world!"})

(defn- about-handler [req]
  {:status 200
   :headers {"Content-Type" "text/plain; charset=utf-8"}
   :body "弊社についてご紹介します。"})

(defn- not-found-handler [req]
  {:status 404
   :headers {"Content-Type" "text/plain; charset=utf-8"}
   :body "お探しのページは見つかりませんでした。"})

(def route
  ["/" {"hello" hello-handler
        "about" {:get about-handler}
        true not-found-handler}])

(def handler
  (bidi.ring/make-handler route))

(ring.adapter.jetty/run-jetty handler {:host "localhost"
                                       :port 8080
                                       :join? false})

これで条件分岐をルーティングライブラリにやってもらいつつ、コードを綺麗にすることができました。

*ここで bidi.ring/make-handler は関数を返す関数であることに注意してください。

Middlewareとは何か

ルーティングライブラリを使うことでhandler関数を分けることができましたが、今度はhandlerで共通な処理を含めたいときに困るようになりました。

Ringにはhandlerの処理の前後に任意の処理を実行する概念としてRing Middlewareがあります。

とは言え複雑なものではなく、引数でhandler(関数)を受け取り、新しいhandler(関数)を作って返すだけです。

(require 'ring.adapter.jetty)
(require 'bidi.ring)

(defn- hello-handler [req]
  {:status 200
   :body "Hello world!"})

(defn- about-handler [req]
  {:status 200
   :headers {"Content-Type" "text/plain; charset=utf-8"}
   :body "弊社についてご紹介します。"})

(defn- not-found-handler [req]
  {:status 404
   :headers {"Content-Type" "text/plain; charset=utf-8"}
   :body "お探しのページは見つかりませんでした。"})

(def route
  ["/" {"hello" hello-handler
        "about" {:get about-handler}
        true not-found-handler}])

(defn- hoge-middleware
  "/hoge以外へのリクエストかつレスポンスが404ならいつもhogeを返すようにする"
  [handler]
  ; 関数が戻り値
  (fn [{:as req :keys [uri]}]                        ; リクエストマップの値を使える
    (let [req (assoc-in req [:params :hoge] "HOGE!") ; リクエストマップの値を自由に書き換えられる
          {:as res :keys [status]} (handler req)     ; ここで引数のhandler(= 元々のhandler)を実行
          ]                                          ; handlerの結果(= レスポンスマップ)に手を加えられる
      (if (and (= status 404) (not= uri "/hoge"))
        {:status 200
         :body "HOGE!"}
        res))))

(def handler
  (-> (bidi.ring/make-handler route) ; handlerを作り、
      (hoge-middleware)              ; middlewareの引数として渡す
      ))

(ring.adapter.jetty/run-jetty handler {:host "localhost"
                                       :port 8080
                                       :join? false})
$ http http://localhost:8080/foo
HTTP/1.1 200 OK
Content-Length: 5
Date: Tue, 02 Jul 2019 09:57:05 GMT
Server: Jetty(9.4.12.v20180830)

HOGE!

APIでJSONを返すにはどうしたらいいのか

最後にRing Middlewareのライブラリを使ってJSONを返すAPIを作ってみます。

そのためにMuuntajaを使います。 MuuntajaはJSONだけでなく様々なデータ形式に対応しており、リクエストされたContent-Typeに対応したレスポンスにしてくれるというとても便利なライブラリです。

(require 'ring.adapter.jetty)
(require 'bidi.ring)
(require 'muuntaja.middleware)

(defn- hello-handler [{:as req :keys [body-params]}]
  {:status 200
   :body body-params}) ; リクエストされたbodyの値をそのまま返すだけ

(defn- about-handler [req]
  {:status 200
   :headers {"Content-Type" "text/plain; charset=utf-8"}
   :body "弊社についてご紹介します。"})

(defn- not-found-handler [req]
  {:status 404
   :headers {"Content-Type" "text/plain; charset=utf-8"}
   :body "お探しのページは見つかりませんでした。"})

(def route
  ["/" {"hello" hello-handler
        "about" {:get about-handler}
        true not-found-handler}])

(defn- hoge-middleware
  "/hoge以外へのリクエストかつレスポンスが404ならいつもhogeを返すようにする"
  [handler]
  ; 関数が戻り値
  (fn [{:as req :keys [uri]}]                        ; リクエストマップの値を使える
    (let [req (assoc-in req [:params :hoge] "HOGE!") ; リクエストマップの値を自由に書き換えられる
          {:as res :keys [status]} (handler req)     ; ここで引数のhandler(= 元々のhandler)を実行
          ]                                          ; handlerの結果(= レスポンスマップ)に手を加えられる
      (if (and (= status 404) (not= uri "/hoge"))
        {:status 200
         :body "HOGE!"}
        res))))

(def handler
  (-> (bidi.ring/make-handler route) ; handlerを作り、
      (hoge-middleware)              ; middlewareの引数として渡す
      (muuntaja.middleware/wrap-format) ; 追加
      ))

(ring.adapter.jetty/run-jetty handler {:host "localhost"
                                       :port 8080
                                       :join? false})
# リクエストも表示させてみます
$ http -v POST http://localhost:8080/hello foo=bar
POST /hello HTTP/1.1
Accept: application/json, */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 14
Content-Type: application/json
Host: localhost:8080
User-Agent: HTTPie/1.0.2

{
    "foo": "bar"
}

HTTP/1.1 200 OK
Content-Length: 13
Content-Type: application/json;charset=utf-8
Date: Tue, 02 Jul 2019 10:11:05 GMT
Server: Jetty(9.4.12.v20180830)

{
    "foo": "bar"
}

おわりに

ここまでで、RingのHandlerとMiddlewareについてご理解いただけましたでしょうか。 これらが理解できれば、あとはWebアプリケーションに共通して必要なMiddlewareを足していけば、すぐに通常のWebアプリケーションに必要な機能が揃います。

次回以降、定番のMiddlewareについて解説する記事も書いていこうと思います。


追記

続きはこちらです。

tech.toyokumo.co.jp


トヨクモ株式会社ではClojureを書きたいエンジニア、PHP/Vue.jsを書きたいエンジニア、技術が好きなエンジニアを募集しております。

よろしければ採用ページをご覧ください。