Kit Framework文档
Table of Contents
- 1. 留言板 (Guestbook) 应用
- 2. Kit 与 REPL 交互方式
- 3. Kit-clj 配置说明
- 4. 模块
- 5. Kit 框架概述
- 6. HTML模板
- 7. 静态资源
- 8. Reitit 路由
- 9. Websockets
- 10. 请求和响应
- 11. Kit 中间件
- 12. Kit Web 框架中的会话管理
- 13. Kit 数据库配置
- 14. cache
- 15. 调度 Quartz 集成
- 16. Logging (日志)
- 17. Clojure测试工具
- 18. Kit 默认使用 Undertow 服务器
- 19. 环境变量
- 20. Clojure 应用部署指南
- 21. 编辑器
1. 留言板 (Guestbook) 应用
本教程将指导你使用 Kit 构建一个简单的留言板应用程序. 该留言板允许用户留言并查看他人留下的消息列表. 该应用程序将演示 HTML 模板,数据库访问和项目架构的基础知识.
如果你还没有偏好的 Clojure 编辑器. 建议使用 Calva 来跟随本教程.
1.1. 快速开始 Github Codespaces
你可以通过从以下仓库启动一个开发容器.在基于浏览器的环境里跟随本教程:https://github.com/kit-clj/playground/.
1.2. 安装 JDK
Clojure 运行在 JVM 上.需要安装 JDK.如果你的系统上还没有 JDK.推荐使用 OpenJDK.可以在 这里 下载.请注意.Kit 需要 JDK 11 或更高版本才能使用默认设置工作.或者.按照你系统安装软件包的说明进行操作.
1.3. 安装构建工具
为了构建和运行项目.Kit 支持 Clojure Deps 和 CLI.注意 Kit 需要 tools.build 版本 1.10.3.933 或更高.
要安装 Clojure CLI.请根据你的操作系统按照以下步骤操作.
1.3.1. MacOS
brew install clojure/tools/clojure
1.3.2. Linux
curl -L -O https://github.com/clojure/brew-install/releases/latest/download/posix-install.sh chmod +x posix-install.sh sudo ./posix-install.sh
对于 macOS 和 Linux.你都需要安装 clj-new.如下所示:
clojure -Ttools install com.github.seancorfield/clj-new '{:git/tag "v1.2.404"}' :as clj-new
有关自定义选项的信息.例如如何更改安装位置.请参阅 官方文档.
1.4. 创建新应用
安装 Clojure CLI 后.你可以在终端中运行以下命令来初始化你的应用程序:
clojure -Tclj-new create :template io.github.kit-clj :name kit/guestbook
cd guestbook
以上命令将基于 kit-clj 模板创建一个名为 kit/guestbook 的新项目.
1.5. Kit 应用剖析
新创建的应用程序具有以下结构:
├── Dockerfile
├── README.md
├── build.clj
├── deps.edn
├── env
│ ├── dev
│ │ ├── clj
│ │ │ ├── user.clj
│ │ │ └── kit
│ │ │ └── guestbook
│ │ │ ├── dev_middleware.clj
│ │ │ └── env.clj
│ │ └── resources
│ │ └── logback.xml
│ └── prod
│ ├── clj
│ │ └── kit
│ │ └── guestbook
│ │ └── env.clj
│ └── resources
│ └── logback.xml
├── kit.edn
├── kit.git-config.edn
├── project.clj
├── resources
│ └── system.edn
├── src
│ └── clj
│ └── kit
│ └── guestbook
│ ├── config.clj
│ ├── core.clj
│ └── web
│ ├── controllers
│ │ └── health.clj
│ ├── handler.clj
│ ├── middleware
│ │ ├── core.clj
│ │ ├── exception.clj
│ │ └── formats.clj
│ └── routes
│ ├── api.clj
│ └── utils.clj
└── test
└── clj
└── kit
└── guestbook
└── test_utils.clj
让我们看看应用程序根目录中的文件都做了什么:
deps.edn- 由 deps 用于管理项目配置和依赖项build.clj- 由 Clojure CLI 工具用于管理构建过程Dockerfile- 用于方便 Docker 容器部署README.md- 按照惯例放置应用程序文档的地方resources/system.edn- 用于系统配置.gitignore- 要从 Git 中排除的资源列表.例如构建生成的文件
1.5.1. 源目录 (Source Directory)
我们所有的代码都位于 src/clj 文件夹下.由于我们的应用程序名为 kit/guestbook.这是项目的根命名空间.让我们看看为我们创建的所有命名空间.
- guestbook
config.clj- 这是读取你的system.edn文件以创建 integrant 配置 map 的地方core.clj- 这是应用程序的入口点.包含启动和停止服务器的逻辑
- guestbook.web
web命名空间用于定义应用程序中处理服务器通信的边缘部分.例如接收 HTTP 请求和返回响应.handler.clj- 定义路由和请求处理的入口点.
- guestbook.web.controllers
controllers命名空间是控制器所在的位置.默认情况下.会为你创建一个健康检查控制器.当你添加更多控制器时.应在此处为它们创建命名空间.healthcheck.clj- 默认控制器.返回有关服务器的基本统计信息
- guestbook.web.middleware
middleware命名空间包含实现横切功能的函数.例如会话管理,强制转换等.这些函数可以包装在路由组周围以提供通用功能.core.clj- 默认中间件和特定环境中间件的聚合exception.clj- 用于在控制器内对异常进行分类并返回适当 HTTP 响应的逻辑formats.clj- 处理将请求数据强制转换为 Clojure 数据结构.以及将响应数据转换回字符串
- guestbook.web.routes
routes命名空间是定义 HTTP 路由的地方.默认情况下.会为你创建/api路由.当你添加更多路由时.应在此处为它们创建命名空间.api.clj- 一个带有 Swagger UI 的路由命名空间(默认为/api)utils.clj- 用于从请求中获取数据的通用辅助函数
1.5.2. Env 目录
特定环境的代码和资源位于 env/dev, env/test 和 env/prod 路径下. dev 配置将在开发和测试期间使用. test 在测试期间使用.而 prod 配置将在应用程序打包用于生产时使用.
- Dev 目录
任何旨在在开发期间使用的源代码都放在
dev/clj中. 创建项目时.默认会生成以下命名空间:user.clj- 一个实用程序命名空间. 用于存放你希望在 REPL 开发期间运行的任何代码. 你在开发期间从这里启动和停止服务器.guestbook/env.clj- 包含开发配置默认值guestbook/dev_middleware.clj- 包含用于开发的中间件. 这些中间件不应编译到生产代码中
在开发期间使用的资源放在
dev/resources中.默认情况下.此文件夹将包含为开发调整的 logback 配置:logback.xml文件用于配置开发日志记录配置文件, 类似地.测试配置放在test/resources中:
- Prod 目录
此目录是
dev目录的对应部分. 包含将在应用程序构建用于生产时使用的命名空间和资源的版本.创建项目时.prod/clj文件夹将包含以下命名空间:guestbook/env.clj命名空间.包含生产配置
同时.
prod/resources将包含为生产使用调整的 logback 配置:logback.xml- 默认的生产日志记录配置
- Test 目录
这是我们放置应用程序测试的地方. 提供了一些测试实用程序.
- Resources 目录
这是我们放置将与应用程序一起打包的所有资源的地方.
resources下public目录中的任何内容都将由服务器提供给客户端.
1.6. 启动我们的服务器
REPL 是你在 Clojure 中最好的朋友.让我们通过运行以下命令来启动本地开发 REPL:
clj -M:dev
如果你打算从 Emacs 和 CIDER 连接到 REPL.可以或者使用 clj -M:dev:cider (这也适用于 VS Code 和 Calva).或者.如果你想启动 nREPL 但不需要 CIDER 中间件.请执行 clj -M:dev:nrepl.有关使用 Clojure 编辑器连接 REPL 的更多信息.请参阅 Guestbook 示例 README.
进入 REPL 后. 可以通过运行 env/dev/user.clj 中提供的命令来启动系统:
(go) ;; 启动系统 (halt) ;; 停止系统 (reset) ;; 在更改代码后刷新系统
要确认你的服务器正在运行.请访问 http://localhost:3000/api/health.
1.7. 系统 (System)
系统资源(例如 HTTP 服务器端口,数据库连接)在 resources/system.edn 文件中定义. 例如, 此键定义了 HTTP 服务器配置.如主机,端口和 HTTP 处理程序:
:server/http {:port #long #or [#env PORT 3000] :host #or [#env HTTP_HOST "0.0.0.0"] :handler #ig/ref :handler/ring}
现在我们已经了解了默认项目的结构.让我们看看如何通过模块添加一些额外的功能.
1.8. Kit 模块 (Modules)
Kit 模块包含可用于将代码和资源注入 Kit 项目的模板. 模块在 kit.edn 文件中定义.默认情况下.配置将指向官方模块存储库:
:modules {:root "modules" :repositories [{:url "git@github.com:kit-clj/modules.git" :tag "master" :name "kit-modules"}]}
由于我们的应用程序需要提供一些 HTML 内容.让我们添加官方的 HTML 模块.在你的 REPL 中. 可以执行以下操作:
;; 这将从 git 下载官方 Kit 模块 (kit/sync-modules) ;; 这将列出可用的模块 (kit/list-modules) ;; => ;; :kit/html - 使用 Selmer 添加对 HTML 模板的支持 ;; :kit/sql - 添加对 SQL 的支持.可用配置文件 [ :postgres :sqlite ].默认配置文件 :sqlite ;; :kit/cljs - 使用 shadow-cljs 添加对 cljs 的支持 ;; nil ;; 为了能够提供 HTML 页面.安装 :html 模块 (kit/install-module :kit/html) ;; => ;; updating file: resources/system.edn ;; injecting ;; path: [:reitit.routes/pages] ;; value: {:base-path "", :env #ig/ref :system/env} ;; updating file: deps.edn ;; injecting ;; path: [:deps selmer/selmer] ;; value: #:mvn{:version "1.12.44"} ;; injecting ;; path: [:deps ring/ring-defaults] ;; value: #:mvn{:version "0.3.3"} ;; injecting ;; path: [:deps luminus/ring-ttl-session] ;; value: #:mvn{:version "0.3.3"} ;; updating file: src/clj/kit/guestbook/core.clj ;; applying ;; action: :append-requires ;; value: [[kit.guestbook.web.routes.pages]] ;; html installed successfully! ;; restart required! ;; nil
从 kit/install-module 的输出中我们可以看到需要重启 REPL. 让我们这样做. 再次启动后.我们可以通过运行 (go) 启动服务器并导航到 localhost:3000 来测试我们的模块是否已正确安装.
1.8.1. HTML 模板
该模块在 resources/html 目录下生成了以下文件:
home.html- 主页error.html- 错误页面模板
此目录保留用于表示应用程序页面的 HTML 模板.
该模块还生成了 kit.guestbook.web.pages.layout 命名空间.可帮助你使用 Selmer 模板引擎呈现 HTML 页面.
1.8.2. 路由 (Routing)
该模块还帮助我们在 kit.guestbook.web.routes.pages 下生成了一些路由.有关更多详细信息.请参阅路由文档.
1.9. 添加数据库
与安装 HTML 模块类似.我们可以添加一个名为 :kit/sql 的带有 SQLite 的 SQL 模块.默认配置文件开箱即用地包含了 SQLite.但如果我们想明确指定.
也可以写成 (kit/install-module :kit/sql {:feature-flag :sqlite}).
(kit/install-module :kit/sql) ;; updating file: resources/system.edn ;; injecting ;; path: [:db.sql/connection] ;; value: #profile {:dev {:jdbc-url "jdbc:sqlite:_dev.db"}, :test {:jdbc-url "jdbc:sqlite:_test.db"}, :prod {:jdbc-url #env JDBC_URL}} ;; injecting ;; path: [:db.sql/query-fn] ;; value: {:conn #ig/ref :db.sql/connection, :options {}, :filename "sql/queries.sql"} ;; injecting ;; path: [:db.sql/migrations] ;; value: {:store :database, :db {:datasource #ig/ref :db.sql/connection}, :migrate-on-init? true} ;; updating file: deps.edn ;; injecting ;; path: [:deps io.github.kit-clj/kit-sql] ;; value: #:mvn{:version "0.1.0"} ;; injecting ;; path: [:deps org.xerial/sqlite-jdbc] ;; value: #:mvn{:version "3.34.0"} ;; updating file: src/clj/kit/guestbook/core.clj ;; applying ;; action: :append-requires ;; value: [[kit.edge.db.sql]] ;; sql installed successfully! ;; restart required!
再次重启并创建你的第一个数据库迁移 (migration).
(migratus.core/create (:db.sql/migrations state/system) "add-guestbook-table")
这将在你的 resources/migrations 目录下生成两个文件.它们看起来像这样.但前缀不同:
20211109173842-add-guestbook-table.up.sql 20211109173842-add-guestbook-table.down.sql
Kit 使用 Migratus 进行迁移.迁移通过 up 和 down SQL 文件进行管理.这些文件按照惯例使用日期进行版本控制.并将按其创建顺序应用.
让我们在 <date>-add-guestbook-table.up.sql 文件下添加一些内容来创建我们的 messages 表:
CREATE TABLE guestbook (id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(30), message VARCHAR(200), timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
guestbook 表将存储描述消息的所有字段.例如评论者的姓名,消息内容和时间戳.接下来.让我们相应地替换 <date>-add-guestbook-table.down.sql 文件的内容:
DROP TABLE IF EXISTS guestbook;
迁移将使用 system.edn 中的配置自动运行:
:db.sql/migrations {:store :database, :db {:datasource #ig/ref :db.sql/connection}, :migrate-on-init? true}
1.10. 访问数据库
SQL 查询位于 resources/sql 文件夹中.
queries.sql- 定义 SQL 查询及其关联的函数名称
让我们看一下 queries.sql 模板文件.其内容应如下所示:
-- :name create-user! :! :n -- :doc 创建一个新的用户记录 INSERT INTO users (id, first_name, last_name, email, pass) VALUES (:id, :first_name, :last_name, :email, :pass) -- :name update-user! :! :n -- :doc 更新一个现有用户记录 UPDATE users SET first_name = :first_name, last_name = :last_name, email = :email WHERE id = :id -- :name get-user :? :1 -- :doc 根据 id 检索用户. SELECT * FROM users WHERE id = :id
该文件最初包含一些占位符查询.以帮助你记住基本的 SQL 语法.正如我们所见.每个函数都使用以 -- :name 开头的注释后跟函数名称来定义.下一个注释提供了函数的文档字符串.最后是纯 SQL 的主体.有关此语法的完整文档.你可以查看 HugSQL 文档.参数使用 : 表示法表示.让我们用我们自己的查询替换现有的查询:
-- :name save-message! :! :n -- :doc 创建一条新消息 INSERT INTO guestbook (name, message) VALUES (:name, :message) -- :name get-messages :? :* -- :doc 选择所有可用的消息 SELECT * FROM guestbook
现在我们的模型已经设置好了.让我们重新加载应用程序.并在 REPL 中测试我们的查询:
(reset) (def query-fn (:db.sql/query-fn state/system)) (query-fn :save-message! {:name "m1" :message "hello world"}) ;; => 1 (query-fn :get-messages {}) ;; => [{:id 1, :name "m1", :message "hello world", :timestamp 1636480432353}]
在这个例子中.新定义的 query-fn 函数允许你执行在 queries.sql 中定义的 SQL 函数.
它通过使用随 kit-sql~(你安装的 ~kit/sqlite 的依赖项)提供的 :db.sql/query-fn 组件来实现这一点.
如你所见. query-fn 接受两个参数:要调用的 SQL 查询函数的名称.以及该函数所需参数的 map.
有关像 :db.sql/query-fn 这样的组件如何工作的更多信息.请参阅访问组件 (Accessing Components).
1.11. 在路由组件中暴露数据库查询
现在我们已经添加了查询.我们需要更新 resources/system.edn 以使这些查询在页面路由组件中可用.为此.在组件定义中添加一个 :query-fn 键.如下所示:
:reitit.routes/pages {:base-path "", :query-fn #ig/ref :db.sql/query-fn :env #ig/ref :system/env}
该键引用了 :db.sql/query-fn 组件.该组件负责使用在 resources/sql/queries.sql 文件中找到的模板来实例化查询函数:
:db.sql/query-fn {:conn #ig/ref :db.sql/connection, :options {}, :filename "sql/queries.sql"}
就像在"访问数据库"部分末尾的 REPL 示例中一样.~:db.sql/query-fn~ 组件来自 kit-sql.与该示例不同的是:
- 我们传递了特定的数据库连接信息
- 我们只提供来自一个特定文件的查询函数.
有关像 :db.sql/query-fn 这样的组件如何工作的更多信息.请参阅访问组件 (Accessing Components).
1.12. 为留言板创建控制器 (Controller)
我们将创建一个新的控制器.负责在数据库中保存新消息.让我们创建一个名为 kit.guestbook.web.controllers.guestbook 的新命名空间.
该命名空间应放在 src/clj/kit/guestbook/web/controllers/ 文件夹下相应的文件 guestbook.clj 中. 我们将向该命名空间添加以下内容:
(ns kit.guestbook.web.controllers.guestbook (:require [clojure.tools.logging :as log] [ring.util.http-response :as http-response])) (defn save-message! [{:keys [query-fn]} {{:strs [name message]} :form-params :as request}] (log/debug "saving message" name message) (try (if (or (empty? name) (empty? message)) (cond-> (http-response/found "/") (empty? name) (assoc-in [:flash :errors :name] "name is required") ; 名称是必需的 (empty? message) (assoc-in [:flash :errors :message] "message is required")) ; 消息是必需的 (do (query-fn :save-message! {:name name :message message}) (http-response/found "/"))) (catch Exception e (log/error e "failed to save message!") ; 保存消息失败! (-> (http-response/found "/") (assoc :flash {:errors {:unknown (.getMessage e)}})))))
如你所见.该命名空间包含一个 save-message! 函数.该函数执行将新消息添加到 guestbook 表的查询.
查询是从传递给处理程序的第一个参数(即 Integrant 系统 map)中访问的. query-fn 键包含查询函数的 map.
这些函数的名称是从 resources/sq/queries.sql 文件中的 SQL 模板中的 -- :name 注释推断出来的.
我们的函数将从包含表单数据的请求中获取 form-params 键.并尝试将消息保存在数据库中.控制器将重定向回主页.并将任何错误设置为响应上的 flash 会话.
1.13. 创建页面和处理表单输入
HTML 页面的路由在 kit.guestbook.web.routes.pages 命名空间中定义.让我们在命名空间声明中引用我们的 kit.guestbook.web.controllers.guestbook 和 kit.guestbook.web.routes.utils 命名空间.
(ns kit.guestbook.web.routes.pages (:require ;; ... 其他 require [kit.guestbook.web.controllers.guestbook :as guestbook]))
现在我们可以通过更新 home-page 处理函数来添加从数据库渲染消息的逻辑.使其看起来如下:
(defn home [{:keys [query-fn]} {:keys [flash] :as request}] (layout/render request "home.html" {:messages (query-fn :get-messages {}) :errors (:errors flash)}))
该函数现在渲染 home.html 模板.并将来自数据库的消息(使用 :messages 键)和任何错误(使用 :errors 键)传递给它.
最后.我们将在 page-routes 函数中添加 /save-message 路由.当表单提交发生时.此路由会将请求传递给我们上面定义的 guestbook/save-message! 函数:
(defn page-routes [opts] [["/" {:get (partial home opts)}] ["/save-message" {:post (partial guestbook/save-message! opts)}]])
现在我们已经设置好了控制器.让我们打开位于 resources/html 目录中的 home.html 模板.目前.它只是渲染一个静态页面.我们将更新我们的 content div 以迭代消息并在列表中打印每一条消息:
<div class="content container"> <div class="columns"> <div class="column"> <h3>Messages</h3> <ul class="messages"> {% for item in messages %} <li> <time>{{item.timestamp}}</time> <p>{{item.message}}</p> <p> - {{item.name}}</p> </li> {% endfor %} </ul> </div> </div> </div>
如上所示.我们使用 for 迭代器来遍历消息.由于每条消息都是一个包含 message,~name~ 和 timestamp 键的 map.我们可以按名称访问它们.
最后, 我们将创建一个表单以允许用户提交他们的消息.如果提供了 name 和 message 值.我们将填充它们.
并渲染与它们相关的任何错误. 请注意, 表单还使用了 csrf-field 标签.这是防止跨站请求伪造 (CSRF) 所必需的.
<div class="columns"> <div class="column"> {% if errors.unknown %} <div class="notification is-danger">{{errors.unknown}}</div> {% endif %} <form method="POST" action="/save-message"> {% csrf-field %} <p> <label> Name: <input class="input" type="text" name="name" value="{{name}}" /> </label> </p> {% if errors.name %} <div class="notification is-danger">{{errors.name}}</div> {% endif %} <p> <label> Message: <textarea class="textarea" name="message">{{message}}</textarea> </label> </p> {% if errors.message %} <div class="notification is-danger">{{errors.message}}</div> {% endif %} <input type="submit" class="button is-primary is-outlined has-text-dark" value="comment" /> </form> </div> </div>
我们最终的 content div 应如下所示:
<div class="content container"> <div class="columns"> <div class="column"> <h3>Messages</h3> <ul class="messages"> {% for item in messages %} <li> <time>{{item.timestamp}}</time> <p>{{item.message}}</p> <p> - {{item.name}}</p> </li> {% endfor %} </ul> </div> </div> <div class="columns"> <div class="column"> {% if errors.unknown %} <div class="notification is-danger">{{errors.unknown}}</div> {% endif %} <form method="POST" action="/save-message"> {% csrf-field %} <p> <label> Name: <input class="input" type="text" name="name" value="{{name}}" /> </label> </p> {% if errors.name %} <div class="notification is-danger">{{errors.name}}</div> {% endif %} <p> <label> Message: <textarea class="textarea" name="message">{{message}}</textarea> </label> </p> {% if errors.message %} <div class="notification is-danger">{{errors.message}}</div> {% endif %} <input type="submit" class="button is-primary is-outlined has-text-dark" value="comment" /> </form> </div> </div> </div>
我们的网站现在应该可以运行了.但它看起来有点平淡.让我们使用 Bulma CSS 框架为其添加一些样式.我们将在模板的 <head> 中添加以下引用:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css">
最后.我们将更新位于 resources/public/css 文件夹中的 screen.css 文件.以更好地格式化我们的表单:
ul { list-style: none; } ul.messages li { position: relative; font-size: 16px; padding: 5px; border-bottom: 1px dotted #ccc; } li:last-child { border-bottom: none; } li time { font-size: 12px; padding-bottom: 20px; } form, .error { padding: 30px; margin-bottom: 50px; position: relative; }
当我们在浏览器中重新加载页面时.应该会看到留言板页面.我们可以通过在评论表单中添加评论来测试一切是否按预期工作.
要了解更多可以与 Kit 一起使用的 HTML 模板选项.请参阅 HTML 模板 (HTML Templating).
1.14. 添加一些测试
测试位于 test 源路径下.
我们现在可以在终端中运行 clj -M:test.以查看我们的数据库交互是否按预期工作.
1.15. 打包应用
你可以通过运行以下命令将应用程序打包以进行独立部署:
clj -Sforce -T:build all
这将创建一个可运行的 jar.你可以使用以下命令运行它:
export JDBC_URL="jdbc:sqlite:guestbook_dev.db" java -jar target/guestbook-standalone.jar
请注意.在作为 jar 运行时. 我们必须提供 JDBC_URL 环境变量, 因为它没有与应用程序一起打包.
#+endsrc
2. Kit 与 REPL 交互方式
2.1. 开发阶段
- 本地REPL:
clj -M:dev - nREPL:
clj -M:dev:nrepl(终端或编辑器), CIDER:clj -M:dev:cider
2.2. 生产环境/独立应用
连接运行中的系统REPL, 两种库:
- kit-repl: 配置
+socket-repl或+full, 端口 7200 (默认, 可通过 system.edn 或REPL_PORT环境变量修改) - kit-nrepl: 配置
+nrepl, 端口 7000 (默认, 可通过system.edn或NREPL_PORT环境变量修改)
启动服务器( (go) 命令)即启动REPL. 默认监听本地连接.
3. Kit-clj 配置说明
3.1. Profiles (配置文件)
Profiles 是 clj-new 模板参数, 用于新建项目时预设功能. 通过 profiles, 可以避免手动添加和配置库.
与 modules 不同, profiles 绑定到项目生成工具 clj-new, 仅在创建项目时使用.
创建项目的基本命令: clojure -Tclj-new create :template io.github.kit-clj :name yourname/app, 这将使用默认模板.
默认包含的库:
- kit-core
- kit-undertow
其他 profiles:
- +xtdb: 添加 kit-xtdb
- +hato: 添加 kit-hato
- +metrics: 添加 kit-metrics
- +nrepl: 添加 kit-nrepl, 移除 kit-repl. (开发时无需此 profile 也可以使用 nREPL)
- +quartz: 添加 kit-quartz
- +redis: 添加 kit-redis
- +selmer: 添加 kit-selmer
- +socket-repl: 添加 kit-repl
- +sql: 添加 kit-sql 和 kit-postgres
- +full: 添加 kit-xtdb, kit-hato, kit-metrics, kit-quartz, kit-redis, kit-repl, kit-selmer, 和 kit-sql
使用 profile 的示例:
- 单个:
clojure -Tclj-new create :template io.github.kit-clj :name yourname/app :args '[+selmer]' - 多个:
clojure -Tclj-new create :template io.github.kit-clj :name yourname/app :args '[+selmer +xtdb]'
3.2. Libraries (库)
- kit-core: 其他库使用的基础工具函数
- kit-xtdb: 连接 XTDB 数据库节点
- kit-hato: 基于 hato 的 HTTP 客户端
- kit-metrics: 基于 iapetos 的可配置指标
- kit-nrepl: nREPL 组件 (开发时无需此库也可使用 nREPL)
- kit-quartz: 基于 cronut 的 quartz 调度器, 提供 cronut API 和 aero 扩展
- kit-redis: 基于 carmine 的 core.cache 的 Redis 扩展
- kit-repl: Socket REPL 集成绑定
- kit-selmer: selmer 模板引擎配置
- kit-sql: 通用 SQL 集成绑定, 使用 conman, next.jdbc, hugsql 和 migratus, 默认导入支持 Postgresql 的 kit-postgres
- kit-postgres: Postgresql 数据绑定和工具库
- kit-undertow: 基于 luminus-undertow 的服务器绑定
4. 模块
Kit 被设计成模块化的. 其核心是构建应用程序的最小基础, 大部分功能由可选模块提供.
当你创建一个新项目时, 你可以选择想要使用的模块. 所选模块将被自动配置并添加到你的项目中.
可以随时使用 kit-module 工具在项目中添加或删除模块.
4.1. 可用模块
4.1.1. HTTP
提供 HTTP 请求处理.
Kit 支持以下 HTTP 库:
aleph
http-kit
immutant
jetty
undertow (默认)
这些库在很大程度上彼此兼容, 并且可以通过更新你 system.edn 配置中的 :http/server 键进行替换.
4.1.2. SQL
提供 SQL 数据库访问.
Kit 支持以下 SQL 数据库:
h2 (默认)
mysql
oracle
postgres
sqlite
sqlserver
可以通过更新你 system.edn 配置中的 :db.sql/connection 键来配置它们.
4.1.3. 日志
提供结构化日志记录.
Kit 使用 tools.logging 外观, 并以 logback 作为默认后端.
你可以通过编辑 resources/logback.xml 文件来自定义日志记录行为.
4.1.4. 调度
使用 chime 库提供任务调度支持.
可以通过更新你 system.edn 配置中的 :scheduler/config 键来配置调度器.
4.1.5. 静态内容
提供静态内容服务支持.
当你直接从后端提供前端资源时, 此模块非常有用.
通常建议在生产环境中使用像 Nginx 这样的反向代理来提供静态内容.
4.1.6. Heroku
提供将应用程序部署到 Heroku 的支持.
此模块会向你的项目添加一个 Procfile 和一个 system.properties 文件.
4.1.7. Docker
提供使用 Docker 将应用程序容器化的支持.
此模块会向你的项目添加一个 Dockerfile 和一个 .dockerignore 文件.
4.1.8. Kubernetes
提供将应用程序部署到 Kubernetes 的支持.
此模块会向你的项目添加一个 skaffold.yaml 文件.
4.1.9. 示例代码
此模块可用于通过示例页面和测试来引导应用程序.
示例代码演示了如何构建应用程序结构以及如何使用所选模块.
4.2. 默认库
Kit 默认使用以下库:
- aero 用于配置管理
- buddy 用于身份验证和授权
- clj-http-lite 用于发出 HTTP 请求
- conman 用于 SQL 连接池和查询管理
- migratus 用于数据库迁移
- reitit 用于路由
- ring 用于 HTTP 请求/响应处理
- selmer 用于 HTML 模板渲染
4.3. REPL
Kit 为交互式开发提供了两种 REPL 选项:
- nREPL (默认)
- Socket REPL
可以在创建新项目时选择你偏好的 REPL, 或者通过更新你 system.edn 配置中的 :dev/repl 键在它们之间切换.
5. Kit 框架概述
5.1. 干净架构
Kit 鼓励使用干净架构来编写 Web 应用. Web 应用中的工作流程通常由客户端请求驱动. 由于请求通常需要与资源(例如数据库)交互, 因此我们通常必须从处理请求的路由访问该资源. 为了隔离有状态代码, 我们的顶层函数应该处理副作用管理.
以一个用户身份验证路由为例. 客户端将在请求中提供用户名和密码. 路由必须从数据库中提取用户凭据, 并将它们与客户端提供的凭据进行比较.
然后决定用户是否成功登录, 并将其结果传达回客户端.
在此工作流程中, 处理外部资源的代码应该本地化到提供路由的命名空间和处理数据库访问的命名空间.
路由处理函数将负责调用从数据库获取凭据的函数. 确定密码和用户名是否匹配的代码代表核心业务逻辑. 这段代码应该是纯的, 并显式地接受提供的凭据以及在数据库中找到的凭据. 这种结构可以在下图中看到:
pure code
+----------+
| business |
| logic |
| |
+-----+----+
|
------|---------------------
| stateful code
+-----+----+ +-----------+
| route | | |
| handlers +---+ database |
| | | |
+----------+ +-----------+
保持业务逻辑的纯净性确保我们可以在不考虑外部资源的情况下进行推理和测试. 同时, 处理副作用的代码被推到一个薄薄的外层, 使我们易于管理.
5.2. Integrant 概述
Kit 的核心是 Integrant. 它用于管理组件生命周期. 理论上, 库的每个边缘(执行输入/输出操作的元素)都应定义为 Integrant 组件. 如果熟悉 component 或 mount, Integrant 引入的概念听起来会很相似.
在 Kit 中, Integrant 组件在 system.edn 文件中定义. 此文件通过 aero 读取和解析, 允许一些额外的读取宏. 此配置告诉 Integrant 在初始化时传递给每个组件的参数. 每个键都是一个单独的组件, 并且必须在代码中定义一个 initialize 方法才能使系统正常启动.
Integrant 组件的完整生命周期为:
- expand(展开)
- init (初始化)
- suspend (停止但保留状态)
- resume(恢复)
- halt (停止并丢弃状态)
每个组件在 Integrant 中都有关联的多方法函数, 例如, 来自 kit redis 缓存:
;; 初始化时, 我们使用初始配置创建缓存 (defmethod ig/init-key :cache/redis [_ config] (cache/seed (RedisCache. {}) config)) ;; 挂起时, 不执行任何操作 (defmethod ig/suspend-key! :cache/redis [_ _]) ;; 恢复时, 我们调用一个函数来检查新选项是否与旧选项匹配 ;; 如果匹配, 则不执行任何操作, 否则重新初始化缓存 (defmethod ig/resume-key :cache/redis [key opts old-opts old-impl] (ig-utils/resume-handler key opts old-opts old-impl))
有关更多详细信息, Integrant 的自述文件(readme)写得很好, 并包含其他示例和摘要.
5.3. REPL 工作流程
为了方便起见, 生成的 user.clj 文件需要从 integrant.repl 中引入一些辅助函数.
最有用的函数是:
(go): 用于从未初始化状态启动应用程序(reset): 挂起, 刷新配置并恢复. 在进行更改并希望热加载它们时非常有用.(halt): 停止应用程序
可以访问系统状态原子 state/system.
如果您想从 REPL 运行测试, user.clj 中会生成一个辅助函数:
(defn test-prep! [] (integrant.repl/set-prep! (fn [] (-> (<project-ns>.config/system-config {:profile :test}) (ig/expand)))))
无论您的环境如何, 此函数都使用测试配置文件, 允许您像在该环境中一样执行测试. 如果您的测试环境有一组瞬态数据接收器(数据库, 缓存等), 而开发环境有一组永久性数据接收器, 则此功能特别有用.
5.4. 访问组件
现在我们已经讨论了 Integrant 的工作原理, 让我们看看控制器如何访问 Integrant 管理的组件. 假设我们定义了一些 SQL 查询, 并在 resources/system.edn 中添加了以下条目:
:db.sql/query-fn {:conn #ig/ref :db.sql/connection, :options {}, :filename "sql/queries.sql"}
上述配置定义了一个名为 :db.sql/query-fn 的组件, 负责使用 resources/sql/queries.sql 文件中的模板实例化查询函数.
使用它的组件必须显式引用该组件. 例如, 如果我们想从 :reitit.routes/pages 组件访问 SQL 查询, 那么我们必须按如下方式引用它:
:reitit.routes/pages {:query-fn #ig/ref :db.sql/query-fn ;; 查询引用 :base-path "", :env #ig/ref :system/env}
通过上述连接, 引用 :db.sql/query-fn 的 :query-fn 键将被注入到传递给实例化 :reitit.routes/pages 控制器的多方法的 opts 中:
(defmethod ig/init-key :reitit.routes/pages [_ {:keys [base-path ] :or {base-path ""} :as opts}] (layout/init-selmer!) [base-path route-data (page-routes opts)])
多方法应将 opts 传递给定义路由的函数, 然后再传递给将从 opts 映射中访问 :query-fn 键的请求处理函数, 如下所示:
(defn home [{:keys [query-fn]} {:keys [flash] :as request}] (layout/render request "home.html" {:messages (query-fn :get-messages {}) :errors (:errors flash)})) (defn page-routes [opts] [["/" {:get (partial home opts)}]])
6. HTML模板
6.1. Selmer 模板引擎
Selmer 类似于 Django 和 Rails 的模板引擎, 如果你熟悉 Django 或类似的模板语言, 应该会很容易上手.
6.1.1. 创建模板
Selmer 将表示逻辑与程序逻辑分离. 模板是带有动态元素标签的 HTML 文件. 动态元素在渲染步骤中解析. 以下是一个示例模板:
<html> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <title>My First Template</title> </head> <body> <h2>Hello {{name}}</h2> </body> </html>
模板使用由键/值对组成的映射表示的上下文进行渲染. 上下文包含我们希望在运行时在模板中渲染的变量. 上面, 我们有一个表示渲染名为 name 的单个变量的页面的模板.
有两个用于渲染模板的函数, 称为 render 和 render-file. render 函数接受一个表示模板的字符串, 而 render-file 函数接受一个表示包含模板的文件路径的字符串.
如果我们将上面定义的模板保存在一个名为 index.html 的文件中, 那么我们可以像下面这样渲染它:
(ns example.routes.home (:require [selmer.parser :refer [render-file]])) (render-file "html/index.html" {:name "John"})
render-file 函数期望模板位于相对于应用程序 resources 文件夹的路径中.
上面, 我们传递了一个字符串作为变量 name 的值. 但是, 我们不限于字符串, 可以传入任何我们喜欢的类型. 例如, 如果我们传入一个集合, 我们可以使用 for 标签迭代它:
<ul> {% for item in items %} <li> {{item}} </li> {% endfor %} </ul>
(render-file "html/items.html" {:items (range 10)})
如果一个项目恰好是一个映射, 我们可以通过名称访问键, 如下所示:
(render "<p>Hello {{user.first}} {{user.last}}</p>" {:user {:first "John" :last "Doe"}})
当模板中未指定特殊处理时, 将使用参数的 .toString 值.
默认情况下, Selmer 缓存已编译的模板. 如果文件的上次修改时间戳发生更改, 则会触发重新编译. 或者, 您可以通过分别调用 (selmer.parser/cache-on!) 和 (selmer.parser/cache-off!) 来打开和关闭缓存.
6.1.2. 过滤器
过滤器允许在渲染变量之前对它们进行后处理. 例如, 可以使用过滤器将变量转换为大写, 计算哈希值或计算长度. 过滤器通过在变量名后使用 | 来指定, 如下所示:
{{name|upper}}
提供以下内置过滤器: (略, 原文档已列出)
6.1.3. 自定义过滤器
可以使用 selmer.filters/add-filter! 函数轻松添加自己的过滤器. 过滤器函数应该接受元素并返回一个将替换原始值的值.
6.1.4. 标签
Selmer 提供两种类型的标签. 第一种是内联标签, 例如 extends 和 include 标签. 这些标签是独立的语句, 不需要结束标签. 另一种类型是块标签. 这些标签有开始和结束标签, 并对文本块进行操作. 例如 if … endif 块.
默认标签: (略, 原文档已列出)
6.1.5. 自定义标签
除了已提供的标签外, 您还可以轻松定义自己的自定义标签. 这是通过使用 add-tag! 宏来完成的.
6.1.6. 模板继承
Selmer 模板可以使用 block 标签引用其他模板. 有两种方法可以引用模板. 我们可以使用 extends 标签或 include 标签来实现这一点.
扩展模板
当我们使用 extends 标签时, 当前模板将使用它扩展的模板作为基础. 基本模板中任何与当前模板名称匹配的块都将被覆盖.
子模板的内容必须封装在块中. 父模板中存在的块之外的任何内容都将被忽略.
包含模板
include 标签允许在当前模板中包含来自其他模板的块.
Hiccup 模板引擎
Hiccup 是 Clojure 的一个流行的 HTML 模板引擎. 使用 Hiccup 的优势在于我们可以使用 Clojure 的全部功能来生成和操作我们的标记. 这意味着您不必学习单独的 DSL 来生成具有自己的规则和怪癖的 HTML.
在 Hiccup 中, HTML 元素由 Clojure 向量表示, 元素的结构如下所示:
[:tag-name {:attribute-key "attribute value"} tag-body]
例如, 如果我们想创建一个包含段落的 div, 我们可以这样写:
[:div {:id "hello", :class "content"} [:p "Hello world!"]]
这对应于以下 HTML:
<div id="hello" class="content"><p>Hello world!</p></div>
Hiccup 提供了设置元素 id 和 class 的快捷方式, 因此除了我们上面写的之外, 我们可以简单地写:
[:div#hello.content [:p "Hello world!"]]
Hiccup 还提供了一些辅助函数来定义常见元素, 例如表单, 链接, 图像等. 所有这些函数都只是输出上述格式的向量. 这意味着如果一个函数没有做你需要的, 你可以手动写出元素的字面形式, 或者取其输出并修改它以适应你的需要. (下略, 原文档已详细介绍)
7. 静态资源
应用程序的资源位于 resources 文件夹下. 资源包括任何你想要包含在应用程序中的非源代码文件. 这些文件可能是数据库迁移文件, SQL 查询文件等等.
默认情况下, resources/public 文件夹保留给 HTTP 服务器直接提供的资源. 你应该将图片和 CSS 等资源放在这里. 如果你希望将公共资源放在不同的目录下, 可以自定义此路径. 这在 system.edn 文件的 :handler/ring 组件的 :site-defaults-config 键下配置.
7.1. 优势
- 客户端和服务端使用同一种语言
- 前后端代码共享
- 更简洁一致的语言
- 不可变数据结构
- 强大的标准库
7.2. 快速入门
- 运行
(kit/install-module :kit/cljs)添加 :kit/cljs 模块. 如果项目之前未下载模块, 请先运行(kit/sync-modules). - 重启应用.
- 在项目根目录运行
npm install安装 JavaScript 依赖. - 运行
npx shadow-cljs watch app以监听模式启动 shadow-cljs 编译器. - 使用你喜欢的编辑器连接到 7002 端口的 shadow-cljs nREPL.
- 在浏览器中打开项目根页面 (默认为 http://localhost:3000).
- 在 shadow-cljs REPL 中, 运行
(shadow.cljs.devtools.api/repl :app). - 在 shadow-cljs REPL 中运行
(js/alert "Hi")验证是否连接正确. 浏览器窗口应该会弹出提示框. - 现在你可以编辑
src/cljs目录下的core.cljs文件来编写 ClojureScript 代码.
7.3. 添加 ClojureScript 支持
运行 (kit/sync-modules), 然后运行 (kit/install-module :kit/cljs) 添加资源文件. 这将添加使用 shadow-cljs 编译 ClojureScript 的支持. 之后请务必重启应用.
7.4. 管理 JavaScript 和 ClojureScript 依赖
NPM 模块
项目使用 NPM 管理 JavaScript 模块. 确保已安装 NPM. 添加 :kit/cljs 模块将创建一个包含以下内容的 package.json 文件:
{ "devDependencies": { "shadow-cljs": "^2.14.3" }, "dependencies": { "react": "^17.0.2", "react-dom": "^17.0.2" } }
在启动 shadow-cljs 编译器之前, 请确保运行 npm install 安装上述模块.
7.5. ClojureScript 库
ClojureScript 库使用 shadow-cljs.edn 中的 :dependencies 键进行管理. 模块将为此文件生成以下内容:
{:nrepl {:port 7002} :source-paths ["src/cljs"] :dependencies [[binaryage/devtools "1.0.3"] [nrepl "0.8.3"] [reagent "1.1.0"] [cljs-ajax "0.8.4"]] :builds {:app {:target :browser :output-dir "target/classes/cljsbuild/public/js" :asset-path "/js" :modules {:app {:entries [kit.guestbook.app]}} :devtools {:after-load kit.guestbook.core/mount-root}}}}
7.6. 运行编译器
开发 ClojureScript 应用最简单的方法是以监听模式运行编译器. 这样, 你对命名空间所做的任何更改都将自动重新编译, 并立即在页面上生效.
要在此模式下启动编译器, 请运行:
npx shadow-cljs watch app
这将启动 shadow-cljs 并连接浏览器 REPL. 现在, 你对 ClojureScript 源代码所做的任何更改都将自动重新编译.
当你运行 uberjar 任务时, ClojureScript 将根据 build.clj 中的以下函数使用生产设置进行编译:
(defn build-cljs [] (println "npx shadow-cljs release app...") (let [{:keys [exit], :as s} (sh "npx" "shadow-cljs" "release" "app")] (when-not (zero? exit) (throw (ex-info "could not compile cljs" s)))))
7.7. shadow-cljs 与 nREPL
默认情况下, 运行 npx shadow-cljs watch app 命令还会在 7002 端口上启用 nREPL. 这是由 shadow-cljs.edn 中的 :nrepl {:port 7002} 键控制的.
运行 shadow-cljs 后, 使用你喜欢的编辑器连接到 nREPL 并运行 (shadow.cljs.devtools.api/repl :app). 现在, 你可以通过在 REPL 中执行 (js/alert "Hi") 来测试一切是否正常运行. 这应该会在浏览器中显示一个警告. 要退出 ClojureScript nREPL, 请运行 :cljs/quit.
请注意, 要使 JavaScript 警告正常工作, 你必须在浏览器窗口中打开项目的首页. 否则, 你的 REPL 将显示以下错误: No available JS runtime. 这是因为浏览器中需要代码将 JavaScript 运行时与 shadow-cljs 连接起来, 如下所述.
安装 :kit/cljs 模块会将该代码添加到你的 home.html:
<div id="app"></div> <script src="/js/app.js"></script>
第一行指示 Reagent 应用的挂载点, 默认情况下在 core.cljs 中定义.
第二行确保当你打开浏览器中的此页面时, 将加载包含已编译为 JavaScript 的 ClojureScript 代码的 app.js 文件. REPL 需要此代码才能直接连接到你的浏览器窗口, 从而允许像常规 Clojure 代码一样进行交互式编码.
7.8. 与 JavaScript 交互
所有全局 JavaScript 函数和变量都可以通过 js 命名空间访问.
- 方法调用
(.method object params)(.log js/console "hello world!") - 访问属性
(.-property object)(.-style div) - 设置属性
(set! (.-property object))(set! (.-color (.-style div) "#234567"))
有关常见 JavaScript 操作的 ClojureScript 同义词的更多示例, 请参阅 ClojureScript 同义词.
7.9. Reagent
Reagent 是使用 Kit 构建 ClojureScript 应用的推荐方法.
Reagent 由 React 支持, 并提供了一种使用 Hiccup 风格语法操作 DOM 的极其高效的方式. 在 Reagent 中, 每个 UI 组件都是一个表示特定 DOM 元素的数据结构. 通过采用以 DOM 为中心的 UI 视图, Reagent 使编写可组合组件变得简单直观.
一个简单的 Reagent 组件如下所示:
[:label "Hello World"]
组件也可以是函数:
(defn label [text] [:label text])
组件的值存储在 Reagent 原子中. 这些原子与常规 Clojure 原子的行为相同, 但有一个重要的区别. 更新原子时, 会导致取消引用它的任何组件重新渲染. 让我们看一个例子.
重要提示: 确保在命名空间中需要 Reagent 原子, 否则将使用常规 Clojure 原子, 并且组件不会在更改时重新渲染.
(ns myapp (:require [reagent.core :as reagent])) (def state (reagent/atom nil)) (defn input-field [label-text] [:div [label label-text] [:input {:type "text" :value @state :on-change #(reset! state (-> % .-target .-value))}]])
上面, input-field 组件由我们之前定义的 label 组件和一个 :input 组件组成. 输入将更新 state 原子并将其渲染为其值.
请注意, 即使 label 是一个函数, 我们也没有调用它, 而是将其放在一个向量中. 这样做的原因是我们正在指定组件层次结构. 组件将在需要渲染时由 Reagent 运行.
这种行为使得实现 React Flux 模式变得非常简单.
Views--->(actions) --> Dispatcher-->(callback)--> Stores---+ Ʌ | | V +--(event handlers update)--(Stores emit "change" events)--+
我们的视图组件将更新分派到原子, 这些原子表示存储. 原子反过来会在状态更改时通知任何取消引用它们的组件.
在前面的示例中, 我们使用全局原子来保存状态. 虽然这对于小型应用很方便, 但这种方法无法很好地扩展. 幸运的是, Reagent 允许我们在组件中具有局部状态. 让我们看看它是如何工作的.
(defn input-field [label-text id] (reagent/with-let [value (reagent/atom nil)] [:div [label "The value is: " @value] [:input {:type "text" :value @value :on-change #(reset! value (-> % .-target .-value))}]]))
我们要做的就是在闭包内为原子创建一个局部绑定. 返回的函数是当原子的值更改时 Reagent 将调用的函数.
最后, 通过调用 reagent.dom/render 函数来渲染组件:
(ns myapp (:require [reagent.core :as reagent] [reagent.dom :as d])) (defn render-simple [] (d/render [input-field] (.-body js/document))
7.10. 客户端路由
Reitit 用于处理客户端和服务器路由. 我们需要在路由命名空间中需要 Reitit 以及 Google Closure 历史记录和事件助手.
(ns <app>.core (:require [reagent.core :as r] [reitit.core :as reitit] [goog.events :as events] [goog.history.EventType :as HistoryEventType]) (:import goog.History))
我们现在将添加一个 session 原子来保存所选页面以及几个页面:
(def session (r/atom {:page :home})) (defn home-page [] [:div "Home"]) (defn about-page [] [:div "About"]) (def pages {:home #'home-page :about #'about-page})
我们现在可以创建一个 page 函数, 该函数将检查会话的状态并渲染相应的页面:
(defn page [] [(pages (:page @session))])
我们现在可以添加一个路由, 该路由将在选择路由时分派与每个页面关联的键:
(def router (reitit/router [["/" :home] ["/about" :about]]))
最后, 我们将添加函数来匹配路由并挂钩到浏览器导航:
(defn match-route [uri] (->> (or (not-empty (string/replace uri #"^.*#" "")) "/") (reitit/match-by-path router) :data :name)) (defn hook-browser-navigation! [] (doto (History.) (events/listen HistoryEventType/NAVIGATE (fn [event] (swap! session assoc :page (match-route (.-token event))))) (.setEnabled true)))
调用 hook-browser-navigation! 时, 它将挂钩到页面事件, 并在分派页面导航事件时调用 match-route 函数.
有关更多详细信息, 请参阅 Reitit 文档.
7.11. Ajax
ClojureScript 模块使用 cljs-ajax 处理 Ajax 操作.
7.11.1. ajax-request
ajax-request 是接受以下参数的基本请求函数:
uri- 请求的 URImethod- 表示 HTTP 请求类型的字符串, 例如: "PUT", "DELETE" 等.format- 指示响应格式的关键字. 可以是:raw,:json,:edn或:transit, 默认为:transithandler- 成功处理程序, 一个接受响应作为单个参数的函数error-handler- 错误处理程序, 一个接受表示错误的地图的函数, 键为:status和:status-textparams- 要发送到服务器的参数映射
7.11.2. GET/POST 助手
GET 和 POST 助手接受一个 URI, 后跟一个选项映射:
:handler- 成功操作的处理程序函数应接受单个参数, 该参数是反序列化的响应:error-handler- 错误的处理程序函数, 应接受带有键:status和:status-text的映射:format- 请求的格式可以是:raw,:json,:edn或:transit, 默认为:transit:response-format- 响应格式. 如果你将其留空, 它将从Content-Type标头中检测格式:params- 将随请求一起发送的参数映射:timeout- ajax 调用的超时时间. 如果留空则为 30 秒:headers- 要随请求一起设置的 HTTP 标头的映射:finally- 一个不带参数的函数, 将在回调期间触发, 以及任何其他处理程序
(ns foo (:require [ajax.core :refer [GET POST]])) (defn handler [response] (.log js/console (str response))) (defn error-handler [{:keys [status status-text]}] (.log js/console (str "something bad happened: " status " " status-text))) (GET "/hello") (GET "/hello" {:handler handler}) (POST "/hello") (POST "/send-message" {:headers {"Accept" "application/transit+json"} :params {:message "Hello World" :user "Bob"} :handler handler :error-handler error-handler})
在上面的示例中, 当服务器以成功状态响应时, 将调用处理程序. 响应处理程序函数应接受单个参数. 参数将包含来自服务器的反序列化响应.
库尝试根据响应标头自动发现编码, 但是可以使用 :response-format 键显式指定响应格式.
error-handler 函数应接受包含错误响应的单个参数. 该函数将接收包含错误状态和描述以及服务器返回的任何数据的整个响应映射.
:status- 包含 HTTP 状态代码:status-text- 包含状态的文本描述:original-text- 包含服务器响应文本:response- 包含反序列化成功时的反序列化响应
未提供处理程序函数时, 在将请求发送到服务器后, 不会采取进一步操作.
请求正文将使用 ring-middleware-format 库进行解释. 库将根据 Content-Type 标头反序列化请求, 并使用我们在上面设置的 Accept 标头序列化响应.
路由应简单地返回一个响应映射, 其正文设置为响应的内容:
(ns <app>.routes.services (:require [ring.util.response :refer [response status]])) (defn save-message! [{:keys [params]}] (println params) (response {:status :success})) (defn service-routes [] ["" ["/send-message" {:post save-message!}]])
请注意, 默认情况下启用 CSRF 中间件. 中间件包装应用的 home-routes. 它将拦截任何非 HEAD 或 GET 的服务器请求.
(defn home-routes [base-path] [base-path {:middleware [middleware/wrap-csrf middleware/wrap-formats]} ["/" {:get home-page}]])
我们现在需要将 CSRF 令牌与请求一起传递. 一种方法是在请求的 x-csrf-token 标头中传递令牌, 其值为令牌的值.
为此, 我们首先需要将令牌设置为页面上的隐藏字段:
<input id="csrf-token" type="hidden" value="{{csrf-token}}"></input>
然后我们必须在请求中设置标头:
(POST "/send-message" {:headers {"Accept" "application/transit+json" "x-csrf-token" (.-value (.getElementById js/document "csrf-token"))} :params {:message "Hello World" :user "Bob"} :handler handler :error-handler error-handler})
7.12. WebSockets
Kit 使用优秀的 sente 库提供对 WebSockets 的支持. 为了简化安装过程, 我们提供了 :kit/sente 模块, 它扩展了现有的 :kit/cljs 模块, 以在服务器和客户端上添加对 WebSockets 的支持.
7.12.1. 安装
安装过程与其他所有 kit 模块相同. 启动 REPL 并执行以下操作:
(kit/sync-modules) ;; 从远程模块存储库同步模块
(kit/list-modules) ;; 列出可用模块
(kit/install-module :kit/sente) ;; 安装 WebSockets 支持
之后, 你需要重启 REPL 和 shadow-cljs watch 进程, 就可以开始了.
7.12.2. 服务端
模块在 <<ns-name>>.web.routes.ws.clj 中添加了一个新路由以处理 WebSockets. 接收到的 WebSocket 事件使用 on-message 多方法处理, 其默认实现只是记录事件, 什么也不做. 还有两个示例消息处理程序.
(defmethod on-message :guestbook/echo [{:keys [id client-id ?data send-fn] :as message}] (let [response "Hello from the server"] (send-fn client-id [id response])))
Echo 处理程序从客户端接收 :guestbook/echo 消息, 并响应发送消息的客户端. 注意 sente 提供的参数列表, 其中包括以下内容:
:id- WebSocket 事件 ID, 在本例中为:guestbook/echo:client-id- 连接的客户端的 ID.:?data- 与事件一起发送的数据:send-fn- 通过套接字发送消息的函数, 由 sente 提供
有关键的完整文档, 请查看 sente 文档.
另一个示例函数使用作为消息键提供的 :connected-uids 原子, 通过 WebSocket 向所有连接的客户端发送消息.
(defmethod on-message :guestbook/broadcast [{:keys [id client-id ?data send-fn connected-uids] :as message}] (let [response (str "Hello to everyone from the client " client-id)] (doseq [uid (:any @connected-uids)] (send-fn uid [id response]))))
你可以自由地为你的事件实现消息处理, 并将其放在代码中的任何位置.
如果你想添加额外的处理, 例如错误处理或记录所有事件, 你可以在 handle-message! 函数中执行此操作, 该函数是一个通用事件调度程序.
7.12.3. 客户端
该模块提供用于连接到服务器的 <<ns-name>>.ws.cljs 文件.
首先, 你需要从 <<ns-name>>.core.cljs 文件中 require 它.
(ns <<ns-name>>.core (:require [<<ns-name>>.ws :as ws] [reagent.core :as r] [reagent.dom :as d]))
此外, 你需要确保你的 WebSockets 已初始化并处理传入事件. 为此, 你需要定义事件处理程序, 并将其传递给 WebSocket 初始化函数.
(defn handler [resp] (println "response: " resp)) (defn ^:export ^:dev/once init! [] (ws/start-router! handler) ;; (ajax/load-interceptors!) (mount-root))
重新加载网页后, 你将在 JavaScript 控制台中看到已建立 WebSocket 连接的日志条目.
从客户端通过 WebSocket 发送事件很容易. 你只需要调用 ws/send-message! 函数.
例如, 你可以修改 home-page 以渲染两个额外的按钮, 这些按钮将在单击时向服务器发送事件. 像这样:
(defn home-page [] [:div [:h2 "Welcome to Reagent!"] [:input {:type :button :value "Click to echo" :on-click #(ws/send-message! [:guestbook/echo "Hallo Server"])}] [:input {:type :button :value "Click to broadcast" :on-click #(ws/send-message! [:guestbook/broadcast "Hallo everyone"])}]])
7.12.4. 下一步是什么?
在这个例子中, 我们故意将客户端的事件消息处理留为准系统. 你如何实现它取决于你作为开发人员. 你可能想要使用多方法方法, 类似于我们在服务器端的方法. 或者, 如果你正在使用 re-frame, 你可能希望将 WebSocket 事件处理程序挂钩到你的 re-frame 消息总线.
8. Reitit 路由
Reitit 使用 Reitit 定义应用路由. 路由是应用的入口点, 用于建立服务器和客户端之间的通信协议.
8.1. 路由
Reitit 路由处理器只是接受请求映射并返回响应映射的函数. 路由被定义为字符串路径和可选(非顺序)路由参数子路由的向量. 路由是从 URL 模式到包含键控在请求方法上的处理程序的映射:
["/" {:get (fn [request] {:status 200 :body "GET request"}) :post (fn [request] {:status 200 :body "POST request"})}]
body 可以是一个函数, 它必须接受请求作为参数:
(fn [request] {:status 200 :body (keys request)})
上面的路由读取请求映射中的所有键并显示它们. 输出如下所示:
["reitit.core/match","reitit.core/router","ssl-client-cert","cookies","remote-addr","params","flash","handler-type","headers","server-port","muuntaja/request","content-length","form-params","server-exchange","query-params","content-type","path-info","character-encoding","context","uri","server-name","anti-forgery-token","query-string","path-params","muuntaja/response","body","multipart-params","scheme","request-method","session"]
Reitit 支持三种参数. 这些可以是路由参数, 查询参数和 body 参数. 例如, 如果我们创建以下路由:
["/foo/:bar" {:post (fn [{:keys [path-params query-params body-params]}] {:status 200 :body (str "path params: " path-params "\nquery params: " query-params "\nbody params: " body-params)})}]
然后我们可以通过 cURL 查询它:
curl --header "Content-Type: application/json" \ --request POST \ --data '{"username":"xyz","password":"xyz"}' \ 'localhost:3000/foo/bar?foo=bar'
参数将被解析如下:
path params: {:bar "bar"}
query params: {"foo" "bar"}
body params: {:password "xyz", :username "xyz"}
在 guestbook 应用程序示例中, 我们看到定义了以下路由:
["/" {:get home-page :post save-message!}]
当收到 GET 请求时, 此路由提供主页, 并在收到 POST 请求时提取名称和消息表单参数. 请注意, 默认情况下, POST 请求必须包含 CSRF 令牌. 这由下面的 middleware/wrap-csrf 声明处理:
(defn home-routes [base-path] [base-path ["/" {:get home-page :post save-message!}]])
有关管理 CSRF 中间件的更多详细信息, 请参考此处.
8.2. 返回值
路由块的返回值至少决定传递给 HTTP 客户端的响应体, 或者至少决定 ring 堆栈中的下一个中间件. 最常见的是字符串, 如上面的示例所示. 但是, 我们也可以返回一个响应映射:
["/" {:get (fn [request] {:status 200 :body "Hello World"})}] ["/is-403" {:get (fn [request] {:status 403 :body ""})}] ["/is-json" {:get (fn [request] {:status 200 :headers {"Content-Type" "application/json"} :body "{}"})}]
8.3. 静态资源
默认情况下, 位于 resources/public 目录下的任何资源都可供客户端使用. 这由 <app>.handler 命名空间中的 reitit.ring/resource-handler 处理程序处理:
(ring/create-resource-handler {:path "/"})
可以使用 clojure.java.io/resource 函数访问应用程序类路径上的任何资源:
(slurp (clojure.java.io/resource "myfile.md"))
通常, 非源资源应放在项目的 resources 目录中.
8.4. 处理文件上传
给定一个名为 upload.html 的页面, 其中包含以下表单:
<h2>Upload a file</h2> <form action="/upload" enctype="multipart/form-data" method="POST"> {% csrf-field %} <input id="file" name="file" type="file" /> <input type="submit" value="upload" /> </form>
然后我们可以渲染页面并处理文件上传, 如下所示:
(ns myapp.upload (:require [myapp.layout :as layout] [ring.util.response :refer [redirect file-response]]) (:import [java.io File FileInputStream FileOutputStream])) (def resource-path "/tmp/") (defn file-path [path & [filename]] (java.net.URLDecoder/decode (str path File/separator filename) "utf-8")) (defn upload-file "uploads a file to the target folder when :create-path? flag is set to true then the target path will be created" [path {:keys [tempfile size filename]}] (try (with-open [in (new FileInputStream tempfile) out (new FileOutputStream (file-path path filename))] (let [source (.getChannel in) dest (.getChannel out)] (.transferFrom dest source 0 (.size source)) (.flush out))))) (defn home-routes [base-path] [base-path ["/upload" {:get (fn [req] (layout/render request "upload.html")) :post (fn [{{:keys [file]} :params}] (upload-file resource-path file) (redirect (str "/files/" (:filename file))))}] ["/files/:filename" {:get (fn [{{:keys [filename]} :path-params}] (file-response (str resource-path filename)))}]])
:file 请求表单参数指向一个包含要上传的文件描述的映射. 我们上面的 upload-file 函数使用此映射中的 :tempfile, :size 和 :filename 键将文件保存在磁盘上.
可以通过如下更新 wrap-defaults 在 <app>.middleware/wrap-base 函数中添加文件上传进度侦听器:
(wrap-defaults (-> site-defaults (assoc-in [:security :anti-forgery] false) (dissoc :session) (assoc-in [:params :multipart] {:progress-fn (fn [request bytes-read content-length item-count] (log/info "bytes read:" bytes-read "\ncontent length:" content-length "\nitem count:" item-count))})))
或者, 如果您使用 Nginx 作为前端, 则可以使用其上传进度模块.
8.5. 组织应用程序路由
将应用程序路由按功能组织在一起是一个好习惯. 您的应用程序通常会有两种类型的路由. 第一种用于提供由浏览器渲染的 HTML 页面. 第二种是用于公开服务 API 的路由. 客户端通过 AJAX 访问这些路由以从服务器检索数据.
路由组使用 Integrant 组件定义如下:
(derive :reitit.routes/api :reitit/routes) (defmethod ig/init-key :reitit.routes/api [_ {:keys [base-path] :or {base-path ""} :as opts}] [base-path (route-data opts) (api-routes opts)])
所有派生 :reitit/routes 的路由组件都由 :router/routes 组件聚合:
(defmethod ig/init-key :router/routes [_ {:keys [routes]}] (apply conj [] routes))
最后, 您会注意到 Ring 处理程序使用由 :router/routes Integrant 多方法定义的路由器. 该组件由 reosurces/system.edn 中 router 键下的 router 组件引用:
:handler/ring {:router #ig/ref :router/core ...}
:handler/ring 的 Integrant 初始化多方法然后使用提供的选项中的 :router 键:
(defmethod ig/init-key :handler/ring [_ {:keys [router api-path] :as opts}] (ring/ring-handler router (ring/routes (when (some? api-path) (swagger-ui/create-swagger-ui-handler {:path api-path :url (str api-path "/swagger.json")})) (ring/create-default-handler {:not-found (constantly {:status 404, :body "Page not found"}) :method-not-allowed (constantly {:status 405, :body "Not allowed"}) :not-acceptable (constantly {:status 406, :body "Not acceptable"})})) {:middleware [(middleware/wrap-base opts)]}))
官方 Reitit 文档中提供了更多文档.
- 限制访问
某些页面只有在满足特定条件时才能访问. 例如, 您可能希望定义仅对管理员可见的管理页面, 或者仅在会话中有用户时才可见的用户个人资料页面.
8.6. 基于路由组限制访问
限制访问的最简单方法是将 buddy-auth 的 restrict 中间件应用于不应公开访问的路由组. 首先, 我们将在 <app>.middleware 命名空间中添加以下代码:
(ns <app>.middleware (:require ... [buddy.auth.middleware :refer [wrap-authentication]] [buddy.auth.backends.session :refer [session-backend]] [buddy.auth.accessrules :refer [restrict]] [buddy.auth :refer [authenticated?]])) (defn on-error [request response] {:status 403 :headers {"Content-Type" "text/plain"} :body (str "Access to " (:uri request) " is not authorized")}) (defn wrap-restricted [handler] (restrict handler {:handler authenticated? :on-error on-error})) (defn wrap-base [{:keys [metrics site-defaults-config cookie-secret] :as opts}] (let [cookie-store (cookie/cookie-store {:key (.getBytes ^String cookie-secret)})] (fn [handler] (cond-> ((:middleware env/defaults) handler opts) true (wrap-authentication (session-backend)) true (defaults/wrap-defaults (assoc-in site-defaults-config [:session :store] cookie-store))))))
我们将包装 authentication 中间件, 如果 :identity 键出现在会话中, 它将在请求中设置 :identity 键. session 后端是最简单的后端, 但是 Buddy 提供了许多不同的身份验证后端, 如此处所述.
authenticated? 帮助器用于检查请求中的 :identity 键, 并在它存在时将其传递给处理程序. 否则, 将调用 on-error 函数.
我们现在可以使用 api 命名空间中的 wrap-restricted 中间件来包装我们希望私有的路由组, 如下所示:
(defn route-data [opts] (merge opts {:coercion malli/coercion :muuntaja formats/instance :swagger {:id ::api} :middleware [... ;; wrap restricted routes wrap-restricted]}))
有关将中间件与 Reitit 一起使用的更多信息, 请参阅此处的官方文档.
8.7. 基于 URI 限制访问
使用 Buddy 的 buddy.auth.accessrules 命名空间, 我们可以定义基于其 URI 模式的规则来限制对特定页面的访问.
8.8. 指定访问规则
让我们看一下如何创建规则来指定只有当 :identity 键出现在会话中时才能访问受限路由.
首先, 我们将在 <app>.middleware 命名空间中引用几个 Buddy 命名空间.
(ns myapp.middleware (:require ... [buddy.auth.middleware :refer [wrap-authentication]] [buddy.auth.accessrules :refer [wrap-access-rules]] [buddy.auth.backends.session :refer [session-backend]] [buddy.auth :refer [authenticated?]]))
接下来, 我们将为路由创建访问规则. 规则使用向量定义, 其中每个规则都使用映射表示. 一个简单的规则, 用于检查用户是否已通过身份验证, 如下所示.
(def rules [{:uri "/restricted" :handler authenticated?}])
我们还将定义一个错误处理函数, 当拒绝访问特定路由时将使用该函数:
(defn on-error [request value] {:status 403 :headers {} :body "Not authorized"})
最后, 我们必须添加必要的中间件, 以使用会话后端启用访问规则和身份验证.
(defn wrap-base [{:keys [metrics site-defaults-config cookie-session] :as opts}] (let [cookie-store (cookie/cookie-store {:key (.getBytes ^String cookie-secret)})] (fn [handler] (cond-> ((:middleware env/defaults) handler opts) true (wrap-access-rules {:rules rules :on-error on-error}) true (wrap-authentication (session-backend)) true (defaults/wrap-defaults (assoc-in site-defaults-config [:session :store] cookie-store))))))
请注意, 中间件的顺序很重要, wrap-access-rules 必须位于 wrap-authentication 之前.
当用户成功通过身份验证时, 通过在会话中设置 :identity 键来触发基于 Buddy 会话的身份验证.
(def user {:id "bob" :pass "secret"}) (defn login! [{:keys [params session]}] (when (= user params) (-> "ok" response (content-type "text/html") (assoc :session (assoc session :identity "foo"))))) ...
9. Websockets
Kit 默认使用 ring-undertow-adapter 提供 Websocket 支持, 具体配置细节请参考你所用 adapter 的文档. 使用 ring-undertow-adapter 配置 websocket handler 的方法如下. handler 通过一个 Ring handler 函数创建, 该函数返回一个包含 :undertow/websocket 及其配置的 map:
:on-open- 函数, 接收一个包含:channel键的 map (可选):on-message- 函数, 接收一个包含:channel和:data键的 map (可选):on-close-message- 函数, 接收一个包含:channel和:message键的 map (可选):on-error- 函数, 接收一个包含 :channel 和:error键的 map (可选)
handler 在 ring.adapter.undertow.websocket 命名空间中提供了一些辅助函数用于 websocket 通信, 例如 send-text, send-binary 和 send 函数. send 函数会自动推断内容类型. 下面是一个 websocket handler 的示例:
(require '[ring.adapter.undertow.websocket :as ws]) (fn [request] {:undertow/websocket {:on-open (fn [{:keys [channel]}] (println "WS open!")) :on-message (fn [{:keys [channel data]}] (ws/send "message received" channel)) :on-close-message (fn [{:keys [channel message]}] (println "WS closeed!"))}})
如果在 handler 函数返回的 map 中提供了 headers, 它们将被包含在 WebSocket 升级请求的响应中. 与 WebSocket 握手相关的 handler(例如 Connection)将被覆盖, 以确保 WebSocket 握手正确完成:
(defn- websocket-handler-with-headers [request] {:headers {"X-Test-Header" "Hello!"} :undertow/websocket {}})
10. 请求和响应
10.1. 概述
Reitit Ring 处理程序使用 system.edn 中配置的默认中间件. Kit 生成的默认 API 路由使用以下中间件配置, 位于 <<project-ns>>.web.routes.api 中:
[;; 查询参数和表单参数 parameters/parameters-middleware ;; 内容协商 muuntaja/format-negotiate-middleware ;; 编码响应体 muuntaja/format-response-middleware ;; 异常处理 coercion/coerce-exceptions-middleware ;; 解码请求体 muuntaja/format-request-middleware ;; 强制转换响应体 coercion/coerce-response-middleware ;; 强制转换请求参数 coercion/coerce-request-middleware ;; 异常处理 exception/wrap-exception]
此配置处理请求和响应参数的强制转换和异常处理.
10.2. 请求
默认情况下, 请求参数(例如来自表单 POST 的参数)将被自动解析并设置为请求上的 :params 键.
请求参数将在请求的 :params 键下提供. 中间件还将在设置适当的 MIME 类型时处理响应体的编码.
10.3. 响应
Ring 响应使用 ring-http-response 库生成. 该库提供了一些辅助函数, 用于生成具有各自 HTTP 状态代码的响应.
例如, ring.util.http-response/ok 辅助函数用于生成状态为 200 的响应. 以下代码将生成一个有效的响应映射, 其内容设置为其 :body 键.
(ok {:foo "bar"}) ;; 调用 response 的结果 {:status 200 :headers {} :body {:foo "bar"}}
响应体可以是字符串, 序列, 文件或输入流. 响应体必须与其状态代码相对应.
如果是字符串, 它将按原样发送回客户端. 如果是序列, 则将表示每个元素的字符串发送到客户端. 最后, 如果响应是文件或输入流, 则服务器将其内容发送到客户端.
10.4. 响应编码
默认情况下, 当路由返回包含 :body 键的映射时, muuntaja 中间件库用于推断响应类型:
{:body {:foo "bar"}}
中间件位于应用程序的 =<project-ns>.web.middleware.formats~ 命名空间中:
(ns <project-ns>.web.middleware.formats (:require [luminus-transit.time :as time] [muuntaja.core :as m])) (def instance (m/create (-> m/default-options (update-in [:formats "application/transit+json" :decoder-opts] (partial merge time/time-deserialization-handlers)) (update-in [:formats "application/transit+json" :encoder-opts] (partial merge time/time-serialization-handlers)))))
Muuntaja 将使用 Content-Type 标头推断请求的内容, 并使用 Accept 标头推断响应格式.
默认情况下, 我们已经有一些由 luminus-transit.time 提供的时间序列化和反序列化处理程序来帮助我们读取和写入 Java 时间对象.
10.5. 设置标头
通过调用 ring.util.http-response/header 并传递 HTTP 标头映射来设置其他响应标头. 请注意, 键必须是字符串.
(-> "hello world" response (header "x-csrf" "csrf"))
10.6. 设置内容类型
您可以使用 ring.util.http-response/content-type 函数设置自定义响应类型, 例如:
(defn project-handler [req] (-> (clojure.java.io/input-stream "report.pdf") (ok) (content-type "application/pdf")))
10.7. 设置自定义状态
通过将内容传递给 ring.util.http-response/status 函数来设置自定义状态:
(defn missing-page [req] (-> "your page could not be found" (ok) (status 404)))
10.8. 重定向
重定向由 ring.util.http-response/found 函数处理. 该函数将在响应上设置 302 重定向状态.
(defn old-location [] (found "/new-location"))
有关其他可用辅助函数, 请参阅 ring-http-response.
11. Kit 中间件
Kit 使用 Ring 和 Reitit 进行路由, 应用处理程序是一个标准的 Ring 处理程序, 可以像其他基于 Ring 的应用一样包装在中间件中.
传统上, 你可以将 Ring 中间件定义为函数, Reitit 也允许我们将其定义为数据.
中间件允许将处理程序包装在函数中, 这些函数可以修改请求的处理方式. 中间件函数通常用于扩展 Ring 处理程序的基本功能, 以满足特定应用程序的需求.
中间件就是一个函数, 它接受一个带有可选参数的现有处理程序, 并返回一个具有新增行为的新处理程序. 一个中间件函数的例子如下:
(defn wrap-nocache [handler] (fn [request] (let [response (handler request)] (assoc-in response [:headers "Pragma"] "no-cache"))))
可以看到, 包装器接受处理程序并返回一个函数, 该函数又接受请求. 由于返回的函数是在处理程序存在的范围内定义的, 它可以在内部使用它. 调用时, 它将使用请求调用处理程序, 并将" Pragma: no-cache" 添加到响应映射中. 更多详细信息请参考 Ring 官方文档.
同样的代码, 以 Reitit 兼容的数据中间件形式编写为:
(defn nocache-handler [handler] (fn [request] (let [response (handler request)] (assoc-in response [:headers "Pragma"] "no-cache")))) (def wrap-nocache {:name ::wrap-nocache :description "Calls the handler with the request and add Pragma: no-cache to the response map" :wrap nocache-handler})
任何开发中间件, 例如用于显示堆栈跟踪的中间件, 都应该添加到 <app>.dev-middleware 命名空间中的 wrap-dev 函数中. 该命名空间位于 env/dev/clj 源路径下, 并且只会在开发模式下包含.
(defn wrap-dev [handler] (-> handler wrap-reload wrap-error-page wrap-exceptions))
注意, 中间件的顺序很重要, 因为请求会被每个中间件函数修改. 例如, 任何依赖于会话的中间件函数都必须放在创建会话的 wrap-defaults 中间件之前. 原因是请求在到达内部中间件函数之前会先经过外部中间件函数.
例如, 当我们使用 wrap-formats 和 wrap-defaults 包装处理程序时, 如下所示:
(-> handler wrap-formats wrap-defaults)
请求按以下顺序通过这些函数:
请求 -> wrap-defaults -> wrap-formats -> 处理程序
另一方面, 通过 Reitit 路由中 :middleware 键设置的任何中间件都保持写入顺序, 即:
["/api" {:middleware [middleware-1 middleware-2]}]
按以下顺序执行中间件:
middleware-1 -> middleware-2
这更容易理解, 通常你应该在适当的路由中设置中间件, 这样它们就不会应用于应用程序的所有路由, 除非必要.
12. Kit Web 框架中的会话管理
12.1. 会话 (Sessions)
Kit 默认使用基于 Cookie 的会话管理. 会话中间件的配置位于 :handler/ring 组件的 :cookie-session-config 中. 会话超时时间以秒为单位指定, 默认为 24 小时(即 86400 秒)不活动. 以下是默认配置, 其业务逻辑由 ring.middleware.session.cookie/cookie-store 提供:
{:cookie-secret #or [#env COOKIE_SECRET "16charsecrethere"] :cookie-name "<project-ns>" :cookie-default-max-age 86400}
12.2. 访问会话
Ring 使用请求映射跟踪会话, 当前会话位于 =:session~ 键下. 以下是一个简单的会话交互示例:
(ns myapp.home (:require [ring.util.response :refer [response]])) (defn set-user! [id {session :session}] (-> (response (str "User set to: " id)) (assoc :session (assoc session :user id)) (assoc :headers {"Content-Type" "text/plain"}))) (defn remove-user! [{session :session}] (-> (response "User removed") (assoc :session (dissoc session :user)) (assoc :headers {"Content-Type" "text/plain"}))) (defn clear-session! [] (-> (response "Session cleared") (assoc :session nil) (assoc :headers {"Content-Type" "text/plain"}))) (def app-routes ["" {:middleware [middleware/wrap-csrf middleware/wrap-formats]} ["/login/:id" {:get (fn [{:keys [path-params] :as req}] (set-user! (:id path-params) req))}] ["/remove" {:get remove-user!}] ["/logout" {:get clear-session!]])
12.3. 闪现会话 (Flash Sessions)
闪现会话的寿命为单个请求, 可以使用 :flash 键而不是常规会话的 :session 键访问它们.
12.4. Cookie
Cookie 位于请求的 :cookies 键下, 例如:
{:cookies {"username" {:value "Bob"}}}
反之, 要在响应上设置 Cookie, 只需使用所需的 Cookie 值更新响应映射:
(-> "response with a cookie" response (assoc-in [:cookies "username" :value] "Alice"))
除了 :value 键之外, Cookie还可以包含以下附加属性:
:domain: 将 Cookie 限制到特定域.:path: 将 Cookie 限制到特定路径.:secure: 如果为 true, 则将 Cookie 限制为 HTTPS URL.:http-only: 如果为 true, 则将 Cookie 限制为 HTTP(无法通过例如 JavaScript 访问).:max-age: Cookie 过期之前的秒数.:expires: Cookie 过期的特定日期和时间.
12.5. Cookie 编码
Java 对象(例如日期)在存储在 Cookie 会话中时必须显式编码. 以下示例说明了如何使用 tick 库为时区日期时间添加读取器:
(defn wrap-base [{:keys [cookie-opts]}] (let [{:keys [cookie-secret cookie-name cookie-default-max-age]} cookie-opts cookie-store (session.cookie/cookie-store {:key (.getBytes ^String cookie-secret) :readers {'inst (fn [x] (some-> x (tick/parse) (tick/inst))) 'time/zoned-date-time #'tick/zoned-date-time}})] (fn [handler] (cond-> handler true (session/wrap-session {:store cookie-store :cookie-name cookie-name :cookie-attrs {:max-age cookie-default-max-age}}) true (cookies/wrap-cookies)))))
13. Kit 数据库配置
Kit 支持两种数据库范式: XTDB (原 Crux) 和 SQL 风格数据库, 您也可以轻松实现自定义连接.
13.1. SQL 数据库
Kit 默认使用 Migratus 进行 SQL 数据库迁移, 使用 HugSQL 进行数据库交互.
使用 +sql 等数据库配置文件时, 将设置迁移和默认连接. 此配置文件默认使用 PostgreSQL, 但任何 SQL 数据库均可兼容.
13.2. 配置迁移
首先, 您需要在 system.edn 中配置数据库连接. 在 +sql 配置文件中, 开发数据库的连接字符串格式如下: jdbc:postgresql://localhost/<app-name>?user=<app-name>&password=<app-name>, 您可以根据需要调整.
:db.sql/connection #profile {:dev {:jdbc-url "jdbc:postgresql://localhost/<app-name>?user=<app-name>&password=<app-name>"} :test {} :prod {:jdbc-url #env JDBC_URL :init-size 1 :min-idle 1 :max-idle 8 :max-active 32}}
然后, 创建 SQL 脚本以迁移数据库模式和回滚迁移. 迁移按 ID 的数字顺序应用. 通常, 使用当前日期作为文件名 前缀. 文件应位于 resources/migrations 目录下. 模板将为 users 表生成示例迁移文件.
例如:
resources/migrations/20210720004935-add-users-table.down.sqlresources/migrations/20210720004935-add-users-table.up.sql
默认配置会在启动时运行所有新的迁移. 您可以通过将 migrate-on-init? 的值修改为 false 来更改此设置.
:db.sql/migrations {:store :database :db {:datasource #ig/ref :db/connection} :migrate-on-init? true}
您也可以通过 REPL 运行迁移. migratus.core 命名空间提供以下辅助函数:
(migratus.core/reset (:db.sql/migrations state/system)): 通过回滚所有已应用的迁移(使用相应的 down 脚本)并运行所有迁移(up 脚本)来重置数据库状态.(migratus.core/migrate (:db.sql/migrations state/system)): 运行待处理的迁移.(migratus.core/rollback (:db.sql/migrations state/system)): 回滚最后一组迁移.(migratus.core/create (:db.sql/migrations state/system) "add-guestbook-table"): 使用给定名称创建 up/down 迁移文件.
重要提示: 必须先初始化数据库连接, 然后才能在 REPL 中运行迁移.
13.3. SQL 查询
默认情况下, HugSQL 会解析在 system.edn 和 resources/queries.sql 文件中定义的 SQL 查询. 您可以更新文件名以指示不同的路径, 例如 sql/queries.sql.
:db.sql/query-fn {:conn #ig/ref :db.sql/connection :filename "queries.sql"}
您也可以使用多个文件, 并在 :filenames 键中将它们指定为向量:
:db.sql/query-fn {:conn #ig/ref :db.sql/connection :filenames ["queries.sql" "other-queries.sql"]}
此 Integrant 组件是对执行 SQL 查询的函数的引用, 以及您希望传入的任何参数. 例如, 假设您定义了以下 SQL 查询:
-- :name get-user-by-id :? :1 -- :doc returns a user object by id, or nil if not present SELECT * FROM users WHERE id = :id -- :name add-user! :n insert into users (id, password) values (:id, :password)
您可以使用以下 query-fn 调用来运行此 SQL 查询:
(query-fn :get-user-by-id {:id 1})
要在事务中运行查询, 您必须使用 next.jdbc/with-transaction, 如下所示:
(let [conn (:db.sql/connection system)] (next.jdbc/with-transaction [tx conn] (query-fn tx :add-user! {:id "foo" :password "secret"}) (query-fn tx :get-user-by-id {:id "foo"})))
请注意, 您必须使用由 with-transaction 创建的 tx 连接, 才能将查询视为在事务范围内. 有关更多示例, 请参阅 next.jdbc 的官方文档中的事务部分.
为了便于参考, 以下是 Kit SQL edge 的完整定义:
(defn queries-dev [load-queries] (fn ([query params] (conman/query (load-queries) query params)) ([conn query params & opts] (conman/query conn (load-queries) query params opts)))) (defn queries-prod [load-queries] (let [queries (load-queries)] (fn ([query params] (conman/query queries query params)) ([conn query params & opts] (conman/query conn queries query params opts))))) (defmethod ig/init-key :db.sql/query-fn [_ {:keys [conn options filename filenames env] :or {options {}}}] (let [filenames (or filenames [filename]) load-queries #(apply conman/bind-connection-map conn options filenames)] (with-meta (if (= env :dev) (queries-dev load-queries) (queries-prod load-queries)) {:mtimes (mapv ig-utils/last-modified filenames)})))
如您所见, 双参数 query-fn 使用您在初始系统配置中传入的数据库. 但是, 三参数及以上变体允许您传入自定义连接, 从而允许 SQL 事务. 上述定义还允许 Kit 在您在 :dev 环境中操作时自动重新加载查询源(无论是单个文件还是多个文件) - 对于非 dev 配置文件, 它只会加载一次.
13.4. 使用 HugSQL
HugSQL 采用类似于 HTML 模板的方法来编写 SQL 查询. 查询使用纯 SQL 编写, 动态参数使用 Clojure 关键字语法指定. HugSQL 将使用 SQL 模板自动生成与数据库交互的函数.
通常, 查询放在 resources/sql/queries.sql 文件中. 然而, 一旦你的应用增长, 你可以考虑将查询分割成多个文件.
您可以看到下面示例 SQL 函数的格式:
-- :name create-user! :! :n -- :doc creates a new user record INSERT INTO users (id, first_name, last_name, email, pass) VALUES (:id, :first_name, :last_name, :email, :pass)
您可以使用 -- :name 注释指定生成的函数的名称. 名称后跟命令和结果标志.
以下命令标志可用:
:?- 带有结果集的查询(默认):!- 任何语句:<!- 支持INSERT ... RETURNING:i!- 支持插入和 jdbc.getGeneratedKeys
结果标志如下:
:1- 一行作为哈希映射:*- 多行作为哈希映射的向量:n- 受影响的行数(插入/更新/删除):raw- 传递未触及的结果(默认)
查询本身使用纯 SQL 编写, 动态参数通过在参数名称前加上冒号来表示.
13.5. 调试 HugSQL 查询
以下代码说明了如何使用 hugsql.core/hugsql-command-fn 多方法来记录正在生成的查询:
(defn log-sqlvec [sqlvec] (log/info (->> sqlvec (map #(clojure.string/replace (or % "") #"\n" "")) (clojure.string/join " ; ")))) (defn log-command-fn [this db sqlvec options] (log-sqlvec sqlvec) (condp contains? (:command options) #{:!} (hugsql.adapter/execute this db sqlvec options) #{:? :<!} (hugsql.adapter/query this db sqlvec options))) (defmethod hugsql.core/hugsql-command-fn :! [_sym] ~log-command-fn) (defmethod hugsql.core/hugsql-command-fn :<! [_sym] ~log-command-fn) (defmethod hugsql.core/hugsql-command-fn :? [_sym] ~log-command-fn)
有关更多详细信息, 请参阅 HugSQL 的官方文档.
14. cache
kit-redis 库为我们提供了一个连接 Redis 时, 基于 clojure.core.cache 的良好接口. 非常感谢 crache 的启发.
14.1. 设置
假设我们已经在本地机器上设置并运行了 Redis. 我们可以如下配置 integrant 组件:
:cache/redis {:ttl 3600 :conn {:pool {} :spec #profile {:dev {:host "localhost" :port 6379} :test {:host "localhost" :port 6379} :prod {:uri #env REDIS_URI}}}}
14.2. 基本用法
缓存的典型用法是查找键, 如果未命中, 则执行单独的 IO 操作以查找值(如果存在). 如果我们返回 nil, 我们将假定它不存在, 并且不将其添加到缓存中. 这可能并非适用于所有情况! 有时您可能希望缓存 nil. 出于此处的目的, 我们假设我们不这样做.
(require '[clojure.core.cache :as cache]) (defn cache-lookup-or-add [cache key lookup-fn & [ttl]] (or (cache/lookup cache key) (let [value (lookup-fn)] (cache/miss cache key {:val value :ttl ttl}) value)))
现在让我们使用我们的函数. 假设我们有一种场景, 我们希望通过 ID 获取用户, 并缓存我们找到的任何用户. 如果我们找不到用户, 我们将抛出一个类型为 ::no-user-found 的异常.
假设我们已经编写了一个名为 :get-user-by-id 的数据库查找函数, 该函数根据给定的 ID 检索单个用户条目. 我们将 query-fn 和我们的 cache/redis(作为缓存)作为第一个参数传递到我们的 ctx 中.
(defn user-by-id [{:keys [query-fn cache] :as _ctx} id] (or (cache-lookup-or-add cache ;; 缓存中匹配的键 (str "users/" id) ;; 未在缓存中找到时执行的未命中函数 #(query-fn :get-user-by-id {:id id})) (throw (ex-info "No user found" {:id id :type ::no-user-found}))))
15. 调度 Quartz 集成
15.1. 基本设置
使用 kit-quartz 库, 可以将 Quartz scheduler 集成到 integrant 组件中.
以下是一个每天运行一次的作业的 cronut/:cronut/scheduler integrant 组件定义示例:
:cronut/scheduler {:schedule #profile {:test [] :default [{:job #ig/ref :myapp/daily-job :trigger #cronut/cron "0 0 1 1/1 * ?"}]} :disallowConcurrentExecution? true}
这里引用了 :myapp.core/daily-job, 需要将其定义为一个新的 integrant 组件. #cronut/cron 字符串是一个 cron 表达式, 这里暂不讨论.
首先在 system.edn 中定义组件:
:myapp.core/daily-job {:identity "myapp.core/daily-job" :description "Runs once a day and prints hi" :recover? true :durable? false}
在 myapp.core 中定义作业:
(defmethod ig/init-key :myapp.core/daily-job [_ ctx] (reify org.quartz.Job (execute [_this _] (log/info "Start daily job") (println "Hello!") (log/info "End daily job"))))
15.2. 作业
每个 integrant Quartz 作业都应该返回一个实现 org.quartz.Job 接口的对象. 可以通过多种方式实现, 例如使用 reify 或在类, 记录等上实现接口. 更多细节请参考 cronut 文档.
15.3. 触发器
Cronut 提供三种不同的触发器, 通过标记字面量定义:
- #cronut/cron: cron 表达式字符串, 例如: :trigger #cronut/cron "0 0 1 1/1 * ?"
- #cronut/interval: 作业执行的时间间隔(毫秒), 例如: :trigger #cronut/interval 3500
- #cronut/trigger: 完整的 Quartz 触发器配置. 以下是 cronut 文档中的示例:
15.3.1. interval
:trigger #cronut/trigger {:type :simple :interval 3000 :repeat :forever :identity ["trigger-two" "test"] :description "sample simple trigger" :start #inst "2019-01-01T00:00:00.000-00:00" :end #inst "2019-02-01T00:00:00.000-00:00" :misfire :ignore :priority 5}
15.3.2. cron
:trigger #cronut/trigger {:type :cron :cron "*/6 * * * * ?" :identity ["trigger-five" "test"] :description "sample cron trigger" :start #inst "2018-01-01T00:00:00.000-00:00" :end #inst "2029-02-01T00:00:00.000-00:00" :time-zone "Australia/Melbourne" :misfire :fire-and-proceed :priority 4}
完整的文档请参考 cronut 代码库.
15.4. Quartz JDBC JobStore
内存中的 scheduler 只适用于开发环境. 在生产环境中, 需要水平扩展后端时, 这不再适用. 幸运的是, Quartz 可以轻松地与您选择的 JobStore 实现同步. 这里以 JDBC 配置为例.
首先需要迁移. 这取自 quartz 核心迁移脚本. 您也可以在这里找到许多其他迁移脚本.
接下来, 在 prod/resources 文件夹中创建一个文件 quartz.properties 并插入以下代码, 将 myapp 和 MyApp 替换为您选择的应用程序名称. (此处省略 SQL 迁移脚本, 原文已提供)
将以下配置添加到 quartz.properties 文件中, 并将 myapp 和 MyApp 替换为你的应用名称:
#============================================================================ # Configure Main Scheduler Properties #============================================================================ org.quartz.scheduler.instanceName = MyAppClusteredScheduler org.quartz.scheduler.instanceId = AUTO org.quartz.threadPool.threadCount = 4 #============================================================================ # Configure JobStore #============================================================================ org.quartz.jobStore.class = org.quartz.impl.jdbcjobstore.JobStoreTX org.quartz.jobStore.driverDelegateClass = org.quartz.impl.jdbcjobstore.PostgreSQLDelegate org.quartz.jobStore.useProperties = false org.quartz.jobStore.dataSource = myapp org.quartz.jobStore.tablePrefix = QRTZ_ org.quartz.jobStore.isClustered = true org.quartz.jobStore.clusterCheckinInterval = 20000 #============================================================================ # Configure Datasources #============================================================================ org.quartz.dataSource.myapp.driver = org.postgresql.Driver org.quartz.dataSource.myapp.maxConnections = 5 # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! # Should set these as env properties or in system.edn # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! org.quartz.dataSource.myapp.URL = org.quartz.dataSource.myapp.user = org.quartz.dataSource.myapp.password =
在生产环境中启动应用程序时, 请确保传入 org.quartz.dataSource.myapp.URL, org.quartz.dataSource.myapp.user 和 org.quartz.dataSource.myapp.password 属性. 如果无法这样做(由于各种 devops 限制), 也可以在 system.edn 中传递这些属性, 如下所示:
:quartz/env-properties {:org.quartz.dataSource.myapp.URL #env QUARTZ_JDBC_URL :org.quartz.dataSource.myapp.user #env QUARTZ_JDBC_USER :org.quartz.dataSource.myapp.password #env QUARTZ_JDBC_PASSWORD}
也可以在 MongoDB 和其他您选择的数据存储中设置 JobStore 实现.
15.5. Quartz JobStore 文档
(此处省略, 原文已提供链接)
16. Logging (日志)
默认情况下, 日志功能由 clojure.tools.logging 库提供. 该库提供了一些宏, 它们会委托给特定的日志实现. kit 中使用的默认实现是 logback 库.
clojure.tools.logging 中有六个日志级别, 任何 Clojure 数据结构都可以直接记录. 日志级别为 trace, debug, info, warn, error 和 fatal.
(ns example (:require [clojure.tools.logging :as log])) (log/info "Hello") ;; =>[2015-12-24 09:04:25,711][INFO][myapp.handler] Hello (log/debug {:user {:id "Anonymous"}}) ;; =>[2015-12-24 09:04:25,711][DEBUG][myapp.handler] {:user {:id "Anonymous"}} (log/error (Exception. "I'm an error") "something bad happened") ;; =>[2015-12-24 09:43:47,193][ERROR][myapp.handler] something bad happened ;; java.lang.Exception: I'm an error ;; at myapp.handler$init.invoke(handler.clj:21) ;; at myapp.core$start_http_server.invoke(core.clj:44) ;; at myapp.core$start_app.invoke(core.clj:61) ;; ...
16.1. Logging Configuration (日志配置)
默认的日志记录器配置位于 resources/logback.xml 文件中, 内容如下:
<?xml version="1.0" encoding="UTF-8"?> <configuration scan="true" scanPeriod="10 seconds"> <statusListener class="ch.qos.logback.core.status.NopStatusListener" /> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <!-- encoders are assigned the type ch.qos.logback.classic.encoder.PatternLayoutEncoder by default --> <encoder> <charset>UTF-8</charset> <pattern>%date{ISO8601} [%thread] %-5level %logger{36} - %msg %n</pattern> </encoder> </appender> <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>log/yourname.guestbook2.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>log/yourname.guestbook2.%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> <logger name="com.zaxxer.hikari" level="warn" /> <logger name="org.apache.http" level="warn" /> <logger name="org.xnio.nio" level="warn" /> <logger name="io.undertow" level="warn" /> <root level="DEBUG"> <appender-ref ref="STDOUT" /> <appender-ref ref="FILE" /> </root> </configuration>
可以通过设置指向日志配置文件路径的 Java 系统属性 logback.configurationFile 来提供外部日志配置. 例如, 我们可以创建一个名为 prod-log-config.xml 的生产配置, 并将其记录到 /var/log/myapp.log 位置.
<?xml version="1.0" encoding="UTF-8"?> <configuration> <statusListener class="ch.qos.logback.core.status.NopStatusListener" /> <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>/var/log/myapp.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>/var/log/myapp.%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> <root level="INFO"> <appender-ref ref="FILE" /> </root> </configuration>
然后我们可以使用以下标志启动应用程序, 以使其使用此日志配置:
java -Dlogback.configurationFile=prod-log-config.xml -jar myapp.jar
有关配置 logback 的更多信息, 请参阅官方文档. #+endsrc
17. Clojure测试工具
Kit 为项目 test 目录中的默认测试工具提供了一个名为 test-utils 的命名空间, 其中包含一些便捷的测试工具.
(ns <app-ns>.test-utils (:require [<app-ns>.core :as core])) (defn system-state [] (or @core/system state/system)) (defn system-fixture [] (fn [f] (when (nil? (system-state)) (core/start-app {:opts {:profile :test}})) (f)))
system-fixture 可用作任何需要启动系统的测试之前的 fixture. 它确保系统使用测试环境启动.
可以使用 system-state 获取系统当前的运行状态. 如果您需要引用, 访问或覆盖单个测试的组件, 这将非常方便.
18. Kit 默认使用 Undertow 服务器
18.1. Undertow 配置
通过 :worker-threads 和 :io-threads 键分别设置工作线程和IO线程数. 例如, 修改 system.edn:
:server/undertow
{:port #long #or [#env PORT 3000]
:handler #ig/ref :handler/ring
:worker-threads 200
:io-threads 4}
自定义配置 (例如运行时计算 io-threads), 可以覆盖 ig/init-key server/undertow 的默认值. kit.edge.server.undertow 中的定义:
(defmethod ig/expand-key :server/undertow [k config] {k (merge {:port 3000 :host "0.0.0.0"} config)})
例如:
(defmethod ig/expand-key :server/undertow [k config] {k (merge {:port 3000 :host "0.0.0.0" :io-threads (* 2 (.availableProcessors (Runtime/getRuntime)))} config)})
更多配置选项, 请参考 ring-undertow-adapter 文档.
Undertow 使用独立的线程池管理 IO 和工作线程. :dispatch? 标志决定请求是否由 IO 线程分派到单独的工作线程. 由于分派请求到工作线程有开销, 对于某些请求(例如硬编码文本响应), 直接在 IO 线程中处理可能更高效.
19. 环境变量
19.1. 12要素应用
Kit 旨在简化12要素应用的开发. 12要素方法规定配置应与代码分离, 应用程序不应因部署环境的不同而需要不同的打包方式.
19.2. 配置
19.2.1. Aero 集成
Kit 利用 Aero 读取系统环境变量到 system.edn 配置文件中. 例如, 加载 PORT 环境变量, 如果不存在则默认为 3001:
#long #or [#env PORT 3001]
#long: 将值解析为长整型.#or: 返回列表中第一个真值.#env: 从环境变量中加载值, 默认为nil.
19.2.2. 默认环境变量
Kit 项目默认使用以下环境变量:
PORT: 应用程序尝试绑定的 HTTP 端口, 默认为 3000.REPL_PORT: REPL Socket 服务器端口, 默认为 7000.REPL_HOST: 数据库连接 URL.COOKIE_SECRET: 16 位字符的会话 Cookie 加密密钥, 默认为16charsecrethere. 重要提示: 在生产环境中必须更改此值.
19.2.3. Config 命名空间
默认情况下, 所有系统配置都加载到 system.edn 文件中. 如有需要, 可以加载多个文件, 甚至根据租户配置进行合并. 这可以通过项目中的 <project-ns>.config 命名空间进行扩展. 默认实现很简单, 只加载 system.edn 中的配置作为 Integrant 使用的 system-config.
(ns <project-ns>.config (:require [kit.config :as config])) (def ^:const system-filename "system.edn") (defn system-config [options] (config/read-config system-filename options))
19.3. 环境特定代码
某些代码(例如开发中间件)依赖于应用程序的运行模式. Kit 使用 =env/dev/clj~ 和 env/prod/clj 源路径来实现此目的. 默认情况下, 源路径包含具有环境特定配置的 <app>.env 命名空间.
- 开发配置
(ns <project-ns>.env (:require [clojure.tools.logging :as log] [<project-ns>.dev-middleware :refer [wrap-dev]])) (def defaults {:init (fn [] (log/info "\n-=[ starting using the development or test profile]=-")) :started (fn [] (log/info "\n-=[ started successfully using the development or test profile]=-")) :stop (fn [] (log/info "\n-=[ has shut down successfully]=-")) :middleware wrap-dev :opts {:profile :dev}})
开发配置引用了同一源路径中的
<app>.dev-middleware命名空间. 任何特定于开发的中间件都应放在此处. - 生产配置
(ns <project-ns>.env (:require [clojure.tools.logging :as log])) (def defaults {:init (fn [] (log/info "\n-=[ starting]=-")) :started (fn [] (log/info "\n-=[ started successfully]=-")) :stop (fn [] (log/info "\n-=[ has shut down successfully]=-")) :middleware (fn [handler _] handler) :opts {:profile :prod}})
生产环境中仅运行
<app>.middleware命名空间中定义的中间件.
19.3.1. 存储密钥
应避免将密钥存储在版本控制系统跟踪的文件中. Aero 提供了 #include 宏, 可以从其他文件加载内容. 可以使用以下方法加载敏感数据:
{:secrets #include "secrets.edn"}
或根据配置文件加载:
{:secrets #include #profile {:dev "dev-config.edn"
:prod "prod-config.edn"
:test "test-config.edn"}}
请注意, 提供的路径是相对于 system.edn 的. 对于自定义行为, 建议创建自己的解析器. 更多提示和用法模式, 请参阅 Aero 文档.
20. Clojure 应用部署指南
20.1. 独立可执行文件
生成独立可执行jar包:
clj -Sforce -T:build all
生成的jar包位于target目录下, 运行:
java -jar <app>.jar
指定端口:
export PORT=8080 java -jar <app>.jar
20.2. VPS 部署
推荐使用DigitalOcean等VPS服务. 安装Ubuntu系统并参考相关指南安装Java. 以下指南适用于Ubuntu 15.04及以上版本.
通常的做法是用Nginx反向代理uberjar.
20.3. 应用部署
- 创建部署用户:
sudo adduser -m deploy sudo passwd -l deploy
- 创建应用目录(例如 */var/myapp/) 并上传jar包:
scp myapp.jar user@<domain>:/var/myapp/
- SSH连接到服务器并运行应用:
java -jar /var/myapp/myapp.jar
- 测试应用:
curl http://127.0.0.1:3000/
应用应该可以通过 http://<domain>:3000 访问. 如果无法访问, 请检查防火墙设置.
20.4. systemd 启动配置
- 创建 */lib/systemd/system/myapp.service/ 文件:
[Unit] Description=My Application After=network.target [Service] WorkingDirectory=/var/myapp EnvironmentFile=-/var/myapp/env Environment="DATABASE_URL=jdbc:postgresql://localhost/app?user=app_user&password=secret" ExecStart=/usr/bin/java -jar /var/myapp/myapp.jar User=deploy [Install] WantedBy=multi-user.target
- 限制JVM内存使用(可选): 在/[Service]/部分添加:
_JAVA_OPTIONS="-Xmx256m"
- 启用systemd服务:
sudo systemctl daemon-reload sudo systemctl enable myapp.service
- 重启服务器并检查进程:
ps -ef | grep java
确保应用以 deploy 用户运行.
20.5. Nginx 反向代理
- 安装Nginx:
sudo apt-get install nginx
- 备份并修改 */etc/nginx/sites-available/default/:
server { listen 80 default_server; listen [::]:80 default_server ipv6only=on; server_name localhost mydomain.com www.mydomain.com; access_log /var/log/myapp_access.log; error_log /var/log/myapp_error.log; location / { proxy_pass http://localhost:3000/; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_redirect off; } }
- 重启Nginx:
sudo service nginx restart
- 测试应用是否可以通过 /http://<domain>* 访问.
- 配置Nginx服务静态资源(可选): 将静态资源上传到服务器(例如 */var/myapp/static/) , 并在Nginx配置中添加:
location /static/ {
alias /var/myapp/static/;
}
- 启用gzip压缩 (在 */etc/nginx/nginx.conf/ 中):
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript;
20.6. HTTPS 配置
建议使用HTTPS. 可以使用Certbot获取Let's Encrypt证书.
有两种HTTPS配置方式: 在应用中配置或使用Nginx反向代理.
20.6.1. ring-undertow-adapter SSL 配置
Kit框架使用ring-undertow-adapter, 需要将证书转换为.p12格式的keystore文件, 并在 resources/system.edn 中的 *:server/http/ map中添加:
:ssl-port 443 :keystore "/path/to/your/keystore.jks" :key-password "password-for-keystore"
20.6.2. Nginx SSL 配置
修改Nginx配置, 将HTTP请求重定向到HTTPS, 并使用证书. 具体配置参考原文.
20.7. Heroku 部署
参考原文.
20.8. 启用 Socket REPL
可以通过设置 REPL_PORT 环境变量来配置REPL端口. 建议通过SSH端口转发连接到远程REPL.
20.9. 资源
参考原文.
#+beginsrc org
21. 编辑器
21.1. 为 Kit 配置 Emacs 和 CIDER
为了在开发过程中获取正确的源路径, CIDER 需要在根目录中包含以下内容的 .dir-locals.el 文件:
((clojure-mode . ((cider-preferred-build-tool . clojure-cli) (cider-clojure-cli-aliases . ":dev:test"))))