Coffee Break Clojure, Vol.6

上一篇 讲解了如何用 Leiningen 管理 Clojure 项目的目录结构和依赖项,今天我们来了解 Clojure 生态中最常见的 Web 后端库 Ring 。不对,和那个 IndieWeb Ring 没有关系。

初识 Ring

Ring 是对 Web 服务器的低层抽象,也就是说没有实现页面路由(router),和通常的后端框架(比如 Go 的 Gin、Python 的 Django、Java 的 Spring Web 等)有很大区别。

根据 Ring Spec 1.6.1 ,Ring 主要有三个概念:

  1. Handler(处理器)
  2. Middleware(中间件)
  3. Adapter(适配器)

Ring 提供了三种 API: 同步 API、异步 API 和 WebSocket API。不同的 API 在概念上略有不同,我们先从最简单的同步 API 讲起。

Handler 就是一个函数,接收 HTTP 请求,返回 HTTP 响应。

;; 一个示例 Handler
(fn [request] response)

异步 API 的区别不大,只不过,由于 Handler 的结果需要异步返回,如果出了错也要异步报错,所以要传入两个回调函数用来响应和报错。

(fn [request respond raise]
 (if (not exception)
 (respond response)
 (raise exception)))

一个 Handler 可以同时实现同步和异步 API。

(fn
 ([request]
 response)
 ([request respond raise]
 (respond response)))

Middleware 接收一个或多个 Handler,也可能接受一些参数,最后返回新的 Handler。可以理解为设计模式中的装饰器模式(decorator pattern),用来调整或增强 Handler 的行为,比如把 Handler 返回的响应体都解析成 JSON 字符串。

Adapter 接收一个 Handler 和其他配置参数,在调用时启动 HTTP 服务器。

;; 调用一个 Adapter 的示例
(run-adapter handler options)

把 options 设置成 {:async? true},Adapter 就能以异步模式运行了。前提是 Handler 支持异步 API,Adapter 也要支持 :async? 才行。异步 API 可以让服务器并行处理多个请求,如果对并发度有要求可以用。

接收 HTTP 请求的就是 Adapter,他会把请求包装成 Request Map 传递给 Handler,随后把 Handler 返回的 Response Map 构造成 HTTP 响应发送给客户端。可以把 Adapter 理解为设计模式中的代理模式(proxy pattern),用户不直接调用 Handler,而是通过 Adapter 调用 Handler,Adapter 会处理 HTTP 协议相关的细节。

上述组件其实都不必有代码依赖关系,因为函数可以作为数据传递,上图的虚线都表示数据流。Handler 先是经过 Middleware 被改装成了新的 Handler,然后被传入 Adapter;Adapter 在 Web 服务器有请求进来时调用 Handler 产生响应数据,把响应数据包装成响应体后传送给客户端。

简单的 Web 服务器

先在 project.clj 里添加两个新依赖:

 :dependencies [[org.clojure/clojure "1.10.1"]
 [ring/ring-core "1.8.2"]
 [ring/ring-jetty-adapter "1.8.2"]])

其中一个是 Ring 的核心库,另一个是 Jetty 的 Ring Adapter。ring-core 也提供了一些 Middleware,我们其实只需要写 Handler。

创建 src/antfigher/handler.clj,antfigher 是我用来演示的项目名。我们先让 Handler 只返回 Hello! 这一条信息。

(ns antfighter.handler)

(defn handler [request]
 {:status 200 
 :body "Hello!"})

接下来需要启动 Web 服务器,如果你安装了上一篇提到的 lein-ring 插件,就可以直接用命令行启动了。在 project.clj 中添加这些配置:

 :plugins [[lein-ring "0.12.6"]]
 :ring {:handler antfighter.handler/handler}

:plugins 安装了 Ring 插件,:ring 则是这个插件的配置项,其中 :handler 指定了 antfigher.handler 命名空间下的 handler 符号,也就是我们刚才定义的 handler 函数。

在终端输入 lein ring server 启动服务器,之后工具会自动在浏览器里打开 http://localhost:3000,然后就能看到 Hello! 显示在浏览器页面里。

如果要打包成可以直接在 JVM 上执行的 .jar 文件,可以用 lein ring uberjar 编译。不过,我不建议依赖这个 Leiningen 插件来编译应用,而且不少应用其实除了启动 Ring 服务器,还需要启动别的东西,比如后台处理线程、消息队列等等。lein ring uberjar 只会把指定的 Handler 编译进去。

在 core.clj 文件的 -main 函数里编写启动逻辑:

(ns antfighter.core
 (:gen-class)
 (:require [antfighter.handler :refer [handler]]
 [ring.adapter.jetty :refer [run-jetty]]))

(defn -main
 "I start a Jetty server."
 [& args]
 (run-jetty handler {:port 3000}))

run-jetty 就是 Ring Jetty Adapter,它接收 handler 函数和作为配置项的映射,这里我们传入的配置是 {:port 3000},也就是在 3000 端口上启动 Jetty 服务器。

这时运行 lein run,再手动打开 localhost:3000,也能看到 Hello!。如果用 lein uberjar 把项目编译成 .jar,然后用 java -jar xxx.jar 运行,也是一样的效果。

