暗无天日

=============>DarkSun的个人博客

读:Querying Without a Query Language——不用查询语言的查询

Jan Nilsson 在 Querying Without a Query Language 里提了一个和直觉对着干的观点:查询是"设计"出来的,不是"构建"出来的。用"属性-操作符-值"三元组,按领域模型的结构来组织过滤条件,把拼接 SQL、组装 Criteria API 的脏活从客户端挪到系统内部。

这个思路有意思,但原文全是概念描述,没有一行代码。本文用 Clojure 把它翻译成可运行的实现,看看"不用查询语言的查询"到底长什么样。

问题:查询为什么越写越乱

一个后端系统的查询逻辑,通常不是设计出来的,是长出来的。

一开始只要过滤课程名 = "数学"。ORM 里加个 where 就搞定。然后要加学分过滤、要过滤关联的学生名字、要按学生成绩排序……每一步看起来都合理,但累积到某个点,你会发现代码里到处都是拼接条件、手动 join、处理分页和去重的逻辑。想搞清楚"这个接口到底查了什么",得把分散在多个文件里的查询构建代码全读一遍。

Nilsson 的观察是:框架本身没问题,问题是我们从来没把查询当成需要设计的东西。 如果查询的结构跟随领域结构,很多拼接逻辑就消失了。

核心理念:属性、操作符、值

这个模型把查询化简到三个要素:

  • 一个 属性 (domain 里的字段名)
  • 一个 操作符EQGTLTINLIKE ,就这几个)
  • 一个

组合起来就是这样:

;; 查询:课程名 = "高等数学"
{:course {:title {:op :eq :value "高等数学"}}}

;; 查询:学分 > 2 且 有学生名叫"张三"
{:course {:credits {:op :gt :value 2}}
 :students {:name {:op :eq :value "张三"}}}

注意第二个查询的写法:学生过滤条件放在了 :students 下面,查询结构就是领域结构的翻版(课程下面挂学生)。不需要写 JOIN 也不需要用子查询,因为系统已经知道课程和学生之间靠什么字段关联,客户端不用在查询里再声明一遍。

"构建"和"设计"到底差在哪

"查询是设计出来的"这个说法容易让人困惑。两个词的区别一句话就说清了: 领域结构的知识放在哪里

用具体查询对比。查"有学生叫张三的课程":

构建查询 (现在大多数人做的)——每次写查询时,在代码里重新声明表怎么关联:

SELECT c.* FROM course c
JOIN student s ON c.id = s.course_id
WHERE s.name = '张三'

设计查询 (Nilsson 的方案)——系统已知课程和学生的关系,你只声明过滤什么:

{:students {:name {:op :eq :value "张三"}}}

系统自己知道要 JOIN student 表、用 course-id 关联、过滤 name = "张三"。换一个查询(查成绩 > 80 的学生的课程),构建方式得重写一遍 JOIN,设计方式只改过滤字段名。

这里的"设计",意思是: 花一次功夫把领域模型(实体、关系、关联字段)定义清楚,之后所有查询都复用这套结构 。客户端只填过滤条件,不写查询语句。设计做在前面,后面的查询自然就简单了。

两阶段执行

当查询包含关联过滤时(如"有成绩 > 80 的学生的课程"),一次 SQL 搞不定。为什么?因为两个问题搅在了一起:一是"哪些课程符合条件"(这需要查学生表才知道),二是"每门课下面返回哪些学生"(被过滤的只返回匹配的,没被过滤的返回全部)。传统做法把这两个问题绑在一个 JOIN 查询里,两阶段执行把它们拆开:

  1. 阶段一:确定匹配的根对象 。把要返回哪些根实体这件事单独解决,包括去查关联表来确定。结果是匹配的根对象 ID 集合。
  2. 阶段二:构建返回结果 。加载选中的根对象,附加关联数据。关键规则是:查询里提了哪个关联的过滤条件,返回结果里那个关联就只包含匹配的子实体;查询里没提的关联,返回全部子实体。过滤条件同时做两件事:选根对象,切关联数据。

举个例子:"学分 > 2 且 有成绩 > 80 的学生的课程"——阶段一会把课程范围从全部 3 门缩到 2 门(高等数学、大学物理),阶段二给每门课程附上成绩 > 80 的学生(高等数学有张三和李四,大学物理有赵六),成绩不达标的学生不出现在结果里。

