惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

H
Help Net Security
T
ThreatConnect
SecWiki News
SecWiki News
F
Future of Privacy Forum
AWS News Blog
AWS News Blog
C
Cisco Blogs
A
Arctic Wolf
Vercel News
Vercel News
The GitHub Blog
The GitHub Blog
Scott Helme
Scott Helme
V
V2EX
博客园 - 叶小钗
阮一峰的网络日志
阮一峰的网络日志
K
Kaspersky official blog
G
Google Developers Blog
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
P
Privacy International News Feed
C
Cyber Attacks, Cyber Crime and Cyber Security
N
News | PayPal Newsroom
Schneier on Security
Schneier on Security
NISL@THU
NISL@THU
Microsoft Azure Blog
Microsoft Azure Blog
量子位
The Hacker News
The Hacker News
Stack Overflow Blog
Stack Overflow Blog
Security Latest
Security Latest
M
Microsoft Research Blog - Microsoft Research
Google Online Security Blog
Google Online Security Blog
博客园_首页
C
CXSECURITY Database RSS Feed - CXSecurity.com
I
InfoQ
Google DeepMind News
Google DeepMind News
Y
Y Combinator Blog
The Cloudflare Blog
Microsoft Security Blog
Microsoft Security Blog
Martin Fowler
Martin Fowler
Cisco Talos Blog
Cisco Talos Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
T
Troy Hunt's Blog
F
Fox-IT International blog
S
Security @ Cisco Blogs
博客园 - 司徒正美
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
C
Comments on: Blog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
L
LINUX DO - 最新话题
GbyAI
GbyAI
Project Zero
Project Zero
腾讯CDC
T
Tailwind CSS Blog

暗无天日