开发时建议用 lein ring server,因为自带热重载,保存代码后就能看到效果。

处理 HTTP 请求

Handler 接收的参数 request 是个映射,结构如下:

Key Type Required
:body java.io.InputStream
:headers {String String} Yes
:protocol String Yes
:query-string String
:remote-addr String Yes
:request-method Keyword Yes
:scheme Keyword Yes
:server-name String Yes
:server-port Integer Yes
:ssl-client-cert java.security.cert.X509Certificate
:uri String Yes

Handler 也应该返回一个映射,结构如下:

Key Type Required
:body ring.core.protocols/StreamableResponseBody
:headers {String String} or {String [String]} Yes
:status Integer Yes

上述表格摘自 Ring Spec 。大部分情况下我们都只需要使用 :body 和 :headers,也就是响应头(或请求头)以及响应体(或请求体),响应时还需要带上 :status,即 HTTP 状态码。

当然,如果你不用任何路由库,纯手动路由,那还要用到 :request-method(请求方法,如 GET POST)和 :uri(用来解析路径)。

Handler 做的事情其实就是对请求做些处理,然后构造响应。比方说,如果客户端用 GET 方法请求 /hello 目录才返回 Hello!,对其他路由都返回 404 和 Not Found,就可以这样写:

(ns antfighter.handler
 (:import [java.net URI]))

(defn handler [request]
 (let [method (:request-method request)
 uri (:uri request)
 path (.getPath (URI. uri))]
 (if (and (= method :get)
 (= path "/hello"))
 {:status 200 
 :body "Hello!"}
 {:status 404 
 :body "Not Found"})))

不过,访问 /hello 会得到 Hello!,但访问 /hello/ 却是 Not Found。我们的路由匹配方式有缺陷。处理这些细节太麻烦了,有没有别的办法?

沉着冷静地路由

Compojure 是 Ring 的主要贡献者 weavejester 维护的路由库。这个名字显然来自 Composure,意思是「沉着冷静」。

如果要新建项目的话,其实可以直接用 Leiningen 的 compojure 模板:

lein new compojure hello-world

不过我自己的感受是,不用脚手架从零搭建会减少很多认知负债,在一开始就把各种组件、依赖关系和架构搞清楚是最好的。熟悉之后再图方便使用模板。

下文提及的符号基本都定义在 compojure.core 命名空间里,读者应该会使用 require 引入符号(如果不会,就去读 上一篇 ),我就不写 require 了。

Compojure 的核心是 routes 函数,用来把多个 Handler 组合成一个。一般和 GET、POST、PUT 等路由宏一起使用,每个宏对应一个 HTTP 方法,可以用 ANY 宏匹配任意方法。这些宏展开之后,最终返回的是对应方法和路径的 Handler。

(def api-routes
 (routes 
 (GET "/hello" request (handle-hello request))
 (POST "/receive" request (handle-receive request))))

(run-jetty api-routes {:port 3000})

上述代码可以简写为:

(defroutes api-routes
 (GET "/hello" request (handle-hello request))
 (POST "/receive" request (handle-receive request)))

(run-jetty api-routes {:port 3000})

request 可以直接传给处理逻辑,也可以解构(destructure)之后再传递:

(defroutes api-routes
 (GET "/hello" [username] (say-hello username))
 (POST "/receive" [data] (receive data)))

放在向量里的符号会被自动绑定到对应名称的 Query String 或 POST 参数上。

curl http://localhost:3000/hello?username=Alice 
# => Hello Alice!

Compojure 也可以用 Clojure 自带的 解构 ,比如把数据从 Session 里取出来:

(GET "/user" {{:keys [user-id]} :session}
 (str "The current user is " user-id))

关于解构,一般用向量取出 Query 和 POST 参数就够了,更多用法可以参考 Compojure Wiki 。如果觉得解构的语法太麻烦,直接用 Request map 也没有问题。

这些宏也天然支持动态路由:

