在 Clojure 中用 Ring 库编写 Web 应用程序
1. 简介
Ring 是一个用于在 Clojure 中编写 Web 应用程序的库。它支持编写功能齐全的 Web 应用程序所需的一切,并拥有一个蓬勃发展的生态系统,使其更加强大。
在本教程中,我们将介绍 Ring,并展示我们可以用它实现的一些事情。
Ring 不像许多现代工具包那样是为创建 REST API 而设计的框架。它是一个较低级别的框架,用于处理一般的 HTTP 请求,专注于传统的 Web 开发。但是,一些库建立在它之上以支持许多其他所需的应用程序结构。
2. 依赖
在我们开始使用 Ring 之前,我们需要将它添加到我们的项目中。我们需要的最小依赖项是:
我们可以将这些添加到我们的 Leiningen 项目中:
:dependencies [[org.clojure/clojure "1.10.0"]
[ring/ring-core "1.7.1"]
[ring/ring-jetty-adapter "1.7.1"]]
然后我们可以将它添加到一个最小的项目中:
(ns ring.core
(:use ring.adapter.jetty))
(defn handler [request]
{:status 200
:headers {"Content-Type" "text/plain"}
:body "Hello World"})
(defn -main
[& args]
(run-jetty handler {:port 3000}))
在这里,我们定义了一个处理函数——我们将很快介绍它——它总是返回字符串“Hello World”。此外,我们添加了我们的 main 函数来使用这个处理程序——它将侦听端口 3000 上的请求。
3. 核心概念
Leiningen 有几个核心概念,一切都围绕这些概念构建:请求、响应、处理程序和中间件。
3.1. 要求
请求是传入 HTTP 请求的表示。Ring 将请求表示为映射,允许我们的 Clojure 应用程序轻松地与各个字段进行交互。此地图中有一组标准键,包括但不限于:
- :uri – 完整的 URI 路径。
- :query-string – 完整的查询字符串。
- :request-method – 请求方法,是 :get、:head、:post、:put、:delete或 :options 之一。
- :headers – 提供给请求的所有 HTTP 标头的映射。
- :body –表示请求正文的InputStream (如果存在)。
中间件可以根据需要向此映射添加更多键。
3.2. 回应
同样,响应是传出 HTTP 响应的表示。Ring 也将这些表示为具有三个标准键的映射:
- :status – 要发回的状态码
- : headers - 所有要发回的 HTTP 标头的映射
- : body - 要发回的可选正文
和以前一样,中间件可能会在我们的处理程序产生它和最终结果发送到客户端之间改变它。
Ring 还提供了一些帮助程序,使构建响应更容易。
其中最基本的是ring.util.response/response函数,它创建一个状态码为200 OK的简单响应:
ring.core=> (ring.util.response/response "Hello")
{:status 200, :headers {}, :body "Hello"}
对于常见的状态代码,还有一些其他方法可以与之一起使用——例如bad-request、not-found和redirect:
ring.core=> (ring.util.response/bad-request "Hello")
{:status 400, :headers {}, :body "Hello"}
ring.core=> (ring.util.response/created "/post/123")
{:status 201, :headers {"Location" "/post/123"}, :body nil}
ring.core=> (ring.util.response/redirect "https://ring-clojure.github.io/ring/")
{:status 302, :headers {"Location" "https://ring-clojure.github.io/ring/"}, :body ""}
我们还有 status方法可以将现有响应转换为任意状态码:
ring.core=> (ring.util.response/status (ring.util.response/response "Hello") 409)
{:status 409, :headers {}, :body "Hello"}
然后我们有一些方法可以类似地调整响应的其他特性——例如,content-type、header或 set-cookie:
ring.core=> (ring.util.response/content-type (ring.util.response/response "Hello") "text/plain")
{:status 200, :headers {"Content-Type" "text/plain"}, :body "Hello"}
ring.core=> (ring.util.response/header (ring.util.response/response "Hello") "X-Tutorial-For" "Blogdemo")
{:status 200, :headers {"X-Tutorial-For" "Blogdemo"}, :body "Hello"}
ring.core=> (ring.util.response/set-cookie (ring.util.response/response "Hello") "User" "123")
{:status 200, :headers {}, :body "Hello", :cookies {"User" {:value "123"}}}
请注意,set-cookie方法向响应映射添加了一个全新的条目。这需要 wrap-cookies中间件对其进行正确处理以使其正常工作。
3.3. 处理程序
现在我们了解了请求和响应,我们可以开始编写处理函数来将它们联系在一起。
处理程序是一个简单的函数,它将传入请求作为参数并返回传出响应。我们在这个函数中做什么完全取决于我们的应用程序,只要它符合这个合同。
在最简单的情况下,我们可以编写一个总是返回相同响应的函数:
(defn handler [request] (ring.util.response/response "Hello"))
我们也可以根据需要与请求进行交互。
例如,我们可以编写一个处理程序来返回传入的 IP 地址:
(defn check-ip-handler [request]
(ring.util.response/content-type
(ring.util.response/response (:remote-addr request))
"text/plain"))
3.4. 中间件
中间件是一个在某些语言中很常见的名称,但在 Java 世界中却很少见。从概念上讲,它们类似于 Servlet 过滤器和 Spring 拦截器。
在 Ring 中,中间件是指包装主处理程序并以某种方式调整其某些方面的简单函数。这可能意味着在处理传入请求之前对其进行更改,在生成响应后更改传出响应,或者可能仅记录处理所需的时间。
通常,中间件函数采用处理程序的第一个参数来包装并返回具有新功能的新处理程序函数。
中间件可以根据需要使用尽可能多的其他参数。例如,我们可以使用以下内容为来自包装处理程序的每个响应设置Content-Type标头:
(defn wrap-content-type [handler content-type]
(fn [request]
(let [response (handler request)]
(assoc-in response [:headers "Content-Type"] content-type))))
通读它,我们可以看到我们返回了一个接受请求的函数——这是新的处理程序。然后这将调用提供的处理程序,然后返回响应的变异版本。
我们可以通过简单地将它们链接在一起来使用它来生成一个新的处理程序:
(def app-handler (wrap-content-type handler "text/html"))
Clojure 还提供了一种以更自然的方式将多个链接在一起的方法——通过使用线程宏 。这是一种提供要调用的函数列表的方法,每个函数都有前一个函数的输出。
*特别是,我们需要第一个线程宏->*。**这将允许我们使用提供的值作为第一个参数调用每个中间件:
(def app-handler
(-> handler
(wrap-content-type "text/html")
wrap-keyword-params
wrap-params))
然后生成了一个处理程序,该处理程序是包装在三个不同中间件函数中的原始处理程序。
4. 编写处理程序
现在我们了解了构成 Ring 应用程序的组件,我们需要知道我们可以使用实际的处理程序做什么。这些是整个应用程序的核心,也是大部分业务逻辑的所在。
我们可以将我们希望的任何代码放入这些处理程序中,包括数据库访问或调用其他服务。Ring 为我们提供了一些额外的能力,可以直接处理传入的请求或传出的响应,这也非常有用。
4.1. 服务静态资源
任何 Web 应用程序可以执行的最简单的功能之一就是提供静态资源。Ring 提供了两个中间件函数来简化这个过程—— wrap-file和 wrap-resource。
wrap-file中间件采用 文件系统上的目录。如果传入请求与此目录中的文件匹配,则返回该文件,而不是调用处理函数:
(use 'ring.middleware.file)
(def app-handler (wrap-file your-handler "/var/www/public"))
以非常相似的方式,wrap-resource中间件采用类路径前缀来查找文件:
(use 'ring.middleware.resource)
(def app-handler (wrap-resource your-handler "public"))
在这两种情况下,包装的处理函数只有在没有找到返回给客户端的文件时才会被调用。
Ring 还提供了额外的中间件,使这些中间件更简洁,可以通过 HTTP API 使用:
(use 'ring.middleware.resource
'ring.middleware.content-type
'ring.middleware.not-modified)
(def app-handler
(-> your-handler
(wrap-resource "public")
wrap-content-type
wrap-not-modified)
wrap-content-type中间件将根据 请求的文件扩展名自动确定要设置的 Content-Type标头。wrap-not-modified中间件将 If -Not-Modified标头与 Last-Modified值进行比较以支持 HTTP 缓存,仅在需要时才返回文件。
4.2. 访问请求参数
在处理请求时,客户端可以通过一些重要的方式向服务器提供信息。其中包括查询字符串参数——包含在 URL 和表单参数中——作为 POST 和 PUT 请求的请求负载提交。
在我们可以使用参数之前,我们必须使用wrap-params中间件来包装处理程序。这会正确解析参数,支持 URL 编码,并使它们可用于请求。这可以选择指定要使用的字符编码,如果未指定,则默认为 UTF-8:
(def app-handler
(-> your-handler
(wrap-params {:encoding "UTF-8"})
))
完成后,请求将更新以使参数可用。这些进入传入请求中的适当键:
- :query-params – 从查询字符串中解析出来的参数
- :form-params – 从表单主体中解析出来的参数
- :params – :query-params 和 :form-params 的组合
我们可以完全按照预期在我们的请求处理程序中使用它。
(defn echo-handler [{params :params}]
(ring.util.response/content-type
(ring.util.response/response (get params "input"))
"text/plain"))
此处理程序将返回包含来自参数input的值的响应。
如果仅存在一个值,则参数映射到单个字符串,如果存在多个值,则映射到列表。
例如,我们得到以下参数映射:
// /echo?input=hello
{"input "hello"}
// /echo?input=hello&name=Fred
{"input "hello" "name" "Fred"}
// /echo?input=hello&input=world
{"input ["hello" "world"]}
4.3. 接收文件上传
通常我们希望能够编写用户可以上传文件的 Web 应用程序。在 HTTP 协议中,这通常使用 Multipart 请求来处理。这些允许单个请求同时包含表单参数和一组文件。
**Ring 带有一个名为 wrap-multipart-params的中间件来处理这种请求。**这类似于 wrap-params解析简单请求的方式。
wrap-multipart-params自动解码任何上传的文件并将其存储到文件系统中,并告诉处理程序它们在哪里可以使用它们:
(def app-handler
(-> your-handler
wrap-params
wrap-multipart-params
))
默认情况下,上传的文件存储在临时系统目录中,并在一个小时后自动删除。请注意,这确实要求 JVM 在接下来的一个小时内仍在运行以执行清理。
如果愿意,还有一个 in-memory store,但很明显,如果上传大文件,这可能会导致内存不足。
如果需要,我们也可以编写我们的存储引擎,只要它满足 API 要求。
(def app-handler
(-> your-handler
wrap-params
(wrap-multipart-params {:store ring.middleware.multipart-params.byte-array/byte-array-store})
))
一旦设置了这个中间件,上传的文件就可以在params键下的传入请求对象上使用。这与使用wrap-params中间件相同。此条目是一个映射,其中包含使用文件所需的详细信息,具体取决于所使用的存储。
例如,默认的临时文件存储返回值:
{"file" {:filename "words.txt"
:content-type "text/plain"
:tempfile #object[java.io.File ...]
:size 51}}
其中*:tempfile*条目是一个 java.io.File对象,它直接表示文件系统上的文件。
4.4. 使用 Cookie
Cookie 是一种机制,服务器可以在其中提供少量数据,客户端将在后续请求中继续发回这些数据。这通常用于会话 ID、访问令牌或持久性用户数据,例如配置的本地化设置。
Ring 有中间件,可以让我们轻松地使用 cookie。这将自动解析传入请求的 cookie,并允许我们在传出响应上创建新的 cookie。
配置此中间件遵循与以前相同的模式:
(def app-handler
(-> your-handler
wrap-cookies
))
此时,所有传入的请求都将解析其 cookie 并将其放入 request 中的 :cookies键中。这将包含 cookie 名称和值的映射:
{"session_id" {:value "session-id-hash"}}
然后,我们可以通过将 :cookies键添加到传出响应中来将 cookie 添加到传出响应中。我们可以通过直接创建响应来做到这一点:
{:status 200
:headers {}
:cookies {"session_id" {:value "session-id-hash"}}
:body "Setting a cookie."}
还有一个帮助函数,我们可以使用它来将 cookie 添加到响应中,类似于我们之前设置状态代码或标题的方式:
(ring.util.response/set-cookie
(ring.util.response/response "Setting a cookie.")
"session_id"
"session-id-hash")
根据 HTTP 规范的需要,**Cookie 还可以设置其他选项。**如果我们使用 set-cookie,那么我们在键和值之后提供这些作为映射参数。这映射的关键是:
- :domain – 将 cookie 限制到的域
- :path – 将 cookie 限制到的路径
- :secure – true 仅在 HTTPS 连接上发送 cookie
- :http-only – true使 JavaScript 无法访问 cookie
- :max-age – 浏览器删除 cookie 的秒数
- :expires – 浏览器删除 cookie 之后的特定时间戳
- :same-site – 如果设置为 :strict,则浏览器不会将此 cookie 与跨站点请求一起发回。
(ring.util.response/set-cookie
(ring.util.response/response "Setting a cookie.")
"session_id"
"session-id-hash"
{:secure true :http-only true :max-age 3600})
4.5. 会话
Cookie 使我们能够存储客户端在每次请求时发送回服务器的信息位。实现这一点的更强大的方法是使用会话。这些完全存储在服务器上,但客户端维护确定要使用哪个会话的标识符。
与这里的所有其他内容一样,会话是使用中间件函数实现的:
(def app-handler
(-> your-handler
wrap-session
))
默认情况下,这会将会话数据存储在内存中。如果需要,我们可以更改此设置,Ring 带有一个替代存储,它使用 cookie 来存储所有会话数据。
与上传文件一样,如果需要,我们可以提供我们的存储功能。
(def app-handler
(-> your-handler
wrap-cookies
(wrap-session {:store (cookie-store {:key "a 16-byte secret"})})
))
我们还可以调整用于存储会话密钥的 cookie 的详细信息。
例如,要使会话 cookie 持续一小时,我们可以这样做:
(def app-handler
(-> your-handler
wrap-cookies
(wrap-session {:cookie-attrs {:max-age 3600}})
))
此处的 cookie 属性与wrap-cookies中间件支持的相同 。
会话通常可以作为数据存储来使用。这在函数式编程模型中并不总是有效,因此 Ring 实现它们的方式略有不同。
相反,我们从请求中访问会话数据,并返回一个数据映射以作为响应的一部分存储到其中。这是要存储的整个会话状态,而不仅仅是更改的值。
例如,以下内容会记录请求处理程序的次数:
(defn handler [{session :session}]
(let [count (:count session 0)
session (assoc session :count (inc count))]
(-> (response (str "You accessed this page " count " times."))
(assoc :session session))))
以这种方式工作,我们可以简单地通过不包括 key 从会话中删除数据。我们还可以通过为新地图返回nil来删除整个会话。
(defn handler [request]
(-> (response "Session deleted.")
(assoc :session nil)))
5. Leiningen插件
Ring 为Leiningen 构建工具 提供了一个插件来帮助开发和生产。
我们通过将正确的插件详细信息添加到project.clj文件来设置插件:
:plugins [[lein-ring "0.12.5"]]
:ring {:handler ring.core/handler}
重要的是 lein-ring 的版本对于 Ring 的版本是正确的。这里我们一直在使用 Ring 1.7.1,这意味着我们需要 lein-ring 0.12.5。一般来说,只使用两者的最新版本是最安全的,如 Maven 中心或使用 lein search命令:
$ lein search ring-core
Searching clojars ...
[ring/ring-core "1.7.1"]
Ring core libraries.
$ lein search lein-ring
Searching clojars ...
[lein-ring "0.12.5"]
Leiningen Ring plugin
:ring调用的 :handler参数 是我们要使用的处理程序的完全限定名称。这可以包括我们定义的任何中间件。
使用这个插件意味着我们不再需要一个 main 函数。我们可以使用 Leiningen 在开发模式下运行,或者我们可以构建一个生产工件用于部署目的。我们的代码现在完全符合我们的逻辑,仅此而已。
5.1. 构建生产工件
设置完成后,我们现在可以构建一个 WAR 文件,我们可以将其部署到任何标准 servlet 容器:
$ lein ring uberwar
2019-04-12 07:10:08.033:INFO::main: Logging initialized @1054ms to org.eclipse.jetty.util.log.StdErrLog
Created ./clojure/ring/target/uberjar/ring-0.1.0-SNAPSHOT-standalone.war
我们还可以构建一个独立的 JAR 文件,它将完全按照预期运行我们的处理程序:
$ lein ring uberjar
Compiling ring.core
2019-04-12 07:11:27.669:INFO::main: Logging initialized @3016ms to org.eclipse.jetty.util.log.StdErrLog
Created ./clojure/ring/target/uberjar/ring-0.1.0-SNAPSHOT.jar
Created ./clojure/ring/target/uberjar/ring-0.1.0-SNAPSHOT-standalone.jar
这个 JAR 文件将包含一个主类,它将在我们包含的嵌入式容器中启动处理程序。这也将尊重PORT的环境变量,使我们能够轻松地在生产环境中运行它:
PORT=2000 java -jar ./clojure/ring/target/uberjar/ring-0.1.0-SNAPSHOT-standalone.jar
2019-04-12 07:14:08.954:INFO::main: Logging initialized @1009ms to org.eclipse.jetty.util.log.StdErrLog
WARNING: seqable? already refers to: #'clojure.core/seqable? in namespace: clojure.core.incubator, being replaced by: #'clojure.core.incubator/seqable?
2019-04-12 07:14:10.795:INFO:oejs.Server:main: jetty-9.4.z-SNAPSHOT; built: 2018-08-30T13:59:14.071Z; git: 27208684755d94a92186989f695db2d7b21ebc51; jvm 1.8.0_77-b03
2019-04-12 07:14:10.863:INFO:oejs.AbstractConnector:main: Started [[email protected]](/cdn_cgi/l/email_protection){HTTP/1.1,[http/1.1]}{0.0.0.0:2000}
2019-04-12 07:14:10.863:INFO:oejs.Server:main: Started @2918ms
Started server on port 2000
5.2. 在开发模式下运行
出于开发目的,我们可以直接从 Leiningen 运行处理程序,而无需手动构建和运行它。这使得在真实浏览器中测试我们的应用程序变得更容易:
$ lein ring server
2019-04-12 07:16:28.908:INFO::main: Logging initialized @1403ms to org.eclipse.jetty.util.log.StdErrLog
2019-04-12 07:16:29.026:INFO:oejs.Server:main: jetty-9.4.12.v20180830; built: 2018-08-30T13:59:14.071Z; git: 27208684755d94a92186989f695db2d7b21ebc51; jvm 1.8.0_77-b03
2019-04-12 07:16:29.092:INFO:oejs.AbstractConnector:main: Started [[email protected]](/cdn_cgi/l/email_protection){HTTP/1.1,[http/1.1]}{0.0.0.0:3000}
2019-04-12 07:16:29.092:INFO:oejs.Server:main: Started @1587ms
如果我们设置了它,这也会尊重 PORT 环境变量。
此外,我们可以将 Ring Development 库添加到我们的项目中。如果这可用,则开发服务器将尝试自动重新加载任何检测到的源更改。这可以为我们提供一个有效的工作流程来更改代码并在我们的浏览器中实时查看它。这需要添加ring-devel依赖:
[ring/ring-devel "1.7.1"]