👨🏽‍💻

Clojure 与 PostgreSQL:优雅的构建 SQL 查询

Honey SQL 的基本使用

Honey SQL 是一个在 Clojure 语言生态中的 SQL 语句构建器 ( SQL builder )。
Clojure 是一个面向数据的编程语言 ( Data-Oriented programming ),在这门编程语言中,数据是不可变的,程序不断的对「数据」进行变换,从而得到变换后的新数据,但几乎不会去改变已有数据内部的内容。
Honey SQL 在设计上十分贴合 Clojure 面向数据编程的理念,其核心仅是 honey.sql/format 这一个函数。其以一个描述 SQL 查询动作的字典作为函数输入,以一个拼接好的 SQL 语句作为函数输出。并辅以插件系统进行定制和扩展。
(sql/format {:select :* :from :users}) ; => ["SELECT * FROM users"]
Honey SQL 的数据格式十分贴近 SQL 语句格式,这对于熟悉 SQL 语法结构的大家来说,使用起来是十分方便的。相较于各式 ORM 的花式 API 格式,贴近 SQL 的结构设计可以显著降低使用者的学习成本。

新增一条数据即使用 insert-into 指令,values 里面是一个数据,里面就是普普通通的 Clojure 字典数据结构。
(->> {:insert-into :users :values [{:username "username" :level "level" :is_admin false}] :returning :*} sql/format) ; => => ["INSERT INTO users (username, level, is_admin) VALUES (?, ?, FALSE) RETURNING *" "username" "level"]
sql/format 出来的依然是一个数组,其中: - 第一个是拼接好的 SQL 语句 - 后面 N 个是该 SQL 语句中的 N 个参数(数量与 ? 数量相对应)
这么做是为了防止 SQL 注入。Prepared Statement & SQL injection
[<sql> & <params>]的格式数据,可以直接作为 next.jdbc/execute!的参数,从而真实的执行所拼接出来的 SQL 语句。

