Clojure Web 開發 -- Ring 使用指南

jiacai2050發表於2017-04-05

在 Clojure 眾多的 Web 框架中,Ring 以其簡單統一的 HTTP 抽象模型脫穎而出。Ring 充分體現了函數語言程式設計的思想——通過一系列函式的組合形成了一個易於理解、擴充套件的 HTTP 處理鏈。

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

Ring SPEC

Ring 規範裡面有如下5個核心概念:

  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 作者):

 +---------------+
 |  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。

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

  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 模式就比較合適了。例如可以進行一些邏輯判斷決定是否需要呼叫某函式:

(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 對請求的影響。

Middleware 這一模式在函數語言程式設計中非常常見,Clojure 生態裡面新的構建工具 boot-clj 裡面的 task 也是通過這種模式組合的。

$ 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 時一定要注意順序。一圖勝千言:

Clojure Web 開發 -- Ring 使用指南
middleware 應用順序圖

總結

在 Java EE 中,編寫 Web 專案通常是配置各種 XML 檔案,程式碼還沒開始寫就配置了一大堆jar包依賴,這些 jar 包很有可能會衝突,然後需要花大量時間處理這些依賴衝突,真心麻煩。

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

除了上面的介紹,Ring 生態裡面還有 lein-ring ,它可以在不重啟服務的情況下重新載入有修改的名稱空間(以及其影響的),開發從未如何順暢。

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

擴充套件閱讀

相關文章