Clojure Web 開發 -- Ring 使用指南

在 Clojure 衆多的 Web 框架中,Ring 以其簡單統一的 HTTP 抽象模型脫穎而出。Ring 充分體現了函數式編程的思想——經過一系列函數的組合造成了一個易於理解、擴展的 HTTP 處理鏈。javascript

本篇文章首先介紹 Ring 核心概念及其實現原理,而後介紹如何基於 Ring + Compojure 實現一 RESTful 服務。html

Ring SPEC

Ring 規範裏面有以下5個核心概念:java

  1. handlers,應用邏輯處理的主要單元,由一個普通的 Clojure 函數實現
  2. middleware,爲 handler 增長額外功能
  3. adapter,將 HTTP 請求轉爲 Clojure 裏的 map,將 Clojure 裏的 map 轉爲 HTTP 相應
  4. request map,HTTP 請求的 map 表示
  5. response map,HTTP 相應的 map 表示

這5個組件的關係可用下圖表示(By Ring 做者):git

+---------------+
 |  Middleware   |
 |  +---------+  |             +---------+      +--------+
 |  |         |<-- request ----| | | | | | Handler | | | Adapter |<---->| Client | | | |--- response -->| | | | | +---------+ | +---------+ +--------+ +---------------+複製代碼

Hello World

(ns learn-ring.core
  (:require [ring.adapter.jetty :refer [run-jetty]]))

(defn handler [req]
  {:headers {}
   :status 200
   :body "Hello World"})

(defn middleware [handler]
  "Audit a log per request"
  (fn [req]
    (println (:uri req))
    (handler req)))

(def app
  (-> handler
      middleware))

(defn -main [& _]
  (run-jetty app {:port 3000}))複製代碼

運行上面的程序,就能夠啓動一 Web 應用,而後在瀏覽器訪問就能夠返回Hello World,同時在控制檯裏面會打印出請求的 uri。github

run-jetty 是 Ring 提供的基於 jetty 的 adapter,方便開發測試。其主要功能是兩個轉換:web

  1. HttpServletRequest ---> request map
  2. response map ---> HttpServletResponse
;; ring.adapter.jetty
(defn- ^AbstractHandler proxy-handler [handler]
  (proxy [AbstractHandler] []
    (handle [_ ^Request base-request request response]
      (let [request-map  (servlet/build-request-map request)
            response-map (handler request-map)]
        (servlet/update-servlet-response response response-map)
        (.setHandled base-request true)))))

;; ring.util.servlet

;; HttpServletRequest --> request map

(defn build-request-map
  "Create the request map from the HttpServletRequest object."
  [^HttpServletRequest request]
  {:server-port        (.getServerPort request)
   :server-name        (.getServerName request)
   :remote-addr        (.getRemoteAddr request)
   :uri                (.getRequestURI request)
   :query-string       (.getQueryString request)
   :scheme             (keyword (.getScheme request))
   :request-method     (keyword (.toLowerCase (.getMethod request) Locale/ENGLISH))
   :protocol           (.getProtocol request)
   :headers            (get-headers request)
   :content-type       (.getContentType request)
   :content-length     (get-content-length request)
   :character-encoding (.getCharacterEncoding request)
   :ssl-client-cert    (get-client-cert request)
   :body               (.getInputStream request)})

;; response map --> HttpServletResponse

(defn update-servlet-response
  "Update the HttpServletResponse using a response map. Takes an optional
  AsyncContext."
  ([response response-map]
   (update-servlet-response response nil response-map))
  ([^HttpServletResponse response context response-map]
   (let [{:keys [status headers body]} response-map]
     (when (nil? response)
       (throw (NullPointerException. "HttpServletResponse is nil")))
     (when (nil? response-map)
       (throw (NullPointerException. "Response map is nil")))
     (when status
       (.setStatus response status))
     (set-headers response headers)
     (let [output-stream (make-output-stream response context)]
       (protocols/write-body-to-stream body response-map output-stream)))))複製代碼

Middleware

Ring 裏面採用 Middleware 模式去擴展 handler 的功能,這實際上是函數式編程中經常使用的技巧,用高階函數去組合函數,實現更復雜的功能。在 Clojure 裏面,函數組合更常見的是用 comp,好比編程

