普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月24日暗无天日

WSL9x —— 在 Windows 9x 里跑 Linux 内核 6.19

2026年4月24日 08:00
最近看到一个很有意思的项目:把 Linux 内核搬进了 Windows 9x。微软的 WSL(Windows Subsystem for Linux)让 Linux 跑在 Windows 10/11 里,已经够让人吃惊了。但开源开发者 Hailey 更进一步——她让 Linux 内核 6.19 跑在了 Windows 9x(95、98、ME)里。项目名叫 [[https://codeberg.org/nicholatian/wsl9x][WSL9x]]。 * 它怎么做到的? WSL9x 的核心思路是用 Linux 的 User Mode Linux(UML)架构——一种让 Linux 内核作为用户态进程运行的设计。Hailey 基于 UML 做了定制(对应 Codeberg 上的 =win9x-um-6.19= 分支),把内核移植到了 Win9x 环境下:补丁后的内核被加载到固定的内存地址( =0xd0000000= ),一个 VxD(Win9x 的虚拟设备驱动)负责把内核从磁盘读进来,然后处理页面错误和系统调用。 比较巧妙的是系统调用的处理方式。Win9x 没有提供 Linux 标准的系统调用中断表支持,所以 WSL9x 没有走常规的 =int 0x80= 路线,而是通过异常处理函数来拦截和转发 Linux 的系统调用。 终端交互靠一个叫 =wsl.com= 的 16 位 DOS 程序,它把 Linux 的终端输出管道传回你运行它的那个 DOS 窗口。对,你在一台跑 Windows 98 的机器上打开 MS-DOS 提示符,输入 =wsl= ,就能看到 Linux 内核的启动输出。 * 不需要虚拟化,i486 就能跑 整个方案不依赖任何硬件虚拟化——没有 VT-x、没有 AMD-V。Hailey 说它理论上能在 i486 处理器上运行。巧合的是,i486 正好是 Linux 内核即将放弃支持的最老架构。这么一看,WSL9x 等于给了一台快要被 Linux 内核抛弃的老机器一个继续跑 Linux 的机会——只不过是在 Windows 9x 里面跑。 项目目前没有提供预编译的二进制文件,想玩的话需要自己从 [[https://codeberg.org/nicholatian/wsl9x][Codeberg 上的源码]] 构建并部署到一台真正的 Windows 9x 系统上。虽然实用性有限,但作为一个技术实验,它的思路和实现都很有意思。
昨天 — 2026年4月23日暗无天日

TIL: 用 diff-hl 在 fringe 中显示 git 变更

2026年4月23日 08:00
读到 James Dyer 的一篇文章[fn:1],介绍了 =diff-hl= 这个包——在 fringe(Emacs 窗口两侧的边栏)中高亮显示当前 buffer 相对于 git 最新提交的变更:新增的行标绿色,修改的行标黄色,删除的行标红色。不用跳出 buffer 就能看到自己改了什么。 * 配置 #+BEGIN_SRC emacs-lisp (use-package diff-hl :ensure t :hook (dired-mode . diff-hl-dired-mode) :config (global-diff-hl-mode 1) (diff-hl-flydiff-mode 1) (unless (display-graphic-p) (diff-hl-margin-mode 1))) #+END_SRC 几个要点: - =global-diff-hl-mode= 开启全局 fringe 高亮,但默认只在 *保存文件后* 更新 - =diff-hl-flydiff-mode= 改为 *边打字边更新* ,不用先保存再看变更——这个几乎是必开的 - =diff-hl-dired-mode= 在 dired 中用颜色标记哪些文件有 git 变更,一眼看出项目状态 - =diff-hl-margin-mode= 是给终端用户(非图形界面)的回退方案——终端没有 fringe,改在行号旁边的 margin 区域显示 * 配合内置 VC 工具 =diff-hl= 的高亮位置跟 Emacs 内置的 VC(Version Control)工具是对齐的。把光标移到某行高亮处,按 =C-x v == ( =vc-diff= ),会直接跳到 diff 中对应的 hunk——不需要先记住改了哪里再去找。 如果开了 =repeat-mode= (之前写过[fn:2]),第一次按 =C-x v ]= 跳到下一个变更后,后续只需按 =]= 就能继续跳,不用重复 =C-x v= 前缀。=]= 和 =[= 之间也能随意切换方向。 [fn:1] https://www.emacs.dyerdwelling.family/emacs/20260421070329-emacs--getting-diff-hl-just-right/ [fn:2] [[file:TIL-repeat-mode省去重复按键前缀.org][TIL: repeat-mode 省去重复按键前缀]]

读:llm-test —— 用 LLM agent 驱动 Emacs 测试

2026年4月23日 08:00
Andrew Hyatt(Emacs 核心贡献者)最近开源了一个实验性项目[fn:1]: =llm-test= 。它的核心想法是——用 LLM 代替人来测试 Emacs 包。你用自然语言描述"用户应该看到什么",LLM agent 会启动一个干净的 Emacs 进程,像人一样操作它(按键、输入文字、执行命令),然后判断测试是否通过。 * 怎么工作 整个流程分四步: 1. 用 YAML 文件写测试描述,就是一段英文说明要测什么、期望什么结果 2. =llm-test= 解析 YAML,为每个测试描述注册一个 ERT 测试 3. 运行测试时,启动一个 =emacs -Q= 的干净 daemon 进程 4. LLM agent 通过 tool calling 驱动这个 Emacs,直到判定通过或失败 #+BEGIN_SRC yaml group: auto-fill mode setup: | Enable auto-fill-mode in a text-mode buffer. Set fill-column to 40. tests: - description: | Type a long paragraph that exceeds fill-column. Verify that the text is automatically wrapped. - description: | Type a numbered list item that exceeds fill-column. Verify that continuation lines are indented properly. #+END_SRC 这个例子测试的是 =auto-fill-mode= :开一个文本 buffer,设置 =fill-column= 为 40,然后让 LLM 输入一段超过 40 列的文字,检查 Emacs 是否自动换行了。测试描述就是自然语言,不需要写一行 Elisp。 * Agent 怎么"看" Emacs LLM agent 看不到图形界面——测试 Emacs 是以 daemon 模式运行的。那它怎么知道 Emacs 当前什么状态? 答案是:每次 agent 执行完一个操作(按键、执行命令、eval 代码等), =llm-test= 会自动附加一份 JSON 格式的 frame 快照。这个快照包含当前所有窗口的可见内容(就像截图,但是文字版)、光标位置、minibuffer 状态和 echo area 消息。 #+BEGIN_SRC json { "selected-window": {"number": 0, "buffer": ""}, "windows": [ { "number": 0, "buffer": "", "mode": "", "point": 1, "lines": ["", ""] } ], "minibuffer": {"active": false, "prompt": "", "input": ""}, "message": "" } #+END_SRC Agent 不需要主动请求"给我看看现在屏幕上有什么"——每次操作后自动收到。这让 agent 的行为更像真人:按了一个键,"看"到结果,决定下一步做什么。 Agent 可以调用的工具包括: - =eval-elisp= :在测试进程中执行任意 Elisp 并返回结果 - =send-keys= :模拟按键(如 =C-x C-f= 、 =M-x= ) - =type-text= :逐字输入文本(保留空格和特殊字符) - =run-command= :按名称执行命令(比 =M-x= 更可靠) - =sleep= :等待异步操作完成 - =suggest-improvement= :记录 UI/UX 改进建议(不影响测试结果) - =pass-test= / =fail-test= :判定测试结果,结束循环 整个 agent loop 最多跑 80 轮( =llm-test-max-iterations= 默认值),每轮是一次 LLM 请求/响应往返。 * 跟传统测试的区别 传统的 Emacs 测试用 ERT(Emacs Lisp 测试框架),测试代码直接调用函数、检查返回值。比如测试 =auto-fill= ,你要写 Elisp 设置 buffer、插入文字、检查换行位置。这测的是 *函数的行为* 。 =llm-test= 测的是 *用户的体验* 。Agent 不知道你的内部函数怎么调用——它只知道按键、看屏幕、判断结果是否符合描述。这跟真实用户使用 Emacs 的方式一模一样。 这种思路的优势: - *能测传统测试很难覆盖的场景* :比如" =M-x= 输入命令时的补全是否正确"、" =dired= 中按 =g= 刷新后文件列表是否更新"——这些涉及多个命令组合和 UI 状态变化的交互流程,用 Elisp 写测试非常痛苦,用自然语言描述却很自然 - *测试描述就是文档* :YAML 里的自然语言描述既是测试用例,也是用户行为的文档 - *对被测代码零侵入* :不需要为测试暴露内部接口或写 test helper 但也有明显的代价: - *非确定性* :同一个测试跑两次,LLM 可能走不同的操作路径,甚至得出不同的结论。这不是传统测试的"确定性"范式 - *成本* :每个测试都要多次调用 LLM API,一个测试组跑下来可能消耗不少 token - *速度* :每轮 agent loop 都是一次 API 调用,比直接 eval Elisp 慢几个数量级 - *依赖模型质量* :Andrew Hyatt 在 README 中建议使用 Claude Sonnet 级别的模型,不过他指出只要指令足够清晰,便宜的模型也能胜任 * "LLM 当测试员"的适用场景 =llm-test= 不会取代传统 ERT 测试。对于"函数 =f(x)= 输入 5 应该返回 10"这类确定性逻辑测试,Elisp 单元测试更快更准。 它适合的是另一类测试——那些"人一眼就能看出来对不对,但用代码描述很麻烦"的场景: - 包的 UI 工作流是否顺畅(按键 → 期望看到某个界面) - 多步骤交互的端到端验证(打开文件 → 编辑 → 保存 → 确认状态) - 发现 UI/UX 问题( =suggest-improvement= 工具让 agent 可以主动提出改进建议) 这个思路也可以延伸到其他 GUI 应用——只要你能给 LLM 提供"屏幕状态"和"操作接口",就能用同样的 agent loop 模式做测试。 =llm-test= 的 frame state JSON 快照设计就是一个很好的参考:不需要真的截图,把视觉信息结构化为文本就够了。 [fn:1] https://github.com/ahyatt/llm-test

TIL: AI 时代的橡皮鸭调试

2026年4月23日 08:00
读到 opensourceforu 的一篇文章[fn:1],提醒我一个容易忘记的事:调试效率的关键不是工具多强,而是你能不能把问题解释清楚。 * 橡皮鸭调试法 "橡皮鸭调试"(Rubber Duck Debugging)这个名字来自 *The Pragmatic Programmer* (1999 年)这本书:程序员桌上放一只橡皮鸭,卡住的时候就对着鸭子逐行解释代码。很多时候解释到一半,自己就发现问题了。 这个现象其实大家都不陌生。你肯定经历过:写了半天邮件描述一个 bug,写到一半突然想通了;或者在会议上给同事讲一个问题的来龙去脉,讲着讲着自己就意识到了错在哪。鸭子只是个象征,真正的机制是: *把模糊的想法变成清晰的表述,这个过程本身就是思考* 。 为什么解释能帮你找到 bug?因为脑子里想的逻辑和嘴里说出来的逻辑不一样。脑子里你可以跳步、模糊处理、含糊带过;但要说给别人听,你就必须一步步走:输入是什么?然后呢?这个条件为什么需要?失败了怎么办?这些被跳过的"然后呢"往往是 bug 藏身的地方。另外,说出来的假设会变得可疑——"这个值不可能为 null"一旦说出口,你马上会意识到"等等,万一呢?" * AI 是一只会提问的鸭子 传统橡皮鸭是被动倾听者——你对着它说,它不回应。AI 不同,它可以主动提问:"你确定这个 API 调用不会超时吗?""如果输入是空列表会怎样?" 但用 AI 调试有一个陷阱:如果你直接把代码丢给 AI 说"帮我找 bug",你只是在用 AI 替你思考。短期内问题解决了,长期来看你的调试能力没成长。正确的用法是把 AI 当鸭子而不是当助手——你来解释,让它质疑你的逻辑。 一个实用的 prompt: #+begin_example 扮演一只橡皮鸭。不要直接给我解决方案。 听我解释代码逻辑,然后指出不清晰的地方、 遗漏的步骤或错误的假设。 #+end_example 这样 AI 会帮你发现思维盲点,而不是直接帮你填上。 * 为什么这个方法仍然重要 AI 工具越来越强,一键就能生成代码、定位错误。但软件质量的上限取决于你对问题的理解程度,而不是工具的能力。不管工具怎么进化,"解释清楚"这项能力永远是稀缺的——因为它的本质不是技术能力,而是把混乱的直觉变成有序的逻辑。 [fn:1] https://www.opensourceforu.com/2026/04/the-relevance-of-rubber-duck-debugging-in-the-age-of-ai/

Clojure X-Men:当编程语言特性变成超能力

2026年4月23日 08:00
Carin Meier 在 2014 年写过一篇有趣的短文[fn:1]:假设有人天生拥有 Clojure 语言特性的超能力,他们会是什么样的人?原文只有故事没有代码。本文借用她的创意,为每个"超能力"补上真实的代码示例,看看这些特性到底在做什么。 * Luke:看到无限的未来 Luke 是个懒人,凡事能拖就拖。但他发现自己能看到未来——不是看到确定的某个画面,而是看到未来的所有可能性。虽然只能看到几毫秒之后的事,但这已经够了。 他的超能力是 Clojure 的 *惰性求值*(lazy evaluation)。 普通编程语言中,你要先造好一个完整的列表才能用它。比如生成一百万个数字,就得先把一百万个数字全部算出来放在内存里。Clojure 不一样——它给你一个"承诺":你需要多少,我就算多少,你不需要的部分永远不算。 #+begin_src clojure ;; range 不加参数会生成无限序列 ;; 但因为惰性求值,它不会真的把无穷多个数字全算出来 (take 5 (range)) #+end_src #+begin_example (0 1 2 3 4) #+end_example =range= 返回的是一个无限序列的描述,不是真的把无穷多个数塞进内存。=take 5= 说"我只要前 5 个",于是 Clojure 只算 5 个就停下来。你也可以对无限序列做变换: #+begin_src clojure (take 3 (map #(+ % 100) (range))) #+end_src #+begin_example (100 101 102) #+end_example =map= 把每个元素加 100,=take 3= 只取前 3 个。中间的无限序列从来没有被完整计算过。这就是 Luke 的能力——他不需要看到全部的未来,只需要看到眼前需要的那部分。 * Spress:让万物开口说话 Spress 五岁就发现自己的超能力:她能让任何东西"说话"。指着一桶水说"牛",水桶就会"哞"叫。她不需要改造水桶本身,只需要定义一套新的行为规则,然后把规则套到任何东西上。 她的超能力是 Clojure 的 *protocol*(协议)——解决"表达式问题"的机制。 表达式问题是编程语言设计中的经典难题:你有一组数据类型和一组操作,想在不修改已有代码的前提下同时增加新的类型和新的操作。面向对象语言容易加新类型(写个子类),但加新操作(给已有类加新方法)很难。函数式语言反过来,容易加新操作(写个新函数),但加新类型很难。 Clojure 的 protocol 让你两者都能做到: #+begin_src clojure ;; 定义一个协议:speak(说话) (defprotocol Speak (speak [this])) ;; 给已有的 String 和 Number 类型扩展 speak 行为 ;; 不需要修改 String 或 Number 的源码 (extend-protocol Speak String (speak [this] (str this " says: 喵")) Number (speak [this] (str this " says: 汪"))) #+end_src #+begin_src clojure (speak "cat") ;; => "cat says: 喵" (speak 42) ;; => "42 says: 汪" #+end_src String 和 Number 是 Java/Clojure 内置的类型,你改不了它们的源码。但通过 =extend-protocol= ,你可以给它们加上 =speak= 行为,就像 Spress 给水桶加上了"哞"叫的能力一样。以后你还可以定义新的类型,也让它们实现 =Speak= 协议——新类型、新操作,互不干扰。 * Multi:一心多用 普通人看东西、听声音、感受触摸,这些感官输入最终都要排队进入意识——一个单线程的瓶颈。Multi 不一样,他的大脑能同时对所有感官输入做高级推理。结果就是,他反应超快,决策超聪明。 他的超能力是 Clojure 的 *并发*(concurrency)。 Clojure 提供了几种并发工具。最直观的是 =future= :把一段代码丢到另一个线程去执行,当前线程继续干别的事,需要结果的时候再等它。 #+begin_src clojure ;; future 启动另一个线程执行 (let [f (future (Thread/sleep 100) "来自未来的结果")] ;; 在这里可以做别的事... ;; 需要结果时用 @ 等待 @f) #+end_src #+begin_example "来自未来的结果" #+end_example 如果你有一批计算任务,=pmap= (parallel map)能自动把它们分到多个核上并行执行: #+begin_src clojure ;; pmap 像 map 一样工作,但自动并行 (doall (pmap #(* % %) (range 1 6))) #+end_src #+begin_example (1 4 9 16 25) #+end_example =pmap= 把 5 个平方计算分配到多个线程上同时执行,比串行的 =map= 快。Clojure 的并发工具不只是这些, 比如: + =atom= 做原子更新 + =core.async= 做消息传递 核心思想都是为了让多核的能力为你所用,而不是被锁和竞态条件搞得焦头烂额。 * Dot:跟异类说话 Dot 天生跟动物亲近。走进森林,鹿和鸟会主动靠近。有次她被压在树下,一头熊走过来帮她搬开了木头。她能毫不费力地跟其他物种沟通。 她的超能力是 Clojure 的 *Java 互操作*(interop)。 Clojure 跑在 JVM 上,Java 生态里几百万个库就是她的"动物"。她不需要特殊的桥梁或适配器,直接用 Clojure 语法调 Java 方法: #+begin_src clojure ;; 调用 Java 的 String.toUpperCase (.toUpperCase "hello clojure") #+end_src #+begin_example "HELLO CLOJURE" #+end_example #+begin_src clojure ;; 读取 Java 的常量 Math/PI #+end_src #+begin_example 3.141592653589793 #+end_example #+begin_src clojure ;; 调用 Java 的系统方法 (System/getProperty "java.version") #+end_src #+begin_example "21.0.10" #+end_example 不用写适配层,不用写包装类,Clojure 调 Java 就像调自己人一样。反过来,Java 代码也能调用 Clojure 函数。两门语言之间没有隔阂——这就是 Dot 的天赋。 * Bob:一眼看穿本质 Bob 是 X-Men 的队长。他最大的能力是:面对任何复杂问题,他能立刻剥离掉不重要的细节,抓住核心。他从不被花哨的框架和设计模式迷惑,因为他知道,真正有力量的东西都是简单的。 他的超能力是 Clojure 的 *极简哲学*(simplicity)。 Clojure 的设计哲学是:能用数据解决的问题,就不要发明新概念。你不需要定义一个 Person 类,用 map 就行: #+begin_src clojure ;; 不需要定义类,用 map 表示数据 (def person {:name "Bob" :power "simplicity"}) #+end_src #+begin_example {:name "Bob", :power "simplicity"} #+end_example #+begin_src clojure ;; 用关键字当函数,直接取值 (:name person) #+end_src #+begin_example "Bob" #+end_example #+begin_src clojure ;; 需要加字段?assoc 一下 (assoc person :age 30) #+end_src #+begin_example {:name "Bob", :power "simplicity", :age 30} #+end_example 没有类定义,没有构造函数,没有 getter/setter,没有继承体系。一个 map 加几个通用函数(=assoc= 、=dissoc= 、=update= 、=merge=),就能完成绝大多数数据操作。Clojure 认为复杂性是敌人——语言应该帮你消除不必要的抽象,而不是制造更多的抽象。 * 还可能有更多 原文最后说:可能还有更多拥有 Clojure 超能力的人没被发现,我们只能希望他们被 Bob 找到,用力量做善事。现实中,Clojure 的特性远不止这五个,比如,宏(macro)、持久化数据结构(persistent data structure)、软件事务内存(STM)……每个都值得单独聊聊。但用超级英雄角色来理解编程语言特性,这个角度本身就挺有趣——把抽象的概念套上人格,比直接读文档容易记住得多。 [fn:1] https://gigasquidsoftware.com/blog/2014/07/27/clojure-x-men/

SEM Assistant: 当 Elisp 守护进程遇上 LLM

2026年4月23日 08:00
SEM Assistant[fn:1] 是一个用 Elisp 写的自托管守护进程。它解决的问题是:手机上快速捕获信息(想法、链接、任务),服务端自动处理,结果写回你自己的 Org 文件。这跟笔记同步的不同之处在于它在管道里接了 LLM,只不过 LLM 只做有限的文本变换,流程控制完全由 Elisp 代码决定。这个"LLM 是工具不是老板"的设计思路,对任何想把 LLM 接入自己工作流的人都有参考价值。 * SEM Assistant 做什么 整体数据流是这样的: #+begin_example 手机捕获 → WebDAV → inbox-mobile.org → Elisp 管道路由 → LLM 文本处理 → Org 文件 #+end_example 具体来说,它在服务端跑一个 Emacs 守护进程,通过 WebDAV 接收手机端(organice)传来的捕获内容,写入 =inbox-mobile.org= 。然后根据条目的标签走不同的处理流程: - =:task:= 标签 :: 走任务归一化 + 规划流程。LLM 帮你把随手写的任务拆解成结构化的 TODO 列表 - =:link:= 标签(或标题以 =http://= 开头) :: 走 URL 捕获流程。LLM 帮你从网页内容生成 org-roam 笔记 - 其他 :: 跳过,记录日志 处理完的结果写回到对应的 Org 文件( =tasks.org= 或 org-roam 笔记库),并定期 git 同步备份。 **目录约定** 所有数据都在 =/data= 下,每个文件有明确的角色: | 路径 | 作用 | |------+------| | =/data/inbox-mobile.org= | 手机端传来的原始捕获,管道路由的入口 | | =/data/tasks.org= | 任务处理后的输出文件 | | =/data/rules.org= | 调度规则文件,控制 cron 触发哪些流程 | | =/data/org-roam/org-files= | org-roam 笔记库,URL 捕获的结果写到这里 | | =/data/org-roam= | git 同步的仓库根目录 | **具体使用场景** 场景一:在手机上随手记了一个任务。打开 organice,在 =inbox-mobile.org= 里写一条带 =:task:= 标签的标题,比如"准备下周的技术分享"。保存后,服务端的 cron 定时触inbox处理流程:Elisp 识别到 =:task:= 标签,把这条文本发给 LLM,LLM 归一化任务描述并生成规划(拆解成"确定主题"、"准备 slides"、"排练"等子任务),Elisp 把结果写入 =tasks.org= ,然后从收件箱中移除这条。 场景二:在手机浏览器里看到一个有趣的文章,复制链接,在 organice 里粘贴为 =inbox-mobile.org= 的一条标题(以 =https://= 开头,甚至不需要加 =:link:= 标签,系统会自动识别)。cron 触发后,LLM 从网页内容中提取关键信息,生成一篇 org-roam 笔记节点,写入 =/data/org-roam/org-files= 下。 **三个后台循环** SEM Assistant 在后台跑着三个定时循环: 1. =inbox processing= :: 扫描收件箱,按标签路由,调用 LLM 处理,写回结果 2. =URL-to-org-roam= :: 把链接转成 org-roam 笔记节点 3. =git sync= :: 定期把 org-roam 笔记库 commit + push 这三个循环由 cron 驱动(项目自带 crontab 配置)。你也可以手动触发: #+begin_src shell # 手动触发收件箱处理 docker compose exec emacs emacsclient -s sem-server -e "(sem-core-process-inbox)" # 手动触发 git 同步 docker compose exec emacs emacsclient -s sem-server -e "(sem-git-sync-org-roam)" #+end_src git 同步需要在容器内配置好 SSH 密钥( =/root/.ssh/id_rsa= )和 git 身份(name/email),这样每次同步都会自动 commit 和 push 到远程仓库。 * 关键设计:LLM 是工具不是编排者 这是整个项目最有价值的设计思想。 现在很多 AI 工具让 LLM 直接驱动整个工作流——LLM 决定下一步做什么、调什么 API、写什么文件。SEM Assistant 反其道而行:Elisp 代码是编排者,LLM 只是管道中的一个处理步骤。 LLM *只负责三件事*: - 归一化 :: 把随手写的任务文本整理成统一格式 - 规划 :: 把一个模糊的任务拆解成可执行的 TODO 列表 - 摘要 :: 从网页内容中提取关键信息生成笔记 它 *不负责的事*: - 决定走哪条处理流程(由标签决定,代码控制) - 决定结果写到哪个文件(由路径约定决定) - 决定是否重试(由 Elisp 的重试逻辑决定) - 决定是否调用下一个步骤(由确定性管道控制) 这种设计的核心好处是:管道行为是可预测的。你可以看 Elisp 代码就知道数据怎么流转,不需要猜测"AI 会不会走错路"。 * 安全模型:LLM 输出是不可信的 既然 LLM 是不可信组件,SEM Assistant 专门做了几层防护: 1. *敏感内容屏蔽*:Org 文件中用 =#+begin_sensitive= / =#+end_sensitive= 包裹的内容在发送给 LLM 之前会被替换掉(masked),LLM 看不到原文。处理完后自动恢复 2. *上下文裁剪*:任务规划时,传给 LLM 的不是完整的任务库,而是经过精简和匿名化的上下文——减少不必要的数据暴露 3. *输出验证*:LLM 的输出被当作不可信文本处理,经过验证后才写入文件 4. *确定性兜底*:重试、错误日志、死信队列(DLQ)、cron 重复执行防护——这些全都由 Elisp 代码确定性处理,不依赖 LLM 这套安全模型可以总结为一句话:LLM 只能接触它需要处理的数据,只能产生文本,不能做任何决策。 * 可复用的设计原则 从 SEM Assistant 的架构中,可以提炼出几个通用的设计原则: 原则一:*确定性管道 + LLM 有限调用* 把"流程控制"和"文本处理"分成两层。流程用确定性代码(Elisp、Python、Shell 都行),LLM 只在需要理解或生成文本的环节介入。这样管道行为可预测、可调试、可测试。 原则二:*文件契约* 所有组件通过文件系统通信,约定好目录结构和文件名。比如 =/data/inbox-mobile.org= 是收件箱,=/data/tasks.org= 是任务输出。文件是接口——任何工具只要遵守文件格式就能接入。 原则三:*标签即路由* 用 Org-mode 的标签(=:task:=、=:link:=)作为路由条件,决定数据走哪条管道。简单、直观、用户可以在手机端直接控制。 原则四:*操作数据对人类可读* 所有数据都是纯文本 Org 文件,你可以直接打开看、直接编辑、直接用 git 管理。不依赖任何数据库或专有格式。 * 安装与配置 SEM Assistant 用 Docker Compose 部署,整体架构是三个容器:Emacs 守护进程、WebDAV 服务器、organice(手机端 Org 编辑器)。 **前提条件:** - 一台有公网 IP 的服务器 - 两个域名:一个给 WebDAV(比如 =org.example.com= ),一个给 organice(比如 =organice.example.com= ),都指向同一个 IP - Docker 和 Docker Compose 已安装 **第一步:克隆仓库并配置环境变量** #+begin_src shell git clone https://github.com/SemyonSinchenko/sem-assistant-el.git cd sem-assistant-el cp .env.example .env #+end_src 编辑 =.env= 文件,填入以下配置: #+begin_example WEBDAV_DOMAIN=org.example.com ORGANICE_DOMAIN=organice.example.com ORGANICE_ORIGIN=https://organice.example.com WEBDAV_USERNAME=your-username WEBDAV_PASSWORD=your-password #+end_example =ORGANICE_ORIGIN= 必须严格匹配 =https://ORGANICE_DOMAIN= 。=ORGANICE_IMAGE= 是可选的,默认使用 =twohundredok/organice:5826= 。 **第二步:申请 TLS 证书** 两个域名都需要 HTTPS。用 certbot 申请: #+begin_src shell docker compose --profile certbot up -d certbot #+end_src 确认证书文件存在于 =/etc/letsencrypt/live/$DOMAIN/= 下(=fullchain.pem= 和 =privkey.pem= )。 **第三步:启动服务** #+begin_src shell docker compose up -d webdav emacs #+end_src 这会启动 WebDAV 服务器和 Emacs 守护进程。organice 通过 WebDAV 容器的反向代理提供(根据 Host 头路由到 =ORGANICE_DOMAIN= ),不需要单独启动。 CORS 策略是严格单源的——只允许 =ORGANICE_ORIGIN= 的跨域请求,且带凭证。OPTIONS 预检请求不需要认证(兼容浏览器),其他 WebDAV 方法仍需认证。 **第四步:配置 git 同步** 进入 Emacs 容器,配置 SSH 密钥和 git 身份: #+begin_src shell # 进入容器 docker compose exec emacs bash # 配置 git 身份 git config --global user.name "Your Name" git config --global user.email "you@example.com" # 确认 SSH 密钥已就位 ls /root/.ssh/id_rsa #+end_src =sem-git-sync-org-roam= 函数会用这个密钥 push 到远程仓库。 **第五步:配置 LLM** 项目需要配置 LLM 的 API 密钥和 endpoint。参考项目中的 =general-prompt.example.txt= 和 =arxiv-prompt.example.txt= 了解 prompt 模板的格式。 **第六步:启动 cron 定时任务** 项目自带 =crontab= 文件,定义了三个循环的触发频率。安装到容器中后,收件箱处理和 git 同步就会自动运行。 你也可以跳过 cron,完全用手动触发(见上文的手动触发命令)。 **日常使用流程** 1. 在手机浏览器打开 =https://organice.example.com= ,连接到你的 WebDAV 2. 编辑 =inbox-mobile.org= ,添加带 =:task:= 或 =:link:= 标签的条目,或者直接粘贴 URL 作为标题 3. 保存后,cron 会自动触发处理(或你手动执行 =sem-core-process-inbox= ) 4. 处理完的结果出现在 =tasks.org= 或 org-roam 笔记库中 5. git 同步自动 push 到远程仓库 **回滚** 如果部署出问题,回滚步骤: 1. 保持 WebDAV 容器运行(数据还在) 2. 还原 =ORGANICE_*= 环境变量和 Apache 模板配置 3. 先重启 webdav 服务,再重启依赖服务 4. certbot 继续管理现有证书,不需要额外操作 [fn:1] https://github.com/SemyonSinchenko/sem-assistant-el

用 dmsg 给 Elisp 加上结构化调试日志

2026年4月23日 08:00
写 Elisp 调试的时候,大多数人靠的就是 =message= 往 =*Messages*= buffer 里塞字符串。但用多了就会发现几个很烦的问题: - =*Messages*= 里什么都有: package 初始化的日志、mode 激活的信息、你自己的 debug 输出全混在一起,翻半天找不到想看的那条 - 没有调用栈: 你看到一条消息,但完全不知道它是从哪个函数、哪条代码路径打出来的 - 没有日志级别: debug 信息和 error 混在一起,没法按严重程度过滤 - =*Messages*= 是个只读 buffer: 想过滤或者导出都得自己想办法 dmsg.el[fn:1] 就是来解决这些问题的。它提供了带时间戳、日志级别和自动调用栈捕获的调试日志系统,还有一个专用的 dmsg-mode 让你交互式地浏览和过滤日志。 * 基本用法 安装很简单,clone 仓库后 =require= 就行: #+begin_src emacs-lisp (add-to-list 'load-path "~/github/dmsg.el/") (require 'dmsg) #+end_src #+RESULTS: : dmsg 用 =dmsg= 宏打日志,默认级别是 debug: #+begin_src emacs-lisp (defun my-function (x) (dmsg "x is %S" x)) (my-function 42) #+end_src #+begin_example ,* DBG [2026-04-23 09:50:41.015] x is 42 #+end_example 可以指定日志级别(debug / info / warn / error 四个级别,严重程度递增): #+begin_src emacs-lisp (dmsg 'warn "unexpected: %s" "something odd") (dmsg 'error "failed: %s" "connection lost") #+end_src #+begin_example WARN [2026-04-23] 09:51:21.883 [eval] unexpected: something odd ERR [2026-04-23] 09:51:25.053 [eval] failed: connection lost #+end_example 每条日志都会写入专用的 =*DEBUG*Messages*= buffer,不会和 =*Messages*= 混在一起。日志格式长这样: #+begin_example ,* DBG [2026-04-23 10:30:45.123] [my-function] x is 42 #+end_example 分别是日志级别(DBG)、时间戳、展开后的调用栈帧和消息内容。调用栈默认是隐藏的,后面会说怎么查看。 * %= 格式符:自动给变量加标签 dmsg 加了一个实用的格式符 ~%=X~ (X 是普通的 format 转换字符,比如 s、d、S),它会自动把参数变成 =label=value= 的形式,label 直接从源码的变量名提取: #+begin_src emacs-lisp (let ((buf "foo.el") (line 10)) (dmsg "at %=s %=d" buf line)) #+end_src 输出类似: #+begin_example ,* DBG [2026-04-23 10:31:00.456] [eval] at buf=foo.el line=10 #+end_example 调试的时候如果一次打好几个变量,这个功能能省不少事——不用手动写 ~buf=~ 和 ~line=~ ,也避免搞混哪个值对应哪个变量。 * 专用 buffer 和交互式浏览 dmsg 的日志写入 =*DEBUG*Messages*= buffer,这个 buffer 使用 dmsg-mode,提供了一组快捷键来浏览和过滤: | 快捷键 | 功能 | |-----------+----------------------------------------| | == | 切换紧凑调用链显示 | | =b= | 在侧边窗口打开详细调用栈 | | =c= | 隐藏/恢复所有当前条目 | | =e= | 清空 buffer | | =f= | 按正则表达式过滤 | | =s= | 将可见条目导出到带时间戳的 .log 文件 | | =l1=-=l4= | 设置最低显示级别(1=debug, 4=error) | header line 始终显示 =visible/total= 计数、活跃的过滤条件和当前级别阈值,一眼就能看出当前状态。 ** 调用栈自动捕获 每条日志都自动捕获了调用栈。默认状态下调用栈是隐藏的,按 == 可以展开为紧凑的函数调用链: #+begin_example my-function ← some-handler ← process-event #+end_example 函数名是可点击的,点击后直接跳转到函数的定义位置(通过 =find-function= 实现)。 按 =b= 可以在侧边窗口看到完整的调用栈详情,包含每个函数的参数值。 ** 过滤和导出 dmsg 支持多种过滤方式,全部通过 overlay 实现——只改变显示,不修改 buffer 内容,随时可以恢复: - =f= 按正则过滤消息文本 - =c= 隐藏/恢复所有当前条目 - =l1= 到 =l4= 设置最低显示级别 - =dmsg-max-entries= 限制最大显示条数(超出时自动隐藏最旧的条目) 想把日志分享给别人或存档?按 =s= 可以把当前可见的条目导出到一个带时间戳的 =.log= 文件。 * 拦截 message 和捕获错误 除了主动调用 =dmsg= 打日志,dmsg 还提供了两个功能来对付已有的代码。 =dmsg-on-message= 可以拦截 =message= 函数的调用,把匹配正则的输出也转到 dmsg buffer 里: #+begin_src emacs-lisp (dmsg-on-message "error\\|warning") ; 拦截包含 error 或 warning 的 message (message "something error happened") (dmsg-on-message nil) ; 关闭拦截 #+end_src 这对于排查第三方包的日志特别有用——不用改别人的代码,直接把感兴趣的 =message= 输出捞到 dmsg 里来,还能看到调用栈。 =dmsg-on-error= 可以给指定函数加上错误日志。当这个函数抛错时,错误会被记录为 error 级别,然后正常重新抛出,不影响原有的错误处理逻辑: #+begin_src emacs-lisp (dmsg-on-error 'my-problematic-function) #+end_src 这在调试 =post-command-hook= 里的间歇性错误时特别方便,因为这类错误往往被 Emacs 的错误处理吞掉了,你只知道"出了个错"但不知道具体是什么。 [fn:1] https://github.com/haji-ali/dmsg.el

用 org-habit 追踪非每日习惯

2026年4月23日 08:00
很多人用 Org-mode 管理任务,但追踪习惯——尤其是非每日习惯——的时候往往会卡住。比如"每周跑三次步,跑完至少休息一天,最多休息三天"这种需求,用普通的 TODO 或 checkbox 列表很难搞定。Org-mode 内置了一个 =org-habit= 模块,专门解决这类问题:它能按你设定的频率自动安排下次执行日期,还能在 agenda 里用彩色图形展示你的连续完成情况。 * 开启 org-habit =org-habit= 是 Org-mode 的内置扩展,默认没开。在你的配置文件(通常是 =~/.emacs.d/init.el= )里加上: #+begin_src emacs-lisp (add-to-list 'org-modules 'org-habit t) #+end_src 然后需要让 Org-mode 记录状态变更的时间戳,这样 =org-habit= 才有数据画图。关键是在 =org-todo-keywords= 里给需要记录的状态加上 =!= 标记—— =!= 的意思是"切换到这个状态时自动记录时间戳"。下面例子里的 =TODO(t!)= 表示:快捷键 =t= 触发,并且切换时自动记录时间。 =DONE(d!)= 同理: #+begin_src emacs-lisp ;; TODO 和 DONE 都自动记录时间戳 (setq org-todo-keywords '((sequence "TODO(t!)" "NEXT(n)" "|" "DONE(d!)" "CANC(c!)")) ;; 创建 TODO 时也记录时间 (setq org-treat-insert-todo-heading-as-state-change t) ;; 状态变更记录到 LOGBOOK drawer(就是条目下方用 :LOGBOOK: ... :END: 包裹的区域) (setq org-log-into-drawer t) #+end_src 最后,习惯图形是在 org-agenda 里显示的,所以习惯所在的文件要加入 =org-agenda-files=: #+begin_src emacs-lisp (setq org-agenda-files '("~/org/habits.org")) #+end_src * 创建习惯条目 习惯条目本质上是一个特殊的 TODO 节点,需要两样东西:=STYLE: habit= 属性和带 repeater 的 SCHEDULED 日期。 创建步骤: 1. 先写一个普通的 TODO 条目(=C-c C-t= 切换到 TODO 状态) 2. 在条目上按 =C-c C-s= 设置 SCHEDULED 日期,输入 =.+2d/4d= 作为 repeater(日期部分会自动填入当天) 3. 按 =C-c C-x p= 或 =M-x org-set-property= 设置 =STYLE= 为 =habit= 最终效果长这样(注意:行首没有逗号,下面示例中的逗号是 Org-mode 在 example 块里转义 =*= 用的): #+begin_example ** TODO 跑步 SCHEDULED: <2026-04-23 Wed .+2d/4d> :PROPERTIES: :STYLE: habit :END: #+end_example ** Repeater 语法是核心 =SCHEDULED= 后面的 =.+2d/4d= 是整个习惯追踪的关键: - =.+2d= :: 最小间隔 2 天。完成后的下次安排至少在 2 天之后(跑完步至少休息一天) - =/4d= :: 最大间隔 4 天。如果超过 4 天没做,agenda 上就会标红提醒你 也就是说,这是一个"每 2 到 4 天做一次"的习惯。当你把它标记为 =DONE= 时,Org-mode 会自动把 =SCHEDULED= 更新为下一个合法日期。 Repeater 的第一个字符决定了计算起点: | 语法 | 含义 | |----------+--------------------------------------------| | =.+Nd/Md= | 从上次完成日期算起(适合"休息 X 天再做") | | =++Nd/Md= | 从上次安排日期算起(固定周期) | | =+Nd= | 简单重复,不设最大间隔 | 大多数习惯追踪场景用 =.+Nd/Md= 最合适——它以你的实际完成时间为基准,不会因为你某天忘了做就把后面的日期全打乱。 * 实际使用 完成一次习惯后,在条目上按 =C-c C-t= 把状态从 =TODO= 切到 =DONE=: - 状态会短暂显示 =DONE=,然后自动切回 =TODO= - =SCHEDULED= 日期自动更新为下次执行日期 - =LOGBOOK= 里多一条完成记录,带上时间戳 #+begin_example ** TODO 跑步 SCHEDULED: <2026-04-25 Fri .+2d/4d> :PROPERTIES: :STYLE: habit :LAST_REPEAT: [2026-04-23 Wed 08:00] :END: :LOGBOOK: - State "DONE" from "TODO" [2026-04-23 Wed 08:00] - State "DONE" from "TODO" [2026-04-21 Mon 07:30] :END: #+end_example 配置好之后,用 =M-x org-agenda= 选择 agenda 视图(通常是按 =a= 进入周/日视图),就能看到习惯的彩色图形了。绿色表示按时完成,黄色表示该做了但还没做,红色表示已经超期。每列是一天, =!= 表示今天,让你一眼看出自己的连续完成情况(streak)和最近的节奏。 * 几个实用细节 - =org-habit-show-all-today= :: 设为 =t= 可以让所有习惯都在今天的 agenda 上显示,不管今天是不是安排日 - 不用为习惯断裂感到沮丧——org-habit 的设计本身就允许间隔波动,重要的是重新开始 - 如果某个习惯不再需要了,把 =STYLE= 属性删掉就变回普通 =TODO=

ERT 测试交互命令的三种方式

2026年4月23日 08:00
最近翻 Emacs 31 的 [[https://git.savannah.gnu.org/cgit/emacs.git/commit/etc/NEWS?id=3d822669eee7f6e685368b8a298b6f0924f382da][NEWS 文件]] 时,看到一条新增:ERT 增加了 =ert-play-keys= 函数,专门用来模拟用户按键。ERT 本身就有 =ert-simulate-keys= 和 =ert-simulate-command= 两个类似工具,这个新的到底解决了什么老工具解决不了的问题?顺着 NEWS 里的线索去看 [[https://git.savannah.gnu.org/cgit/emacs.git/diff/lisp/emacs-lisp/ert-x.el?id=5b6fc8ebfcd6bce6a0e5fb7160ec7a2aeb561baf][源码]] 和 [[https://git.savannah.gnu.org/cgit/emacs.git/diff/test/lisp/emacs-lisp/ert-x-tests.el?id=5b6fc8ebfcd6bce6a0e5fb7160ec7a2aeb561baf][测试文件]] 后发现,这三个工具各有明确的适用场景,本文逐一介绍。 **前置条件:** 这三个工具都在 =ert-x.el= 里,不在 =ert.el= 里。ERT 自 Emacs 24.1 起就是内置的,但只 =(require 'ert)= 是不够的,必须额外加载扩展库: #+begin_src emacs-lisp (require 'ert-x) #+end_src =ert-simulate-keys= 和 =ert-simulate-command= 从 Emacs 24.1 就有了,而 =ert-play-keys= 是 Emacs 31 才新增的。 * ert-simulate-keys:模拟 minibuffer 输入 =ert-simulate-keys= 的原理是把按键事件注入到 =unread-command-events= 变量中,这样当被测代码调用 =read-from-minibuffer= 之类从命令事件队列读输入的函数时,就会读到我们预先塞进去的内容。 #+begin_src emacs-lisp (ert-deftest test-simulate-keys-for-minibuffer () "ert-simulate-keys 可以向 read-from-minibuffer 提供预设输入。" (ert-simulate-keys (listify-key-sequence "hello\n") (should (string= (read-from-minibuffer "Input: ") "hello")))) #+end_src 但它的局限也很明显:它 *只能* 用于从 =unread-command-events= 读输入的函数。如果你想测试"用户按下某个键,触发了 keymap 里绑定的命令"这种场景, =ert-simulate-keys= 做不到——因为按键事件只是被塞进了 =unread-command-events= 变量,并没有一个命令循环在跑来消费它们。只有 =read-from-minibuffer= 这类函数会主动从 =unread-command-events= 取输入。 #+begin_src emacs-lisp (ert-deftest test-simulate-keys-cannot-start-commands () "ert-simulate-keys 无法启动按键命令——事件只存在 unread-command-events 中。" (ert-with-test-buffer () (let (command-ran) (let* ((map (let ((map (make-sparse-keymap))) (define-key map [?b] (lambda () (interactive) (setq command-ran t))) map)) (minor-mode-map-alist (cons (cons t map) minor-mode-map-alist))) ;; 事件被放入 unread-command-events,但没有命令循环来处理它们 (ert-simulate-keys (listify-key-sequence "b") ;; body 里没有函数消费这些事件 nil)) ;; ?b 的 keymap 绑定没有被触发 (should (eq command-ran nil))))) #+end_src * ert-simulate-command:手动调用指定命令 =ert-simulate-command= 的思路完全不同:你不需要模拟按键,而是直接告诉它"我要调用这个命令,参数是这些"。它会模拟命令循环的行为——运行 =pre-command-hook= 、设置 =this-command= 、调用命令、运行 =post-command-hook= ——但不会经过 keymap 查找。 #+begin_src emacs-lisp (ert-deftest test-simulate-command () "ert-simulate-command 可以直接以交互方式调用命令。" (ert-with-test-buffer () (let (hook-ran) (let ((pre-command-hook (list (lambda () (setq hook-ran t))))) (ert-simulate-command (list (lambda (x) (interactive (list "test input")) (insert x) :ok) "test input"))) (should (eq hook-ran t))) (should (string= "test input" (buffer-substring (point-min) (point-max)))))) #+end_src 注意 =ert-simulate-command= 有一个重要限制:命令 *不是* 通过 =call-interactively= 调用的。这意味着如果你的命令内部调用 =called-interactively-p= 来判断自己是不是被交互调用的,结果会是 =nil= 。下面这个测试展示了这个问题: #+begin_src emacs-lisp (ert-deftest test-simulate-command-not-call-interactively () "ert-simulate-command 中 called-interactively-p 返回 nil。" (let (result) (ert-simulate-command (list (lambda () (interactive) (setq result (called-interactively-p 'any))))) ;; 不是通过 call-interactively 调用的,所以返回 nil (should (eq result nil)))) #+end_src 另外, =ert-simulate-command= 需要你明确知道要调用哪个命令。如果你需要测试"用户按下某个键之后发生了什么"(即经过 keymap 查找后的完整流程),它帮不上忙。 * ert-play-keys:模拟真实的按键序列(Emacs 31 新增) Emacs 31 新增的 =ert-play-keys= 填补了上面两个工具留下的空白。它的实现只有一行: #+begin_src emacs-lisp (defun ert-play-keys (keys) "Play the key sequence KEYS as if it was user input." (funcall (kmacro keys))) #+end_src 它调用 =kmacro= 把按键序列当作键盘宏来执行。因为键盘宏走的是完整的命令循环路径——包括 keymap 查找、命令执行、hook 触发——所以它可以触发 keymap 绑定的命令,也能正确处理 =called-interactively-p= 。 使用时需要配合 =ert-with-test-buffer= 的 =:selected t= 参数(或 =ert-with-buffer-selected= ),这会在临时窗口中选中 buffer,让按键序列能正确路由到目标 buffer: #+begin_src emacs-lisp (ert-deftest test-play-keys-triggers-keymap () "ert-play-keys 可以触发 keymap 绑定的命令。" (ert-with-test-buffer (:selected t) (let (command-ran) (let* ((map (let ((map (make-sparse-keymap))) (define-key map [?b] (lambda () (interactive) (setq command-ran t) (insert "ran"))) map)) (minor-mode-map-alist (cons (cons t map) minor-mode-map-alist))) (ert-play-keys (vconcat [?b]))) (should (eq command-ran t)) (should (string= "ran" (buffer-substring (point-min) (point-max))))))) #+end_src =ert-play-keys= 还可以在一次调用中混合不同类型的输入——既有触发命令的按键,也有直接插入的文本: #+begin_src emacs-lisp (ert-deftest test-play-keys-mixed-input () "ert-play-keys 可以混合按键命令和文本插入。" (ert-with-test-buffer (:selected t) (let* ((map (let ((map (make-sparse-keymap))) (define-key map [?X] (lambda () (interactive) (insert "[pressed-X]"))) map)) (minor-mode-map-alist (cons (cons t map) minor-mode-map-alist))) ;; [?X] 触发 keymap 命令,"hello" 作为文本插入 (ert-play-keys (vconcat [?X] "hello"))) (should (string= "[pressed-X]hello" (buffer-substring (point-min) (point-max)))))) #+end_src * 三种方式对比 | 特性 | =ert-simulate-keys= | =ert-simulate-command= | =ert-play-keys= | |------+---------------------+------------------------+-----------------| | 触发 keymap 绑定 | 否 | 否 | 是 | | 支持 =called-interactively-p= | N/A | 否 | 是 | | 运行 =pre/post-command-hook= | 否 | 是 | 是 | | 模拟文本插入 | 仅通过 minibuffer | 需手动传参 | 是(直接输入) | | 使用方式 | 宏(包裹 body) | 函数(传入命令) | 函数(传入按键序列) | | Emacs 版本 | 一直有 | 一直有 | 31+ | 简单来说: - 需要测试命令的 minibuffer 输入 → =ert-simulate-keys= - 需要测试特定命令的执行效果(不需要经过 keymap) → =ert-simulate-command= - 需要模拟用户真实按键操作 → =ert-play-keys=

Elisp 性能优化的六个实战教训

2026年4月23日 08:00
有人用 Elisp 写了一个 Org-mode 静态站点生成器,处理 10000 篇博文的时间从 5 分半压到 1 分 15 秒,热重载单篇只需 7 毫秒。实现过程中踩过的坑和发现的优化技巧,对任何在 Emacs 里处理大量文件的人都有参考价值。本文提取其中的工程实践,用你能在自己项目里直接用的方式讲清楚。 * 只解析你需要的东西 Elisp 里解析 Org 文件的标准方式是调用 =org-element-parse-buffer= 。它的默认行为是完整解析整个 buffer——包括行内标记、链接、脚注,全部递归展开。 但如果你只需要提取文件开头的标题和日期,完整解析就是巨大的浪费。 =org-element-parse-buffer= 接受一个可选的 =GRANULARITY= 参数: - ='headline= :: 只解析标题结构 - ='greater-element= :: 不递归进入大元素内部,只解析顶层 - ='element= :: 解析所有元素但不解析行内对象 - ='object= :: 完整解析(默认) 原文作者只需要标题和日期等元数据,把粒度从默认的 ='object= 改成 ='greater-element= ,单文件解析时间直接砍了一截。这不是微优化——对 10000 个文件来说,每个文件少解析几百个行内元素,累积效果显著。 #+begin_src emacs-lisp ;; 完整解析(慢) (org-element-parse-buffer) ;; 只解析顶层元素(快得多) (org-element-parse-buffer 'greater-element t) #+end_src 选择哪个粒度取决于你需要什么。提取元数据用 ='greater-element= ,需要处理链接和行内标记才用 ='object= 。 * 别让 find-file-noselect 拖慢批量操作 Emacs 有两种方式读取文件内容: 1. =find-file-noselect= —— 正经打开文件,创建一个完整的 buffer 2. =with-temp-buffer= + =insert-file-contents= —— 把文件内容塞进临时 buffer 原文作者一开始选了 =find-file-noselect= ,觉得它更"正确"。然后用 Emacs 内置的性能分析器( =M-x profiler-start= )一看,发现这个函数吃掉了 13% 的 CPU。原因: =find-file-noselect= 会触发文件锁、版本控制查询、major-mode hook、org-persist 缓存写入等一系列交互式操作。对每个文件都来一遍,10000 个文件就是在重复 10000 次不必要的开销。 #+begin_src emacs-lisp ;; 慢:触发完整的 buffer 初始化流程 (org-with-file-buffer filename (org-element-parse-buffer 'greater-element t)) ;; 快:用临时 buffer 直接读内容 (with-temp-buffer (insert-file-contents filename) (org-element-parse-buffer 'greater-element t)) #+end_src 切换到 =with-temp-buffer= 后,10000 篇博文的处理时间从 3 分 44 秒降到 1 分 15 秒。教训:批量处理文件时,用临时 buffer 而不是 =find-file-noselect= 。后者是为交互式编辑设计的,带着整套"官僚系统"。 * 用 cl-progv 隔离全局状态 Emacs 的状态管理建立在全局变量上—— =org-export-with-toc= 、 =org-html-link-home= 等都是全局变量。在同一个进程里处理多个 route 时,一个 route 设置的变量会"泄漏"到下一个 route。 直觉上的做法是用 =let= 绑定: #+begin_src emacs-lisp (let ((org-export-with-toc t)) (opd-route ...)) ;; 这一步只是把 route 配置记下来 ;; 另一个 route (opd-route ...) (opd-export) ;; 真正的导出在这里执行 #+end_src 问题是: =opd-route= 不会立即执行导出,它只是把 route 的配置(输入模式、输出路径、过滤器等)存到一个列表里。真正的 HTML 生成发生在 =opd-export= 被调用的时候——但那时 =let= 的作用域已经结束了, =org-export-with-toc= 的局部绑定已经不存在了。换句话说,你在一个 =let= 块里填了一张表单,但处理表单的人要等到 =let= 块结束之后才看它,那时候表单上写的"特殊要求"已经没人记得了。 那能不能把 =opd-export= 放进 =let= 里?也不行,因为不同 route 需要*不同的*变量值——blog route 要目录( =org-export-with-toc= 为 =t= ),RSS route 不要目录( =nil= )。 =opd-export= 一次执行所有 route,你没法在一个 =let= 里同时给不同 route 设不同的值。 解决方案是用 =cl-progv= ,这个来自 Common Lisp 的宏可以在运行时动态绑定一组变量。每个 route 把自己的变量设置写在 =:env= 属性里,引擎处理到那个 route 时才临时绑定,处理完自动恢复: #+begin_src emacs-lisp (cl-progv '(org-export-with-toc org-html-link-home) ;; 变量名列表 '(nil "http://localhost:8080") ;; 对应的值列表 ;; 在这个作用域里,org-export-with-toc 为 nil ;; org-html-link-home 为 "http://localhost:8080" (org-export-as 'rss)) #+end_src =cl-progv= 接受变量名列表和值列表,在执行 body 期间动态绑定这些变量,执行完后自动恢复。这比手动在每个 lambda 里 =let= 绑定更干净——你可以把变量配置做成数据,从 route 定义里传入,而不需要在每个导出函数里硬编码。 * 让数据形状决定行为,而不是手动标记 原文遇到一个问题:blog route 和 rss route 都匹配 =*.org= 文件,两者都在全局注册表里写入 URL,互相覆盖。最初的解法是加一个 =:canonical= 标记,手动指定哪个 route 有写入权限。 后来发现根本不需要这个标记。关键是观察聚合(aggregate)后数据的形状: - 1:1 route(每个文件对应一个输出):数据的顶层有 =abspath= 属性 - 1:N route(多个文件聚合为一个输出,如 RSS): =abspath= 藏在嵌套列表里 #+begin_src emacs-lisp (defun opd--tree-has-abspath-p (tree path) "递归搜索 Lisp 树中是否包含 (abspath . PATH) 。" (cond ((and (consp tree) (eq (car tree) 'abspath) (equal (cdr tree) path)) t) ((consp tree) (or (opd--tree-has-abspath-p (car tree) path) (opd--tree-has-abspath-p (cdr tree) path))) (t nil))) #+end_src 用数据结构本身的特征来区分行为,而不是引入额外的标记。这是 duck typing 的思想:不问"你是什么",而是看"你长什么样"。消除了一个配置项,也消除了用户忘记设置它导致的 bug。 * 先 profile,再优化 原文最有价值的教训之一:不要猜瓶颈在哪,用 Emacs 自带的性能分析器。 #+begin_src emacs-lisp ;; 启动 CPU profiler ;; M-x profiler-start cpu ;; 执行你的操作 ;; 查看报告 ;; M-x profiler-report #+end_src 原文作者的直觉告诉他瓶颈在字符串模板处理上。profiler 显示真正的罪魁祸首是 =find-file-noselect= (13% CPU)和 =org-persist-write-all-buffer= (12% CPU)。直觉和数据的差距巨大。 性能优化的正确顺序: 1. 先写正确的代码 2. 用 profiler 找到真正的瓶颈 3. 只优化 profiler 指出的热点 这个顺序对任何语言都适用,但在 Elisp 里尤其重要——因为 Elisp 的性能特征和直觉经常不一致。你以为慢的地方可能根本不是瓶颈,真正的热点藏在你不注意的地方。 * 哈希表替代线性扫描 当文件数量从几十个增长到几千个时,线性扫描列表的成本从可以忽略变成不可接受。原文的处理方式很直接:任何需要按文件名查找的场景,用哈希表代替列表。 #+begin_src emacs-lisp ;; 慢:线性扫描,O(n) (member target-file file-list) ;; 快:哈希表查找,O(1) (gethash target-file file-hash-table) #+end_src 原文在路由注册表(registry)、文件缓存(filecache)、依赖图(depcache)三个地方都用哈希表,消除了所有线性扫描。对于 10000 个文件的场景,这个差异是"每个文件多查 10000 次"和"每个文件查 1 次"的差距。 这不是过早优化——当你的数据量明确会达到千级别时,选择哈希表是基本的数据结构选择,就像去超市买东西用购物袋而不是一趟趟用手捧。 * 总结 从这些优化中可以提炼出几条通用的 Elisp 工程原则: 1. *用正确的工具做正确的事* —— 临时 buffer 读文件, =find-file-noselect= 留给交互式场景 2. *只处理你需要的数据* —— =org-element-parse-buffer= 的粒度参数是个宝藏 3. *用 profiler 驱动优化* —— 不要猜,让数据说话 4. 用 =cl-progv= 隔离状态 —— 全局变量是 Emacs 的设计遗产, =cl-progv= 是在批量场景下控制它的有效手段 5. *让数据形状驱动逻辑* —— duck typing 比手动标记更不容易出错 6. *数据量大了就用哈希表* —— 这不是过早优化,是基本的数据结构选择

fcitx5 下 Emacs 无法切换输入法的排查

2026年4月23日 08:00
* 背景 我之前从 fcitx4 升级到了 fcitx5,解决了键盘输入卡顿的问题(详见[[file:fcitx启动后键盘输入卡顿的排查.org][fcitx 启动后键盘输入卡顿的排查]])。升级后,普通应用(浏览器、终端等)都能正常切换输入法,唯独 =Emacs= 不行——按切换快捷键毫无反应,只能输入英文。 * 故障现象 - fcitx5 在其他应用(Firefox、终端等)中正常工作 - 在 Emacs 中无法切换到中文输入法 - Emacs 是通过 =systemd user service= 以 daemon 模式启动的 - 系统为 Arch Linux,Emacs 版本 31.0.50,使用 Lucid(Xaw)工具包 * 排查过程 ** 第一步:检查环境变量 输入法框架依赖三个关键环境变量来告诉应用程序"该用哪个输入法"。我先检查了当前 shell 中的值: #+begin_src shell echo "GTK_IM_MODULE=$GTK_IM_MODULE" echo "QT_IM_MODULE=$QT_IM_MODULE" echo "XMODIFIERS=$XMODIFIERS" #+end_src #+begin_example GTK_IM_MODULE=fcitx5 QT_IM_MODULE=fcitx5 XMODIFIERS=@im=fcitx5 #+end_example 三个变量都有值,看起来没问题。 #+BEGIN_QUOTE *小知识:这三个环境变量是干什么的?* Linux 下的应用程序通过三种不同的协议连接输入法: 1. =XMODIFIERS=@im=xxx= :告诉 *所有 X11 应用* ,通过 XIM 协议连接名为 =xxx= 的输入法服务器。这是最古老的方案,几乎所有 X 应用都支持,但功能最基础。 2. =GTK_IM_MODULE=xxx= :让 *GTK 应用* 加载专门的输入法模块,比 XIM 体验更好(支持光标跟随、预编辑文字等)。 3. =QT_IM_MODULE=xxx= :同理,让 *Qt 应用* 加载专门的输入法模块。 简单来说:=XMODIFIERS= 是万能兜底方案,=GTK_IM_MODULE= 和 =QT_IM_MODULE= 是各自框架的增强方案。 #+END_QUOTE ** 第二步:运行 fcitx5-diagnose fcitx5 自带了一个诊断工具,可以自动检查各种常见配置问题: #+begin_src shell fcitx5-diagnose #+end_src 诊断结果里出现了多处警告: #+begin_example 环境变量 XMODIFIERS 的值被设为了"@im=fcitx5"而不是"@im=fcitx"。 请检查您是否在某个初始化文件中错误的设置了它的值。 环境变量 GTK_IM_MODULE 的值被设为了"fcitx5"而不是"fcitx"。 环境变量 QT_IM_MODULE 的值被设为了"fcitx5"而不是"fcitx"。 #+end_example 诊断工具建议把所有值从 =fcitx5= 改成 =fcitx= 。看起来很合理——虽然软件叫 =fcitx5= ,但输入法模块注册的名字可能不带版本号。 于是我按建议修改了 =~/.xinitrc= : #+begin_src shell # 修改前 export GTK_IM_MODULE=fcitx5 export QT_IM_MODULE=fcitx5 export XMODIFIERS="@im=fcitx5" # 修改后 export GTK_IM_MODULE=fcitx export QT_IM_MODULE=fcitx export XMODIFIERS="@im=fcitx" #+end_src 但问题没有解决——Emacs 还是无法切换输入法。 #+BEGIN_QUOTE *小知识:=.xinitrc= 是什么?* 当你用 =startx= 命令启动图形界面时,=X 服务器= 会读取 =~/.xinitrc= 这个文件,执行里面的命令来初始化桌面环境。通常在里面设置环境变量、启动输入法、启动窗口管理器等。 #+END_QUOTE ** 第三步:发现 Emacs 是 systemd 服务启动的 我意识到一个关键问题:我的 Emacs 不是从 =.xinitrc= 启动的,而是通过 =systemd user service= 以 daemon 模式在后台运行的。 #+BEGIN_QUOTE *小知识:Emacs daemon 模式* Emacs 可以以"守护进程"方式运行(=emacs --fg-daemon= 或 =emacs --daemon=),在后台常驻。之后用 =emacsclient= 连接到这个后台进程来打开窗口。好处是: - 启动速度极快(不用每次都重新加载配置) - 多个客户端共享同一个 Emacs 进程(状态、缓冲区等) #+END_QUOTE 这意味着 Emacs 的环境变量 *不来自 =.xinitrc=* ,而是来自 systemd service 文件中的 =Environment= 配置。查看服务文件: #+begin_src shell cat ~/.config/systemd/user/emacs.service #+end_src #+begin_example [Service] Type=simple ExecStart=/usr/bin/emacs --fg-daemon Environment=... GTK_IM_MODULE=fcitx QT_IM_MODULE=fcitx XMODIFIERS="@im=fcitx" ... #+end_example 果然!Emacs 的环境变量是独立的: - =GTK_IM_MODULE=fcitx= - =QT_IM_MODULE=fcitx= - =XMODIFIERS="@im=fcitx" 看起来都是正确的 ** 第四步:发现根因——XIM 服务器名称不匹配 既然 =.xinitrc= 和 =emacs.service= 都设了 =XMODIFIERS= ,那问题出在哪里?我检查了 fcitx5 实际注册到 X 服务器的 XIM 服务名称: #+begin_src shell xprop -root XIM_SERVERS #+end_src #+begin_example XIM_SERVERS(ATOM) = @server=fcitx5 #+end_example 然后检查 Emacs 进程实际的 =XMODIFIERS= 值: #+begin_src shell cat /proc/$(pgrep -x emacs)/environ | tr '\0' '\n' | grep XMODIFIERS #+end_src #+begin_example XMODIFIERS=@im=fcitx #+end_example *找到根因了!* - fcitx5 注册的 XIM 服务器名: =@server=fcitx5= - Emacs 的 =XMODIFIERS= 值: =@im=fcitx= 两者不匹配!XIM 协议要求 =XMODIFIERS= 的值必须和 XIM 服务器的注册名一致。Emacs 拿着 =@im=fcitx= 去找名为 =fcitx= 的 XIM 服务器,但实际注册的名字是 =fcitx5= ,当然找不到。 #+BEGIN_QUOTE *小知识:XIM 协议的连接机制* XIM(X Input Method)是 X11 的输入法协议,工作流程是: 1. 输入法框架(如 fcitx5)启动后,在 X 服务器上注册一个 XIM 服务器,名字形如 =@server=fcitx5= 2. 应用程序读取 =XMODIFIERS= 环境变量(如 =@im=fcitx5=) 3. 应用程序拿着这个名字去 X 服务器上查找对应的 XIM 服务器 4. 找到后建立连接,输入法就可以工作了 如果 =XMODIFIERS= 的名字和 XIM 服务器注册的名字对不上,应用程序就找不到输入法服务器,输入法自然无法使用。 #+END_QUOTE 这也说明 =fcitx5-diagnose= 的建议是 *误导* 的:它建议把 =XMODIFIERS= 改成 =@im=fcitx= ,但 fcitx5 的 XIM 服务器偏偏注册为 =fcitx5= 。对于通过 XIM 协议连接输入法的应用(比如使用 Lucid 工具包的 Emacs),这个建议反而是错的。 ** 第五步:确认 Emacs 走的是 XIM 而非 GTK 模块 为了确认我的 Emacs 确实走 XIM 协议,我检查了 Emacs 的构建特性: #+begin_src shell emacs --batch --eval '(princ (format "%s\n" system-configuration-features))' #+end_src #+begin_example ... X11 XDBE XIM XINPUT2 ... LUCID ... #+end_example 关键信息: - =XIM= ——Emacs 支持 XIM 协议 - =LUCID= ——Emacs 使用 Lucid(Xaw)工具包,不是 GTK Lucid 工具包不使用 =GTK_IM_MODULE= ,只走 XIM 协议。所以对 Emacs 来说,唯一重要的环境变量就是 =XMODIFIERS= ,而且它的值必须和 XIM 服务器注册名一致。 * 解决方案 明确了根因后,修复很简单: ** 修改 emacs.service 中的 XMODIFIERS 将 =XMODIFIERS="@im=fcitx"= 改为 =XMODIFIERS="@im=fcitx5"= : #+begin_example # 修改前 Environment=... XMODIFIERS="@im=fcitx" ... # 修改后 Environment=... XMODIFIERS="@im=fcitx5" ... #+end_example ** 重启 Emacs 服务 #+begin_src shell systemctl --user daemon-reload systemctl --user restart emacs #+end_src 修改后确认 Emacs 进程的环境变量已更新: #+begin_src shell cat /proc/$(pgrep -x emacs)/environ | tr '\0' '\n' | grep XMODIFIERS # XMODIFIERS=@im=fcitx5 #+end_src 重启后 Emacs 中顺利切换到中文输入法,问题解决。 ** 恢复 .xinitrc 由于我的桌面环境也使用 fcitx5 ,且 fcitx5 的 XIM 服务器注册名为 =fcitx5= ,所以 =~/.xinitrc= 中的值保持为 =fcitx5= 是正确的: #+begin_src shell export GTK_IM_MODULE=fcitx5 export QT_IM_MODULE=fcitx5 export XMODIFIERS="@im=fcitx5" #+end_src * 复盘 ** 根因链条 #+begin_example fcitx5 注册 XIM 服务器名为 "@server=fcitx5" → emacs.service 中 XMODIFIERS="@im=fcitx" → 名称不匹配 → Emacs 通过 XIM 找不到输入法服务器 → 无法切换输入法 #+end_example ** 走过的弯路 这次排查最大的弯路是 *轻信了 =fcitx5-diagnose= 的建议* 。它建议把所有环境变量从 =fcitx5= 改成 =fcitx= ,我照做了,但这反而让 XMODIFIERS 和 XIM 服务器名不匹配的问题更加恶化了。 ** 关键经验 1. *=xprop -root XIM_SERVERS= 可以查看 XIM 服务器注册名* :排查 XIM 问题时,先确认服务器叫什么名字,再和 =XMODIFIERS= 对比 2. *systemd 服务的环境变量是独立的* :不继承 =.xinitrc= 或 shell 的环境,需要在 service 文件中单独设置。遇到服务启动的程序出问题时,用 =cat /proc/$PID/environ | tr '\0' '\n' | grep XXX= 检查实际的环境变量 3. *诊断工具的建议不一定全对* :=fcitx5-diagnose= 的建议在大部分场景下是对的,但对于 XIM 这种特殊场景会给出错误建议。理解原理比盲目执行建议更重要 4. *Emacs 的工具包决定了它用什么输入法协议* :Lucid 走 XIM(看 =XMODIFIERS= ),GTK 走 GTK IM Module(看 =GTK_IM_MODULE= )。排查前先搞清楚 Emacs 用的什么工具包
昨天以前暗无天日

读 Seeing the Whole System

2026年4月22日 08:00
原文:[[https://dzone.com/articles/seeing-the-whole-system][Seeing the Whole System - DZone]] 这篇 DZone 上的文章从一个事故应急响应的真实场景出发,讲透了可观测性(observability)领域最痛的问题:你的监控数据散落在四五个互不相干的系统里,出了事得靠人脑手动拼凑上下文。然后讲了 OpenTelemetry(简称 OTel)怎么从架构层面解决这个问题。 * 你可能也经历过的事故现场 事故响应进行到第 47 分钟,值班工程师已经开了 6 个浏览器 tab:Grafana 看基础设施指标,Splunk 搜应用日志,Jaeger 查链路追踪,还有一个 18 个月前谁搭的 Kibana 面板,还有一个团队 6 周前开通的 Datadog 试用版,但和其他系统完全没有打通。 根因是一个下游依赖在高负载下开始出现响应超时,导致某个没配队列监控的服务队列出现堆积。线索分布在四个互不相干的系统里,工程师得用脑子手动关联。 这个场景不是个例。大多数组织的监控工具链是这么长出来的:A 团队需要指标,上了 Prometheus;B 团队做链路追踪,选了 Jaeger;安全团队要日志聚合,部署了 ELK;新来的工程师喜欢 Datadog,自己开了个试用。每个决定单独看都没错,但最终结果是四五个互不相干的系统,各自只能看到环境的一部分。 当故障跨系统边界传播时(尤其在微服务环境下,经常出现这样的故障),代价就很明显了:不同系统之间的追踪数据无法互通——比如 A 服务用了 Jaeger 埋点,B 服务用了 Datadog 埋点,两个服务的 trace 数据对不上;一个系统的日志时间戳和另一个系统的指标尖峰对不上,还得花时间排除到底是时区不同还是真实的因果顺序。 * OpenTelemetry 是什么 OTel 不是一个工具,而是一套规范(specification)+ API + SDK + Collector。它解决的核心问题是:让应用代码只管发射遥测数据,不关心数据发给哪个后端。 具体来说: 1. 应用代码通过 OTel SDK 埋点,数据通过 =OTLP= (OpenTelemetry Protocol)协议发送 2. Collector 是一个独立的中间服务,负责接收、处理、路由遥测数据 3. 切换后端(比如从 Jaeger 换成 Datadog)只需要改 Collector 配置,应用代码完全不用动 #+BEGIN_SRC yaml :eval no # Collector 最小配置:一个接收器 + 一个处理器 + 一个导出器 receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 processors: batch: timeout: 5s send_batch_size: 1024 exporters: otlp/jaeger: endpoint: jaeger:4317 service: pipelines: traces: receivers: [otlp] processors: [batch] exporters: [otlp/jaeger] #+END_SRC 在 OTel 成熟之前(核心组件大约在 2023 年才达到生产稳定性),给应用做可观测性埋点意味着绑定某个厂商的 agent 或 SDK。想从 A 厂商换到 B 厂商?需要改代码、换库、重新测试。OTel 的厂商无关的设计把这个成本降到了配置变更。 * Collector:最容易用错的组件 原文观察到团队对 Collector 有两种典型的误用: 1. *用得太简单*:把 Collector 当透传管道,原样转发所有数据到后端,不做过滤、采样、富化。配置虽然集中了,但浪费了中间处理层的能力 2. *过度复杂化*:一开始就往 5 个后端同时发数据,加上复杂的处理器链和多套采样策略。6 个月后没人能完整解释这个配置 做得好的团队遵循一个模式:从一个 receiver、一个 processor、一两个 exporter 开始,逐步扩展。Collector 配置放 Git 里,变更走 code review。Collector 本身也当服务对待——有 owner、有 SLO、有值班轮换。 一个实用的 Collector 模式是 *tail-based sampling* (尾部采样):在源头全面埋点,在 Collector 层配置只把 10-15% 的 trace 发到昂贵的存储后端,但保留 100% 的错误和慢请求 trace。该看的问题一个不漏,但摄入成本大幅降低。 * 关联查询:统一遥测最大的价值 统一遥测标准最大的好处不是省钱或换后端方便,而是可以做关联查询——从一个指标异常,跳到解释它的 trace,再跳到定位具体操作的日志行。 OTel 的 trace 上下文传播机制(即请求从 A 服务调到 B 服务时,自动把 trace ID 带过去)让这个关联变成自动的:同一个请求经过的每个服务都用同一个 trace ID 串起来: #+BEGIN_SRC text :eval no # traceparent header 格式:version-trace_id-parent_id-trace_flags traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 #+END_SRC 如果你的日志也带着这个 trace ID (OTel 的日志埋点会处理),就可以从 trace 中的慢 span 直接跳到该 span 产生的日志行,在一个系统里完成。原文提到一个初级工程师用这种方式做了根因分析,值班负责人估计这在 OTel 迁移前要 40 分钟,实际只用了 9 分钟。 * 还没有解决的问题 - *自动埋点的局限*:OTel 的 auto-instrumentation 对标准 HTTP 调用、数据库查询、gRPC 很好用,但对自定义消息队列、遗留协议、内部框架仍然需要手动埋点——这部分工作很麻烦 - *日志集成滞后*:trace 和 metric 在 OTel 中已经成熟稳定,但日志规范的 SDK 实现仍在追赶。原文建议的渐进策略是:先在现有日志输出中加上 trace ID 和 span ID,等运维图景更清晰后再迁移收集路径 - *Collector 本身需要维护*:处理几十个服务的高基数遥测数据的 Collector 需要容量规划、故障分析、持续运维。不能当"设好就忘"的组件

TIL: 早期网页的图片热区导航

2026年4月22日 08:00
读到 Heydon Pickering 的一篇文章,才知道 HTML 里有个 == 元素,专门用来在一张图片上定义可点击区域——这种技术叫 /image map/ 。 #+BEGIN_SRC html 关于本站 联系我 欢迎访问 #+END_SRC 用 == 把多个 == 包起来,再在 == 上用 =usemap= 属性关联,就能让一张图片的不同区域指向不同的链接。 =shape= 支持 =rect= (矩形)、 =circle= (圆形)和 =poly= (多边形), =coords= 是像素坐标。 这种做法在 2000 年代初很流行。那时候没有 =border-radius= ,没有 web fonts ,CSS 能做的事很少。很多"网页"其实就是设计师在 Photoshop 或 FrontPage 里画好的一张大图,整个塞进 HTML ,然后用 image map 标出导航区域。比起当时更流行的表格布局(在 Dreamweaver 里拖半天单元格,打开浏览器发现全乱了),image map 至少坐标不会跑位。 当然问题也很大:图片大加载慢,文字不可选中不可搜索,屏幕阅读器只能读出"一张图片加几个链接",而且 =coords= 是像素值,图片一缩放区域就全歪了。后来 CSS 能力跟上了,这种做法就自然消失了。 参考:[[https://heydonworks.com/article/the-area-element/][The area element - HeydonWorks]]

用 Emacs 自动生成每周链接推荐

2026年4月21日 08:00
原文:[[https://localghost.dev/blog/automated-weekly-links-posts-with-raindrop-io-and-eleventy/][Automated weekly links posts with raindrop.io and Eleventy]] Sophie Koonin 在一篇博客中描述了她的自动化链接推荐方案:用 Raindrop.io(一个书签管理工具)收集链接,用 Eleventy(一个静态网站生成器)把链接渲染成博文,再用 GitHub Actions 每周日定时跑脚本,自动 commit 和发布。整条链路不需要手动操作,唯一要做的事就是平时看到好文章时随手收藏一下。 这套方案的核心思路—— *把"每周手动发博文"拆解成"收集 → 生成 → 发布"三步自动化* ——并不依赖特定工具。下面我用 Emacs + Org-mode 重新实现同样的效果,因为我的博客本身就是用 EGO(Emacs Git Org,一个基于 Org-mode 的静态站点生成器)构建的。 ** 整体链条 #+begin_src 看到好文章 → org-capture 保存链接到 links.org ↓ GitHub Actions 每周日定时运行 elisp 脚本 ↓ 从 links.org 提取本周链接 → 生成 Org 博文 ↓ 自动 commit → 触发博客构建 → 发布 #+end_src ** 第一步:org-capture 收集链接 在博客仓库中创建一个 =links.org= 文件专门存放链接,然后配一个 org-capture 模板: #+begin_src emacs-lisp (add-to-list 'org-capture-templates '("l" "收集链接" entry (file "~/blog/links.org") "* %? %t\n %x\n" :empty-lines 1)) #+end_src 使用方法:在浏览器里复制 URL,切到 Emacs, =M-x org-capture= ,选 =l= ,输入链接标题, =C-c C-c= 保存。 =%t= 插入当天日期( =<2026-04-19 Sat>= 格式), =%x= 粘贴剪贴板中的 URL。 收集到的内容示例: #+begin_src org ,* 一篇不错的 Rust 教程 <2026-04-19 Sat> https://example.com/rust-tutorial ,* Hacker News 上关于 Emacs 的讨论 <2026-04-20 Sun> https://news.ycombinator.com/... 这篇讨论串里有几个关于 tramp 配置的好建议 #+end_src ** 第二步:elisp 脚本生成博文 接下来需要一个 elisp 函数,读取 =links.org= ,筛选出本周的链接,生成一篇 EGO 格式的博文。 #+begin_src emacs-lisp (defun my/generate-weekly-links-post (links-file output-dir) "从 LINKS-FILE 提取本周链接,生成博文到 OUTPUT-DIR." (interactive "fLinks file: \nDOutput directory: ") (let* ((today (format-time-string "%Y-%m-%d")) (today-prefix (format "<%s" today)) (week-ago-time (time-subtract (current-time) (days-to-time 7))) (week-ago-prefix (format "<%s" (format-time-string "%Y-%m-%d" week-ago-time))) links) (with-temp-buffer (insert-file-contents links-file) (goto-char (point-min)) (while (re-search-forward "^\\* \\(.+?\\) \\(<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\} [^>]+>\\)" nil t) (let* ((title (match-string 1)) (ts (match-string 2)) (body-start (1+ (line-end-position))) (body-end (save-excursion (if (re-search-forward "^\\*" nil t) (1- (match-beginning 0)) (point-max)))) (body (string-trim (buffer-substring-no-properties body-start body-end)))) (when (and (not (string< ts week-ago-prefix)) (string< ts today-prefix)) (push (list title body) links))))) (when links (let ((post-file (expand-file-name (format "每周链接推荐-%s.org" today) output-dir)) (links-count (length links))) (with-temp-buffer (insert (format "#+TITLE: 每周链接推荐 %s\n" today)) (insert "#+AUTHOR: lujun9972\n") (insert "#+TAGS: Emacs之怒\n") (insert (format "#+DATE: [%s %s]\n" today (let ((system-time-locale "zh_CN.utf-8")) (format-time-string "%a")))) (insert "#+LANGUAGE: zh-CN\n") (insert "#+OPTIONS: H:6 num:nil toc:t \\n:nil ::t |:t ^:nil -:nil f:t *:t <:nil\n\n") (insert "本周收集的有意思的链接。\n\n") (dolist (link (nreverse links)) (let ((title (nth 0 link)) (body (nth 1 link))) (if (string-empty-p body) (insert (format "- %s\n" title)) (insert (format "- %s\n %s\n" title body))))) (write-file post-file) (message "生成完毕: %s (%d 条链接)" post-file links-count)))))) #+end_src 函数逻辑: 1. 用正则匹配每个一级标题的时间戳( =<2026-04-19 Sat>= 格式) 2. 用字符串比较筛选:日期 >= 7 天前 且 < 今天 3. 拼接成 EGO 格式的 Org 博文,写到博客目录 字符串比较能正确工作的原因是时间戳格式为 == ,日期部分在前面,字典序和日期序一致。 ** 第三步:GitHub Actions 定时发布 在仓库中添加一个 workflow 文件(如 =.github/workflows/weekly-links.yml= ): #+begin_src yaml name: "生成每周链接推荐" on: schedule: - cron: 0 10 * * 0 # 每周日 UTC 10:00(北京时间 18:00) jobs: generate-links: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: ref: source - name: 安装 Emacs run: sudo apt-get update && sudo apt-get install -y emacs-nox - name: 生成博文 run: | emacs --batch -l generate_weekly_links.el - uses: stefanzweifel/git-auto-commit-action@v5 with: commit_message: "每周链接推荐" #+end_src 其中 =generate_weekly_links.el= 就是上面的函数,末尾加一行调用: #+begin_src emacs-lisp ;; generate_weekly_links.el — 放在博客仓库根目录 ;; ... 函数定义同上 ... (my/generate-weekly-links-post "links.org" "Emacs之怒/") #+end_src 这个 workflow 往 =source= 分支 push 新博文,而现有的博客构建 workflow 已经在监听 =source= 分支的 push 事件,所以新博文会自动构建发布,不需要额外的部署步骤。 ** 小结 整个方案的精髓: *把一个需要坚持的习惯,拆解成"收集、生成、发布"三步自动化* 。你唯一需要做的就是看到好文章时随手 =org-capture= 一下。 不管你用的是 Raindrop.io + Eleventy 还是 Emacs + EGO,思路都一样——找到你的"收集入口",写一个"提取脚本",再用 CI 的定时任务跑起来。工具不同,模式相同。

读:ASCII control characters in my terminal

2026年4月21日 08:00
Julia Evans 写了一篇文章梳理终端中所有 33 个 ASCII 控制字符的作用。这些控制字符是你每天按 Ctrl-C 终止程序、按 Ctrl-Z 挂起进程、按 Ctrl-W 删一个词时实际发送的那些字节。本文是对她文章的解读。 ** 只有 33 个控制码 ASCII 表的前 32 个位置(0-31)加上第 127 个位置,一共 33 个,留给"控制字符"——它们不对应可打印字符,而是表示某种控制动作。其中 26 个对应 =Ctrl-A= 到 =Ctrl-Z= ,另外 7 个对应 Ctrl 加上 =@= 、 =[= 、 =\= 、 =]= 、 =^= 、 =_= 、 =?= 。 这意味着 =Ctrl-1= 、 =Ctrl-2= 这种组合在终端里根本不存在——按下 =Ctrl-1= 跟直接按 =1= 效果一样,因为 ASCII 没有为数字键预留控制码的位置。同样, =Ctrl+Shift+C= 也不是控制码,它由终端模拟器自己处理(用来复制),根本不会发送到终端里运行的程序。 ** 三层处理:谁来响应你的按键 这 33 个控制码并不是统一由某一层处理的,而是分成了三股道: 1. *OS 终端驱动*直接处理的:比如 =Ctrl-C= (发送 SIGINT 信号终止程序)、 =Ctrl-Z= (发送 SIGTSTP 挂起程序)、 =Ctrl-D= (发送 EOF)。这些按键按下后,OS 的终端驱动会直接拦截并产生相应动作,程序本身收不到这个字节。 2. *readline 库*处理的:比如 =Ctrl-W= (删一个词)、 =Ctrl-U= (删整行)、 =Ctrl-R= (搜索历史命令)。这些在 bash、python REPL 等使用 readline 的程序中工作,但在 =cat= 这种不用 readline 的程序中就不起作用。 3. *应用程序自己定义*的:比如 =Ctrl-X= 在一般终端程序里没有固定含义,但 Emacs 把它用作了大量快捷键的前缀。 用 =stty -a= 可以看到 OS 终端驱动处理的所有控制码映射: #+begin_src shell stty -a #+end_src 在我的机器上输出如下(只列出控制字符映射部分): #+begin_src text intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^D; eol = ; eol2 = ; swtch = ; start = ^Q; stop = ^S; susp = ^Z; rprnt = ^R; werase = ^W; lnext = ^V; discard = ^O; min = 1; time = 0; #+end_src 这里 ~intr = ^C~ 表示 =Ctrl-C= 触发中断信号, ~erase = ^?~ 表示退格键(byte 127)触发删除字符, ~kill = ^U~ 表示清行, ~werase = ^W~ 表示删一个词。所有这些映射都可以通过 =stty= 命令修改——比如 =stty intr ^X= 可以把中断信号改到 =Ctrl-X= 上。不过实际上几乎没有人改这些映射,改了只会让自己在各种教程和问答里找不到北。 ** Ctrl-M 就是 Enter,Ctrl-I 就是 Tab 这是一个容易让人困惑的设计: =Ctrl-M= 发送的字节值是 13,跟按 =Enter= 键完全一样; =Ctrl-I= 发送的字节值是 9,跟按 =Tab= 键完全一样。所以如果你想给终端程序设置 =Ctrl-M= 或 =Ctrl-I= 作为快捷键,会发现它们的效果跟 =Enter= 和 =Tab= 一样——因为从程序的角度看,收到的是同一个字节。 这也解释了为什么很多终端程序(包括 bash 的 readline)的快捷键覆盖了 =Ctrl-A= 到 =Ctrl-Z= 的大部分,唯独跳过了 =Ctrl-I= 和 =Ctrl-M= ——不是故意这样设计的,而是这两个位置已经被 =Tab= 和 =Enter= 占了。 ** canonical 模式 vs noncanonical 模式 =Ctrl-W= 和 =Ctrl-U= 的行为取决于终端当前处于哪种模式: - *canonical 模式*(规范模式):OS 终端驱动负责行编辑。你按 =Backspace= 删字符、按 =Ctrl-W= 删词、按 =Ctrl-U= 清行,这些都是 OS 在缓冲区里处理的,程序只有在你按 =Enter= 之后才能看到整行输入。 =cat= 、 =grep= 等非交互程序通常用这种模式。 - *noncanonical 模式*(原始模式):OS 不做任何行编辑,每按一个键程序就立刻收到。 =Ctrl-W= 和 =Ctrl-U= 的删词、清行功能需要程序自己实现。bash、python REPL、vim 等交互式程序用这种模式。 可以用 =strace= 观察一个程序设置了哪些终端模式: #+begin_src shell strace -tt -o /tmp/strace-out vim # 退出 vim 后: grep ioctl /tmp/strace-out | grep SET #+end_src 你会看到 vim 启动时关闭了 =ISIG= (不再由 OS 处理信号)和 =ICANON= (关闭 canonical 模式),改为 raw 模式自己处理所有输入。vim 退出时又把这些设置恢复回去。 ** Backspace 的混乱历史 按 =Backspace= 键时,终端到底发送哪个字节?这个问题居然没有统一答案: - 有些机器发 byte =127= (ASCII 名 =DEL= ) - 有些机器发 byte =8= (ASCII 名 =BS= ,Backspace) 在 Linux 上, =Backspace= 键发送的是 byte =127= ,OS 终端驱动和 readline 都把它映射为"删除前一个字符"。 =Ctrl-H= 发送 byte =8= ,在 readline 里它的效果跟 =Backspace= 一样(都是删除一个字符),但在 =cat= 这种只用 canonical 模式的程序里, =Ctrl-H= 只会打印出 =^H= 而不会删字符——因为 canonical 模式只认识 =stty= 里 =erase= 设置的那个字节(127)。 如果你的 =Backspace= 键行为异常(按了不删字符反而显示 =^H= ),原因就是你的终端发的是 byte =8= 而 =stty= 期望的是 byte =127= 。修复方法是 =stty erase ^H= ,把删除字符的映射从 127 改到 8。 ** Ctrl-S 冻屏之谜 =Ctrl-S= 发送 byte =19= (ASCII 名 =XOFF= ),OS 终端驱动收到后会暂停向终端输出——这就是古老的"软件流控制"。在 90 年代的低速串口终端上这很有用(输出太快来不及看就按 =Ctrl-S= 暂停, =Ctrl-Q= 恢复),但今天几乎没人需要这个功能了。 问题在于 =Ctrl-S= 这个按键被 XOFF 占了,导致 readline 无法用它做"前向搜索历史命令"( =Ctrl-R= 是反向搜索, =Ctrl-S= 本应是正向搜索)。解决方法是用 =stty -ixon= 关闭软件流控制,这样 =Ctrl-S= 就不再触发 XOFF,而是被传给 readline 使用。 ** ASCII 名字可以忽略 每个控制码在 ASCII 标准里都有一个正式名字,比如 byte =3= 叫 =ETX= (End of Text)、byte =26= 叫 =SUB= (Substitute)。但这些名字是为 1960 年代的电报机设计的——那个时代的 =ETX= 表示"报文结束",跟今天终端里 =byte 3= 的作用(发送 SIGINT 终止程序)毫无关系。33 个控制码里,大约一半的 ASCII 名字跟它今天在终端中的功能对不上,所以不如直接忽略这些名字。 原文链接: [[https://jvns.ca/blog/2024/10/31/ascii-control-characters/][ASCII control characters in my terminal]]

读 What to learn

2026年4月21日 08:00
原文:[[https://danluu.com/learn-what/][What to learn]] 这篇文章回答一个问题:你应该学什么?Dan Luu 的答案是,不要跟风学别人推荐的东西,而是找到少数几个适合自己天赋的技能,学深学透,然后去找一群高手扎堆的环境。他用自己的踩坑经历证明了跟风学技术是低 ROI 的。 * 别学别人让你学的东西 Steve Yegge 有一系列博客文章建议学编译器。理由是:懂了编译器,你就会到处看到编译器问题,然后发现很多人在用半吊子方案解决本质上是编译器的问题,而你可以用编译器知识又快又好地解决。 这话不能说错,但问题在于——你可以把"编译器"换成排队论、计算机体系结构、数学优化、运筹学……哪个领域都能讲出同样的道理。所以"学编译器"不是编译器有多特殊,而是 Steve Yegge 恰好懂编译器。大部分职业建议的本质就是"学我学过的东西"。 Dan Luu 早年照着互联网建议学了 Haskell、Lisp、Forth。他甚至参与造了一颗 2GHz 的 Forth 处理器——可能至今仍是世界上性能最高的 Forth 处理器。他花了比绝大多数人都多的精力精通 Forth。结果呢?Forth 的倡导者 Chuck Moore 声称 Forth 比普通语言生产力高 100 倍,Dan Luu 的体验是:连 1 倍都不到。 回头看,Dan Luu 认为跟风学技术的 ROI 非常低。真正高 ROI 的学习,没有一个是网上有人推荐的。 * 高手只靠几招吃遍天 数学家 Gian Carlo Rota 讲过一个故事:一位老数学家瞧不起 Paul Erdős 的工作,说 Erdős 的所有证明都能归结为几个反复使用的技巧。Rota 后来读了 Hilbert(比 Erdős 地位更高的数学家)的不变量理论论文,发现 Hilbert 的证明也靠同样的几个技巧反复用。 *即使 Hilbert 也只有几个技巧。* 柔道也一样。对世界级柔道选手的分析发现,大多数人只靠少数几个投技赢比赛。柔道的本质是专精——你只用最适合你的那几招,练到变成自动反应。动画和电视剧里角色通过学更多招数来变强,但现实中,把已有的招数练到极致往往比囤积几百个"技能"更有效。 Joy Ebertz 给过一个建议: *放大你的优势,而不是修补你的弱点。* 每个人都有强项和弱项,我们花很多时间谈论"改进空间",但如果某个领域你真的弱,花大力气可能只挪动一点点。更好的策略是把本来就很强的事情变成你的超级能力——前提是你没有真正糟糕的短板。 Dan Luu 在竞技游戏里验证过这一点。把他从"不错"提升到"很强"的关键,不是补练他不擅长的东西,而是放弃弱项,集中强化他本来就比别人强的那几个方面,把优势拉大到别人追不上的程度。他觉得这个策略在职场比在游戏和体育里更有效——因为在竞技场景里对手会专门攻击你的弱点,但在工作中没有对手会阻止你选择发挥优势的项目。 * 怎么找到自己的"几招" Dan Luu 自己真正在用、在持续精进的技能有两个:"看数据"和"对抗性思维"。这两个都不是传统意义上的学科——"看数据"不是统计学(他很少用到逻辑回归那么复杂的东西),"对抗性思维"也不对应某个计算机科学分支。它们是跨领域的思维方式。 怎么发现这种东西?Dan Luu 给了两个方法: 1. *问很了解你的人。* 他的经理和 Ben Kuhn 独立指出了同一个他没意识到的技能——跨多个抽象层次提出解决方案。你自己未必能看清自己最擅长什么。 2. *找那些你忍不住要做、但大多数人不做的事。* Dan Luu 测试一个新 bug 追踪系统时,主动往输入框里塞各种异常数据看它崩不崩。有人觉得莫名其妙,有人(包括 Dan Luu 本人)很高兴看到系统被推到极限。对系统做压力测试对他来说不是工作——他要克制自己才会不去这么做。这种"不做反而难受"的事情,就是你应该深化的方向。 * 找对环境比找对方向更重要 找到适合自己的技能只是第一步,还有一个关键因素: *你在什么样的环境里学。* Dan Luu 第一份工作在 Centaur(一家芯片公司),那里的验证团队用比同行少得多的人做到了同样甚至更好的效果。这是一个高密度的学习环境。他觉得在那里学到的对抗性测试思维,靠自己看书上网学不到——真正厉害的人脑子里有太多无法压缩进书本的隐性知识。 对于"看数据"这个技能,他在过去几年(在一个能和高水平同事反复讨论数据局限性的环境里)每年的进步幅度, *超过了之前十年的总和* 。 值得注意的是,环境是局部的,不是公司级别的。Dan Luu 说他现在的雇主可能是他待过的三家大公司里最不数据驱动的,但他周围的同事恰好非常擅长理解数据的局限性,而他每天花大量时间和这些人一起工作——这就够了。 Dan Luu 发现一个规律:高手密度最高的团队,往往在非常优秀的管理者手下。他见过的两个高手最密集的团队都是如此。原因也说得通——好的管理者留人能力强,外面排着队想加入,自然容易吸引和留住顶尖人才。 * 随机游走也没什么不好 很多职业建议会告诉你:要有长期目标和规划,因为定向行走能走 n 步远,随机游走只能走 sqrt(n) 步远。 Dan Luu 认为这个建议低估了一件事: *找到适合自己的方向的难度。* 他认识的人里,不少人花了很多年(有些人超过十年)才发现自己走的方向根本不适合自己——要么性格不匹配,要么能力不匹配。相比之下,瞎逛然后发现自己 sqrt(n) 步处在一个开心的位置上,并不比闷头走了 n 步却发现不开心更差。 Dan Luu 自己的技能就是通过试错找到的,而他的学习动机也不是为了职业发展——纯粹是因为学东西好玩。这么做二十年之后碰巧变得有用了,但在相当长一段时间里他并不知道会这样,也不知道硬件领域学到的技能能迁移到软件行业。 * 总结:你应该怎么做 1. *别跟风。* 别人推荐你学的东西,对他可能有用,对你大概率没用。Dan Luu 跟风学 Forth 的 ROI 接近于零。 2. *找少数几个适合你的技能,学深。* 你不需要什么都会。Erdős、Hilbert、世界级柔道选手都只靠几招。关键是这几招得适合你的天赋——不是别人擅长的,是你自己擅长的。 3. *放大优势,而不是修补弱点。* 把本来就很强的事变成超级能力,比费力补短板收效更大。工作中没有对手攻击你的弱点,你可以主动选择发挥优势的项目。 4. *怎么发现自己的技能:* 问很了解你的人;留意那些你忍不住要做但大多数人不会做的事。 5. *找到高手扎堆的环境。* 你的学习速度取决于身边的同事,不是公司名气。在高手身边一年能学到比自己摸索十年更多的东西。 6. *别怕随机游走。* 没想清楚方向就先试,试错了再换。瞎逛走到一个开心的位置,比闷头走一条不开心的路强。

Lisp 的括号之痛——一个愚人节玩笑揭开的老伤疤

2026年4月21日 08:00
2026 年 4 月 1 日,Clojure 咨询公司 Flexiana [[https://flexiana.com/news/clojure/new-era-for-clojure-infix-syntax-3][发布了一篇文章]],宣布开源一个叫 Infix 的库,让 Clojure 程序员可以用中缀运算符写数学表达式。 #+begin_src clojure ;; 以前这么写 (+ (* a b) (/ c d)) ;; 有了 Infix 之后这么写 (infix a * b + c / d) #+end_src 代码是真的,测试是通过的,库已经上传到 GitHub。但他们特意选在愚人节发布,文章里到处是自嘲:"The only day of the year when the Clojure community might forgive us"(一年中 Clojure 社区唯一可能原谅我们的日子)。 这个玩笑之所以好笑,是因为它戳中了一个老伤疤:Lisp 的前缀表示法,到底算不算一个问题? * 前缀 vs 中缀:为什么新手总是被劝退 绝大多数编程语言用的是中缀表示法——运算符写在两个操作数中间: #+begin_src text a * b + c / d #+end_src Lisp 家族用的是前缀表示法——函数(运算符)写在最前面,操作数跟在后面,整个表达式用括号包起来: #+begin_src clojure (+ (* a b) (/ c d)) #+end_src 前缀表示法有一个显而易见的好处:*一致性*。不管是加法、函数调用还是控制流,语法结构都一样—— =(operator arg1 arg2 ...)= 。没有运算符优先级的记忆负担,没有"乘法比加法先算"的歧义。 但一致性不等于可读性。Flexiana 的文章里举了个真实的例子——一个定价规则: #+begin_src clojure (<= (count (:items order)) (* 2 (get-in config [:limits :base]))) #+end_src 换成人话就是:"订单里的商品数量,是不是不超过配置里设的基础限制的两倍?" 同样的逻辑用中缀写: #+begin_src text count(items) <= 2 * config.limits.base #+end_src 哪个更容易一眼看懂,不言自明。 * Infix 库的技术实现:编译期的语法糖 Flexiana 并不是第一个试图给 Lisp 加中缀语法的。历史上类似的尝试不少,但 Infix 的实现方式值得说说。 它的核心是一个 *宏* (macro)。Lisp 宏和 C 语言的预处理器宏完全不是一回事——Lisp 宏操作的是代码本身(因为 Lisp 代码就是数据结构),在编译期把一种形式的表达式转换成另一种。 Infix 用的是经典的 *Shunting Yard 算法*(调度场算法),由计算机科学家 Edsger Dijkstra 在 1961 年发明。这个算法能把中缀表达式(人类习惯的写法)转换成前缀或后缀表达式(机器容易处理的形式)。整个库只有大约 300 行代码,分四个命名空间:解析器、优先级表、编译器和公开 API。 关键点在于:*转换发生在编译期,运行时零开销*。最终跑在 JVM 上的代码和你手写的前缀表达式完全一样。Infix 只改变了你 *写* 代码的方式,不改变代码 *跑* 起来的方式。 除了基本的算术,Infix 还做了几件更激进的事: - *箭头 lambda* :=(infix x => x * x + 1)= 替代 Clojure 的匿名函数语法 =#(+ (* % %) 1)= - *线程宏中缀化* :把 Clojure 的 =->>= (thread-last)变成中缀操作符 - *函数定义中缀化* :=infix-defn= 让函数体里可以用中缀表达式 - *提前返回* (guard clause):在 =infix-defn= 里支持 =(return nil)= 形式的提前返回 每一项都在试探 Clojure 社区的容忍底线。 * 为什么 Lisp 社区对"中缀"这么敏感 Flexiana 自己也承认这是"异端"(heresy),并且在文章里列出了反对中缀语法的经典论点: 1. *S-expressions 是同像的* (homoiconic)——代码和数据长一模一样,你写的 =(+ 1 2)= 就是一个列表,和用代码构建的列表没有区别。这是 Lisp 宏系统的根基,也是 Lisp 最强大的特性 2. *前缀表示法消除了歧义* ——不需要记住运算符优先级,不会因为优先级记错而产生 bug 3. *运算符优先级本身就是 bug 来源* ——几乎所有 C 系语言的使用者都踩过优先级的坑 这些论点都对。但它们回答的是"前缀表示法 *理论上* 更好",而不是"前缀表示法 *实践中* 更好用"。 Lisp 社区有一个长期存在的张力:语言设计者认为前缀表示法优越且一致,而大量普通程序员觉得前缀表示法难以阅读。Paul Graham 在《Beating the Averages》里把 Lisp 称为 Viaweb 的"秘密武器"——竞争对手看不懂他们的代码,也追不上他们的迭代速度。Lisp 的括号天然抬高了入门门槛,这到底是筛选还是障碍,取决于你站在哪一边。 Flexiana 的文章里有句话很直白:"We've all been in that meeting where a data scientist looks at =(+ (* a b) (/ c d))= and quietly opens a Python tab."(我们都经历过那种会议:数据科学家看到前缀表达式,默默打开了 Python 标签页。) 这不仅是笑话。这是 Clojure 在数据科学领域几乎无法和 Python 竞争的原因之一。 * 语法糖的价值:一个没有结论的争论 Infix 库引发的争论,本质上是编程语言设计中一个永恒的问题:*语法糖到底有没有价值?* "语法糖"(syntactic sugar)指的是那些不增加语言表达能力、但让代码更好写的语法特性。反对者认为语法糖只是障眼法——它掩盖了语言的真相,让初学者以为语言是某种样子,实际上底层完全是另一回事。Python 的列表推导式、C# 的 LINQ、Kotlin 的作用域函数,都是语法糖的经典案例。 支持者则认为,代码是写给人看的,不是写给机器看的。如果一种语法让代码更容易被更多人理解,那它就是有价值的——即使底层实现不变。 Infix 库恰好站在这个争论的正中央:它 *不改变* Clojure 的任何语义,只改变表达式的书写形式。如果你认为语法糖有价值,那 Infix 就是合理的工具;如果你认为语法糖是技术债,那 Infix 就是用宏实现的自我欺骗。 Flexiana 选在愚人节发布,也许正是因为他们自己也没有答案——他们只是在用玩笑的方式,把这个问题重新摆在所有人面前。 * 后记 我查了一下,[[https://github.com/Flexiana/infix][Infix 库]]确实存在于 GitHub 上,Apache-2.0 许可,代码简洁优雅。至于它到底是不是愚人节玩笑——Flexiana 在文章最后写道:"The library is real. The tests pass. The macros expand."(库是真的。测试通过了。宏正确展开了。) 至于你该不该用——这个问题留给你的良心。

Python Mock 第三方依赖的四种策略

2026年4月20日 08:00
Sophie Koonin 在 localghost.dev 上写了一篇文章,以她的 Choirbot 项目(一个管理合唱团排练的 Slack bot)为例,展示了在 Jest 中 mock 四种不同类型的第三方依赖的方法。本文借鉴了她的思路,但把所有示例改写为 Python 版本——用 =unittest.mock= 和 =responses= 等 Python 工具实现同样的四种策略。每种策略针对不同的依赖接口特点:客户端类、工厂模式、数据库 ORM、HTTP 请求,从简单到复杂逐步展开。 ** 前置知识: =unittest.mock= 是什么 =unittest.mock= 是 Python 标准库中专门用于测试时"替换真实对象"的模块。它的核心思路很简单:测试时你不想真的去调用 Slack API 或连接数据库,那就用一个"假的"对象来代替——这个假对象长得很像真的,但它不会产生任何副作用,你可以随意控制它的返回值、检查它被怎么调用了。 本文用到的三个核心工具: - =MagicMock= :万能替身。你访问它的任何属性、调用它的任何方法都不会报错,默认返回另一个 =MagicMock= 。你可以通过 =.return_value= 设置方法的返回值,通过 =.assert_called_once_with()= 验证方法是否被正确调用。 - =patch= :偷梁换柱器。它能在测试运行期间把代码中的某个类或函数替换成 =MagicMock= ,测试结束后自动恢复原样。用法通常是 =@patch('模块路径.对象名')= 装饰器。 - =spec= 参数:给 =MagicMock= 加上约束。 =MagicMock(spec=SomeClass)= 创建的 mock 只能访问 =SomeClass= 中真实存在的方法,防止你拼错方法名却不报错。 * 策略一: =patch + MagicMock= ——适用于客户端类 如果你已经把第三方 SDK 封装成了自己的客户端类,最高效的 mock 方式不是去 mock 整个 SDK,而是 mock 你自己的客户端。Python 的 =unittest.mock.patch= 可以在测试运行时自动替换目标对象。 #+begin_src python # src/slack_client.py class SlackClient: def __init__(self, token): self.token = token def post_message(self, channel, text): # 调用真正的 Slack API ... def get_reactions(self, channel, timestamp): # 调用真正的 Slack API ... #+end_src 假设被测代码中有一个函数 =detect_raised_hand= ,它内部会实例化 =SlackClient= 并调用其方法: #+begin_src python # src/detector.py from src.slack_client import SlackClient def detect_raised_hand(channel, timestamp, token): client = SlackClient(token) reactions = client.get_reactions(channel, timestamp) return any(r['name'] == 'raised_hands' for r in reactions['message']['reactions']) #+end_src 在测试中用 =patch= 替换 =SlackClient= 类,被测代码内部的 =SlackClient(...)= 会自动返回 mock 实例: #+begin_src python # tests/test_detector.py from unittest.mock import patch from src.detector import detect_raised_hand @patch('src.detector.SlackClient') def test_detect_raised_hand(MockClient): # MockClient 是替换后的 MagicMock 类 # MockClient.return_value 是"实例化"时返回的 mock 实例 client = MockClient.return_value client.get_reactions.return_value = { 'ok': True, 'message': { 'reactions': [{'users': ['U123456'], 'name': 'raised_hands'}] } } # 调用被测函数,它内部会创建 SlackClient 实例(实际得到 mock) result = detect_raised_hand('C001', '1234567890.123456', 'xoxb-fake') assert result is True client.get_reactions.assert_called_once_with('C001', '1234567890.123456') #+end_src *关键点* : =patch('src.detector.SlackClient')= patch 的是被测代码中的引用位置(即 =src.detector= 模块里的 =SlackClient= 名称),而非原始定义位置。这样被测代码内部执行 =SlackClient(token)= 时,实际得到的是 mock 实例。 =MockClient.return_value= 就是这个 mock 实例——每次"实例化"得到的都是同一个 mock 对象。你可以用 =return_value= 设置方法的返回值,用 =assert_called_once_with= 验证调用参数。 * 策略二:辅助函数封装 mock 配置——适用于工厂模式的 SDK 有些 SDK 使用工厂模式:调用一个函数后才返回客户端实例。比如 Google Sheets SDK 的用法是 =build('sheets', 'v4', credentials=creds)= ——每次调用 =build= 都返回一个新的服务对象。 策略一能解决大部分场景,但遇到这种"深层链式调用 + 工厂函数"的库就麻烦了。 =build()= 返回的对象上有 =spreadsheets().values().batchGet().execute()= 这样的多层调用,手动一层一层设 =return_value= 非常繁琐。 解决方法:写一个辅助函数,把 mock 的层级结构封装起来,每个测试只需传入不同的数据。 #+begin_src python # tests/test_sheets.py from unittest.mock import MagicMock, patch from src.sheets import fetch_rehearsal_data def make_sheets_mock(batch_get_data): """创建一个配置好的 Sheets 服务 mock""" mock_service = MagicMock() # 设置完整的调用链:service.spreadsheets().values().batchGet().execute() mock_service.spreadsheets().values().batchGet().execute.return_value = { 'valueRanges': batch_get_data } return mock_service @patch('src.sheets.build') def test_cancelled_rehearsal(mock_build): # 工厂函数 build() 返回我们配置好的 mock mock_build.return_value = make_sheets_mock([ {'range': 'B1:I1', 'values': [['header1', 'header2']]}, {'range': 'B4:I4', 'values': [['Rehearsal cancelled', 'Run Through Title']]} ]) result = fetch_rehearsal_data() assert result[1]['values'][0][0] == 'Rehearsal cancelled' #+end_src 为什么这个方法有效?因为 =MagicMock= 有个天然特性:访问任何不存在的属性都会自动返回一个新的 =MagicMock= 。所以 =mock.spreadsheets().values().batchGet()= 这条链上的每一层都是一个自动创建的 mock 对象,最终你在 =execute= 上设置的 =return_value= 会被正确返回。 这比在 Jest 中用闭包变量 + setter 更直观——Python 的 mock 对象天然支持动态修改属性,不需要额外的 setter 函数来"穿透"工厂函数。 * 策略三:仓储类封装——适用于数据库 ORM 数据库 SDK 的 mock 难度最高,因为涉及查询构造器、连接管理、事务等复杂逻辑。直接 mock 数据库驱动往往会导致测试和实现细节强耦合——你改了查询写法,测试就挂了。 更好的做法是给数据库操作封装一个"仓储类"(Repository),让业务代码只通过仓储类访问数据库。然后在测试中 mock 仓储类的方法。这样测试只关心输入输出,不关心内部查询细节。 #+begin_src python # src/db.py class AttendanceRepo: def __init__(self, db_client): self.db = db_client def get_attendance(self, team_id): return self.db.collection(f'attendance-{team_id}').get() def save_attendance(self, team_id, data): self.db.collection(f'attendance-{team_id}').add(data) #+end_src 在测试中直接 mock 仓储类,不需要碰数据库驱动: #+begin_src python # tests/test_attendance.py from unittest.mock import MagicMock from src.db import AttendanceRepo from src.attendance import AttendanceService def test_notify_installer_when_post_not_found(): repo = MagicMock(spec=AttendanceRepo) repo.get_attendance.return_value = [] # 模拟空数据库 service = AttendanceService(repo=repo) service.update_message(team_id='T001', token='xoxb-xxx') repo.get_attendance.assert_called_once_with('T001') #+end_src =spec= 参数告诉 =MagicMock= 按照 =AttendanceRepo= 的接口创建 mock。好处是:如果你在 =AttendanceRepo= 上删除或重命名了某个方法,对应的测试会立即报错,而不是悄悄通过。 如果你确实需要模拟完整的数据库行为(而不仅仅是 mock 方法调用),可以用专门的 mock 库: - SQLite:直接用内存数据库 =sqlite3.connect(':memory:')= - MongoDB:用 =mongomock= 库,它实现了完整的 MongoDB 接口 - PostgreSQL:用 =testcontainers= 启动一个真实的临时 Docker 容器 *策略三的核心思路* :不是去 mock 底层数据库驱动的每个方法,而是把数据库访问抽象到一层,然后在测试中替换整个抽象层。这样即使以后换数据库(比如从 Firestore 换到 PostgreSQL),测试代码基本不用改。 * 策略四: =responses= 拦截 HTTP 请求——适用于 REST API 对于 =requests= 库发出的 HTTP 请求, =responses= 库能直接拦截并返回预设数据,不需要启动真实服务器。 #+begin_src python # tests/test_holiday.py import responses from src.holiday import is_bank_holiday @responses.activate def test_is_bank_holiday(): responses.add( responses.GET, 'https://www.gov.uk/bank-holidays.json', json={ 'england-and-wales': { 'events': [ {'title': "New Year's Day", 'date': '2024-01-01', 'bunting': True} ] } }, status=200 ) assert is_bank_holiday('2024-01-01') is True assert is_bank_holiday('2024-01-02') is False #+end_src =responses.activate= 装饰器会在测试期间拦截所有 =requests.get/post/...= 调用。 =responses.add= 注册一条规则:当请求匹配指定的 URL 和方法时,返回预设的响应。 需要注意, =responses= 只拦截 =requests= 库的调用。如果你用的是其他 HTTP 库,对应的选择不同: - =httpx= :用 =respx= 库 - =urllib= / 通用:用 =httpretty= 库 - =aiohttp= (异步):用 =aioresponses= 库 * 四种策略的选择指南 | 场景 | 推荐策略 | 核心工具 | |------+----------+----------| | 自己封装了客户端类 | 策略一 | =patch= + =MagicMock= | | SDK 有工厂函数和深层链式调用 | 策略二 | =MagicMock= 链 + 辅助函数 | | 数据库 ORM | 策略三 | 仓储类封装 + =spec= mock | | REST API( =requests= ) | 策略四 | =responses= | 选择的核心逻辑是看你要 mock 的依赖的 /接口形态/ :如果是普通类的方法,策略一用 =patch= 就够了;如果 SDK 有工厂函数和深层链式调用,策略二用辅助函数封装 mock 配置;如果涉及数据库,最好先抽象出仓储层再用策略三;如果是标准 HTTP 请求,策略四用 =responses= 最省事。 原文链接: [[https://localghost.dev/blog/different-ways-to-mock-third-party-integrations-in-jest/][Different ways to mock third-party integrations in Jest]]

读 How to Monetize a Blog:一篇伪装成变现指南的讽刺文

2026年4月20日 08:00
* 这篇"教程"在干什么 [[https://modem.io/blog/blog-monetization/][How to Monetize a Blog]] 是 modem.io 上的一篇文章。乍一看,这是一篇博客变现指南——讲 CPM、CPA、广告网络、受众画像,跟你搜到的任何一篇"如何靠博客赚钱"没什么区别。 但读完全文才发现,这是一篇精心设计的讽刺作品——它就是它所讽刺的那个东西。 * 四层递进的讽刺手法 ** 第一层:正经教程的伪装 文章开头非常像回事。它解释了 CPM(千次展示付费)和 CPA(按行动付费)的区别,介绍了广告网络的运作方式,甚至讨论了如何根据受众画像投放定向广告。这些内容本身是准确的,读起来就像一篇标准的 SEO 变现教程。 这个伪装做得越逼真,后面的反转就越有力。 ** 第二层:荒诞的假广告 从文章开头开始,就穿插着各种广告位。但仔细一看,这些广告卖的是: - "虾探测器"( Shrimp detectors ) - "定制塔罗牌" - "蛇油"( Literal Snake Oil——"从真正的蛇身上现榨的油" ) - "药用盲盒"( Medicinal Loot Boxes——"禁止医生入内" ) - "和三明治交朋友"( Befriend A Sandwich——"你的法式蘸酱三明治知道宝藏在哪吗?" ) 每一则假广告的荒诞程度都在递增。而且这些广告和文章内容毫无关系——这本身就是一种讽刺:广告系统号称能精准匹配内容和广告,实际投放出来的东西根本和内容无关。 ** 第三层:作者自我拆穿 写到大半的时候,作者突然摊牌了。他说: #+begin_quote this entire post is admittedly just meaningless fluff that I'm not even proof-reading. It's all a thoughtless heap of words in disguise, an excuse to fill more ad space. And it is working! 这整篇文章说实话就是毫无意义的填充物,我连校对都没做。这是一堆词的伪装,一个塞更多广告位的借口。而且它奏效了! #+end_quote 然后他"建议"你可以用 AI 来写内容,或者干脆用 lorem ipsum(排版时用来占位置的假文字)来填充文章后半部分——因为只要前面的文字看起来像人写的,读者就不会注意到后面是机器生成的废话。这正是内容农场的真实操作:用廉价内容填充页面,只要能展示广告就行,内容本身毫无价值。 这一层的巧妙之处在于:作者一边在做他正在批评的事(用废话填空间),一边告诉你他在这么做。你作为读者,明知被耍了,但广告已经展示在你面前了。 ** 第四层:文风崩塌 最后几段,文章彻底放弃了教程的伪装。文风从幽默滑向了一种接近克苏鲁式的黑暗散文: #+begin_quote We invent new gods and we bury them in the same breath. We leave scars on the world like canyons carved from rivers of glass. 我们在制造新的神,又在同一口气中埋葬它们。我们在世界上留下伤疤,像河流冲刷出的玻璃峡谷。 #+end_quote 然后是一段"拆了建、建了拆"无限重复的文本,最后以一片黑暗中蠕动的触须意象收尾。 这个文风崩塌大概不是作者失控,而是一种有意的隐喻:当内容创作者完全被流量和广告驱动时,内容本身就会退化成这样的东西——没有意义、没有方向、无限循环。 * 中国互联网的镜像 这篇文章讽刺的是英文互联网的广告生态,但这些手法放在中国互联网上只会变本加厉。 百度的搜索结果页,前几条永远是广告。公众号文章读着读着突然插入一条带货链接。短视频平台的内容越来越像广告,广告越来越像内容。B 站的弹幕里混着"恰饭"的辩解。我们每天消费的内容,有多少是真正为读者写的,又有多少是"有意义的填充物"?和这篇讽刺文里的做法相比,区别只在于:这些平台可不会自爆。 modem.io 这篇文章最狠的地方在于,它没有用论述的方式告诉你"广告正在毁掉互联网",而是让你亲身体验了一把——你读完了它,你的注意力被假广告收割了,然后文章告诉你:刚才经历的就是问题本身。 * 为什么这篇讽刺有效 好的讽刺不靠说理,靠的是让你读完之后回头一看,发现自己刚才的阅读体验本身就是被讽刺的对象。这篇文章做到了: 1. *形式即内容* ——它用广告充斥的排版来讽刺广告充斥的互联网,读者不需要"被说服",只需要"经历一遍" 2. *诚实是最好的嘲讽* ——作者直接承认"这篇文章就是垃圾",这种自曝比任何批评都尖锐 3. *递进的荒诞感* ——从正经教程到假广告到自我拆穿到文风崩溃,每一层都把讽刺推得更深 这种结构性的讽刺手法并不常见——大部分讽刺靠的是文字游戏和双关,而这篇文章让文章的结构本身成为了论点。
❌
❌