借助 Clojure 的 cond-> 宏,我们可以很轻松的构造出局部更新的 SQL 语句。
(defn update-user [tx user_id & {:keys [password level realname]}] (->> {:update :users :where [:= user_id :user_id] :set (cond-> {} (some? password) (assoc :password_hashed (hash-password password)) (some? level) (assoc :level level) (some? realname) (assoc :realname realname) sql/format (jdbc/execute! tx)))
update-user 函数有两个位置参数: - tx: 数据库连接实例,可以是一个事务实例,也可以是个连接池 - user_id: 要更新的用户 id 后面就都是可选的命名参数了,在这里可以实现如果传入了 realname 就局部更新该行的 realname 列的内容。其工作原理是这样的:
(cond-> {} ;后面跟着偶数个 form,每组第一个是条件,第二个是动作 (some? password) ; 如果传入了 password 参数,即 password != nil (assoc :password_hashed (hash-password password) ; 则更新 {} 为,{:password_hashed <xxx>} )
具体 cond->用法参见: cond-> - clojure.core | ClojureDocs

构造复杂查询条件

这是一个包含 PostgreSQL 中 JSONB 特性的复杂查询,关于 Clojure 与 JSONB 特性的介绍,后续会开一篇文章具体聊一聊。在这里只需要关注 :where 子句部分。
(defn fetch-one [tx & {:keys [machine_id natural_id]}] {:pre [(or (some? machine_id) (some? natural_id))]} (->> {:select [[:*] [{:select [[[:json_build_object [:inline "natural_id"] :warehouses.natural_id [:inline "profile_name"] [:-> :warehouses.meta [:inline "profile"] [:inline "name"]]]]] :where [:= :warehouses.warehouse_id :machines.warehouse_id] :from :warehouses} :warehouse]] :from :machines :where (cond-> [:and] (some? machine_id) (conj [:= :machine_id machine_id]) (some? natural_id) (conj [:= :natural_id natural_id])) :limit 1} sql/format log/spy (db/execute! tx) first))
where 子句中,我们用了跟之前介绍 update 时异曲同工的方法,即使用 cond-> 宏进行条件拼接。这里通过几个例子来看一看拼接的效果: - 传入 machine_id 那么拼好的结构是这样的 [:and [:= :machine_id <machine_id>]],会生成 (machine_id = ?) 这样的 SQL 片段。 - 传入 machine_idnatural_id 那么拼好的结构是这样的 [:and [:= :machine_id <machine_id>] [:= :natural_id <natural_id>]],会生成 ((machine_id = ?) and (natural_id = ?)) 这样的 SQL 片段。
在这里,我们很容易就能发现,前缀表达式在可组合性上的优越性。因为在常用的中缀表达式中,我们表达多个逻辑语句的操作的时候,比如有 N 个子句 (a and b and c and d),我们需要 N - 1 一个 and 运算符。进行组合拼接的时候,由于保持 and 运算符的数量不是负数, 即 N - 1 > 0,所以我们也必须要对「只有一个逻辑子句」和「两个及以上逻辑子句」的情况分开进行处理。
log/spy 是调试 Clojure 时比较好用的一种日志输出方法,具体再开一篇文章聊一聊如何调试 Clojure 代码。)

连接到数据库

Honey SQL 仅仅是一个 SQL Builder,它只能通过参数构造出 SQL 语句,本身并不负责与数据库的交互,因此我们需要额外的工具来进行。

使用 next.jdbc

next.jdbc 是一个在 Clojure 生态中比较好用的 JDBC 封装,是一个同步IO模型的封装。近些年,随着 nodejs 和 golang 映入眼帘和不断的发展壮大,对于异步 IO 模型的关注持续高涨,但其实我对基于异步 IO 构建的应用软件是否真能带来实际的吞吐量提升持观望态度。这里有篇内容的观点我觉得挺有趣,直接了解。

使用 mount 管理状态

函数式编程语言中,函数虽然是一等公民,但在程序编写中,我们无时无刻不在和「副作用」作斗争,「不可变」数据结构是我们斗争「副作用」的利器。
(这里再挖一个坑,《纯函数、副作用与不可变数据结构》)
简单来说,在 Clojure 编程实践中,我们要尽可能收敛「可变状态」,并将他们好好的管理起来。其余的逻辑,使用纯函数和不可变数据结构变换来达成。而数据库连接池就是一个可变状态,它可以处于未创建的状态,也可以处于创建好的状态,我们用 mount 来管理它。
(defn init-pool [jdbc-url] (log/debug "connection to database: " jdbc-url) (connection/->pool com.zaxxer.hikari.HikariDataSource {:jdbcUrl jdbc-url})) (defstate pool :start (init-pool (:jdbc-url (mount/args))) :stop (.close pool))
在这里我们创建了一个叫 pool 的可变状态,我们使用 init-pool作为 :start 生命周期钩子的值,用于初始化数据库连接池;在:stop钩子这里,我们调用 .close 来关掉的数据库连接。

这么做有什么好处?

「热重载」是提高 Clojure 开发效率的一大利器,可以在不重启服务器进程的情况下应用代码变更。程序代码是静态的,所以很容易被重载,而服务器进程中积累的状态,则是正确进行热重载的拦路虎。以数据库连接池为例,服务器进程仅需要一个数据库连接池,在服务器启动的时候进行初始化。在我们进行代码热重载的时候,如果变更了初始化数据库连接池的代码,而不去重制并用新的代码初始化连接池,那么进程中的数据库旧的连接池还是使用旧的代码初始化的,「旧的数据库连接池」很可能和「新的代码」造成冲突而无法正确工作。而 mount会在重载连接池相关的代码变更时,关掉旧的连接池,并使用新的代码初始化新的连接池,这一切都是自动的。

分页基础设施

最后,我们来封装一个十分常用的功能「分页」。目前比较常见的分页模式有如下两种: - 基于页面。数据库使用 order by offsetlimit来进行分页。 - 基于游标的。数据库使用 order by limitwhere 来进行分页。
其中,第一种方法,随着页面编号逐渐增加,其查询性能也逐渐下降,但是可以跳转的任意页面,前端展示通常配合数字编号分页器。 而第二种方法,随着页面编号的增加,其查询性能依旧稳定,但是只能跳转到下一页或者上一页,无法跳转到任意页面。
第一种方法相信大家都有所耳闻,第二种方法的具体原来可以参看这篇文章。
其核心是构造一个游标的标志,查询后续内容是,将游标标志传入 where 子句中,使得该游标之前的内容不参与 order by过程,从而使查询性能稳定。在 SQL 层面上来看,其主要是构造一个这样的 SQL 查询。在下图中,我们的游标 marker 是 (data,id) 的组合。
notion image
请注意,使用任何分页方法时,为了保证连续分页查询结果的排序一致性, - 一定要使用 order by 子句。 - order by子句最后一项,一定要具有值唯一性的列,通常使用主键。
如若不是用上述约束,数据库查询出来的行的顺序是不确定的,会导致同一行在不同页面同时出现、同一页面在没有数据变更的情况下查询出来的内容不一致的情况。

游标分页器

这里我们封装了一个游标分页器的辅助函数,其作用是将「游标标记」和「排序」规则组合起来。
(defn with-pagination-marker [dsl order keys marker] {:pre [(or (= :asc order) (= :desc order)) (contains? dsl :select) (not (contains? dsl :order-by))]} (cond-> dsl (= (count keys) (count marker)) (update :where #(vector :and [(if (= :desc order) :< :>) (cons :composite keys) (cons :composite marker)] %)) true (assoc :order-by [[(cons :composite keys) order]])))
我们用几个例子来说明这个函数的作用:
  • 查询第一页:在这里我们使用 (created_at, user_id) 升序排列,游标标记传入 nil 代表是第一页。
(sql/format (with-pagination-marker {:select :* :limit [:inline 10] :from :users} :asc [:created_at :user_id] nil )) ;=> ["SELECT * FROM users ORDER BY (created_at, user_id) ASC LIMIT 10"]
  • 接着第一页查询第二页:这里我们需要使用上次查询结果的最后一项的(created_at, user_id) 作为标记传入,以获得从某个特定的 (created_at, user_id)开始的后一页内容。
(sql/format (with-pagination-marker {:select :* :limit [:inline 10] :from :users} :asc [:created_at :user_id] [#inst "2022-05-29T09:43:16.730-00:00" #uuid "8f62771e-22d1-46c4-89fe-b85dc8d88b3b"] )) ; =>["SELECT * FROM users WHERE ((created_at, user_id) > (?, ?)) ORDER BY (created_at, user_id) ASC LIMIT 10" ; #inst "2022-05-29T09:43:16.730-00:00" ; #uuid "8f62771e-22d1-46c4-89fe-b85dc8d88b3b"]

一个良好的 HTTP 查询接口

上面我们已经做了一个在数据库层面十分易用的分页工具了,但如果实现一个 HTTP 分页的查询接口,上述封装还有所欠缺。其主要缺点是对于「分页游标标记」的处理,这部分应该对前端透明,即前端并不需要感知游标的内容,而只需要用上一页结果返回的游标标记,直接传入下一次分页请求以获得后续页面。
 
(defn- encode-marker [values] (-> values cbor/encode codecs/bytes->b64u codecs/bytes->str)) (defn- decode-marker [marker] (try (-> marker codecs/str->bytes codecs/b64u->bytes cbor/decode) (catch Exception _ nil))) (def ^:private select-values (comp vals select-keys)) (defn paginated! [dsl conn order-by-keys order {:keys [limit marker]}] (-> dsl (with-pagination-marker order order-by-keys (decode-marker marker)) (assoc :limit (or limit 20)) (sql/format) ((partial execute! conn)) ((partial array-map :items)) (#(assoc % :marker (-> % :items last (select-values order-by-keys) encode-marker)))))
这里我们制作了一个简单的辅助函数,它会: - 自动调用 with-pagination-marker 构造分页 SQL 请求 - 自动从结果中获取最后一行的分页游标标记内容,并使用 cbor + base64进行编码,放入查询结果的 marker 字段中。 - 前端下一次请求的时候,将上一次返回结果的 marker 字段传入。该函数会自动解码为分页游标标记,并调用 with-pagination-marker 构造后续的分页 SQL 请求
cbor是一个二进制编码格式,长度够短。base64可以构造出 URL-Safety 的文本,可以放入 GET 请求的 query parameters中。