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可以定义成任何你想叫的名字。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343

推荐阅读更多精彩内容