而"学分 > 2 的课程"(查询里没提学生的事),阶段一会筛选出 2 门课程,阶段二给每门课返回它的全部学生(不管成绩多少),因为学生关联没有被过滤。

这个拆分还顺手解决了一个分页的老毛病:传统 JOIN 查询里,一门课有 3 个学生就会出现在结果里 3 次,按行数翻页时同一门课可能被截到两页去。两阶段执行翻页翻的是阶段一选出来的根对象(课程),跟阶段二拼装关联数据(学生)完全解耦,所以不会出现这个问题。

Clojure 实现

下面用 Clojure 从头实现。

数据定义

(require '[clojure.string :as str])

(def courses
  [{:id 1 :title "高等数学" :credits 4}
   {:id 2 :title "大学物理" :credits 3}
   {:id 3 :title "中国近代史" :credits 2}])

(def students
  [{:id 1 :name "张三" :course-id 1 :grade 85}
   {:id 2 :name "李四" :course-id 1 :grade 92}
   {:id 3 :name "王五" :course-id 2 :grade 78}
   {:id 4 :name "赵六" :course-id 2 :grade 88}
   {:id 5 :name "孙七" :course-id 1 :grade 65}])

课程和学生是一对多关系: student:course-id 对应 course:id

操作符

只支持五个操作符,够覆盖绝大多数查询场景:

(def ops
  {:eq   (fn [field-val value] (= field-val value))
   :gt   (fn [field-val value] (> field-val value))
   :lt   (fn [field-val value] (< field-val value))
   :in   (fn [field-val values] (boolean (some #(= field-val %) values)))
   :like (fn [field-val pattern] (str/includes? (str field-val) (str pattern)))})

每个操作符的第一个参数是实体的字段值,第二个参数是查询条件里写的值。

匹配判断

match? 检查一个实体是否满足一组过滤条件。一组条件之间是 AND 关系(全部满足才算匹配):

(defn match? [entity filters]
  (every? (fn [[k {:keys [op value]}]]
            (let [op-fn (get ops op)
                  field-val (get entity k)]
              (op-fn field-val value)))
          filters))

注意这个模型 不支持 OR。不是漏了,是刻意不要。后面会解释。

查询执行

两阶段执行的完整实现:

(defn query [data q]
  (let [course-filters (:course q)
        student-filters (:students q)
        all-courses (:courses data)
        all-students (:students data)
        ;; ---- Phase 1: 确定匹配的根对象 ID ----
        ;; 1a. 应用根级别过滤
        matched-courses (if course-filters
                          (filter #(match? % course-filters) all-courses)
                          all-courses)
        ;; 1b. 如果有子实体过滤,筛选出关联了匹配子实体的根对象
        matched-ids (if student-filters
                      (let [matched-students (filter #(match? % student-filters) all-students)
                            valid-course-ids (set (map :course-id matched-students))]
                        (set (keep #(when (valid-course-ids (:id %)) (:id %))
                                   matched-courses)))
                      (set (map :id matched-courses)))
        ;; ---- Phase 2: 构建返回结果 ----
        final-courses (filter #(contains? matched-ids (:id %)) all-courses)]
    {:count (count final-courses)
     :results (mapv (fn [c]
                      (let [cs (filter #(= (:course-id %) (:id c)) all-students)]
                        (assoc c :students
                               ;; 被过滤的关联只包含匹配项,否则包含全部
                               (if student-filters
                                 (filterv #(match? % student-filters) cs)
                                 (vec cs)))))
                    final-courses)}))

核心逻辑不到 20 行。关键在阶段一的 1b 和阶段二的 if student-filters 分支。前者保证只有"确实关联了匹配子实体的根对象"才被选中,后者保证被过滤的关联里只出现匹配的子实体。

跑几个查询

(def data {:courses courses :students students})

先来个最简单的——查特定课程名:

(query data {:course {:title {:op :eq :value "高等数学"}}})
{:count 1,
 :results
 [{:id 1, :title "高等数学", :credits 4,
   :students
   [{:id 1, :name "张三", :course-id 1, :grade 85}
    {:id 2, :name "李四", :course-id 1, :grade 92}
    {:id 5, :name "孙七", :course-id 1, :grade 65}]}]}

没有学生过滤条件,所以返回了课程的全部 3 个学生。

查有特定学生的课程——过滤关联:

(query data {:students {:name {:op :eq :value "张三"}}})
{:count 1,
 :results
 [{:id 1, :title "高等数学", :credits 4,
   :students
   [{:id 1, :name "张三", :course-id 1, :grade 85}]}]}

注意返回的学生列表只有张三,不是全部 3 个。因为学生关联被过滤了,只有匹配的子实体被包含。

组合过滤——根级别 + 关联级别:

(query data {:course {:credits {:op :gt :value 2}}
             :students {:grade {:op :gt :value 80}}})
{:count 2,
 :results
 [{:id 1, :title "高等数学", :credits 4,
   :students
   [{:id 1, :name "张三", :course-id 1, :grade 85}
    {:id 2, :name "李四", :course-id 1, :grade 92}]}
  {:id 2, :title "大学物理", :credits 3,
   :students
   [{:id 4, :name "赵六", :course-id 2, :grade 88}]}]}

高等数学里孙七被排除了(成绩 65 不达标),大学物理里王五被排除了(成绩 78 不达标)。中国近代史根本没出现(学分 = 2,不满足 > 2 的条件)。

IN 操作符:

(query data {:course {:title {:op :in :value ["高等数学" "大学物理"]}}})
{:count 2,
 :results
 [{:id 1, :title "高等数学", :credits 4, :students [...]}
  {:id 2, :title "大学物理", :credits 3, :students [...]}]}

LIKE 操作符:

(query data {:course {:title {:op :like :value "数学"}}})
{:count 1,
 :results
 [{:id 1, :title "高等数学", :credits 4, :students [...]}]}

刻意限制的设计哲学

这个模型有几个"缺失"的功能,但它们是 故意 的:

  1. 不支持 OR 。过滤条件之间只能是 AND。 Nilsson 的观点是:一旦引入 OR,查询的可理解性就崩塌了。A AND B 的语义没有歧义,A OR B 会迅速演变成嵌套布尔表达式。如果需要 OR,回到 SQL 或 Criteria API 手写去。这个模型不拦着你,它只管默认路径。
  2. 不支持聚合 。没有 COUNTSUMGROUP BY 。<br/> 聚合查询和过滤查询的目标不同:前者做统计,后者取数据。混在一起会让语义变模糊。
  3. 不追求覆盖所有场景 。 这个模型定义了一条"默认路径"。80% 的查询场景(按属性过滤、跨关联过滤、按领域结构返回数据)用这套就够了。超出这个范围的,回退到手写查询。

这不是"功能缺失",是"有意的约束"。跟前端的受控组件、数据库的约束检查一样,限制表达能力,换来一致性和可理解性。

局限

这个模型的适用场景有明显的边界:

  • 领域结构简单清晰 。实体之间的关系是已知的、稳定的。如果领域模型本身就一团乱,用它来做查询结构只会更乱。
  • 查询模式以"过滤 + 关联"为主 。如果系统里有大量统计报表、跨实体聚合,这套模型帮不上忙。
  • 对性能的控制更弱 。查询执行逻辑封装在系统里,DBA 或开发者没法针对单个查询做索引优化,除非系统本身足够智能。

Nilsson 自己也说了,这个模式来自一个"领域被描述为元数据并提供给系统"的真实项目。也就是说,它假设系统已经知道实体和关系的完整定义。如果你的项目没有这套元数据基础设施,直接在现有 ORM 上实现这个模式会比较吃力。

结语

这个模型的价值不在于"取代 SQL 或 Criteria API",它也没这个野心。它真正的价值在于让你想清楚一件事:你的查询,到底是在"描述要什么数据",还是在"教数据库怎么取数据"?

描述要什么数据,声明式的,跟着领域结构走。教数据库怎么取数据,过程式的,跟着存储结构走。大多数后端系统把这两种东西混在一个方法里写,不乱才怪。

Jan Nilsson 的文章把这两个东西拆开了。二十行代码就能验证这种拆分的可行性。下次设计查询接口时,值得多想一步。

Clojure : 查询设计 : 领域驱动