((comp #(* % 2) inc) 1)
;; 4複製代碼

這對一些簡單的函數很是合適,可是若是邏輯比較複雜,Middleware 模式就比較合適了。例如能夠進行一些邏輯判斷決定是否須要調用某函數:json

(defn middleware-comp [handler]
  (fn [x]
    (if (zero? 0)
      (handler (inc x))
      (handler x))))

((-> #(* 2 %)
      middleware-comp) 1)
;; 4
((-> #(* 2 %)
      middleware-comp) 0)
;; 2複製代碼

雖然 Middleware 使用很是方便,可是有一點須要注意:多個 middleware 組合的順序。後面在講解 RESTful 示例時會演示不一樣順序的 middleware 對請求的影響。flask

Middleware 這一模式在函數式編程中很是常見,Clojure 生態裏面新的構建工具 boot-clj 裏面的 task 也是經過這種模式組合的。api

$ cat build.boot
(deftask inc-if-zero-else-dec
  [n number NUM int "number to test"]
  (fn [handler]
    (fn [fileset]
      (if (zero? number)
        (handler (merge fileset {:number (inc number)}))
        (handler (merge fileset {:number (dec number)}))))))

(deftask printer
  []
  (fn [handler]
    (fn [fileset]
      (println (str "number is " (:number fileset)))
      fileset)))

$ boot inc-if-zero-else-dec -n 0    printer
number is 1
$ boot inc-if-zero-else-dec -n 1    printer
number is 0複製代碼

RESTful 實戰

因爲 Ring 只是提供了一個 Web 服務最基本的抽象功能,不少其餘功能,像 url 路由規則,參數解析等均需經過其餘模塊實現。Compojure 是 Ring 生態裏面默認的路由器,一樣短小精悍,功能強大。基本用法以下:

(def handlers
  (routes
   (GET "/" [] "Hello World")
   (GET "/about" [] "about page")
   (route/not-found "Page not found!")))複製代碼

使用這裏的 handlers 代替上面 Hello World 的示例中的 handler 便可獲得一個具備2條路由規則的 Web 應用,同時針對其餘路由返回 Page not found!

Compojure 裏面使用了大量宏來簡化路由的定義,像上面例子中的GETnot-found等。Compojure 底層使用 clout 這個庫實現,而 clout 自己是基於一個 parser generator(instaparse) 定義的「路由」領域特定語言。核心規則以下:

(def ^:private route-parser
  (insta/parser
   "route    = (scheme / part) part*
    scheme   = #'(https?:)?//'
    <part>   = literal | escaped | wildcard | param
    literal  = #'(:[^\\p{L}_*{}\\\\]|[^:*{}\\\\])+'
    escaped  = #'\\\\.'
    wildcard = '*'
    param    = key pattern?
    key      = <':'> #'([\\p{L}_][\\p{L}_0-9-]*)'
    pattern  = '{' (#'(?:[^{}\\\\]|\\\\.)+' | pattern)* '}'"
   :no-slurp true))複製代碼

Compojure 中路由匹配的方式也很是巧妙,這裏詳細介紹一下。

Compojure 路由分發

Compojure 經過 routes 把一系列 handler 封裝起來,其內部調用 routing 方法找到正確的 handler。這兩個方法代碼很是簡潔:

(defn routing
  "Apply a list of routes to a Ring request map."
  [request & handlers]
  (some #(% request) handlers))

(defn routes
  "Create a Ring handler by combining several handlers into one."
  [& handlers]
  #(apply routing % handlers))複製代碼

routing 裏面經過調用 some 函數返回第一個非 nil 調用,這樣就解決了路由匹配的問題。由這個例子能夠看出 Clojure 語言的表達力。

在使用 GET 等這類宏定義 handler 時,會調用wrap-route-matches 來包裝真正的處理邏輯,邏輯以下:

(defn- wrap-route-matches [handler method path]
  (fn [request]
     (if (method-matches? request method)
       (if-let [request (route-request request path)]
         (-> (handler request)
             (head-response request method))))))複製代碼

這裏看到只有在 url 與 http method 均匹配時,纔會去調用 handler 處理 http 請求,其餘狀況直接返回 nil,這與前面講的 some 聯合起來就造成了完整的路由功能。

因爲 routes 的返回值與 handler 同樣,是一個接受 request map 返回 response map 的函數,因此能夠像堆積木同樣進行任意組合,實現相似於 Flask 中 blueprints 的模塊化功能。例如:

;; cat student.clj
(ns demo.student
  (:require [compojure.core :refer [GET POST defroutes context]])

(defroutes handlers
  (context "/student" []
    (GET "/" [] "student index")))

;;cat demo.teacher
(ns demo.teacher
  (:require [compojure.core :refer [GET POST defroutes context]])

(defroutes handlers
  (context "/teacher" []
    (GET "/" [] "teacher index")))

;; cat demo.core.clj
(ns demo.core
  (:require [demo.student :as stu]
            [demo.teacher :as tea])


;; core 裏面進行 handler 的組合
(defroutes handlers
  (GET "/" [] "index")
  (stu/handlers)
  (tea/handlers))複製代碼

Middleware 功能擴展

參數解析

Compojure 解決了路由問題,參數獲取是經過定製不能的 middleware 實現的,compojure.handler 命名空間提供了經常使用的 middleware 的組合,針對 RESTful 可使用 api 這個組合函數,它會把 QueryString 中的參數解析到 request map 中的:query-params key 中,表單中的參數解析到 request map 中的 :form-params

(def app
  (-> handlers
      handler/api))複製代碼

JSON 序列化

因爲 RESTful 服務中,請求的數據與返回的數據一般都是 JSON 格式,因此須要增長兩個額外的功能來實現 JSON 的序列化。

;; 首先引用 ring.middleware.json

(def app
  (-> handlers
      wrap-json-response
      wrap-json-body
      handler/api))複製代碼

紀錄請求時間

一般,咱們須要紀錄每一個請求的處理時間,這很簡單,實現個 record-response-time 便可:

(defn record-response-time [handler]
  (fn [req]
    (let [start-date (System/currentTimeMillis)]
      (handler req)
      (let [res-time (- (System/currentTimeMillis) start-date)]
        (println (format  "%s took %d ms" (:uri req) res-time))))))

(def app
  (-> handlers
      wrap-json-response
      wrap-json-body
      handler/api
      record-response-time))複製代碼

須要注意的是 record-response-time 須要放在 middleware 最外層,這樣它才能紀錄一個請求通過全部 middleware + handler 處理的時間。

封裝異常

其次,另外一個很常見的需求就是封裝異常,當服務端出現錯誤時返回給客戶端友好的錯誤信息,而不是服務端的錯誤堆棧。

(defn wrap-exception
  [handler]
  (fn [request]
    (try
      (handler request)
      (catch Throwable e
        (response {:code 20001
                   :msg  "inner error})))))

(def app
  (-> handlers
      wrap-json-response
      wrap-json-body
      handler/api
      wrap-exception
      record-response-time))複製代碼

順序!順序!順序!

一個 App 中的 middleware 調用順序很是重要,由於不一樣的 middleware 之間 request map 與 response map 是相互依賴的,因此在定義 middleware 時必定要注意順序。一圖勝千言:

middleware 應用順序圖

總結

在 Java EE 中,編寫 Web 項目一般是配置各類 XML 文件,代碼還沒開始寫就配置了一大堆jar包依賴,這些 jar 包頗有可能會衝突,而後須要花大量時間處理這些依賴衝突,真心麻煩。

Ring 與其說是一個框架,不如說是由各個短小精悍的函數組成的 lib,充分展現了 Clojure 語言的威力,經過函數的組合定義出一套完整的 HTTP 抽象機制,經過宏來實現「路由」特定領域語言,極大簡化了路由的定義,方便了模塊的分解。

除了上面的介紹,Ring 生態裏面還有 lein-ring ,它能夠在不重啓服務的狀況下從新加載有修改的命名空間(以及其影響的),開發從未如何順暢。

Ring + Compojure + lein-ring 你值得擁有。

擴展閱讀

相關文章
相關標籤/搜索