读:把会议当系统来设计 - 暗无天日 读:把 JSON 当编程语言执行——一个迷你解释器的构造过程 - 暗无天日 读:从API调用到Agent循环——构建 Agent 的七个阶段 - 暗无天日 读:Token 经济学的四个第一性原理 读:用 SonarQube 检测 Java 代码中的 Bug 和安全漏洞 读:tetris-sql——用一条SQL查询实现俄罗斯方块 - 暗无天日 TIL: dired 里按时间标记文件——dired-mark-if 与夏令时陷阱 - 暗无天日 读:从端点到行动——面向 AI 代理的后端设计 - 暗无天日 TIL DDD战术模式:用Clojure让代码说人话 - 暗无天日 读:Amin Bandali 与 Protesilaos 谈 Emacs 内置功能的深度定制 读:Clojure 世界的 AI 代理调教术——四个改变行为的 Skill TIL: minibuffer 激活时也能操作其他窗口 - 暗无天日 读:Tramp改了配置怎么不生效 - 暗无天日 读:为 project.el 写一个自定义后端 - 暗无天日 读:AI 时代的敏捷开发 - 暗无天日 TIL: 给 dired 异步命令加 nohup,让外部程序活过 Emacs 退出 TIL: elisp-fontify-semantically——让 Emacs 看懂你的 Elisp 代码 TIL-etags扫描外部库头文件 - 暗无天日 读:The Many Faces of flet——Elisp 局部函数的三种写法 读:df 与 du——为什么两个磁盘用量命令数字对不上 - 暗无天日 TIL-可观测性工具的成本盲区 - 暗无天日 TIL: 把 Emacs Buffer 打印成图片 读:gamegrid.el——Emacs 内置游戏是怎么写出来的 - 暗无天日 TIL: MCP 服务器不到 20 行 Python 就能写出来 TIL-AI 工具普及后的组织观察 - 暗无天日 读:当 Agent 开始写数据库——六个防御模式 - 暗无天日 读:右键菜单——Elisp 开发的隐藏利器 - 暗无天日 读:Git 仓库里的隐藏配置文件 - 暗无天日 读:20条软件工程定律 - 暗无天日 读:编译高性能 Emacs - 暗无天日 读:为什么我在终端里待了十年——一个 Emacs 用户的 GUI 观察 读:Event Sourcing——让你的数据库记住每一次变更 - 暗无天日 读:数据管道中Schema变更的四种形状 - 暗无天日 读:SES——Emacs内置的简易电子表格 - 暗无天日 TIL: 用 parallel 加速 rsync 迁移海量小文件 TIL:给 AI 一个更小的世界——技术选型的上下文窗口约束 - 暗无天日 读:emacs chat 技巧拾遗——从 bandali 的配置里捡到的那些技巧 读:当 Agent 成为生产调用者——四个被打破的运维假设 - 暗无天日 TIL: describe-personal-keybindings 查看你的自定义按键 - 暗无天日 读:Linux 创建指定大小文件的三种方式——dd、fallocate 与 truncate - 暗无天日 读:Yazi——在终端里管理文件的新选择 - 暗无天日 读:软件测试的反馈视角——CLEAR 原则从测试到运维 - 暗无天日 MobileOrg Android:从 API 17 迁移到 API 34 的实战记录 TIL:微服务与复杂度守恒——从单体到分布式的代价转移 - 暗无天日 读:MCP 时代的安全威胁——幻觉权限与三道防线 - 暗无天日 读:超越对话——用 Skills 和 Agents 工程化上下文 TIL:用 :box 给 mode-line 加内边距 org-mobile-push 卡顿排查实战:从黑盒到字节码反编译 - 暗无天日 读:用 LLM 重构遗留代码——三个陷阱与一套方法 - 暗无天日 读:AI 辅助编程的三种错误用法 - 暗无天日 读:Before GitHub - 暗无天日 读:AI in Software Architecture - 暗无天日 读:把成本当作 SLI - 暗无天日 TIL: 用进程树展开定位被脚本包装的 JVM 进程 - 暗无天日 读:Linux 删文件的真相——用 /proc 恢复被进程持有的已删除文件 - 暗无天日 读:Choosing a Python Logging Library in 2026 Emacs buffer 导出:五种方案对比 - 暗无天日 TIL: flymake 错误跳转加入 Evil 跳转列表 TIL: 用 Org-mode 列表管理选择题题库 - 暗无天日 读:sysstat 诊断链——从 sar 到 pidstat 的排查路径 读:理论靠谱,生产翻车的六个集成模式 - 暗无天日 读:双写问题——@Transactional 给不了的跨系统一致性 - 暗无天日 读:PostgreSQL 随机测试数据生成——从快速造数到自动化填充 - 暗无天日 读:逆萨丕尔-沃夫假说与编程语言 - 暗无天日 读:理解 MCP 架构——LLM 直接调 API 与 MCP 协议的对比 读:Emacs 连接数据库时密码放哪里 - 暗无天日 TIL:watch 命令的几个遗漏技巧 - 暗无天日 TIL:Python 3.15 的 sentinel() 内置函数 读:7 Techniques That Supercharged My Claude-Assisted Development 读:AI 编码代理的四种工作流 - 暗无天日 读:Agent 的瓶颈不在模型,在基础设施 - 暗无天日 读:EvoForge——用群体进化优化 AI Agent - 暗无天日 TIL:Google Stitch 的 DESIGN.md,给 AI 读的设计系统说明书 Emacs 批量搜索替换:从场景到命令 - 暗无天日 TIL: image-mode 的 header-line 中显示图片尺寸 dotfile仓库大扫除:清理过时的配置 - 暗无天日 读:The Art of Logging——日志规范清单 - 暗无天日 从CSS选择器到自然语言:网页自动化的两种范式与取舍框架 - 暗无天日 TIL-用 curl + w3m + awk 从 HTML 表格提取数据 读:Shell脚本安全编码的五条铁律 - 暗无天日 读:Emacs newcomers-presets theme —— 30+ 项新手预设一览 读:Protesilaos 的 Emacs 合理默认配置 —— 兼与 newcomers-presets 对比 控制 Bash 历史记录的 6 个场景 读:AI Agent 安全日志——从可见性与隐私的两难说起 - 暗无天日 读:AI Agent 生产化——一份从原型到上线的速查清单 - 暗无天日 读:LLM 生产环境六种失败原型——基准测试无法预测的那些故障 - 暗无天日 读:Prompt Injection 五层纵深防御——从输入过滤到审计追踪 - 暗无天日 读:为什么所有 Prompt Injection 防御都会被攻破——以及架构上该怎么办 - 暗无天日 读:JVM 后端性能调优备忘——从一次生产事故中学到的优化要点 - 暗无天日 读:Java 容器化——从 Fat JAR 到高效 Docker 镜像 读:整洁代码的几个通用原则——从 Go 生态看起 - 暗无天日 读:规则引擎——从 if-else 到业务规则管理 - 暗无天日 AI写作的语言指纹——如何让文字不那么像机器 - 暗无天日 读:50 条 Claude Code 技巧——一个工程经理的六个月使用心得 读:AI 辅助开发为什么让 E2E 测试更有价值 - 暗无天日 读:在Emacs中使用Claude Code(Spacemacs适配版) - 暗无天日 Claude Code 背后的工程哲学——读 Agent Harness Engineering 读:Agent Harness Engineering——AI 智能体不只是模型,还有套件 - 暗无天日 browser-harness:让 AI 直接接管你的浏览器 - 暗无天日 读:Security-First CI/CD —— DevSecOps 自动化实践指南
读:Querying Without a Query Language——不用查询语言的查询
2026-05-25 · via 暗无天日

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