;; 路径中的 :username 会被绑定到 username 符号上
;; 下面的路由会匹配所有形如 /hello/* 的 GET 路径
(GET "/hello/:username" [username] 
 (say-hello username))

;; 可以用正则表达式做更严格的匹配
(GET ["/user/:id", :id #"[0-9]+"] [id]
 (get-user id))

路由可以嵌套,要用到 context 函数,参考 Wiki 里的例子:

(defroutes user-routes
 (context "/user/:user-id" [user-id]
 (GET "/profile" [] ...)
 (GET "/posts" [] ...)))

如果一个请求发过来,没有匹配上任何一条路由,那么 Compojure 会返回 nil。我们还没处理 Not Found 路径。可以用 compojure.route 里的 not-found 函数处理 404 请求。

(require '[compojure.route :as route])

(defroutes api-routes
 (GET "/hello" [username] (say-hello username))
 (POST "/receive" [data] (receive data))
 (route/not-found "Not Found"))

Compojure 不仅帮我们解构 request,还可以智能地处理 response。比如,我们可以把 GET "/hello" 这个路由的响应替换成字符串。

(defroutes api-routes
 (GET "/hello" [username] (str "Hello " username "!"))
 (POST "/receive" [data] (receive data))
 (route/not-found "Not Found"))

"Hello Alice!" 会被自动包装成:

{:status 200
 :headers {"Content-Type" "text/plain; charset=utf-8"}
 :body "Hello Alice!"}

构造这种响应体可能很麻烦,除了让 Compojure 自动处理(说实话,我觉得自动处理有点令人捉摸不透,自己写清楚会好点),还可以用 ring-http-response 工具库:

(require '[ring.util.http-response :refer :all])

(continue)
; {:status 100, :headers {}, :body ""}

(ok "body")
; {:status 200, :headers {}, :body "body"}

(created "url" "body")
; {:status 201, :headers {"Location" "url"}, :body "body"}

(found "url")
; {:status 302, :headers {"Location" "url"}, :body ""}

自由组合中间件

Compojure 能帮我们处理路由、请求体的解构和响应体的封装,但内部结构并不复杂,这些功能是通过 Middleware 实现的:

(defn make-route
 "Returns a function that will only call the handler if the method and path
 match the request."
 [method path handler]
 (-> handler
 (wrap-response)
 (wrap-route-middleware)
 (wrap-route-info [(or method :any) (str path)])
 (wrap-route-matches method path)))

这些 wrap- 开头的函数就是 Middleware,最初的 handler 作为参数被传入这些函数,一层层被包装。wrap-response 给 Handler 增加了自动包装返回值(响应体)的能力,wrap-route-matches 则保证只有请求体里的方法和路径都匹配时才放行。

还记得 defroutes 定义的符号也是个 Handler 吗?Compojure 的路由不过是把多个匹配不同路由的 Handler 组合成了一个大的 Handler。Handler 就是这样被组合、装饰才变得支持多路由和各种额外功能的。

我们还可以继续包装 Handler,比如用 ring-json 库,把响应体 :body 里的 Clojure 数据结构自动解析为 JSON 字符串:

 :dependencies [[org.clojure/clojure "1.10.1"]
 [ring/ring-core "1.8.2"]
 [ring/ring-jetty-adapter "1.8.2"]
 [ring/ring-json "0.5.1"]]) ;; <- 新依赖

ring.middleware.json/wrap-json-body 会解析所有 Content-Type 为 JSON 的响应体。

(require '[ring.middleware.json :refer [wrap-json-body]])

(defroutes api-routes 
 (GET "/test" [] 
 {:status 200
 :headers {"Content-Type" "application/json"}
 :body {:message "Cheshire just visited me."}}))

(def app 
 (wrap-json-body api-routes))

(run-jetty app {:port 3000})
curl http://localhost:3000/test 
# => {"message": "Cheshire just visited me."}

如果需要对响应数据做处理,比起放在业务逻辑里增加复杂度,不如写一个 Middleware,对 Handler 进行装饰。如果是相对通用的需求,可以找找有没有已经实现自己需求的库。


其他参考资料

  • 除了 Ring 默认的 Jetty,我个人比较推荐 http-kit ,它提供了简单且性能较好的 Web 服务器实现,可以用 org.httpkit.server/run-server 替代 run-jetty。这个库也提供了 HTTP 客户端实现。
  • 除了 Compojure,还有更新更现代的 reitit ,不过仍处于 0.x.x 版本阶段,不如 Compojure 稳定。 reitit 的主要亮点是数据驱动,路由不必写成宏形式,可以写成嵌套的向量:
    (defn cqrs-routes [actions]
     ["/api" {:interceptors [::api ::db]}
     (for [[type interceptor] actions
     :let [path (str "/" (name interceptor))
     method (case type
     :query :get
     :command :post)]]
     [path {method {:interceptors [interceptor]}}])])
    
    上面这个示例来自 reitit 文档。说实话,用 for 循环(或者 map)构造路由看起来很酷,但用宏同样可以做到啊。Clojure/Lisp 代码本身就是数据,不需要什么「数据驱动」。 不过 reitit 还有别的好处,比如可以在路由里写拦截器(interceptor)。
  • 你可能会想要 Swagger API 文档,有 ring-swagger 这个库,本质上是在 API 路由的定义里添加了一些元数据和文档描述,然后在运行时用这些数据生成 swagger.json,还把 Swagger UI 也打包在内。 还有专门为 Compojure 设计的 compojure-api ,用 ring-swagger 实现。我试了试,这个库的依赖有点大,打包后可能有十几二十兆。在我看来不如手写文档,除非 API 真的很复杂。

最后

这其实也是我第一次这么仔细地阅读 Ring 和 Compojure 的架构。Ring 的 Handler、Middleware 和 Adapter 其实很优雅,尤其是 Middleware 的组合,以非常优雅清晰也非常函数式的方式拓展了 Handler 的能力。

由于写文的过程中发现了不少之前不了解的细节,所以我打算稍后就去整理一下我项目里的 API 写法。那么,回见!


  1. Jetty 是个 Java Web 服务器;也可以换成别的,这里就用默认的 Jetty 演示了。
  2. 上篇文章提到了,如果要用 Java 运行,就要保留 core.clj 中的 :gen-class。
添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论