clojure web(luminus)接口開發

項目框架

本項目使用luminus做模板,參考luminus-template,執行下面的命令init工程:

lein new luminus alk-wxapi +mysql +service

相關文檔

項目運行

在命令行工具中啟動用lein啟動一個repl,lein沒有安裝的需要自行百度。

?  ~ cd git/redcreation/alk-wxapi
?  alk-wxapi git:(master) lein repl
nREPL server started on port 50529 on host 127.0.0.1 - nrepl://127.0.0.1:50529
REPL-y 0.4.3, nREPL 0.6.0
Clojure 1.10.0
Java HotSpot(TM) 64-Bit Server VM 1.8.0_192-b12
    Docs: (doc function-name-here)
          (find-doc "part-of-name-here")
  Source: (source function-name-here)
 Javadoc: (javadoc java-object-or-class-here)
    Exit: Control+D or (exit) or (quit)
 Results: Stored in vars *1, *2, *3, an exception in *e

user=>

然后在Intellij Idea中遠程連接

ideaconfig

run這個配置,然后在下面的repl環境中執行(start)即啟動server。

常見問題及解決方案

1、處理request

實際項目開發中經常需要打印request內容,這部分在springMVC中一般用aop來解決。
clojure中沒有對象,更別提aop了,但是沒有框架的束縛,處理起request和response反而更加靈活,是用clojure的middleware
處理的,比如一個打印出入參的middleware如下:

(require '[clojure.tools.logging :as log])

(defn log-wrap [handler]
  (fn [request]
    (if-not (:dev env)
      (let [request-id (java.util.UUID/randomUUID)]
        (log/info (str "\n================================ REQUEST START ================================"
                       "\n request-id:" request-id
                       "\n request-uri: " (:uri request)
                       "\n request-method: " (:request-method request)
                       "\n request-query: " (:query (:parameters request))
                       "\n request-body: " (:body (:parameters request))))
        (let [res (handler request)]
          (log/info (str "response: " (:body res)
                         "\n request-id:" request-id))
          (log/info (str "\n================================ response END ================================"))
          res))
      (handler request))))

將此swap配置在全局路由中即可,一般是有個統一配置format的middleware的,放在一起即可。

2、在handler中使用request里自定義的對象

有了上面說的middleware能處理request,那么往request里放個對象,自然不在話下,比如講header里的token轉換成user對象置于request中,在后面handler中直接是用。

(defn token-wrap [handler]
  (fn [request]
    (let [token (get-in request [:headers "token"])
          user (-> token
                   str->jwt
                   :claims)]
      (log/info (str "解析后的user:" (-> token
                                      str->jwt
                                      :claims)))
      (log/info (str "******* the current user is " (:iss user)))
      (handler (assoc request :current-user (:iss user))))))

3、hendler獲取body,path,query的參數

在handle前后,可以用(keys request)查看request里自己傳入的參數,那么在handler里怎么獲取這些參數呢,在Luminus中定義了三種與springMVC類似的參數關鍵詞,對應關系如下:

mvc request luminus 含義
@RequestParam query-params parameters -> query query參數,URL后面問號的參數,或form的參數
@PathVariable path-params parameters -> path path參數,URL中/的參數
@RequestBody body-params parameters ->body post/put方法里的body參數

這三個keyword是ring自身的處理,是原始request里的參數,但是query-params參數被處理成map的key不是keywords,是普通的string,得用(query-params "id")這樣來取值。因此推薦如下示例使用:
推薦從request的parameters中獲取,關鍵字分別是query,path, body。
獲取的例子:

  ;;非推薦方式
  ;;api返回結果: {"data": "path params: {:id \"1\"}\n query params: {\"name\" \"2\"}\n body params: {:message \"22\"}"}
  ["/path/bad/:id"
   {:post {:summary    "路徑上傳參--不推薦此方法獲取--query參數key變成了str"
           :parameters {:path  {:id int?}
                        :query {:name string?}
                        :body  {:message string?}}
           :handler    (fn [{:keys [path-params query-params body-params]}]
                        {:status 200
                         :body   {:data (str "path params: " path-params
                                             "\n query params: " query-params
                                             "\n body params: " body-params)}})}}]

  ;;good handler api返回結果:
  ;{
  ;  "code": 1,
  ;  "message": "操作成功",
  ;  "data": "path params: {:id 1},  query params: {:name \"2\"},  body params: {:message \"22\"} "
  ;}
  ["/path/good/:id"
   {:post {:summary    "路徑上傳參--GOOD--獲取到3種map"
           :parameters {:path  {:id int?}
                        :query {:name string?}
                        :body  {:message string?}}
           :handler    (fn [{{:keys [body query path]} :parameters}]
                        (ok (format "path params: %s,  query params: %s,  body params: %s " path query body)))}}]


  ;;good handler, 接口里三種參數都有,并且想直接獲取map中key的vals
  ;; api返回結果:
  ;{
  ;"code": 1,
  ;"message": "操作成功",
  ;"data": "path params 'id': 1, query params 'name': 2 , body params: {:message \"22\"} "
  ;}
  ["/path/good-all-params/:id"
   {:post {:summary    "路徑上傳參--GOOD--直接得到key的val"
           :parameters {:path  {:id int?}
                        :query {:name string?}
                        :body  {:message string?}}
           :handler    (fn [{{:keys [body]}          :parameters
                             {{:keys [id]} :path}    :parameters
                             {{:keys [name]} :query} :parameters}]
                        (ok (format "path params 'id': %s, query params 'name': %s , body params: %s " id name body)))}}]

原因分析:我們在handler.clj的ring/router后面使用[reitit.ring.middleware.dev :as dev]{:reitit.middleware/transform dev/print-request-diffs}方法打印出中間件的處理邏輯,

handler

結果如下:

--- Middleware ---

  {:body #<io.undertow.io.UndertowInputStream@39931c66>,
   :character-encoding "ISO-8859-1",
   :content-length 21,
   :content-type "application/json",
   :context "",
   :cookies {"JSESSIONID" {:value "GpSiQENkmzM7qQwqWdYxjehYKvNKoEGMG6MIwqwU"},
             "_ga" {:value "GA1.1.521496834.1555489511"},
             "_gid" {:value "GA1.1.947080805.1561170619"}},
   :flash nil,
   :form-params {},
   :handler-type :undertow,
   :headers {"accept" "application/json",
             "accept-encoding" "gzip, deflate, br",
             "accept-language" "zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.7",
             "connection" "keep-alive",
             "content-length" "21",
             "content-type" "application/json",
             "cookie" "_ga=GA1.1.521496834.1555489511; JSESSIONID=GpSiQENkmzM7qQwqWdYxjehYKvNKoEGMG6MIwqwU; _gid=GA1.1.947080805.1561170619",
             "host" "localhost:3000",
             "origin" "http://localhost:3000",
             "referer" "http://localhost:3000/api/api-docs/index.html",
             "user-agent" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36"},
   :multipart-params {},
   :params {:name "2"},
   :path-info "/api/guestbooks/path/good-all-params/1",
   :path-params {:id "1"},
   :query-params {"name" "2"},
   :query-string "name=2",
   :remote-addr "0:0:0:0:0:0:0:1",
   :request-method :post,
   :scheme :http,
   :server-exchange #<io.undertow.server.HttpServerExchange@78f2e776 HttpServerExchange{ POST /api/guestbooks/path/good-all-params/1 request {Accept=[application/json], Accept-Language=[zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.7], Accept-Encoding=[gzip, deflate, br], Origin=[http://localhost:3000], User-Agent=[Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36], Connection=[keep-alive], Content-Length=[21], Content-Type=[application/json], Cookie=[_ga=GA1.1.521496834.1555489511; JSESSIONID=GpSiQENkmzM7qQwqWdYxjehYKvNKoEGMG6MIwqwU; _gid=GA1.1.947080805.1561170619], Referer=[http://localhost:3000/api/api-docs/index.html], Host=[localhost:3000]} response {Server=[undertow]}}>,
   :server-name "localhost",
   :server-port 3000,
   :session nil,
   :ssl-client-cert nil,
   :uri "/api/guestbooks/path/good-all-params/1"}

--- Middleware :reitit.ring.middleware.parameters/parameters ---

  {:body #<io.undertow.io.UndertowInputStream@39931c66>,
   :character-encoding "ISO-8859-1",
   :content-length 21,
   :content-type "application/json",
   :context "",
   :cookies {"JSESSIONID" {:value "GpSiQENkmzM7qQwqWdYxjehYKvNKoEGMG6MIwqwU"},
             "_ga" {:value "GA1.1.521496834.1555489511"},
             "_gid" {:value "GA1.1.947080805.1561170619"}},
   :flash nil,
   :form-params {},
   :handler-type :undertow,
   :headers {"accept" "application/json",
             "accept-encoding" "gzip, deflate, br",
             "accept-language" "zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.7",
             "connection" "keep-alive",
             "content-length" "21",
             "content-type" "application/json",
             "cookie" "_ga=GA1.1.521496834.1555489511; JSESSIONID=GpSiQENkmzM7qQwqWdYxjehYKvNKoEGMG6MIwqwU; _gid=GA1.1.947080805.1561170619",
             "host" "localhost:3000",
             "origin" "http://localhost:3000",
             "referer" "http://localhost:3000/api/api-docs/index.html",
             "user-agent" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36"},
   :multipart-params {},
   :params {:name "2"},
   :path-info "/api/guestbooks/path/good-all-params/1",
   :path-params {:id "1"},
   :query-params {"name" "2"},
   :query-string "name=2",
   :remote-addr "0:0:0:0:0:0:0:1",
   :request-method :post,
   :scheme :http,
   :server-exchange #<io.undertow.server.HttpServerExchange@78f2e776 HttpServerExchange{ POST /api/guestbooks/path/good-all-params/1 request {Accept=[application/json], Accept-Language=[zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.7], Accept-Encoding=[gzip, deflate, br], Origin=[http://localhost:3000], User-Agent=[Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36], Connection=[keep-alive], Content-Length=[21], Content-Type=[application/json], Cookie=[_ga=GA1.1.521496834.1555489511; JSESSIONID=GpSiQENkmzM7qQwqWdYxjehYKvNKoEGMG6MIwqwU; _gid=GA1.1.947080805.1561170619], Referer=[http://localhost:3000/api/api-docs/index.html], Host=[localhost:3000]} response {Server=[undertow]}}>,
   :server-name "localhost",
   :server-port 3000,
   :session nil,
   :ssl-client-cert nil,
   :uri "/api/guestbooks/path/good-all-params/1"}

--- Middleware :reitit.ring.middleware.muuntaja/format-negotiate ---

  {:body #<io.undertow.io.UndertowInputStream@39931c66>,
   :character-encoding "ISO-8859-1",
   :content-length 21,
   :content-type "application/json",
   :context "",
   :cookies {"JSESSIONID" {:value "GpSiQENkmzM7qQwqWdYxjehYKvNKoEGMG6MIwqwU"},
             "_ga" {:value "GA1.1.521496834.1555489511"},
             "_gid" {:value "GA1.1.947080805.1561170619"}},
   :flash nil,
   :form-params {},
   :handler-type :undertow,
   :headers {"accept" "application/json",
             "accept-encoding" "gzip, deflate, br",
             "accept-language" "zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.7",
             "connection" "keep-alive",
             "content-length" "21",
             "content-type" "application/json",
             "cookie" "_ga=GA1.1.521496834.1555489511; JSESSIONID=GpSiQENkmzM7qQwqWdYxjehYKvNKoEGMG6MIwqwU; _gid=GA1.1.947080805.1561170619",
             "host" "localhost:3000",
             "origin" "http://localhost:3000",
             "referer" "http://localhost:3000/api/api-docs/index.html",
             "user-agent" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36"},
   :multipart-params {},
   :params {:name "2"},
   :path-info "/api/guestbooks/path/good-all-params/1",
   :path-params {:id "1"},
   :query-params {"name" "2"},
   :query-string "name=2",
   :remote-addr "0:0:0:0:0:0:0:1",
   :request-method :post,
   :scheme :http,
   :server-exchange #<io.undertow.server.HttpServerExchange@78f2e776 HttpServerExchange{ POST /api/guestbooks/path/good-all-params/1 request {Accept=[application/json], Accept-Language=[zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.7], Accept-Encoding=[gzip, deflate, br], Origin=[http://localhost:3000], User-Agent=[Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36], Connection=[keep-alive], Content-Length=[21], Content-Type=[application/json], Cookie=[_ga=GA1.1.521496834.1555489511; JSESSIONID=GpSiQENkmzM7qQwqWdYxjehYKvNKoEGMG6MIwqwU; _gid=GA1.1.947080805.1561170619], Referer=[http://localhost:3000/api/api-docs/index.html], Host=[localhost:3000]} response {Server=[undertow]}}>,
   :server-name "localhost",
   :server-port 3000,
   :session nil,
   :ssl-client-cert nil,
   :uri "/api/guestbooks/path/good-all-params/1",
   +:muuntaja/request #muuntaja.core.FormatAndCharset
   {:charset "utf-8",
    :format "application/json",
    :raw-format "application/json"},
   +:muuntaja/response #muuntaja.core.FormatAndCharset
   {:charset "utf-8",
    :format "application/json",
    :raw-format "application/json"}}

--- Middleware :reitit.ring.middleware.muuntaja/format-response ---

  {:body #<io.undertow.io.UndertowInputStream@39931c66>,
   :character-encoding "ISO-8859-1",
   :content-length 21,
   :content-type "application/json",
   :context "",
   :cookies {"JSESSIONID" {:value "GpSiQENkmzM7qQwqWdYxjehYKvNKoEGMG6MIwqwU"},
             "_ga" {:value "GA1.1.521496834.1555489511"},
             "_gid" {:value "GA1.1.947080805.1561170619"}},
   :flash nil,
   :form-params {},
   :handler-type :undertow,
   :headers {"accept" "application/json",
             "accept-encoding" "gzip, deflate, br",
             "accept-language" "zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.7",
             "connection" "keep-alive",
             "content-length" "21",
             "content-type" "application/json",
             "cookie" "_ga=GA1.1.521496834.1555489511; JSESSIONID=GpSiQENkmzM7qQwqWdYxjehYKvNKoEGMG6MIwqwU; _gid=GA1.1.947080805.1561170619",
             "host" "localhost:3000",
             "origin" "http://localhost:3000",
             "referer" "http://localhost:3000/api/api-docs/index.html",
             "user-agent" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36"},
   :multipart-params {},
   :params {:name "2"},
   :path-info "/api/guestbooks/path/good-all-params/1",
   :path-params {:id "1"},
   :query-params {"name" "2"},
   :query-string "name=2",
   :remote-addr "0:0:0:0:0:0:0:1",
   :request-method :post,
   :scheme :http,
   :server-exchange #<io.undertow.server.HttpServerExchange@78f2e776 HttpServerExchange{ POST /api/guestbooks/path/good-all-params/1 request {Accept=[application/json], Accept-Language=[zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.7], Accept-Encoding=[gzip, deflate, br], Origin=[http://localhost:3000], User-Agent=[Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36], Connection=[keep-alive], Content-Length=[21], Content-Type=[application/json], Cookie=[_ga=GA1.1.521496834.1555489511; JSESSIONID=GpSiQENkmzM7qQwqWdYxjehYKvNKoEGMG6MIwqwU; _gid=GA1.1.947080805.1561170619], Referer=[http://localhost:3000/api/api-docs/index.html], Host=[localhost:3000]} response {Server=[undertow]}}>,
   :server-name "localhost",
   :server-port 3000,
   :session nil,
   :ssl-client-cert nil,
   :uri "/api/guestbooks/path/good-all-params/1",
   :muuntaja/request {:charset "utf-8",
                      :format "application/json",
                      :raw-format "application/json"},
   :muuntaja/response {:charset "utf-8",
                       :format "application/json",
                       :raw-format "application/json"}}

--- Middleware :reitit.ring.middleware.exception/exception ---

  {:body #<io.undertow.io.UndertowInputStream@39931c66>,
   :character-encoding "ISO-8859-1",
   :content-length 21,
   :content-type "application/json",
   :context "",
   :cookies {"JSESSIONID" {:value "GpSiQENkmzM7qQwqWdYxjehYKvNKoEGMG6MIwqwU"},
             "_ga" {:value "GA1.1.521496834.1555489511"},
             "_gid" {:value "GA1.1.947080805.1561170619"}},
   :flash nil,
   :form-params {},
   :handler-type :undertow,
   :headers {"accept" "application/json",
             "accept-encoding" "gzip, deflate, br",
             "accept-language" "zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.7",
             "connection" "keep-alive",
             "content-length" "21",
             "content-type" "application/json",
             "cookie" "_ga=GA1.1.521496834.1555489511; JSESSIONID=GpSiQENkmzM7qQwqWdYxjehYKvNKoEGMG6MIwqwU; _gid=GA1.1.947080805.1561170619",
             "host" "localhost:3000",
             "origin" "http://localhost:3000",
             "referer" "http://localhost:3000/api/api-docs/index.html",
             "user-agent" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36"},
   :multipart-params {},
   :params {:name "2"},
   :path-info "/api/guestbooks/path/good-all-params/1",
   :path-params {:id "1"},
   :query-params {"name" "2"},
   :query-string "name=2",
   :remote-addr "0:0:0:0:0:0:0:1",
   :request-method :post,
   :scheme :http,
   :server-exchange #<io.undertow.server.HttpServerExchange@78f2e776 HttpServerExchange{ POST /api/guestbooks/path/good-all-params/1 request {Accept=[application/json], Accept-Language=[zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.7], Accept-Encoding=[gzip, deflate, br], Origin=[http://localhost:3000], User-Agent=[Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36], Connection=[keep-alive], Content-Length=[21], Content-Type=[application/json], Cookie=[_ga=GA1.1.521496834.1555489511; JSESSIONID=GpSiQENkmzM7qQwqWdYxjehYKvNKoEGMG6MIwqwU; _gid=GA1.1.947080805.1561170619], Referer=[http://localhost:3000/api/api-docs/index.html], Host=[localhost:3000]} response {Server=[undertow]}}>,
   :server-name "localhost",
   :server-port 3000,
   :session nil,
   :ssl-client-cert nil,
   :uri "/api/guestbooks/path/good-all-params/1",
   :muuntaja/request {:charset "utf-8",
                      :format "application/json",
                      :raw-format "application/json"},
   :muuntaja/response {:charset "utf-8",
                       :format "application/json",
                       :raw-format "application/json"}}

--- Middleware :reitit.ring.middleware.muuntaja/format-request ---

  {:body #<io.undertow.io.UndertowInputStream@39931c66>,
   :character-encoding "ISO-8859-1",
   :content-length 21,
   :content-type "application/json",
   :context "",
   :cookies {"JSESSIONID" {:value "GpSiQENkmzM7qQwqWdYxjehYKvNKoEGMG6MIwqwU"},
             "_ga" {:value "GA1.1.521496834.1555489511"},
             "_gid" {:value "GA1.1.947080805.1561170619"}},
   :flash nil,
   :form-params {},
   :handler-type :undertow,
   :headers {"accept" "application/json",
             "accept-encoding" "gzip, deflate, br",
             "accept-language" "zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.7",
             "connection" "keep-alive",
             "content-length" "21",
             "content-type" "application/json",
             "cookie" "_ga=GA1.1.521496834.1555489511; JSESSIONID=GpSiQENkmzM7qQwqWdYxjehYKvNKoEGMG6MIwqwU; _gid=GA1.1.947080805.1561170619",
             "host" "localhost:3000",
             "origin" "http://localhost:3000",
             "referer" "http://localhost:3000/api/api-docs/index.html",
             "user-agent" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36"},
   :multipart-params {},
   :params {:name "2"},
   :path-info "/api/guestbooks/path/good-all-params/1",
   :path-params {:id "1"},
   :query-params {"name" "2"},
   :query-string "name=2",
   :remote-addr "0:0:0:0:0:0:0:1",
   :request-method :post,
   :scheme :http,
   :server-exchange #<io.undertow.server.HttpServerExchange@78f2e776 HttpServerExchange{ POST /api/guestbooks/path/good-all-params/1 request {Accept=[application/json], Accept-Language=[zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.7], Accept-Encoding=[gzip, deflate, br], Origin=[http://localhost:3000], User-Agent=[Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36], Connection=[keep-alive], Content-Length=[21], Content-Type=[application/json], Cookie=[_ga=GA1.1.521496834.1555489511; JSESSIONID=GpSiQENkmzM7qQwqWdYxjehYKvNKoEGMG6MIwqwU; _gid=GA1.1.947080805.1561170619], Referer=[http://localhost:3000/api/api-docs/index.html], Host=[localhost:3000]} response {Server=[undertow]}}>,
   :server-name "localhost",
   :server-port 3000,
   :session nil,
   :ssl-client-cert nil,
   :uri "/api/guestbooks/path/good-all-params/1",
   :muuntaja/request {:charset "utf-8",
                      :format "application/json",
                      :raw-format "application/json"},
   :muuntaja/response {:charset "utf-8",
                       :format "application/json",
                       :raw-format "application/json"},
   +:body-params {:message "22"}}

--- Middleware :reitit.ring.coercion/coerce-request ---

  {:body #<io.undertow.io.UndertowInputStream@39931c66>,
   :body-params {:message "22"},
   :character-encoding "ISO-8859-1",
   :content-length 21,
   :content-type "application/json",
   :context "",
   :cookies {"JSESSIONID" {:value "GpSiQENkmzM7qQwqWdYxjehYKvNKoEGMG6MIwqwU"},
             "_ga" {:value "GA1.1.521496834.1555489511"},
             "_gid" {:value "GA1.1.947080805.1561170619"}},
   :flash nil,
   :form-params {},
   :handler-type :undertow,
   :headers {"accept" "application/json",
             "accept-encoding" "gzip, deflate, br",
             "accept-language" "zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.7",
             "connection" "keep-alive",
             "content-length" "21",
             "content-type" "application/json",
             "cookie" "_ga=GA1.1.521496834.1555489511; JSESSIONID=GpSiQENkmzM7qQwqWdYxjehYKvNKoEGMG6MIwqwU; _gid=GA1.1.947080805.1561170619",
             "host" "localhost:3000",
             "origin" "http://localhost:3000",
             "referer" "http://localhost:3000/api/api-docs/index.html",
             "user-agent" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36"},
   :multipart-params {},
   :params {:name "2"},
   :path-info "/api/guestbooks/path/good-all-params/1",
   :path-params {:id "1"},
   :query-params {"name" "2"},
   :query-string "name=2",
   :remote-addr "0:0:0:0:0:0:0:1",
   :request-method :post,
   :scheme :http,
   :server-exchange #<io.undertow.server.HttpServerExchange@78f2e776 HttpServerExchange{ POST /api/guestbooks/path/good-all-params/1 request {Accept=[application/json], Accept-Language=[zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.7], Accept-Encoding=[gzip, deflate, br], Origin=[http://localhost:3000], User-Agent=[Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36], Connection=[keep-alive], Content-Length=[21], Content-Type=[application/json], Cookie=[_ga=GA1.1.521496834.1555489511; JSESSIONID=GpSiQENkmzM7qQwqWdYxjehYKvNKoEGMG6MIwqwU; _gid=GA1.1.947080805.1561170619], Referer=[http://localhost:3000/api/api-docs/index.html], Host=[localhost:3000]} response {Server=[undertow]}}>,
   :server-name "localhost",
   :server-port 3000,
   :session nil,
   :ssl-client-cert nil,
   :uri "/api/guestbooks/path/good-all-params/1",
   :muuntaja/request {:charset "utf-8",
                      :format "application/json",
                      :raw-format "application/json"},
   :muuntaja/response {:charset "utf-8",
                       :format "application/json",
                       :raw-format "application/json"},
   +:parameters {:body {:message "22"},
                 :path {:id 1},
                 :query {:name "2"}}}

2019-06-22 11:09:16,537 [XNIO-2 task-2] INFO  alk-wxapi.middleware.log-interceptor - 
================================ REQUEST START ================================
 request-id:8ddb3169-e72f-4b90-8811-d500c50d3057
 request-uri: /api/guestbooks/path/good-all-params/1
 request-method: :post
 request-query: {:name "2"}
 request-body: {:message "22"} 
--- Middleware ---

  {:body #<io.undertow.io.UndertowInputStream@39931c66>,
   :body-params {:message "22"},
   :character-encoding "ISO-8859-1",
   :content-length 21,
   :content-type "application/json",
   :context "",
   :cookies {"JSESSIONID" {:value "GpSiQENkmzM7qQwqWdYxjehYKvNKoEGMG6MIwqwU"},
             "_ga" {:value "GA1.1.521496834.1555489511"},
             "_gid" {:value "GA1.1.947080805.1561170619"}},
   :flash nil,
   :form-params {},
   :handler-type :undertow,
   :headers {"accept" "application/json",
             "accept-encoding" "gzip, deflate, br",
             "accept-language" "zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.7",
             "connection" "keep-alive",
             "content-length" "21",
             "content-type" "application/json",
             "cookie" "_ga=GA1.1.521496834.1555489511; JSESSIONID=GpSiQENkmzM7qQwqWdYxjehYKvNKoEGMG6MIwqwU; _gid=GA1.1.947080805.1561170619",
             "host" "localhost:3000",
             "origin" "http://localhost:3000",
             "referer" "http://localhost:3000/api/api-docs/index.html",
             "user-agent" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36"},
   :multipart-params {},
   :parameters {:body {:message "22"},
                :path {:id 1},
                :query {:name "2"}},
   :params {:name "2"},
   :path-info "/api/guestbooks/path/good-all-params/1",
   :path-params {:id "1"},
   :query-params {"name" "2"},
   :query-string "name=2",
   :remote-addr "0:0:0:0:0:0:0:1",
   :request-method :post,
   :scheme :http,
   :server-exchange #<io.undertow.server.HttpServerExchange@78f2e776 HttpServerExchange{ POST /api/guestbooks/path/good-all-params/1 request {Accept=[application/json], Accept-Language=[zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.7], Accept-Encoding=[gzip, deflate, br], Origin=[http://localhost:3000], User-Agent=[Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36], Connection=[keep-alive], Content-Length=[21], Content-Type=[application/json], Cookie=[_ga=GA1.1.521496834.1555489511; JSESSIONID=GpSiQENkmzM7qQwqWdYxjehYKvNKoEGMG6MIwqwU; _gid=GA1.1.947080805.1561170619], Referer=[http://localhost:3000/api/api-docs/index.html], Host=[localhost:3000]} response {Server=[undertow]}}>,
   :server-name "localhost",
   :server-port 3000,
   :session nil,
   :ssl-client-cert nil,
   :uri "/api/guestbooks/path/good-all-params/1",
   :muuntaja/request {:charset "utf-8",
                      :format "application/json",
                      :raw-format "application/json"},
   :muuntaja/response {:charset "utf-8",
                       :format "application/json",
                       :raw-format "application/json"}}

可以看到在reitit.ring.coercion/coerce-request中間件處理后request里增加了
:parameters { :body {:message "22"}, :path {:id 1}, :query {:name "2"}}
3種類型一致的map,這就是我們為什么推薦使用的原因。

handler里獲取request自定義的對象:

那么,在上一步handle中加入到request中了一個current-user怎么獲取和使用呢?其實,body-params,query-params這些也只是從request中獲取到的而已,既然能從request中獲取這些,那么request里的其他所有自然也能在handler中獲取,看下面的例子:

["/reset/pwd"
    {:post {:summary    "修改密碼"
            :parameters {:body (s/keys :req-un [::old-pwd ::new-pwd])}
            :handler    (fn [{{{:keys [old-pwd new-pwd]} :body} :parameters :as request}]
                          (let [current-id (-> request :current-user :user-id)
                                db-user (db/get-user-id
                                         {:user-id current-id})]
                            (if (check-old-pwd old-pwd (:password db-user))
                              (do (conman.core/with-transaction
                                    [*db*]
                                    (db/update-pwd! {:password  (d/sha-256 new-pwd)
                                                         :user-id current-id}))
                                  {:status 200
                                   :body   {:code    1
                                            :message "修改成功,請用新密碼登錄"}})
                              {:status 400
                               :body   {:code    0
                                        :message "密碼錯誤,請輸入正確的密碼!"}})))}}]

:as request的意思是包含前面指定獲取的參數的所有。

4、分頁,動態hugsql

在springboot里習慣使用spring data jpa,分頁使用Pageable、PageRequest,還能攜帶Sort,放回結果自動分頁,確實方便。在luminusweb里沒有看到分頁的說明,于是在底層的HugSQL里找到的方案,舉個動態sql,并且使用like模糊查詢的例子:

-- :name get-patient-like :? :*
-- :doc 模糊查詢患者列表
SELECT
/*~ (if (:count params) */
  count(*) AS 'total-elements'
/*~*/
    p.`patient_id`,
    p.`name`,
    p.`headimgurl`,
    p.`patient_no`
/*~ ) ~*/
FROM
    `t_patient` p
WHERE
    p.deleted = FALSE
    AND p.`hospital_id` = :hospital-id
/*~ (if (= nil (:keywords params)) */
  AND 1=1
/*~*/
  AND (
        p.`name` LIKE :keywords
        OR p.mobile LIKE :keywords
      OR p.patient_no LIKE :keywords
    )
/*~ ) ~*/
ORDER BY p.`create_time` DESC
--~ (if (:count params) ";" "LIMIT :page, :size ;")

調用:

["/patient/search"
    {:get {:summary    "醫生模糊檢索患者列表"
           :parameters {:query (s/keys :req-un [:base/page :base/size]
                                       :opt-un [::keywords])}
           :handler    (fn [{{{:keys [page size keywords]} :query} :parameters :as request}]
                         (let [hospital-id (-> request :doctor :hospital-id)]
                           {:status 
                            200
                            :body   
                            {:code 1
                             :data {:total-elements 
                                    (->> (db-pat/get-patient-like
                                           {:count       true
                                            :keywords    (str "%" keywords "%")
                                            :hospital-id hospital-id})
                                         (map :total-elements)
                                         (first))
                                    :content
                                    (db-pat/get-patient-like
                                      {:page        (* page size)
                                       :size        size
                                       :hospital-id hospital-id
                                       :keywords    (str "%" keywords "%")})}}}))}}]

說明:接口的page,size為必須參數,keywords是非必須參數,sql中根據count的boolean值判斷是不是求count,根據keywords是否有值判斷是否加模糊查詢條件,實現動態sql調用。
更多hugSQL的高階使用,使用時參考官網
邊用邊學吧。

  • 一個in查詢的例子,下例中的type用逗號隔開傳入:
:get    {:summary    "分頁獲取患者檢查報告列表"
              :parameters {:query (s/keys :req-un [:base/patient-id ::type])}
              :handler    (fn [{{{:keys [type, patient-id]} :query} :parameters}]
                            {:status 200
                             :body   {:code 1
                                      :data (db/get-examine-reports
                                              {:patient-id patient-id
                                               :types      (clojure.string/split type #",")})}})}

sql:

-- :name get-reports :? :*
-- :doc 查詢列表
SELECT
*
FROM `t_report`
WHERE `deleted` = FALSE AND `id` =:id AND `type` in (:v*:types)

調用處保證types是個array就行:

:get    {:summary    "獲取報告列表"
              :parameters {:query (s/keys :req-un [:base/id ::type])}
              :handler    (fn [{{{:keys [type, id]} :query} :parameters}]
                            {:status 200
                             :body   {:code 1
                                      :data (db/get-reports
                                              {:id id
                                               :types      (str/split type #",")})}})}
  • 批量操作,hugsql支持批量操作,語法是:t*,看看sql
-- :name batch-create-cure-reaction-detail! :! :n
-- :doc: 新建
INSERT INTO `t_cure_reaction_detail` (`main_id`, `type`, `dict_key_id`, `dict_key_name`, `dict_value_id`, `dict_value_name`) VALUES
    :t*:reaction-detail

一個UT:

(ns alk-wxapi.routes.service.cure-reaction-service-test
  (:require [clojure.test :as t]
            [alk-wxapi.routes.service.cure-reaction-service :as sut]
            [alk-wxapi.db.db-patient-cure :as db]
            [alk-wxapi.db.core :refer [*db*]]
            [luminus-migrations.core :as migrations]
            [clojure.java.jdbc :as jdbc]
            [alk-wxapi.config :refer [env]]
            [mount.core :as mount]))

(t/use-fixtures
  :once
  (fn [f]
    (mount/start
      #'alk-wxapi.config/env
      #'alk-wxapi.db.core/*db*)
    (migrations/migrate ["migrate"] (select-keys env [:database-url]))
    (f)))

(def test-batch-create-cure-reaction-detail-data
  '[[1
     "REACTION"
     62
     "哮喘癥狀"
     68
     "氣閉"]

    [1
     "REACTION"
     58
     "全身非特異性反應"
     59
     "發熱"]

    [1
     "DISPOSE"
     86
     "處理方式"
     89
     "局部處理(冰敷)"]])

(t/deftest test-batch-create-cure-reaction-detail
  (jdbc/with-db-transaction
    [t-conn *db*]
    (jdbc/db-set-rollback-only! t-conn)

    (t/is (= 2 (db/batch-create-cure-reaction-detail-data!
                 {:reaction-detail test-batch-create-cure-reaction-detail-data})))))

執行結果:

(alk-wxapi.db.db-patient-cure/batch-create-cure-reaction-detail!
  {:reaction-detail [[1
                      "REACTION"
                      62
                      "哮喘癥狀"
                      68
                      "氣閉"]

                     [1
                      "REACTION"
                      58
                      "全身非特異性反應"
                      59
                      "發熱"]

                     [1
                      "DISPOSE"
                      86
                      "處理方式"
                      89
                      "局部處理(冰敷)"]]})
=> 3
2019-06-15 15:16:06,929 [nRepl-session-353b6f60-9fd8-415c-9baa-19f7eb4a97f9] INFO  jdbc.sqlonly - batching 1 statements: 1: INSERT INTO `t_cure_reaction_detail` (`main_id`, `type`, `dict_key_id`, 
`dict_key_name`, `dict_value_id`, `dict_value_name`) VALUES (1,'REACTION',62,'哮喘癥狀',68,'氣閉'),(1,'REACTION',58,'全身非特異性反應',59,'發熱'),(1,'DISPOSE',86,'處理方式',89,'局部處理(冰敷)') 
 

需要注意的是傳入的vector,里面也是vector,按照sql中的順序,不是map結構。下面是一個構造的例子

(conman.core/with-transaction
 [*db*]
 (let [tmz-id (utils/generate-db-id)]
   (db/create-tmz! (assoc body
                          :id tmz-id))
   (when (pos? (count (:detail body)))
     (let [funcs [(constantly tmz-id)
                  :drug-id
                  :injection-num
                  :classify
                  :attribute]
           records (map (apply juxt funcs) (:detail body))]
       (if (pos? (count records))
         (db/batch-create-tmz-detail! {:records records}))))))

對應的傳參方式:

{
  "date": "2019-08-09",
  "patient-id": "222",
  "detail": [
    {
      "drug-id": 1,
      "classify": 167,
      "attribute": "常規法",
      "injection-num": 3
    },
 {
      "drug-id": 2,
      "classify": 168,
      "attribute": "改良法",
      "injection-num": 1
    }

  ]
}
  • 動態sql之=和like查詢
--~ (if (:office-id params) "AND p.office_id = :office-id " "AND 1=1 ")

--~ (if (and (:name params) (not= (:name params) "")) (str "AND p.name  LIKE " "'%" (:name params) "%'") "AND 1=1 ")
/*~
(let [status (:status params)]
     (cond
        (= status "10") "AND c.date is null AND DATE_FORMAT(c.predict_next_date,'%Y-%m-%d') <= :current-date"
        (= status "20") "AND DATE_FORMAT(c.date,'%Y-%m-%d') = :current-date"
        (= status "40") "AND c.date IS NOT NULL AND DATE_FORMAT(c.date,'%Y-%m-%d') < :current-date"
        (= status "30") "AND c.date IS NULL AND DATE_FORMAT(c.predict_next_date,'%Y-%m-%d') > :current-date"
        :else ""))
~*/

HugSql其實支持動態sql的,動態表名也可以,更多使用用到的時候查閱官網

5、mysql中的字段表名和字段下劃線在clojure中用中線連接的統一適配

druids提供了幾個adapter,用來處理轉換關系,比如有駝峰,中線等,我們使用連接符轉換,即創建connection時加入kebab-case:

(defstate ^:dynamic *db*
          :start (do (Class/forName "net.sf.log4jdbc.DriverSpy")
                     (if-let [jdbc-url (env :database-url)]
                       (conman/connect! {:jdbc-url jdbc-url})
                       (do
                         (log/warn "database connection URL was not found, please set :database-url in your config, e.g: dev-config.edn")
                         *db*)))
          :stop (conman/disconnect! *db*))
(conman/bind-connection *db* {:adapter (kebab-adapter)} "sql/queries.sql")

這個adapter在init項目時已經引入了,就看使用不使用。

6、獲取環境變量的值

環境變量比較好獲取,比如微信的配置和獲取

{:weixin           {:app-id "wx9258d165932dad73"
                    :secret "my-secret"}

在dev/test/prod中配置結構相同,

(require '[alk-wxapi.config :refer [env]])
(defn get-weixin-access-token [code]
  (let [url (format "https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code"
                    (-> env
                        :weixin
                        :app-id)
                    (-> env
                        :weixin
                        :secret)
                    code)]
    (log/info "請求微信access-token, url: %s" url) url))

如果配置是一層,使用也只需寫一層key。
特別說明
在將redis的connetion從clj修改成從環境變量中獲取時,也是一樣的配置和獲取,但是碰到了問題,在request里查看env中的redis的各項都有值,但是調用redis的地方卻提示無法創建connection,

(ns alk-wxapi.db.redis
  (:require [taoensso.carmine :as car :refer (wcar)]
            [alk-wxapi.config :refer [env]]
            [mount.core :refer [defstate]]))

(def server1-conn
          :start
          {:pool {}
           :spec {:host       (-> env :redis-host)
                  :port       (-> env :redis-port)
                  :password   (-> env :redis-password)
                  :timeout-ms (-> env :redis-timeout-ms)
                  :db         (-> env :redis-db)}})

(defmacro wcar* [& body] `(car/wcar server1-conn ~@body))

最后得知是因為env被定義了個state,

(ns alk-wxapi.config
  (:require [cprop.core :refer [load-config]]
            [cprop.source :as source]
            [mount.core :refer [args defstate]]))

(defstate env
  :start
  (load-config
    :merge
    [(args)
     (source/from-system-props)
     (source/from-env)]))

但是按照說明文檔redis的conn是個常規的def定義的函數,但是它下面的使用是個宏defmacro,宏是在編譯的執行的,因此在初始化時evn環沒有ready,所以無法創建出connection。需要將server1-conn改成一個state,state有依賴狀態,會等到env完成后才產生。

(defstate server1-conn 
 ...
)

7、jar引入及依賴沖突解決:

  • lein deps :tree 查看包依賴。
  • 引入新的jar時在project.clj:dependencies按說明引入,跟maven一樣,分groupId、artifactId、version。
  • 排除某sdk里的某些沖突包
[com.baidu.aip/java-sdk "4.11.0"
 :exclusions [org.slf4j/slf4j-simple]]

8、spec使用

spec的使用需要引入[clojure.spec.alpha :as s][spec-tools.core :as st],看個spec的定義:

(s/def ::page
  (st/spec
    {:spec            int?
     :description     "頁碼,從0開始"
     :swagger/default "0"
     :reason          "頁碼參數不能為空"}))

(s/def ::size
  (st/spec
    {:spec            int?
     :description     "每頁條數"
     :swagger/default 10
     :reason          "條數參數不能為空"}))

使用:

["/page"
    {:get {:summary    "分頁獲取字典數據"
           :parameters {:query (s/keys :req-un [::page ::size])
                        :handler (s/keys :req-un [page]
                                         :opt-un [size])}
           :handler    (fn [{{{:keys [page, size]} :query} :parameters :as request}]
                         {:status 200
                          :body   {:code 10
                                   :data {:total-elements (->> (db/get-dicts-page {:count true})
                                                               (map :total-elements)
                                                               (first))
                                          :content        (db/get-dicts-page {:page page
                                                                              :size size})}}})}}]
  • spec的參數也可以定義在其他namespace里,使用時加上namespace的名字即可,比如一個叫base的namespace里定義參數如下:
(s/def :base/role
  (st/spec
    {:spec        #{"PATIENT", "DOCTOR"}
     :description "角色"
     :reason      "角色不能為空"}))

這個枚舉類型的spec在另一個namespace里使用時不需要在require里引入這個base,而直接在spec里加namespace的名字,是這樣的:

:parameters {:header (s/keys :req-un [:base/role])}
  • 用coll-of定義出一個list
(s/def ::head-id id?)
(s/def ::url string?)
(s/def ::unmatched-head
  (s/keys :req [::head-id ::url]))

(s/def ::unmatched-head-result
  (st/spec
   {:spec (s/coll-of ::unmatched-head)}))

再比定義一個下面的post的body體:

{
  "patient-id": "string",
  "patient-ext-list": [
    {
      "dict-id": 0,
      "dict-type": "string",
      "dict-value": "string",
      "other-value": "string"
    }
  ]
}

spec定義

(s/def ::dict-id int?)
(s/def ::dict-value string?)
(s/def ::dict-type string?)
(s/def ::other-value string?)
(s/def ::patient-ext-list (s/coll-of (s/keys :req-un [::dict-id ::dict-type ::dict-value ::other-value])))
(s/def ::ext-body (s/keys :req-un [:base/patient-id ::patient-ext-list]))

coll-of函數還接收可選的參數,用來對數組中的元素進行限制,可選參數有如下:

(1):kind- - - -可以指定數組的類型,vector,set,list等;

(2):count- - - -可以限定數組中元素的個數;

(3):min-count- - - -限定數組中元素個數的最小值

(4):max-count- - - -限定數組中元素個數的最大值

(5):distinct- - - -數組沒有重復的元素

(6):into- - - -可以將數組的元素插入到[],(),{},#{}這些其中之一,主要是為了改變conform函數的返回結果

  • 定義一個指定長度的
(s/def ::id
  (st/spec
   {:spec (s/and string? #(= (count %) 6))
    :description "一個長度為6字符串"
    :swagger/default "666666"
    :reason "必須是長度為6的字符串"}))
  • 使用函數驗證參數合法性
(s/def ::head-body-id
  (st/spec
   {:spec (s/and string? (fn [s]
                           (let [[head-id body-id] (clojure.string/split s #"-")]
                             (and (s/valid? ::head-id head-id)
                                  (s/valid? ::body-id body-id)))))
    :description "一個長度為13字符串, head-id 和 body-id 用‘-’ 連起來"
    :swagger/default "666666-999999"
    :reason "必須是長度為13的字符串,用-把body-id和head-id連起來"}))
  • 定義數組
(s/def ::dict-id [string?])    ;Good
(s/def ::dict-id vector?)      ;Bad

更多使用參考:
clojure.spec庫入門學習

但是我們使用Luminus
模板默認的參數校驗庫并不是spec,而是Struct,使用的時候通常引入struct.core就可以,先看個示例的定義:

(ns myapp.home
  (:require
    ...
    [struct.core :as st]))

(def album-schema
  [[:band st/required st/string]
   [:album st/required st/string]
   [:year st/required st/number]])

也可以定義更加復雜的屬性

(def integer
  {:message "must be a integer"
   :optional true
   :validate integer?}))

至于我們為什么推薦用spec,我覺得只是個約定而已。

9、新增接口加入route

創建一個新的namespace,參考官網說明定義出一個routes函數,然后將其加入到handle.clj中即可,像下面這樣一直conj即可:

添加route

10、文件上傳接口

接口定義

(defn format-date-time [timestamp]
  (-> "yyyyMMddHHmmss"
      (java.text.SimpleDateFormat.)
      (.format timestamp)))

;;上傳到本地
(defn upload-file-local [type file]
  (let [file-path (str (-> env :file-path) type
                       "/" (format-date-time (java.util.Date.))
                       "/" (:filename file))]
    (io/make-parents file-path)
    (with-open [writer (io/output-stream file-path)]
      (io/copy (:tempfile file) writer))
    (get-image-data file-path)
    file-path))

(defn common-routes []
  ["/common"
   {:swagger    {:tags ["文件接口"]}
    :parameters {:header (s/keys :req-un [::token ::role])}
    :middleware [token-wrap]}

   ["/files"
    {:post {:summary    "附件上傳接口"
            :parameters {:multipart {:file multipart/temp-file-part
                                     :type (st/spec
                                             {:spec        string?
                                              :description "類型"
                                              :reason      "類型必填"})}}

            :responses  {200 {:body {:code int?, :data {:file-url string?}}}}
            :handler    (fn [{{{:keys [type file]} :multipart} :parameters}]
                          {:status 200
                           :body   {:code    1
                                    :message "上傳成功"
                                    :data    {:file-url (:url (upload-file-local type file))}}})}}]])

如果要將圖片上傳至七牛等有CDN能力的云存儲空間,可以使用別人的輪子,或者自己需要造輪子,我這里使用了一個別人造的上傳七牛的輪子,先在:dependencies里加入依賴

[clj.qiniu "0.2.1"]

調用api

(require '[clj.qiniu :as qiniu])
;;上傳到七牛配置
(defn set-qiniu-config []
  (qiniu/set-config! :access-key "my-key"
                     :secret-key "my-secret"))

(def qiniu-config
  {:bucket "medical"
   :domain "http://prfmkg8tt.bkt.clouddn.com/"
   :prefix "alk/weixin/"})

(defn qiniu-upload-path [type filename]
  (str (-> qiniu-config :prefix)
       type "/"
       (utils/format-date-time (java.util.Date.))
       "/"
       filename))

;;七牛云上傳,返回上傳后地址
(defn upload-file-qiniu [type file]
  (set-qiniu-config)
  (let [filename (:filename file)
        bucket (-> qiniu-config :bucket)
        key (qiniu-upload-path type filename)
        res (qiniu/upload-bucket bucket
                                 key
                                 (:tempfile file))]
    (log/info "上傳七牛云結果:" res)
    (if-not (= 200 (-> res :status))
      (throw (Exception. " 附件上傳失敗 ")))
    (str (-> qiniu-config :domain) key)))

使用的時候將上傳local改成upload-file-qiniu即可。

11、全局跨域配置

在middleware的wrap-base中加入跨域信息,先配置個*的

(ns alk-wxapi.middleware
  (:require
   [alk-wxapi.env :refer [defaults]]
   [alk-wxapi.config :refer [env]]
   [ring.middleware.flash :refer [wrap-flash]]
   [immutant.web.middleware :refer [wrap-session]]
   [ring.middleware.cors :refer [wrap-cors]]
   [ring.middleware.defaults :refer [site-defaults wrap-defaults]]))

(defn wrap-base [handler]
  (-> ((:middleware defaults) handler)
      wrap-flash
      (wrap-session {:cookie-attrs {:http-only true}})
      (wrap-cors :access-control-allow-origin [#".*"]
                 :access-control-allow-methods [:get :put :post :delete])
      (wrap-defaults
       (-> site-defaults
           (assoc-in [:security :anti-forgery] false)
           (dissoc :session)))))

12、增加打包環境

比如增加pre環境,在project.clj中配置uberjar即可,在:profiles里增加,可以參考test環境,比如增加的uberjar-test環境:

   :uberjar-test  {:omit-source    true
                   :aot            :all
                   :uberjar-name   "alk-wxapi-test.jar"
                   :source-paths   ["env/test/clj"]
                   :resource-paths ["env/test/resources"]
                   :jvm-opts       ["-Dconf=test-config.edn"]}

打包:

?  alk-wxapi git:(master) ? lein with-profiles uberjar-test uberjar
Compiling alk-wxapi.common.utils
Compiling alk-wxapi.config
Compiling alk-wxapi.core
Compiling alk-wxapi.db.core
Compiling alk-wxapi.db.db-dicts
Compiling alk-wxapi.db.db-doctor
Compiling alk-wxapi.db.db-guestbook
Compiling alk-wxapi.db.db-hospital
Compiling alk-wxapi.db.db-patient
Compiling alk-wxapi.db.redis
Compiling alk-wxapi.env
Compiling alk-wxapi.handler
Compiling alk-wxapi.middleware
Compiling alk-wxapi.middleware.exception
Compiling alk-wxapi.middleware.formats
Compiling alk-wxapi.middleware.interceptor
Compiling alk-wxapi.middleware.log-interceptor
Compiling alk-wxapi.middleware.token-interceptor
Compiling alk-wxapi.nrepl
Compiling alk-wxapi.routes.base
Compiling alk-wxapi.routes.dicts
Compiling alk-wxapi.routes.doctor
Compiling alk-wxapi.routes.file
Compiling alk-wxapi.routes.guestbook
Compiling alk-wxapi.routes.hospital
Compiling alk-wxapi.routes.patient
Compiling alk-wxapi.routes.patient-cost
Compiling alk-wxapi.routes.patient-examine
Compiling alk-wxapi.routes.public
Compiling alk-wxapi.routes.user
Compiling alk-wxapi.routes.weixin
Compiling alk-wxapi.validation
Warning: skipped duplicate file: config.edn
Warning: skipped duplicate file: logback.xml
Created /Users/mahaiqiang/git/redcreation/alk-wxapi/target/uberjar+uberjar-test/alk-wxapi-0.1.0-SNAPSHOT.jar
Created /Users/mahaiqiang/git/redcreation/alk-wxapi/target/uberjar/alk-wxapi-test.jar
?  alk-wxapi git:(master) ?

13、事務

發起事務使用conman.core/with-transaction,一個例子:

(let [timestamp (java.util.Date.)
      id (utils/generate-db-id)]
  (conman.core/with-transaction 
    [*db*]
    (db/create-guestbook! (assoc body-params
                            :timestamp timestamp
                            :id id))
    (db/get-guestbook {:id id})
    (throw (ex-info (str "異常,事務回滾,列表中查看該id的數據是否存在,id:" id) {}))))

注意:只有在transaction中的exception發生,事務的機制才會生效,我測試時就正好稀里糊涂把throw放到了with-transaction里面,導致總是不會回滾。

14、工具類

工具類Utils單獨一個namespace,目前收納

  • 獲取uuid
(defn generate-db-id []
  (clojure.string/replace (str (java.util.UUID/randomUUID)) "-" ""))
  • 日期時間格式化
(defn format-time [timestamp]
  (-> "yyyy-MM-dd HH:mm:ss"
      (java.text.SimpleDateFormat.)
      (.format timestamp)))

(defn format-date-time [timestamp]
  (-> "yyyyMMddHHmmss"
      (java.text.SimpleDateFormat.)
      (.format timestamp)))

15、定時任務

有個比較重量級的http://clojurequartz.info/articles/guides.html庫,quartz與在java里的一樣,只不過是clojure的實現。
我們項目里沒有很復雜的需要動態修改的定時任務,因此選擇了一個輕量級的庫:chime,api參考github。下面是項目中的一個demo

(ns alk-wxapi.common.scheduler
  (:require [chime :refer [chime-ch]]
            [clj-time.core :as t]
            [clj-time.periodic :refer [periodic-seq]]
            [clojure.core.async :as a :refer [<! go-loop]]
            [clojure.tools.logging :as log])
  (:import org.joda.time.DateTimeZone))

;; FIXME 定時功能應該還沒有做    (^_^)

(defn times []
  (rest (periodic-seq (.. (t/now)
                          (withZone (DateTimeZone/getDefault))
                          #_(withTime 0 0 0 0))
                      (t/minutes 10))))
(defn channel []
  (a/chan))

(defn chime []
  (chime-ch (times) {:ch (channel)}))

(defn start-scheduler []
  (let [chime-channle (chime)]
    (go-loop []
      (when-let [msg (<! chime-channle)]
        (log/error (format "親愛的 %s, Clojure repl搞一個小時了,休息一下?"
                           (System/getProperty "user.name")))
        (recur)))
    chime-channle))

該定時任務項目啟動后一個小時執行一次,執行只是簡單打個log,效果如下:


定時任務

16、優雅地打印jdbc的執行sql

項目中默認的jdbc驅動是mysql自身的啟動,所以默認的databaseurl也許是這樣的

:database-url "mysql://localhost:3306/demo?user=root&password=password

然而,這樣的配置是不會打印出jdbc執行的真正sql的,而我們有時候很需要這些sql,因為他們代表著邏輯,有時候debug也會需要。
那么怎么配置才能達到目的呢?
我們使用的是log4jdbc,因此需要在project.clj中引入該庫,

[com.googlecode.log4jdbc/log4jdbc "1.2"]

引入以后修改需要查看sql的profile里的edn配置文件,比如本地dev-config.edn

:database-url "jdbc:log4jdbc:mysql://localhost:3306/demo?user=root&password=password

然后jdbc連接處自然也得變,routes/db/core.clj

(defstate ^:dynamic *db*
          :start (do (Class/forName "net.sf.log4jdbc.DriverSpy")
                     (if-let [jdbc-url (env :database-url)]
                       (conman/connect! {:jdbc-url jdbc-url})
                       (do
                         (log/warn "database connection URL was not found, please set :database-url in your config, e.g: dev-config.edn")
                         *db*)))
          :stop (conman/disconnect! *db*))

默認的log配置,使用logback是配置的方式。
這樣會在log控制臺看到很多jdbc的log,因為默認這些日志都是info的,需要調整logback里日志級別。
為了分開打印log、error、sql的log,附上我本地的logback配置文件

<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="10 seconds">
    <statusListener class="ch.qos.logback.core.status.NopStatusListener"/>
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <!-- 如果只是想要Info級別的日志,只是過濾info還是會輸出Error日志,因為Error的級別高,使用filter,可以避免輸出Error日志 -->
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <!--過濾 Error-->
            <level>ERROR</level>
            <!--匹配到就禁止-->
            <onMatch>DENY</onMatch>
            <!--沒有匹配到就允許-->
            <onMismatch>ACCEPT</onMismatch>
        </filter>
        <file>log/info-wxapi.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>log/info-wxapi.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <maxFileSize>100MB</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
            <!-- keep 30 days of history -->
            <maxHistory>30</maxHistory>
        </rollingPolicy>
        <encoder>
            <charset>UTF-8</charset>
            <pattern>%date{ISO8601} [%thread] %-5level %logger{36} - %msg %n</pattern>
        </encoder>
    </appender>

    <appender name="ERRORFILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <!--如果只是想要 Error 級別的日志,那么需要過濾一下,默認是 info 級別的,ThresholdFilter-->
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>Error</level>
        </filter>
        <file>log/error-wxapi.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>log/error-wxapi.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <maxFileSize>100MB</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
            <!-- keep 30 days of history -->
            <maxHistory>10</maxHistory>
        </rollingPolicy>
        <encoder>
            <charset>UTF-8</charset>
            <pattern>%date{ISO8601} [%thread] %-5level %logger{36} - %msg %n</pattern>
        </encoder>
    </appender>

    <appender name="SQLFILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>log/sql-wxapi.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>log/sql-wxapi.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <maxFileSize>100MB</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
            <!-- keep 30 days of history -->
            <maxHistory>10</maxHistory>
        </rollingPolicy>
        <encoder>
            <charset>UTF-8</charset>
            <pattern>%date{ISO8601} [%thread] %-5level %logger{36} - %msg %n</pattern>
        </encoder>
    </appender>

    <logger name="org.apache.http" level="warn"/>
    <logger name="org.xnio.nio" level="warn"/>
    <logger name="com.zaxxer.hikari" level="warn"/>
    <logger name="io.undertow.session" level="warn"/>
    <logger name="io.undertow.request" level="warn"/>
    <logger name="jdbc.audit" level="warn"/>
    <logger name="jdbc.sqltiming" level="warn"/>
    <logger name="jdbc.connection" level="warn"/>
    <logger name="jdbc.resultset" level="warn"/>

    <logger name="wxapi" level="INFO" additivity="false">
        <appender-ref ref="FILE"/>
        <appender-ref ref="ERRORFILE"/>
    </logger>

    <logger name="jdbc.sqlonly" level="INFO" additivity="false">
        <appender-ref ref="SQLFILE"/>
    </logger>

    <root level="ERROR">
        <appender-ref ref="ERRORFILE"/>
        <appender-ref ref="FILE"/>
    </root>
</configuration>

:as request的意思是包含前面指定獲取的參數的所有。
當然,如你所知,clojure確實足夠靈活,取參方式也還有方式,比如

["/path/good-all-params/:id"
   {:post {:summary    "更多方式"
           :parameters {:path  {:id int?}
                        :query {:name string?}
                        :body  {:message string?}}
           :handler    (fn [{{data :body} :parameters
                             {{:keys [id]} :path}    :parameters]
                        (ok (format " body params: %s " data)))}}]


這里參數名稱data可以定義成任何你想叫的名字。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容