在 Clojure 眾多的 Web 框架中,Ring 以其簡單統一的 HTTP 抽象模型脫穎而出。Ring 充分體現了函數語言程式設計的思想——通過一系列函式的組合形成了一個易於理解、擴充套件的 HTTP 處理鏈。
本篇文章首先介紹 Ring 核心概念及其實現原理,然後介紹如何基於 Ring + Compojure 實現一 RESTful 服務。
Ring SPEC
Ring 規範裡面有如下5個核心概念:
- handlers,應用邏輯處理的主要單元,由一個普通的 Clojure 函式實現
- middleware,為 handler 增加額外功能
- adapter,將 HTTP 請求轉為 Clojure 裡的 map,將 Clojure 裡的 map 轉為 HTTP 相應
- request map,HTTP 請求的 map 表示
- 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,方便開發測試。其主要功能是兩個轉換:
HttpServletRequest
--->request map
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 裡面使用了大量巨集來簡化路由的定義,像上面例子中的GET
、not-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 時一定要注意順序。一圖勝千言:
總結
在 Java EE 中,編寫 Web 專案通常是配置各種 XML 檔案,程式碼還沒開始寫就配置了一大堆jar包依賴,這些 jar 包很有可能會衝突,然後需要花大量時間處理這些依賴衝突,真心麻煩。
Ring 與其說是一個框架,不如說是由各個短小精悍的函式組成的 lib,充分展示了 Clojure 語言的威力,通過函式的組合定義出一套完整的 HTTP 抽象機制,通過巨集來實現“路由”特定領域語言,極大簡化了路由的定義,方便了模組的分解。
除了上面的介紹,Ring 生態裡面還有 lein-ring ,它可以在不重啟服務的情況下重新載入有修改的名稱空間(以及其影響的),開發從未如何順暢。
Ring + Compojure + lein-ring 你值得擁有。