MoreRSS

site iconEltrac | 極客死亡計劃修改

常讨论心理学和社会观察相关内容,不敢说很懂哲学;也会写笔记和生产力工具相关内容;还是学生。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Eltrac | 極客死亡計劃的 RSS 预览

带刺大黄鱼如何帮我反思软件?

2026-11-11 01:23:41

我在《 爱拯救之 》一文中提到,博客现在运行在安装 OpenBSD 的操作系统上,而不是 Linux。我本想尝试更多的 BSD 系统,可我把 FreeBSD 的 .iso 挂载到 VPS 之后,在 VNC 里捣鼓了半天也没把系统安装好。最后还是换回 OpenBSD,在十分钟内就完成了安装。

FreeBSD 你们要不要反思一下:为什么 OpenBSD 不到 800MB 的 .iso 里纯文本的安装界面会比你们的 图形界面 更好用?

不多说了,还是先来关注 OpenBSD 吧。

Linux people do what they do because they hate Microsoft. We do what we do because we love Unix.

——Theo de Raadt

什么是 OpenBSD?

OpenBSD pufferfish being a cowboy

众所周知,Linux 和 macOS 都是类 Unix 系统,而 Unix 原本是 AT&T(美国通话电话公司)推出的商业操作系统,在 20 世纪 70 年代授权给外部使用,催生了包括加州大学伯克利分校开发的 BSD(Berkeley Software Distribution)在内的多种基于 Unix 的系统。1

是的,Linux 是 Unix-like,而 BSD 是 Unix-based。Linux 与 BSD 的另一个重大区别是授权协议,Linux 以 GPL 2.0 协议授权,而 BSD 则使用与 MIT 类似的 BSD 协议。宽松的 BSD 协议让来自 BSD 世界的软件润物细无声般地进入其他操作系统,甚至成为支撑起全球网络连接的必要组件,重要程度完全不亚于 Linux。

NetBSD 的创始人之一 Theo de Raadt 在与开发团队的分歧之后,创建了 OpenBSD——我不太喜欢《大教堂与集市》里说「分支不好」的观点,显然人们早就开始分支开源软件了,分歧是难免的,开发者在自己喜欢的团队中才能开发出好的软件,事实证明 OpenBSD 的确很成功(可能并非通俗意义上的成功)。

OpenBSD 给自己的定位是:主动安全的类 Unix 操作系统。ssh 到一台 OpenBSD 机器上,就能看到这样的欢迎语:

OpenBSD 8.0 (GENERIC.MP) #84: Tue Oct 6 23:13:49 MDT 2026

Welcome to OpenBSD: The proactively secure Unix-like operating system.

Please use the sendbug(1) utility to report bugs in the system.
Before reporting a bug, please try to reproduce it with the latest
version of the code. With bug reports, please try to ensure that
enough information to reproduce the problem is enclosed, and if a
known fix for it exists, include that as well.

他们在官网上写的内容还要更笃定:致力于成为 行业第一安全的操作系统 。为此他们采取了很多措施,也发明了不少安全技术和方法论,这些技术被大量商业公司采用。

我不太擅长谈安全细节,所以就举一个例子:W^X——W 是写入(write),X 是执行(execute),中间的 ^ 是异或,意思是说一块内存区域要么可以写入,要么可以执行,不能既写入又执行。这样就极大削减了内存出现安全漏洞的概率。

他们还有各种原则,比如系统里不必要的服务绝对不默认启用,不需要的端口默认不开放,root 默认无法 ssh 登录2。这些在 Linux 世界也是最佳实践,一般来说一台有公网 IP 的服务器都应该为此修改配置,而 OpenBSD 系统的默认状态就非常安全。他们还非常在乎代码的整洁、可读性和可维护性,认为更少的代码就意味着更不容易出现安全漏洞,也非常重视代码审计。我很欣赏他们的工程态度。

到目前为止,OpenBSD 的默认配置只发现过两次远程安全漏洞,分别是 CVE-2002-0639 和 CVE-2007-1365,现在 Linux 一个月内就能超过这个数。

除了机制的安全性,OpenBSD 也奉行极简。不仅源代码简单易读,基础系统也相当小——安装镜像小,内存占用小,CPU 要求小,自带的服务很少,自带的软件包也很少…… 我安装了 OpenBSD 之后才发现,原来系统的进程列表和软件包列表,是人类能够读完的。知道运行在自己系统里的大部分软件都是做什么的,真的令人安心。这也使得 OpenBSD 对老设备非常友好。

另外,尽管源于美国公司,OpenBSD 基金会位于加拿大,不受美国法律制约。有意思的是,OpenBSD 在自己的 Cryptography 页面里写到:他们发布加密算法和密码学软件,因为加拿大法律允许出口加密算法——很多国家是禁止的。

好的软件很少源自商业世界

OpenBSD 可能不为人熟知,但只要是开发者就基本上离不开 OpenBSD 项目开发的软件,普通用户也会间接接触到,就像这世界上到处都是 Linux 而没有技术背景的大部分人却毫不知情一样。

你用 ssh 登录服务器吗?或者用 ssh:// 连接 Git 远程服务器吗?如果是的话,那你就用过 OpenSSH ,即 OpenBSD Secure Shell。它最初在 OpenBSD 系统上实现,如今已经集成到了 Windows、macOS、其他 BSD 系统和许多 Linux 发行版当中,还有不少软件依赖 ssh 进行安全连接。

在 Windows 上用过 ssh 吗?如果有,那你就用过 LibreSSL ,这是 OpenSSL 的 Fork,与谷歌的 BoringSSL 是同类软件。除了 OpenBSD 本身,还有不少操作系统、编程语言的 HTTP/TLS 库和软件也采用 LibreSSL。

用过 tmux 吗?这是个终端复用器,用来保持会话和多开会话,可以用同一个 ssh 连接打开多个终端窗口,断联之后还能重新连接之前的会话,也能临时守护进程。它也来自 OpenBSD。

在 macOS Sequoia 之后的版本上用过 rsync 吗?如果是,那你就用过 openrsync ——可能不太准确,因为 macOS 上的 rsync 其实是 Apple 用 openrsync 改的。OpenBSD 这边的 openrsync 仍在开发中,也有从 Apple 那边合并一些代码过来用。

据说还有不少公司的防火墙借鉴了 OpenBSD 的 pf(4) (Packet Filter),的确简单好用,现在运行极客死亡计划的服务器就使用 pf 过滤和拦截网络请求。我一开始以为自己离不开 Caddy 了,后来发现 OpenBSD 自带的 httpd(8) 和 acme-client(1) 就足够好用。Docker 和 Podman 也是不必要的, chroot(8) 的隔离简单直白,只要对可复现性没有要求就够用,适合部署运维复杂度低的服务。3

OpenBSD 和相关的软件简直是操作系统界的 Motherfucking Website ,只不过更谦卑,不那么惹人耳目,而且有种诡异的魅力。

BSD 协议

说实话,关于开源协议,我个人往往更偏好 GPL 这类 copyleft 协议,甚至是 AGPL 这个最强的 copyleft 协议。开源软件除了有开源派和自由软件派,还有 GPL 簇拥和 BSD 簇拥(都是政治派别,可以这么说)。

光从协议本身而言我更偏好 GPL,但若是上升到政治问题,再去审视这两派的争端,我不得不说,我更欣赏 BSD 代表的态度。

Be as politics-free as possible; solutions should be decided on the basis of technical merit.

—— Project Goals - OpenBSD

如果你不了解这两种授权协议的具体内容,可以这样理解:GPL 具有传染性,要求使用者必须也用 GPL 协议授权衍生作品,跟 CC 协议的 ShareAlike 条款类似;BSD 相当放纵,仅要求分发者保留署名,并且不得使用原作者的名字为自己的衍生产品做宣传。以上仅仅是概括,GPL 和 BSD 都还有不同的版本,我的描述不构成任何建议。

简单来说,GPL 把自由软件的概念强行摆在所有人面前,让人不得不把注意力放在它身上(不得不说其实很朋克),也要求别人必须成为自己的一份子;而 BSD 则不在乎使用者和自己有没有相同的政治观点,它包容几乎所有的政治观点。

我自己经常使用 GPL,因为我想要保护自己的作品,但我也认识到 GPL 的存在不仅给科技公司造成了法务问题,还给自由软件同胞造成了困扰。Linux 使用 GPL v2.0 协议,据说 Linus 本人很不喜欢 GPL v3.0,阵营内部也有分歧。GPL 协议和 EPL 协议不兼容,这使得不少 Java 和 Clojure 开发者不能直接用 GPL 授权自己的项目,必须增加附加条款。GPL 由 Richard Stallman 写就,各种条款是出了名地难解读,常常造成争议,LibreOffice 也有一些与 GPL 相关的 版权问题 。

与此同时,我回头看见 BSD,用不满一页的条款授予了用户自由使用的权利。面对各种观点纷争和政治问题,我仿佛听见他说:

I don’t care.

缺少了 GPL 的保护,BSD 授权的软件有变得难以维持吗?目前看来, OpenBSD 基金会 每年都会收到大量的捐款,Google、Meta 和 Microsoft 都位列其中4,当然捐献最多的还是 OpenBSD 社群,也就是所有小捐款的总和。

当然这不代表 BSD 协议就是最好的,另一边, Asahi Linux 最近把和 BSD 一样宽松的 MIT 协议 换成了 GPL ,因为有不少人(借助 LLM)窃取他们的工作成果。用 LLM 快速洗版的事件上演得越来越频繁了,目前的法律法规似乎根本没办法管制,那些差点把我服务器搞挂的 爬虫 也仍然逍遥法外。

Accept ‘bad things’ in return for benefits of AI

—— Sam Altman

更复杂的问题是大部分人不在乎授权协议,从很早开始就这样了,国内更是如此。就拿爬虫举例,追求商业价值的公司只会关注某个仓库有多少 Stars,值得不值得爬,而不会在意某个仓库用了什么授权协议,爬了之后要怎么使用数据才算合规。

用户和开发者似乎也很少有人会纠结协议问题,大部分人都想要让所有人能够自由使用自己的作品,而很少考虑协议的保护作用和对用户的影响。我也理解大部分开发者不喜欢读法律文书,我也是,我至今没有完整读过 GPL 协议,甚至没有认真读过那么短的 MIT 协议,我也要反思。

并非怀旧

搬到 OpenBSD 上之后,我把 Forgejo 换成了 cgit,现在我的 Git 仓库都在 git.eltr.ac ——另外,cgit 的架构其实很有意思,简单来说,现代的 Web 服务都有两个 Web 服务器,一个是程序自己内嵌的 Web 服务器,另一个是 Nginx、Apache 和 Caddy 这样的服务器,而 cgit 采用的 CGI 协议则是让支持 CGI 的服务器(比如 httpd)在收到 HTTP 请求时去唤起 C 程序。

从 Forgejo 迁移到 cgit 的直接原因是 OpenBSD 上没有 Forgejo 的 port,我也不想自己编译,而且很快我意识到,大部分 Forgejo 的功能我都不需要。我只需要通过 ssh 以专门的 git 用户连接到远程服务器作为 remote,用 cgit 扫描服务器上的 git 仓库生成 HTML 就好。

用 Scheme 写的静态网站生成器 Haunt 就托管在 David Thompson 个人的 Git 服务器上,用 cgit 做前端,用的人依旧不少。 Linux Kernel 用的也是 cgit,没有大部分人期待 Git 服务器该有的功能(项目管理、Issue 追踪、CI/CD 集成……)。不过我自己是会把大部分完成度较高、对外的项目镜像到 Codeberg 上的,因为我不相信自己的运维能力。

我在 OpenBSD 上的软件选择看起来可能有些复古,但这些决定绝非是出于怀旧情绪。一方面,我在审视自己的软件需求。我对 Git 服务器的需求只是方便展示,我从没考虑过用 Forgejo 跟人协作(如果真的需要协作,我会优先选择公共平台),我也陆陆续续把不少难用的 Forgejo Actions 和 Webhook 重写成了本地脚本(热知识:一台个人笔记本无论有多烂,都要比只有一两个核心和一两个 GB 内存的服务器更快),所以事实就是,我真的不需要 Forgejo。

另一方面,我很欣赏这些默默无闻的软件开发者,他们比装备了概率统计机器就认为自己是天才并且在公共场所大喊大叫的人,要值得敬佩得多。加密货币和区块链似乎至今都是找不到钉子的锤子,与它有着相同气质但更加猖獗的大语言模型,似乎也逐渐暴露出了相似的问题。

我一直看到的,是被学舌的鹦鹉迷惑得认为一切都是钉子,上蹿下跳,吵着闹着声称新时代已经到来的投机者。

我正在努力看到的,是在这闹剧开始之前就一直热爱软件,在闹剧平息之后也必将继续将自己奉献给热爱的人。比起痛恨我的敌人,我更愿意把爱献给我的朋友。


  1. 参见: Berkeley Software Distribution - Wikipedia  ↩︎

  2. 不少 VPS 提供商对 OpenBSD 的支持不太好,因为不能在面板里添加 SSH 公钥和非 root 用户,装好之后根本登录不了,必须自己手动通过 VNC 安装。还好 OpenBSD 的安装程序很容易上手 ↩︎

  3. 运维复杂度低的例子:用 Go 写的程序,编译成二进制文件之后上传到服务器上运行,只需要用 systemd 或者 rc 等工具守护进程就好;反例:需要再起个 PostgreSQL 服务器,在三四个地方放置配置文件,还要在 /var/www 放前端静态资源,同时配置反向代理和静态文件服务器…… ↩︎

  4. 我好像没有找到 Apple 呢。 ↩︎

Coffee Break Clojure, Test

2026-11-03 23:58:29

假设有函数 pair-sum,把输入的数字划分为两个一对,然后对每个对求和,返回和组成的序列:

(defn pair-sum [& nums]
 (->> nums
 (partition 2)
 (map (partial apply +))))

系统运行一段时间后有人反馈了 Bug,复现方式如下:

(pair-sum 3 2 1 4 5 3 5)
;; => (5 5 8)

输入上述用例,函数只返回了前六个数字组成的三个对的和,最后一个 5 被丢弃了。经过判断,这的确是个 Bug,解决方案是让落单的输入单独作为和返回。

你当然可以修完 Bug 之后一走了之,但试想一下:之后的贡献者在看到这段代码的时候,会不会对「落单的输入怎么处理」这件事情产生不同的想法?他会不会认为修复后的版本反而是个 Bug?

当然可以在文档和 Issues 里留档,但不是所有人都会把所有文档资料扫一遍再改代码的,忽略注释也没有代价。有没有办法让代码的行为被修改之后,就立刻报错提醒修改者「这里不该这么改」?

Testing, Testing, 1, 2, 3

标准库 clojure.test 提供了编写测试的工具,先引入命名空间:

(ns my.peculiar-math-test
 (:require [clojure.test :refer [deftest testing is]]))

我们引入了三个符号:

  • deftest:顾名思义,用于定义测试,类似于在命名空间里定义函数。基本上可以把测试函数当作没有参数的普通函数,测试内部还能调用其他测试函数。
  • testing:写在测试函数内的更小单元,需要填写字符串来描述测试的范围、目的等等;测试函数内部可以有多个 testing,testing 还能嵌套。
  • is:断言,只有在传给 is 的是真值时才会通过测试。

测试一般写在 test/ 目录下,和 src/ 相对应。在顶层目录运行 lein test 就可以执行所有测试用例,也可以 lein test <ns> 指定命名空间,或者 lein test :only <ns>/<test> 指定命名空间下的某个测试函数。

直接运行 lein test 会发现有 Bug 的代码顺利通过了测试。这是句废话,因为我们根本没写测试。从现在开始,应当认为这是异常情况:Bug 竟然通过了测试?先来解决这个问题,写测试用例捕获刚才的 Bug。

(ns my.peculiar-math-test
 (:require [clojure.test :refer [deftest testing is]]
 [my.peculiar-math :refer [pair-sum]]))

(deftest pair-sum-test
 (testing "Extra number that is not paired should be included in the result"
 (is (= (pair-sum 1 1 1) '(2 1)))))

运行 lein test,会发现测试失败了!

lein test my.peculiar-math-test

lein test :only my.peculiar-math-test/pair-sum-test

FAIL in (pair-sum-test) (peculiar_math_test.clj:7)
Extra number that is not paired should be included in the result
expected: (= (pair-sum 1 1 1) (quote (2 1)))
 actual: (not (= (2) (2 1)))

Ran 1 tests containing 1 assertions.
1 failures, 0 errors.
Subprocess failed (exit code: 1)

改代码,尝试通过测试:

(defn pair-sum [& nums]
 (->> nums
 (partition-all 2)
 (map (partial apply +))))

partition 函数会主动丢弃无法组成足够长度分区的元素,而 partition-all 函数就不会。

再次执行测试,发现测试通过了。

lein test my.peculiar-math-test

Ran 1 tests containing 1 assertions.
0 failures, 0 errors.

本来只需要添加四个字符就可以解决的问题,我们却多写了个测试文件,测试代码都快跟实现代码差不多长了(甚至可以更长),何必呢?

对于 pair-sum 这种逻辑简单的小函数,测试可能帮助不大,但正如前文所说,如果之后代码的行为改变了,原来的 Bug 再次出现,只要运行测试就能立马捕捉到问题。这对复杂的业务逻辑来说尤其有帮助,如果代码的依赖关系较多,修改一个地方造成另一个看起来毫不相干的 Bug 这种事情,常常发生,而测试可以让我们提前发现这些问题。

除了专门用来捕获错误的测试,还可以在编写函数的一开始就写出正确的用例,也就是 Happy Path——程序在理想情况下应该执行的路径。

也可以试着在编写实现代码之前就写出测试用例,然后以「通过测试」为目标编写代码,这种开发方式叫作测试驱动开发(Test-Driven Development), Uncle Bob 就是 TDD 的践行者。据他所言,先写测试再写代码其实要比硬着头皮直接写更快。

I got you covered

当然不能为系统里的每个函数都编写测试,仅在命名空间使用的工具函数就无需测试,支撑业务逻辑的关键函数就要重点测试。在我看来,一个模块的导出(export)函数是必须要测试的。不过 Clojure 的模块没有导出的概念,只要不是 defn- 定义的私有函数,使用 defn 定义的函数都是默认公开可引用的。

可以把模块的一两个被其他模块调用的函数当作导出函数,还可以把导出函数的概念类比为 API。两者都是位于边界位置的存在,导出函数位于模块边界,API 位于系统边界。

模块的概念不需要太过强调,封装性很多时候也只会徒增麻烦,比起纠结哪个函数是导出函数,我更在意哪些函数被测试完全之后,能够保证测试覆盖率最高。

测试覆盖率(test coveragec)是指完整的测试之后,被执行的代码所占总代码的比例。代码的单位可以是行数、语句、分支、分支条件和条件组合等等(见白盒测试的各种概念)。对 Lisp 来说,还可以用形式(forms)作为单位。

Leiningen 插件 cloverage 就是自动化的测试覆盖率计算工具。在项目根目录运行 lein cloverage,这个插件会先对代码插桩(instrument),然后对插桩的代码运行 test/ 目录下的测试,最后生成报告。

以下是 weepinbell 37fd5d4d31 版本的覆盖率报告:

|--------------------------------------+---------+---------|
| Namespace | % Forms | % Lines |
|--------------------------------------+---------+---------|
| weepinbell.config | 100.00 | 100.00 |
| weepinbell.core | 18.66 | 49.02 |
| weepinbell.http.client | 100.00 | 100.00 |
| weepinbell.http.downloader | 99.50 | 100.00 |
| weepinbell.metadata.html-head | 99.30 | 100.00 |
| weepinbell.metadata.jf2 | 100.00 | 100.00 |
| weepinbell.metadata.json-ld | 100.00 | 100.00 |
| weepinbell.metadata.microformats | 81.69 | 89.90 |
| weepinbell.migration | 76.15 | 100.00 |
| weepinbell.processor.facade | 76.26 | 100.00 |
| weepinbell.processor.janitor | 100.00 | 100.00 |
| weepinbell.processor.storage-checker | 90.14 | 91.67 |
| weepinbell.processor.verifier | 81.38 | 100.00 |
| weepinbell.processor.wrapper | 100.00 | 100.00 |
| weepinbell.processor.writer | 100.00 | 100.00 |
| weepinbell.storage.edn-storage | 97.83 | 100.00 |
| weepinbell.storage.h2 | 93.92 | 98.08 |
| weepinbell.storage.worker | 79.40 | 100.00 |
| weepinbell.utils.digest | 100.00 | 100.00 |
| weepinbell.utils.url | 98.67 | 98.25 |
| weepinbell.webhook | 80.83 | 100.00 |
| weepinbell.webhook-presets | 90.65 | 91.67 |
| weepinbell.webserver.counter | 100.00 | 100.00 |
| weepinbell.webserver.facade | 81.25 | 95.24 |
| weepinbell.webserver.feed | 100.00 | 100.00 |
| weepinbell.webserver.handler | 89.64 | 100.00 |
| weepinbell.webserver.receiver | 100.00 | 100.00 |
| weepinbell.webserver.retriever | 100.00 | 100.00 |
|--------------------------------------+---------+---------|
| ALL FILES | 85.07 | 95.99 |
|--------------------------------------+---------+---------|

除了总的 85.07% 形式覆盖率和 95.99% 的代码行覆盖率(也就是说,在运行完整的测试集之后,对应比例的 Lisp forms 和代码行被执行了),还有各个命名空间的覆盖率。此外 cloverage 还会生成 HTML 报告,可以在网页里查看具体是哪些代码行没有被完整覆盖。

要注意的是,被覆盖的代码不代表没有问题,只代表被执行。测试是否可靠,还要看测试用例设计得是否合理,测试断言写得是否恰当(即便跑了测试,不写断言,测试覆盖率也可以很高)。

改完代码之后跑一遍测试,如果测试覆盖率较高且用例设计合理,就能提前捕获到没预想到的问题。这对贡献者也有帮助,刚来的贡献者不熟悉代码库,测试能帮他们快速发现自己有没有改错东西。

测试通过不代表系统没有问题!记住,Bug 的共同特征是:通过了编译器的类型检查,也通过了测试用例。一定会有漏网之鱼,小心为胜。

I Spec with my little eye

Clojure 有一个从 2016 年推出起就备受关注,如今也被广泛应用,但仍然还在 Alpha 阶段的标准库,叫 clojure.spec.alpha。

众所周知,Clojure 是动态类型语言,也常常因此遭人诟病。毕竟 JavaScript 在被广泛投入生产使用之后,也有人非要造个 TypeScript 出来,可见业界有多么讨厌动态类型语言。

动态类型语言的缺陷的确不少,比方说非常依赖运行时类型推断,性能通常不如静态类型语言,而且大部分代码不需要通过编译器的类型检查,或者说类型检查非常宽松。对字符串做 + 操作之所以能在 Go 语言等静态类型语言里放心使用1,是因为类型明确,不会出现 JavaScript 里 "1" + 1 == "11" 的情况(不过这其实是 JavaScript 的设计缺陷,而不是所有动态类型语言的缺陷)。

著名的 Lisp 黑客,也是硅谷创业之父 Paul Graham 在《黑客与画家》里也谈到编程语言选择,他觉得动态类型语言更优,至少开发者体验是最好的。用户体验(UX)很重要,开发者体验(DX)也很重要,开发者感觉良好才能有效率地创造出精良的软件。

所以选动态类型语言也没什么羞耻的,毕竟写起来真的很爽。动态不代表不稳定,用户感知到的不是数据的类型是否可变,而是系统是否稳定好用、行为是否正确,而这两者很容易通过编写测试来保障。完全不写测试的静态类型语言程序照样容易漏洞百出。

静态类型语言的一大优势是编译器能够做很多检查,比方说类型传错了、缺了某个字段,就无法编译,必须停下来修复问题。动态类型语言不修类型问题也能运行,这很爽,但确实是隐患。

clojure.spec.alpha 就用来解决这个问题,可以用它定义一系列数据规格(spec),再用函数检查数据是否符合规格。

定义规格并没有引入新的类型系统,spec 只是谓词函数而已。

(ns my.spec
 (:require [clojure.spec.alpha :as s]))

(s/def ::username string?)

s/def 用于定义规格,而 ::username 是一类特殊的关键字(keyword)。一般的关键字只有一个冒号,带两个冒号的关键字是带命名空间的关键字(namespaced keyword)。第二个参数是个函数,string? 判断输入值是否为字符串。

用 s/valid? 判断数据是否合规:

(s/valid? ::username "Alice")
;; => true

(s/valid? ::username 231)
;; => false

如果要在另一个命名空间里引用:

(require '[my.spec])

(s/valid? :my.spec/username "Bob")
;; => true

可以用 s/and 和 s/or 构建更复杂的规格:

;; 用户名必须是字符串,且长度小于 20
(s/def ::username
 (s/and string? #(< (count %) 20)))

;; 昵称可以为空,也可以是合法的用户名
(s/def ::nickname
 (s/or :no-nickname nil?
 :has-nickname (partial s/valid? ::username)))

s/or 需要为每一种情况命名,用关键词表示。

还需要经常指定的规格是映射(map),可以用 s/keys 检查某个映射里面是否包含指定的键,以及这些键是否符合各自的规格。

(s/def ::user
 (s/keys :req [::username ::age ::gender]
 :opt [::nickname]))

{::username "Eltrac"} 不合法,因为缺失 ::age 和 ::gender 两个键。下面这个映射符合 ::user 规格吗?

{::username "Alice"
 ::age 21
 ::gender :female
 ::nickname :ali}

不合法,因为可选键 ::nickname 不符合 ::nickname 的规格,它要么为 nil,要么为合法的 ::username,也就是说必须是长度小于 20 的字符串。

下面这个数据符合 ::user 规格吗?

{:username "Alice"
 :age 21
 :gender :female
 :nickname "ali"}

不符合,因为这上面的键都是普通关键词,不带命名空间。

要让不带命名空间的键也能通过规格检查,可以修改规格定义:

(s/def ::user
 (s/keys :req-un [::username ::age ::gender]
 :opt-un [::nickname]))

也就是把 :req(required,必须)改成 :req-un,把 :opt(optional,可选)改成 :opt-un,这里的 un 代表 unqualified。

效果是一样的,区别在于使用不带命名空间的键的 map 也能通过检查了。

如果不理解为什么某个数据没有通过检查,可以用 s/explain:

(s/explain ::nickname 123)
;; 123 - failed: nil? at: [:no-nickname] spec: :user/nickname
;; 123 - failed: (partial valid? :my.spec/username) at: [:has-nickname] spec: :user/nickname
;; => nil

上面输出的信息显示,123 既没通过 :no-nickname 的 nil? 检查,也没通过 :has-nickname 的 (s/valid? :my.spec/username) 检查,所以不合格。

clojure.spec.alpha 里常用的函数就是这些了,里面还有不少方便的函数,可以到 ClojureDocs 里面阅读。

To err is to human

我在 第 101 期周刊 里提到了 怂货式编程 ,Ivan Grishaev 观察到很多人会写出这样的 Clojure 代码:

(defn double-number [x]
 (when x
 (* x 2)))

也就是说,只有 x 不为 nil 的时候,才会执行 (* x 2),因为对 nil 做算术运算会报错。一些人害怕报错,所以要在函数里面写 when,防止操作 nil 数据。

乍一看很合理,没有人希望程序报错。可是不报错不代表没有问题,如果 nil 被传给这个函数,函数本身也会返回 nil。在函数式系统里,数据管线往往是一个接一个的函数调用,如果最后得出的结果是 nil,而中间没有一个函数报错,我要怎么才能知道是哪个函数出了问题呢?

Grishaev 认为,如果输入是 nil,这个函数就不该被调用。如果输入值有问题,这个函数不应该胆小地缩在角落不敢说话,应该大喊大叫,“这里出问题了!你们传了个什么数据给我?!喂,程序员,快把问题修好!”

除了让类型操作直接报错,还可以显式地断言(assert)类型问题。这里的断言和测试断言不太一样,稍后会讲到。

(defn double-number [x]
 {:pre [(number? x)]
 :post [#(number? %)]}
 (* x 2))

写在形参后面的 {} 是可选的,可以在里面写 :pre 或者 :post,叫作 Pre-conditional 和 Post-conditional。前者用于检查参数的类型,在函数体之前执行;后者用于检查返回值的类型,在函数体之后执行。

:pre 的值是向量,里面通常是若干个对谓词函数的调用,总之要返回布尔值。在这里面可以直接访问参数。

:post 也是向量,不过里面写的就不是函数调用了,而是函数本身。可以是函数符号,也可以匿名函数,无论是什么函数,都必须只接收一个参数,返回布尔值。之所以不直接调用函数,是因为我们此时不持有对返回值的引用。

clojure.spec.alpha 很适合与 conditional 一起用:

(defn create-user [username]
 {:pre [(some? username) (s/valid? ::username username)]
 :post [(partial s/valid? ::user)]}
 {::username username
 ::age nil
 ::gendar nil})

写在 :pre 和 :post 里的任何一个条件判断返回假值,Clojure 都会抛出 AssertionError,然后给出具体是哪个断言没有成功。这就很方便排错了。

这和测试断言 is 不一样,测试断言是在运行测试时才会出现的,但这里的 AssertionError 会在实现代码里出现。理想情况下,哪种断言都不该出现在生产环境里,程序在编译并发布之前就应该把所有的测试失败和断言失败都修复了。

不过也难免会有漏掉的,如果不想让用户看到断言错误,可以在编译 uberjar 的时候,把 clojure.core/*assert* 设置为 false。

要怎么尽可能避免漏掉断言错误,尽可能保证类型匹配呢?

运行一遍覆盖率较高的测试集,这样就能保证大部分断言都被执行过一次,也就可以在测试阶段发现尽可能多的 AssertionError。


  1. 如果需要大量拼接,建议使用 strings.Builder,直接用 + 拼接字符串非常内存低效。 ↩︎

大脑充血 Vol.102

2026-11-02 07:34:11

夜里无星,竟然也看不到月亮,或许是城市的灯光和厚重的云层遮住了光。我驱车行使在城市外围的一条偏远公路上,路上没有灯,连像样的沥青路都没有,只有混杂着泥土和杂草的石板。车子一路颠簸,我开得很小心,车灯只能照到前方不过三四米的距离。虽说小心,但我开得并不慢。

“能赶到吗?”我在上路之前在地图上反复确认路线和预估到达时间,但也只是干着急。我还想再打开地图看看,可能开了一段距离之后,估算的时间会准确一些呢?但我抽不出手,在这条路上,我必须把双手都紧紧地抓在方向盘上。


吾仅悉落

Misguided Ghosts music cover

Misguided Ghosts

Paramore

Now I’m told that this is life

And pain is just a simple compromise

So we can get what we want out of it

Would someone care to classify

Our broken hearts and twisted minds?

之前听 Paramore 的第三张专辑《Brand New Eyes》时,怎么也找不回听《All We Know Is Falling》1的感觉,觉得太流行了,不够朋克,不够 Emo2。最近一两周我经常在路上下意识地打开这张专辑听,尤其喜欢《Turn It Off》《Looking Up》,还有这首一点也不摇滚的《Misguided Ghosts》。

不过第三张专辑以后的作品,我还是不太喜欢,同名专辑《Paramore》就是纯粹的流行。还有一些讨论抑郁情绪的歌曲,也都没有 Emo Rock 的冲击力了,显得很平淡。


心重如毛

本周的非技术话题是:网络文学和同人小说。

同人小说是文学抵抗

📜

Robert Kingett 是职业作者、盲人和男同性恋。他在这篇文章里分享了自己的一个秘密:他写同人小说。他并没有公开自己在 Archive of Our Own3 上的用户名,因为他需要一个不会跟自己的职业身份联系起来的空间,他需要能在那样的空间里完全地释放自己。

他坦白,自己作为职业作家,必须被发行商包装成不那么复杂、易于营销的扁平形象,比方说「一个生活在迷恋视觉的世界里的盲人」。

Publishers don’t know how to package a soul.

发行商不知道怎么包装灵魂。

之前听有人说 活人感是新的伪人感 ,兴许有部分是真的吧。我不关心主流社交媒体上有关活人感的讨论,所以不便多言。我能认同的是,如果一个人试图表演出自我、反叛(但并不复杂)的「活人」形象,那的确很虚伪。

如今的作者被要求成为「内容创作者」和「个人品牌」,发行商营销的个人特色,大概也是和活人感类似的虚伪之物。不过,我想他们的处境应该算比较好的了,毕竟读书的人少了很多,没有那么多人要求和质问他们。

完全抛去职业作者身份,不以赚钱为目的去写同人小说,在 Kingett 眼里是「反资本主义」:

It is anti-capitalist by its very definition because it refuses to commodify the connection between the storyteller and the listener. It says that joy does not need a transaction fee.

(同人小说)在其定义上就十分反资本主义,因为它拒绝把讲故事的人和听故事的人之间的联系商品化。它声称快乐无需服务费。

《奥德赛》作为一部口头文学,荷马是否是唯一作者并不清晰,在故事被相互传诵的过程中,很难说没有其他人参与故事的创作。《奥德赛》也诞生了难以计数的同人作品,或许《尤利西斯》就算吧,如今的不少原创作品不仅借用荷马史诗的典故、人物和背景,其故事结构也成了一种故事原型——可能不太准确,但《挽救计划》和《火星救援》或许就借用了《奥德赛》“回家”的故事原型,而 Grace 最后没有回家或许就是《挽救计划》脱离故事原型的创新点。

讲故事这件事情,大概在人类还通过燃烧篝火来抵御黑暗时就有了。讲故事和听故事本身就是十分人道和社会化的活动,而一个人、一个机构可以拥有、出售并独占故事的行为,是现代社会的产物。

而同人作品就是抵抗。

It taught me how to write not for a critic in a tweed jacket, but for a person alone in their room at 3 AM who desperately needs to feel like they aren’t the only broken thing in the universe.

(写同人)教会了我怎么不为穿粗花呢外套的批评家写作,而为那些凌晨三点一个人在房间里的人写作,他们迫切地需要感到他们并不是宇宙中唯一残破的存在。

我还没有找到那样的读者,或许我也该去写同人小说呢?如果你不介意,我也希望读者能去这个博客的 小说集 页面读读我最近写的几篇小说。我也觉得,独立互联网上需要更多的虚构作品,而不全是非虚构。目前我只在英文的 Indie web 上见过有博客写作者写虚构作品,中文的虚构作品,读者和作者都很少。

AO3 下线维护,最少两周

📢

前不久,十月二十九日,AO3 报告了一场不明安全事故并下线了服务。

Our current expectation is that AO3 will be down for a minimum of two weeks; however, it could take up to four weeks.

我在他们的状态页面和 Bluesky 账号上都没有找到更详细的描述,至于究竟是什么原因导致了这场安全事故,目前看来尚不清晰。 Brennan 不久前上线了 fanfiction.lol ,是 AO3 的分支,在此次事故之后也迎来了不少新用户。

fanfiction.lol 的服务器在加拿大,Brennan 自己的地下室里(可以说是独立到“底”了)。据称他们接收多语言投稿,如果有中文写作者感兴趣的话,可以联系 Brennan 看看。他人真的挺好的。

AO3 虽然本身非盈利,不商业化,依靠捐款维持服务,运营和作者都是为爱发电,社区整体而言很和谐,但如果网络上的同人小说都集中在 AO3 上,也是个安全隐患。独立和去中心化是两个概念,我之前在《 What’s next for the indie web? 》里也写过。独立网站不能过于依靠 IndieWeb camp 和 Aaron 提供的服务,即便他们都是友善的人。

我想,在互联网上,只有建立联系紧密的小社群,将内容分散开来,才能逼近自由。


车子里有些闷热,还没到开空调的季节,开窗会很凉快,但路边一直有风吹草动,搞得我神经紧张。我老是想起电影里公路旁窜出一只鹿的情节,这种生物会在被强光照射时愣在原地,我怕我会因此不小心把车开下山崖。我死死盯着路面,脖子都酸了,眼皮上方的肌肉也因为长时间皱眉而开始发胀。

“要是他不在呢?”路面平缓了一些,我又开始胡思乱想。我还想到这附近根本不会有鹿出没。我开始下意识地责骂自己,遇到这种情况本该去超市囤货,至少也应该把后备箱填满再开车离开。我早就习惯了自我攻击,这种思绪,已经像是和自己的小打小闹了。装成另一个人和自己讲话,自己在感受到一些憋屈的情绪,然后这个人格就立马消失不见,留下的这个人格,反而会因为熟悉的互动而觉得安心。

前方已经有些光亮了,有白色和红色的亮点在远方闪烁。再往前看,已经能看到自己行使在即将汇入主路的小道上。终于。


爱网站的话,备份它

📜

我偶尔也会像作者一样去翻过去的链接,以前认识的不少人都联系不上了,他们的网站也访问不了。我想 十年之约 这种「我们的博客,十年永不关闭!」的口号也只能是口号。我以前也加入过,如今我不想许下自己实现不了的诺言,这种诺言就比「我要跟你一辈子在一起」好那么一点点。

另外,不久前 IndieWeb 的 webmention.io 被 Aaron 拿 Claude 重写成 PHP,我写这段文字的时候,看到十年之约的网站也被重写了,甚至文字和图片都不是人写的(那我是不是也可以为了守住十年的约定,让一个 “Agent” 接管博客?)。我很不看好这种火急火燎的开发方式:

来自 这个页面 ;这么急是何苦呢?又没有领导逼你立马交付

真的喜欢一个网站,希望它能留下来,最好的选择是在本地保留一份备份。这么做并不难,已经有很成熟的技术了,装个浏览器插件就行。你不需要有 Internet Archive 的体量才能备份网站。

作者自己的工作就是备份万维网(感觉像是互联网版本的图书管理员,很酷的工作),所以也推荐了他们公司的产品。介意的话,最好先自己审视一遍再用。

如果是基于 Chromium 的浏览器,可以用 Webrecorder 插件,按下按钮之后手动点击链接,在网页内部导航,就可以备份访问过的页面。不用 Chromium 的话,也可以用这个 桌面端应用 (用 TypeScript 写的 Electron 应用,AGPL 协议授权)。要备份整个网站的话,还有 Browsertrix 。

作者没有提到的选项,我还可以推荐 SingleFile ,支持 Firefox、Chromium、Edge 和 Safari,可以把整个网页保存为单个文件。不过本质上是个加强版的浏览器自带保存功能,产出的文件是 HTML 或 ZIP,而不是 WARC 这种专门的 Web 归档格式。

我之前还用过一段时间的 EagleFiler (买断制软件,不开源,只支持 macOS),也可以快速保存网页,还能用来整理和归档其他类型的文件。

这方面的开源选择貌似确实比较少,我在考虑开发的 are.na 替代品也打算减轻社交属性,增强归档属性,或许会满足我和不少人的需求。

虽然 AO3 还会回来,但我还是很难不想到:要是有人备份了网站,起一个只读版本的镜像站就好了!

在市场生存中的作者和自由而无所求的作者

📻

这节目我带劲儿地听了好几期,才意识到参与者全都是女性,看来没有臭男人4的世界太美好,我都没有察觉到异样。我跑题了。

其中一位嘉宾谈到自己读过一位网文作者很早期的严肃文学作品,大概是二十岁之前写的,她觉得那部作品很好。后来她发现作者在网文平台上写更符合大众口味的糖水文,进了粉丝群,还聊起作者之前写过的那部严肃作品,结果竟然被老粉劝说,不要提起这部作品。背后的缘由,大概是作者不希望新粉丝发现自己写过的严肃作品,然后被吓到。

Robert Kingett 也希望切割自己的两种写作身份,也就是被包装过的人格和真实的灵魂。没办法,他们都是要赚钱的,要赚钱就要臣服于市场,哪个职业都一样。工匠精神、自我意识乃至职业操守,在市场面前都变得无足轻重了。

今年很火的《同乐者》里的卡罗尔就是职业小说作家,她懂自己的读者想要在小说里读到什么样的情节,但她并不喜欢自己大火、为自己赚了钱的作品。我印象很深刻,她有一部尚未出版的严肃作品,她以为自己已故的妻子会认同她的文学追求,可追问持有她亡妻记忆的同乐者之后,她才知道,那部倾注了心血的作品并不被妻子看好。她当时肯定非常难受。

我也写小说,不久前有过投稿杂志和争取出版的想法,但我很快放弃了。我什么底子都没有,在出版业里是没有创作自由的,要被看见就要去研究各个文学杂志的风格,写出更符合编辑口味的作品,而且大部分编辑社都要求审稿期不能另投,也不允许投稿已经发表过的作品(发在自己博客上大概也不行)。我又不指望靠写作吃饭,还被束手束脚地当然觉得不舒服。我想这也是我的资本,因为对出版市场毫无索求,所以拥有了自由。

播客里还提到各种网文流派,我确实开了眼。除了男频女频,还要更多细分的设定和套路,据说反复阅读相同的情节能让人感到安心,因为读者能够预期接下来会发生什么,有种确定感。好的同套路网文往往也会先复用套路,然后再打破套路。比方说末日囤货文的套路就是主角在世界末日之后意外身亡,穿越回了末日之前,于是开始大量囤货的爽文剧情,而创新点就可以是,政府提前发现了一批囤货的人并审问他们是不是知道什么不为人知的秘密。

嗯?末日穿越文,好像我也可以写呢。


我跟保安亭里疲倦的大爷报了他的楼栋和门牌号,很快就放行了。快九点了,小区里散步的老人和跑跑闹闹的小孩子似乎都在慢慢往自己家里走了。日常的画面让我觉得有些错愕,这些人都不知道 明天太阳不会升起 ,他们就像车轮下的蚂蚁一样照常生活。幸福呢。

我凭记忆开往他住的楼栋。他会在家吗?我想我可能有些暗暗期待他不在家,这样我在脑袋里反复构想的对话就不需要上演了,我可以在他家门口,在敲门后感受到失落,一阵我十分熟悉的失落感,然后离去。或许会有不甘,但那种情绪也不会持续太久了。

我会在他的门上留下一张纸条,一张即便隐去姓名他也能认出的纸条。他也会感到错愕,会在读完纸条后四处张望,会在楼道里疑惑地四处走动。或许他会被吓到,或许他会以为我还在附近(要是我真的敢留下就好了),或许他会想要找到我。可能拿到纸条的是别人,可能他会把这张纸条给和他一起生活的人看?他会吗?我不会给那个人看的吧?那个人是男人吗?不要,我会疯的。拜托,千万不要是个男人。不要…… 可是我已经没办法不去想那个画面了。


鸡头蛇尾

黑客的浏览器

🌐

用 Common Lisp 编写的浏览器,原生支持与 Emacs 和 vi 类似的按键绑定(不需要装什么 Vimium 了),比较有特色的是他们的浏览器历史并不是线性的「前进」和「后退」,而是更灵活的树状结构,很有意思。不过 Nyxt 目前仍在开发当中,只有 Linux 的版本,FreeBSD 和 macOS 都还用不上。

我比较期待的是,Nyxt 可以用 Lisp 拓展,要写浏览器插件终于不用写 JavaScript 或者 TypeScript 了吗?用 Lisp 写的话,就不必拘泥于浏览器开放的零星几个 API,大概也不必担心它哪天像 Chrome 那样停用某个旧标准,让旧插件没法用了(比如 uBlock Origin)。

我感觉我可以写一个「用 Lisp 写的应用」列表了,我先开个头吧:

  • Roam Research :可能是个人知识管理先驱(Clojure)
  • Logseq :大纲式双链笔记软件(Clojure + ClojureScript)
  • Penpot :开源版 Figma(Clojure + ClojureScript)
  • metabase :数据分析工具(Clojure)
  • Haunt :从标记语言到构建过程都完全可自定义的 SSG(Scheme)
  • GIMP :图像编辑软件,用 Scheme 做脚本语言。
  • Reddit: 最初 用 Common Lisp 开发。
  • Yahoo Store:原名 Viaweb,由 Paul Graham 用 Lisp 开发,据称是世界上第一个 SaaS。
  • Hacker News:现在运行在 Common Lisp 之上。
  • CircleCI :用 Clojure 开发的 CI/CD 服务。

因为说的是「应用」软件,所以避开了数量庞大的软件库和开发工具。

PaperWM with Hammerspoon

🥄

PaperWM 是 macOS 上的平铺式滚动窗口管理器,意思是说所有被 PaperWM 管理的软件都会被强制铺满一块区域(取决于设置),可以用快捷键在区域之间快速切换。

一般的平铺式窗口管理器(比如 Hyprland 、 AeroSpace 和 i3 )都会直接让窗口铺满整个屏幕,再增加新窗口的时候就会将屏幕均分,或者按照设置好的布局方式自动排列,还可以分工作区。这样的好处是窗口永远不会相互遮挡,不需要手动调整,而且快捷键用顺了就可以飞快地在窗口之间切换。

除了平铺式,还有平铺滚动式,也就是说新窗口打开的时候不会压缩屏幕空间,不会打乱原本的布局,而是使用一样的布局,把新窗口或者旧窗口顶到另一边去,随后再横向或纵向滚动屏幕就能找到被顶开的窗口。Wayland 上的 Niri 就是这样的窗口管理器,PaperWM 也是(不过体感上和 Niri 差了一大截,毕竟运行在 macOS 上,多少有些限制)。

我本来一直在用 rift ,挺好用也很轻量的 AeroSpace 替代,而且他的菜单栏状态显示也更加直观,还支持动画。不过出在初期阶段,还不太稳定,最近几次更新出现了好几次失灵的问题,前几天更是把我的桌面卡宕机了。我还没办法稳定复现问题,每次都是在某些窗口里使用 rift 快捷键出现的宕机,只能强制重启 MacBook 解决。可能是完全没有对 macOS Sequoia 做适配。

另外,我最近去他们仓库看的时候,看到了这个:

不过 PaperWM 也有这个…… PaperWM 是运行在 Hammerspoon 上的脚本,后者是 macOS 的自动化工具,可以写 Lua 脚本控制 macOS 的各种系统设置,也包括窗口。Hammerspoon 也有非人类贡献者。

我已经不太想做任何评价了,我就一边喝咖啡一边看着 Anthropic 毁书、封禁中国用户、帮雇主 监控 员工的聊天记录、在上市报告里写自己亏损 800 亿美元。哦对,还有 Sam Altman 说 DRAM 装在个人电脑里竟然不拿去训练 LLM,是 极大的浪费 ,还说,为了人工智能的利益,人们应该 接受坏事发生 。世界有这样的精英引领着,真是一天比一天更美好了啊!

Keep up the good work!


车子停在路边已经有好一阵了,妄想和高温逼得我感到自己的血管都要爆裂开。我撑不住,打开了窗户,冷气带着微微的桂花香飘进来,让我稍微觉得好了一点。他在的城市要冷一点,我没带外套,只能用手掌摩擦双臂取暖。

我望着窗外摇曳的树叶出神,缝隙之间能看到亮着白光的窗户。似乎下过雨,空气中有潮湿泥土的味道,地面反射着窗户投下来的光,一闪一闪的。一个白色的身影突然走进我的视野,裙子随着步伐抖动着,这个人走得很慢。过了一会儿我才看清她身旁有个男人,带着黑框眼镜,穿着深蓝色的衬衫。他冷吗?男人没有看前面的路,侧着头听比他矮半截的女人讲话,不多言。他微笑着,不像是自己和陌生人讲话时常常露出的苦笑。女人转头去看他,露出别在头上的白色发卡。从刚刚走过来开始,她的声音一直很平静,步子迈得缓,手上也没有什么动作。她看起来很温柔,温柔到让这个男人不必逞强。

我该走了,但我或许不该启动车子的,但已经太晚了,他看过来了。他喊了我的名字。快,我之前是怎么笑的来着?


本地优先的地图

🗺️

CoMaps 是专注隐私和离线优先的开源地图应用,支持 Android 和 iOS,用 Apache License 授权。他们的地图数据来自 OpenStreetMap ,也是由社区贡献的项目。

简单来说,用户要先下载某个区域(一般是省份)的地图,然后才能离线使用。导航、路线规划等常见功能都是有的。离线的好处是,就算断网了也能用。CoMaps 在户外徒步或骑行的体验可能比在城市里使用要更好,我自己的体验是,从 OpenStreetMap 下载的地图里缺少很多地点的数据,城市里的写字楼、店铺等等更新得不及时。

CoMaps 托管在 Codeberg 上,主要贡献者是 Bastian Greshake Tzovaras ,他也是 Codeberg e.V. 理事会(presidium)的成员,很亲切。

真是 FOSS 吗?

⚖️

这个网站类似于 ToS;DR ,不过不是帮你评估网络服务的使用协议,而是帮你评估自称是开源项目的授权协议和其他的利益纠葛,也就是说,某个软件是不是真的是纯正的 FOSS5。

比方说,LibreOffice 的评级是 Yes, but… ——整体上用 Mozilla Public License 2.0 授权,是自由软件,但代码库里包含不少 LGPLv3 协议的源码,甚至还有 GPLv3,有潜在的协议不兼容的问题。

说起来,不久前我就 解决 过 weepinbell 的协议问题,我使用的 AGPLv3 协议与 Clojure 的 EPL 协议不兼容。我仿造了 Logseq 的做法,给 AGPLv3 协议附加了一些条款以兼容 EPL。说起来,GPL 是出了名地难解读,经常造成各种授权问题,据说 Apple 在切换到 openrsync 之前一直使用老版本的 rsync,就和授权协议有关。我也在重新思考我对开源协议的要求,或许我会更偏好 BSD 协议?不过我大概率会根据项目的用途选择合适的授权协议,不会只用一个。

另外,Logseq 经他们评估,是 纯 FOSS 。业界常用的 MinIO 和 MongoDB 都不是 FOSS,甚至 SQLite 都还有些非 FOSS 拓展。

不过,这个网站的信息也要仔细甄别,并不构成专业的法律建议。如果真要把事情搞清楚,个人需要自己去调查,企业和组织最好找个有经验的法务(如果真的在乎合规性和道德伦理的话,据我观察,至少国内的大部分科技公司是完全不在乎授权协议的,觉得是完全没用、可以直接忽略的东西,反正忽略也没有后果)。


“你怎么到这儿来啦?”我看不懂他脸上的表情,我的脑子已经转不动了。“你是来…… 找我的吗?”穿白裙子的女孩露出了我很熟悉的苦笑,我看向她,觉得她笑得有点奇怪。“哦,这是我朋友。她这几天来这边休假,我陪她转转。”“哦,好。嗯,那个,我是…… 我也是过来玩儿的。这边有个新书活动。我路过这儿,觉得熟悉,就进来转转。”他还是向以前那样一句一句听我把话讲完,我不敢抬头,我知道他一定在盯着我看。“是谁的新书?”背后的白衣女孩突然走近了问我。

“啊,姓林,叫什么我也忘了,我只是想来看看,不是忠实读者。”和陌生人讲话的时候,我总是盯着他们的额头,但她离得太近,怕被看出来,于是就只能改看鼻梁了。“我一般只读已经离世的作家写的书。”这句话我是盯着男人说的。他的鼻子矮矮的,但是眼睛很亮。“你想聊聊吗?”“和我吗?”“嗯。”“在这?”“可以到我车里来。”

他回头看了看女孩,随后说好,问要不要先送女孩回去,谁知道她说:“我留下一起吧,我也想听听。”我俩都迟疑了,他有些尴尬地转过来看我,女孩则从一开始就直勾勾地盯着我。我被人看着的时候没办法冷静思考,笑着说好啊,然后在心里羞辱自己。随后,那个得寸进尺的女人让他去买水和零食,可以多叙叙旧。女孩静静站在车外等他离开,随后转过身,快步从我的车前面走过,面无表情地打开车门,坐上了副驾驶。


漫步遐思

我跟公司的资深开发在技术上也没有太多能交流的,他谈得比较多的一般是不同公司的业务和面试难度(说实话,其实很有帮助,只不过当作谈资的话,我的确很难提得起兴趣),而我在讲一些技术问题和工程实践时,我发现我和他的接触面差别非常微妙。

之前他让我调查 GitHub 下载 .zip 文件的限制,是不是必须要登录验证传 Token 才能下,我的第一反应是,很多包在 aptitude 包管理器里没有,有不少 CI/CD 工作流都是直接从 GitHub 上拉 .zip 下来解压拿二进制文件来用的,也不用 Token。他对此的第一反应是:CI/CD 里不一般都是公司内部项目吗?当然,调查的结果是,的确没有限制,甚至并发拉到 500 都没有出现 429 或者 403 报错。

有这个偏差的原因大概是,他从没在公司内部以外的地方见过 CI/CD,而我写过和看过的 CI/CD 有不少都是挺小众的项目。

此外,他对最近 eMudhra 根证书密钥泄露事件和 PHP2Shell 漏洞都没有了解,我甚至解释了两遍才让他明白我说的「根证书」大概指什么。公司里还有不少资历比我深的前辈,对一些在我看来很常见(指我在不少从业者的博客见过,在书里读到过,我自己也实践过)的技术实践感到惊奇。比方说他们觉得把 SHA256 哈希截断的做法「太怪了」,但实际上只要算过业务的数据规模和阶段后哈希字符串的碰撞概率,保证碰撞概率足够低就行,而他们认为要拿到更短的哈希,应该用(更不安全的)SHA1 规范,而不是把 SHA2 截断,因为很「怪」。没听说过 Lisp 倒是很正常,但不知道 Node.js 是后端技术,就有点匪夷所思了。

这些其实都还好,毕竟大家接收信息的来源不一样。我读的很多是国外软件开发者的博客,还有一些技术书籍,从他们在群里转发的内容来看,他们读得最多的应该是微信公众号。我没有对此做任何评价,请悉知。

我唯一要做评价的是,他们对 LLM 这项技术的判断:他们认为“现在有了 AI”,就没有技术壁垒了,大家的技术差异都被磨平了。可是,LLM 的训练数据全都是他们从开放的互联网上 偷窃 过来的,在 LLM 之前,那些内容本来就是开放给所有人的,何来技术平权?如果真要用政治化的词语来做比喻,这场闹剧更像是革命,一场以知识产权被践踏、资源分配不均进一步加剧、权力和资本进一步往大科技公司集中、消费市场被搅乱、环境被进一步破坏为代价的革命。

我理解大部分人只关注未来发展、产品、商业化和野心,并不关注技术本身,所以涉猎没那么广,也很正常。这或许也是合理的精力分配,成功人士都该这样。

我大概永远不会加入成功人士的行列。我只会说丧气话。我真可悲呢。


“你是来见他最后一面的。”她一针见血地戳破了我,我不知道她怎么做到的。她还是直勾勾地盯着我,神情严肃,扫视了一遍我的车,又问我:“你是什么都没给自己准备,就过来了?”“我不确定还有没有时间,我想可以之后再……”“已经晚了。”“什么?你怎么知道?”

她指向车窗外面的一片天空,那里散发着不详的紫光。“大概还有不到半个小时,短信、广播和个各网络平台上,就会有政府账号叫我们进屋里待着,所有人都不能出门。”我盯着那片天空,隐约看到了扭动着覆盖了天空的触角。“你冒冷汗了。”“你到底是谁。”“我是他朋友,他说过的。”我伸出头往车窗外面张望,想看他还有多久回来。“别看了,他给我发消息说店铺关门了,我让他先回去,我们一会儿上去。”“一会儿?”“哦,但实际上上去的只有我。”

“你要我走?”“我怎么能让和我有一样目的的人留下呢?”“你要做什么?”“你可以识相地立马开车离开,说不定还有时间囤点东西免得饿死。你就算留下也对他做不了什么的,你连时间都没算准。”“我…… 我一有机会就立马开车过来了。”“你是今晚才回来的吗?”我紧绷着神经,左手已经放在了门把手上,但我想我的动作早就被发现了。“我想有些人就是比较不幸吧。”

接着,我看到天边的紫光愈来愈亮,闪电状的纹路也开始蔓延开来。我的脖子感到一阵凉意,泪珠从刀刃上滑落,我闭上了眼睛。


  1. 这张专辑是 Paramore 的第一张专辑,于我出生的那年发行。 ↩︎

  2. Emo 是一种摇滚流派,不要试图把我和网易云伤感文艺青年联系起来。 ↩︎

  3. 应该是全球最大的同人小说档案网站,人称 AO3 ↩︎

  4. 臭男人指代男人中很臭的那一类,不指代全体男人。我力求不做臭男人。我天天洗澡呢。 ↩︎

  5. Free Open-Source Software ↩︎

如何优雅地优雅退出?

2026-10-31 01:36:54

写程序的时候会自然地关注如何启动系统中的各个组件,但很少关心如何关停。这和内存分配一样,使用高层编程语言(甚至是自然语言)编程的程序员们早已习惯垃圾回收机制,从不手动分配和释放内存。

不需要极佳的性能,确实没必要用 C 或者 Zig 等低层语言直接操作内存,但在高层语言里,仍有不少资源需要手动管理,系统中有些部分是成熟的框架也不一定会考虑到的。比方说,一些异步程序可能在共享内存区域或消息队列里存了尚未保存的数据,如果直接退出,这里面的数据就丢失了。

让程序监听并响应中断信号,在收到信号之后处理尚未释放的资源和尚未完成的请求,然后主动退出,这种做法一般叫优雅退出(graceful shutdown)。Java 的 Spring 框架就会在接收到信号的时候自动优雅退出,首先它会停止接收新的请求,然后处理完剩下的请求,最后停止进程。

我本来想看看 Spring 是怎么实现优雅退出的,结果只翻到了一大堆接口和抽象类(我第一次知道接口还能继承接口,而且可以多继承,Java 稳定发挥),我懒得去找实现类了,所以就直接开写吧。

中断是外部事件

程序运行时,main 的入口位置会被写入 程序计数器 ,是第一段被执行的代码。它负责启动系统,需要知道应该调用那些组件的哪些函数,要把哪个组件注入哪个组件,配置要从哪里加载等等。main 在我看来很像导演的角色,它了解所有肮脏的细节,什么都要管。

既然启动是它负责,那么停止也必然要经过它。很多 main 函数里只有启动逻辑,如果执行时按下 Ctrl + C 或者用 rc、systemd、Docker 和 Podman 等服务管理工具向程序发送停止信号,程序就会很生硬地终止。或许框架和语言会做某些处理,但写在内存里的数据是不会保存的,启动的子进程也会很生硬地结束。

假设你的程序启动了一个 Web 服务器,接收 HTTP 请求并发送响应,其中有些请求是异步的,要放进消息队列里处理,所以还要起一个处理器线程,而处理器是并发的,为了保证并发安全,避免竞争条件,还得起一个专门管理数据读取和写入的线程。中断这三个异步子线程的主线程,可能会有意想不到的事情发生:

  • 中断 Web 服务器,可能会导致一部分还没有完成的 HTTP 请求中断,用户要么一直收不到响应,最后超时了,要么会得到 EOF 或「服务器意外关闭了连接」之类的问题。
  • 中断处理器线程,会导致消息队列里还没处理完的任务得不到处理,程序退出之后,写在内存里的数据就全部丢失了。就算把消息写进数据库,也有可能出现某些消息还没来得及写进数据库的情况。
  • 中断数据管理线程,除了没来得及持久化的数据会丢失之外,还有可能出现 I/O 操作被打断,只写入了一半的情况。不过后者其实比较少见,只要不是直接读写文件,有用事务(transaction),完整性还是能够保证的。

不做优雅退出的后果可能没有那么严重,不过「中断」依旧是程序应当考虑的外部事件。设计系统的时候应该评估有哪些数据可能会在停止的时候丢失,哪些资源可能不会被自动回收。

中断也是内部事件

假设有个数据处理管线很长,中间有些逻辑可能卡住,难以检测也难以恢复。为了减少资源占用,就应该在一段时间后取消这个管线,也就是主动中断。

这也叫「取消」,Go 语言是用 context 处理取消逻辑的,下面是来自 官方文档 的代码:

func QueryWithTimeout(ctx context.Context) {
 // Create a Context with a timeout.
 queryCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
 defer cancel()

 // Pass the timeout Context with a query.
 rows, err := db.QueryContext(queryCtx, "SELECT * FROM album")
 if err != nil {
 log.Fatal(err)
 }
 defer rows.Close()

 // Handle returned rows.
}

context.WithTimeout 会创建带超时时间的上下文(context),同时还会返回一个函数 cancel(),可以调用这个函数手动取消这个上下文。defer cancel() 会在当前作用域的最后被调用,也就是函数执行完毕就会自动取消上下文。

上下文是有继承机制的,可以发现 WithTimeout 还接收了 ctx 作为参数,这个上下文是作为参数传进来的。其中 queryCtx 就是 ctx 的子上下文,无论是 queryCtx 本身的超时时间到了、函数执行完毕之后取消了,还是父上下文 ctx 被取消了,queryCtx 都会取消。

可以看到 db.QueryContext 接收了 queryCtx 作为参数,这个函数内部也有处理上下文的逻辑。三层上下文中的任何一个以任何原因被取消,最底下的查询都会终止。

具体是怎么终止的?这个逻辑是由调用栈最顶端,也就是最后调用的那个函数实现的。上下文都实现了 Done() 方法,会返回一个 channel,但上下文被取消,这个 channel 就会送一个空结构体 struct{} 过来,作为中断信号。

下面是 gobyexample.com 的例子

select {
 case <-time.After(10 * time.Second):
 fmt.Fprintf(w, "hello\n")
 case <-ctx.Done():
 err := ctx.Err()
 fmt.Println("server:", err)
 internalError := http.StatusInternalServerError
 http.Error(w, err.Error(), internalError)
}

select 是 Go 语言用来做并发控制的结构,简单来说,哪个 case 里的 channel 先来消息,就先处理哪个。如果十秒钟过后,time.After... 发送了消息过来,就会打印 "hello\n";如果这十秒内,上下文取消了,ctx.Done() 先来消息,那么就会执行错误处理逻辑。

要实现优雅退出,逻辑也和上下文取消类似:先给个各子系统或组件发送中断信号,等待他们内部处理完成,然后再退出程序。

用闭包隐藏状态

优雅退出似乎要管理很多状态:

  1. 对组件本身的引用;
  2. 给组件发送中断信号的管道;
  3. 接收组件完成信号的管道。

main 组件要做的事情是,在接收到外部的中断信号时,给组件发送内部的中断信号,然后等待他们处理完剩下的活儿,发送完成信号,告诉 main 可以退出了。

听起来就很麻烦,有没有办法把这些都隐藏在组件内部,而 main 就不需要关心怎么发信号、怎么收信号了,只需要等着退出就好?

有的,我们可以用闭包函数。

闭包函数就是能访问函数周围作用域的函数,比方说我在一个作用域里定义了变量 x,而同一个作用域里的 f 能够访问 x(但 x 并不是在 f 内部定义的),那么 f 就可以当作包含 x 的闭包函数。

既然要谈闭包(closure),不用 Clojure 就说不过去了。(什么逻辑?)

还记得 Go 语言里创建 context 的时候会返回 cancel() 函数吗?我们完全可以在启动组件时,返回一个用于停止系统的闭包函数,这个闭包函数包含了组件内部的各种细节,但我们要做的就是调用函数,然后等函数执行完成。

假设有这样一个函数:

(defn start-system []
 (let [resource (get-resource)]
 (make-use-of resource)
 ;; 返回回收资源的函数
 (fn []
 (collect resource)))) ;; 这个函数访问了定义在它之外的 resource

在 main 函数里启动之后,注册 SignalHandler1,并在接收到 INT(应该还有 TERM)信号时执行启动时返回的函数。假设函数是同步的,执行完毕就代表资源回收完毕,此时就可以安全退出了。

(import '(sun.misc Signal SignalHandler))

(defn -main [& args]
 (let [stop-system (start-system)]
 (Signal/handle (Signal. "INT")
 (reify SignalHandler
 (handle [_ sig]
 (stop-system))))))

回到一开始的例子,你的系统里有:Web 服务器、处理器线程和数据管理线程。这三个系统都返回三个函数,一个个调用似乎也有点麻烦?

那就全部放进一个函数里!

(defn start-system []
 (let [stop-io-worker (io-worker/start)
 stop-processor (processor/start)
 stop-webserver (webserver/start)])
 (fn []
 (stop-webserver)
 (stop-processor)
 (stop-io-worker))

注意看,启动顺序和停止顺序是相反的。因为处理器依赖 I/O 管理线程,要先启动;Web 服务器如果先开始接收请求,请求就不能直接传递给处理器。我们不可避免地遇到了时序耦合(temporal coupling)问题,也就是函数调用的顺序会影响实际行为。这是反函数式编程范式的,因为纯函数不应该有副作用、不应该依赖可变状态,自然也就不会因为调用顺序而影响行为。

但不是所有东西都能写成纯函数的,尤其是在系统边界,需要与外部信号、数据库和 I/O 打交道的部分。在我看来,只要保证系统内部的大部分代码都是函数式的,状态不可变、副作用可控,就没问题。

停机问题?

前面的例子可能有点太简单了,并不是所有情况下,函数都有办法返回闭包函数。尤其是内部为循环结构的函数,它根本没有办法停下来返回一个函数。这和经典的 停机问题 很像。

int H(procedure, Input);
int U(P)
{
 while(H(P, P)){}
 return 0;
}

U(U) 的结果是什么?H(U,U) 如果输出停机 1,就会陷入死循环;如果输出不停机 0,那么循环就退出了,与不停机的输出矛盾。

要怎么知道正在运行的循环何时停下呢?要怎么通知它停下?

假设有一个异步运行在线程里的循环,它负责操作数据库,它做的事情就是一直从一个 channel 里接收数据库操作,然后执行这些操作。以下代码修改自 weepinbell :

 (go-loop [operation (<! writer-chan)]
 (let [{:keys [op args]} operation]
 (when (not= op :stop)
 (apply (op storage) args)
 (recur (<! writer-chan)))))

operation 是个映射,包含 op(操作)和 args(参数)两个字段。正常情况下会调用 op 在 storage 里对应的函数,传入 args 参数,执行写入或删除的操作。如果 op 是 :stop 关键字,这个循环就不会 recur,也就退出了。

所以,即便没有可以直接调用的函数,还是可以通过往 channel 里放结束信号的方式来停止循环。而且这样做有个好处:channel 里的消息本身就是按顺序排列的,当 :stop 信号被放入 channel 时,循环会像无事发生一样继续处理剩下的请求,直到拿到 :stop 信号才退出,这样队列里没处理的数据就不会丢失了。

但是还有一个问题没解决,我当然可以用 (put! writer-chan :stop) 发送停止信号,但我怎么知道队列里的消息已经被处理完了呢?要是我在 :stop 还没有被处理的时候就终止程序了呢?

那就再用一个 channel 用来传递「线程已经停止」的信号吧。

(defn start-worker
 [storage writer-chan]
 (let [queue-closed? (chan)]
 (go-loop [operation (<! writer-chan)]
 (let [{:keys [op args]} operation]
 (if (not= op :stop)
 (do 
 (apply (op storage) args)
 (recur (<! writer-chan)))
 (do
 (close! writer-chan)
 (close! queue-closed?)))))
 ;; return a function that stops the loop
 (fn []
 (put! writer-chan {:op :stop})
 (println "waiting for storage worker to shut down")
 (<!! queue-closed?))))

在处理 :stop 信号的分支里,我们调用 close! 关闭了 queue-closed? 这个 channel。在 Clojure 里,如果 channel 被关闭,尝试获取它里面的值就会立刻返回 nil;如果 channel 没有被关闭,尝试获取它的值会阻塞当前线程。

在上述例子中,最终返回的闭包函数就在最后用 <!! 获取 queue-closed? 的值,但从始至终,都没有任何人往里面写东西,所以会一直阻塞,直到 :stop 信号被处理、queue-closed? 被关闭。

调用 start-worker 这个函数返回的停止函数,就会在消息队列被彻底清空之后才停止阻塞,停止阻止程序退出。

如果你感兴趣的话,我这里还有一个同时等待多个循环线程退出的 例子 :

(defn start-processor
 "Starts webmention processing pipelines. Returns a stopper function."
 [metadata]
 {:pre [(s/valid? ::processor-meta metadata)]
 :post [fn?]}
 ;; wmc is webmention queue; stopped is channel where processors 
 ;; put signals into, to notify that they're shut down
 (let [wmc (:wmc metadata), stopped (chan)
 concurrency (get-in metadata [:config :pipeline-concurrency])]
 ;; dispatch pipelines
 (doseq [n (range concurrency)
 :let [pipeline (assemble-pipeline metadata)]]
 (dispatch-pipeline-loop n wmc stopped pipeline))
 ;; return a function that stops the loop 
 (fn []
 (dotimes [_ concurrency] (put! wmc {:stop? true}))
 ;; after putting the stop signal, processor might not
 ;; stop immediately. wait for it to process what's left 
 ;; inside webmention queue and close it.
 (timbre/info "waiting for queue to clear, and processor to shut down")
 (dotimes [_ concurrency] (<!! stopped)))))

最后

尽管优雅退出机制研究起来挺有趣,但不可否认,上面这些代码简直是我写过最丑的 Clojure 代码了。处理系统外围的各种事件并不如写抽象的业务逻辑那么优雅,无时无刻都在干脏活,但这活儿总得有人干的。

回见!


  1. 代码看起来有点臃肿,因为 Clojure 寄宿在 Java 上,处理系统信号必须用 Java 类,自然啰唆。 ↩︎

像维护软件一样维护效率系统

2026-10-28 23:49:02

标题可能会让人误以为我要介绍精密复杂的 PKM 体系,但实际上我 我不混“知识管理圈” 很久了。我想我数次安装和卸载 Emacs,仍然用 Neovim 写代码和写文,一部分原因是 Org Mode 对我毫无吸引力,甚至有可能会害了我1。

好的软件不总是精巧复杂的,甚至大部分时候是简单的,或者说清晰明了,而且可拓展、可组合,在需要的时候不会因其形式或接口而派不上用场。比方说 grep 作为命令行工具,可以搜文件里的文本、可以搜目录里的文件名、可以搜终端历史里输入过的命令,也可以很轻易地集成到其他软件里搜别的东西。

这其实也不是我想谈的重点。实际上,即便是 grep 这样小巧简明的软件,在某些情况下也难以施展拳脚,比如面对完全不了解命令行界面的用户时。对他们而言,Raycast 和 Vicinae 等全局搜索框可能更好用,按下 ⌘ + 空格 就可以搜索应用、文件和各种快捷命令并执行。把两个关联不大的软件放在一起比较可能不恰当,但至少能证明我下面要抛出的观点:

好的软件应该是符合需求的软件。

想象你年迈的奶奶一点也看不清楚手机上的小字,而你却在一旁抱怨软件开发者没能统一设计语言,竟然在界面上放这么大的字儿和这么老派的配色,还给了这么多乱七八糟的设置项,一点也不优雅——这可不是好事。

用户画像和需求获取

我有点不太理解为什么有人喜欢浏览各种花哨的 Notion 模板,就像我用不了现成的 Neovim 和 Emacs 配置一样,噪音太多了。有人试图说服我一点一点来,有些功能放在那里不用就好。然而「不管某些小细节」对我来说很难,我需要在大脑里自己起一个垃圾收集器,持续不断地消耗 CPU,就为了释放内存。貌似许多人的 GC2 是编程语言自带的,甚至可能 像 Java 一样 ,大部分时候都不运行。

最近我意识到这可能是我的神经特异性导致的。我今年才开始正视自己的孤独症(Autism)3特质。神经多样性好像已经变成玩笑话了,大家说「自己没点病都不好意思上网」,我厌恶甚至指责这种说辞。要指责的一方面是把神经特异当作「神经反常」「精神病」的陈旧思想,另一方面是,这种说辞看似在攻击无病呻吟者,实际上加剧了神经特异者的边缘化,因为这话背后的意思是:大部分说自己是 ADHD 或 ASD 的人都在装酷。那真正的 ADHD 和 ASD 还怎么敢站在公共面前呢?而且他们本来就更不善社交。

我跑题了。我想说的是,我的确有很多孤独症人群常见的特质,而且这些特质影响了我的日常生活,也影响了我对自我管理体系的需求。

首先,我对感官信号过于敏感,我希望系统本身的感官刺激可控且令我安心。这几天看了一期讲 孤独症人群感官体验 的视频,频道主说自己睡觉的时候会觉得自己的心跳声像摇滚演唱会一样吵,我也是!我时常在半夜起身反复确认有没有地震,因为我的心跳好像震得我全身都在动一样。我和频道主一样,也会忧虑是不是自己的心脏有问题,然后为此辗转反侧。实际上就是我对某些信号太敏感了。

不只是自己的心跳,环境噪音也是。我不喜欢去酒吧这种吵的地方(清吧会好很多),因为要主动屏蔽杂乱的环境信息很累。就算是相对安静的环境,我也时常听到其他人没注意到的声响和小细节——这是一体两面的,对细节的关注在某些情况下是优势,但也是需要正视的劣势。

如果我的效率工具有太多不可控的感官刺激,我用起来会觉得很累。飞书的多维表格就是反例,它大体上是即时通信工具,我很难不被侧边栏的消息和表格各种花里胡哨的浮窗、工具栏分心。即便我并没有点击那些东西的欲望,我也会觉得要感官过载了。

我的情况可能还要再极端一点,我去年 全面抛弃了数字记录 ,因为电子设备本身就是极大的感官干扰。即便全屏打开笔记编辑器,只运行一个应用,我的脑海中也会冒出数种可能性——这就不是感官刺激的范畴了,和注意力机制的关联更大。

之所以 Doom Emacs 里提前配置好的各种插件让我坐立难安,而 Clojure 标准库里丰富的函数并不会对我造成困扰,是因为函数在形式上是单一且确定的,只要当作接收参数然后返回特定值的黑箱就好,而 Emacs Mode 千千万,有的能收发邮件,有的能看 RSS 和 Hacker News,有的还能在 Matrix 上发消息,我永远也不知道下一个会以什么形式出现。4

我希望系统的形式足够简单,简单到用几种甚至一种要素就能表示我想要表示的所有东西。就像 Lisp 只需要用 s-expr 就能表示所有的语言单元一样5,我希望我在管理待办事项和备忘的时候,不需要频繁切换思维方式。

下一个需求有点矫情:我不喜欢有人教我做事。我至今没办法在别人看着我的情况下工作(不久前我的同事跟我交流工作的时候,盯着我写 Agent 的提示词,我感觉自己要爆炸了,原因很复杂)。我能接收批评指正,我不会把情绪带到工作上来,我也很少让情绪影响周围的人,但这意味着我要在大脑里另起一个线程,处理自己的胡思乱想。我当然不希望我自己的系统过于严格,让我没有喘息的机会,我希望我使用它的时候,心情是愉悦的,能立马感受到事情在我的掌控之内。

系统又不是主体,它怎么会教你做事?怎么会审判你?可很多时候就是这样。我错过了自己设置的截止日期,或者在安排要做某件事情的时间里提不起劲儿,我会责备自己太拖延、太懒惰。某个待办上写的事情我只做了一半,但当天已经结束了,我只能推迟到下一天——我知道给框框画勾很爽,但划掉框框再移动到下一页,糟糕的感觉也程度相当。

我还常常有这样的体验:做了某些事情之后(往往是逼自己做的),感觉身体很沉重,就算是做自己喜欢的事情也觉得累,根本不想动。这件事情有可能仅仅是跟指导老师发信息等回复而已。之前提到的那个视频里,频道主把这种现象比作「清醒的宿醉」。在这种情况下,若还有个严格的系统管制我,让我在自己的空间里也不得安宁,我只会想逃。效率系统的效果不应该是让人想逃避做事。

简单来说,我希望,就算我完全精神崩溃,生活糟糕得不能再糟,沉闷地过了几周甚至一个月之后,只要我决定再爬起来,我就可以立马打开我的笔记本,像什么事情都没发生一样做自己该做的事情。

所以,这样一套效率系统的业务需求是:帮助易受感官刺激影响、注意力容易分散、精力波动大的用户,建立简单直接且容易管理的生活和工作计划,并且不会在执行过程中感到压力山大。

需求分析和系统建模

业务需求已经确定,那么现在应该分析用户需求了。上个阶段的产物是对业务需求的整体描述,这个阶段的产物是具体的用例(Use cases)。

用例既描述了具体的用户需求,还界定了系统与外部实体的边界。谁在系统之内?谁在系统之外?谁与系统交互?做什么交互?系统最终要实现哪些交互背后的功能?这些问题都会通过规定用例得到解答。看起来做需求分析就该写用例呢。

但是我不。

软件需求工程的各种理论在我看来既有新学科的不成熟,又十分老派。比方说下面这个需求层次图,把软件需求分成业务需求、用户需求和功能需求(以及非功能需求和约束条件)的做法,其实相当局限。

这张图片来自 这篇论文 ,是有关为中小型企业开发的电商网站的需求分析方法的案例研究。很多需求工程方法论都是针对大型软件的,因为这些软件的需求很复杂、变动大,需要工程化的方法管理和维护。

这就是老派的点。至于为什么说不成熟,是因为这类方法论不仅实际应用不广,学究气息很重,而且很快就被新的软件开发模式替代了。比方说敏捷开发就可能认为,在前期制定过大的需求规格很有可能造成浪费,默认需求是持续变动的。还有不少软件团队不交付用例,而是交付用户故事(User story)。还有一种系统建模方式就叫作 故事驱动建模 ,系统建模也是需求工程的一个环节。

话说回那些简单的软件工具。你要如何想象 grep 的需求规格说明书呢?如果现在有人要开发一个新的 Vim 编辑器,他们会怎么收集和整理需求呢?在用例里写这种东西吗?——“1. 用户输入文本;2. 用户保存文件;3. 用户查看文件。”

总之,应该根据实际需要开发的软件的类型和特质选定需求分析方法,我决定描述类似用户故事的使用场景来分析用户需求:

  1. 用户需要规划一天的计划。
    1. 将前一天尚未完成或者主动推迟的事项移动到这一天;
    2. 从一个大的待办清单里挑选一些这天可以做的,放到这一天;
    3. 添加一些临时发生或一时兴起的事项;
    • 如何减轻第一步的心理压力?
  2. 用户在一天的某个时刻,需要确定接下来做什么。
    1. 打开当天的计划并浏览未完成事项;
    2. 逐项评估用户当下做这件事情的意愿;
    3. 选择意愿最高的事项执行;
    • 如何评估意愿?
    • 所有事项的执行意愿都低下的边界情况怎么处理?
  3. 用户突然收到通知、回忆起某件事情、产生了灵机一动的想法,或者发现了新鲜事物,需要处理。
    1. 评估事项的紧迫性和执行意愿:
      • 如果必须或十分想要立刻完成,先做记录,然后执行;
      • 如果不必立刻执行,先记录到当天的计划中但不执行;
    2. 继续之前的事项;
  4. 用户在一天结束时需要回顾计划完成情况。
    1. 观察所有被记录但尚未执行完毕的计划;
    2. 评估短期内的执行意愿和执行的必要性:
      • 如果必须做,且执行意愿较高,标记为需要移动到下一天(等到情景 1 发生时处理);
      • 如果必须做,但执行意愿不高,标记为搁置,移动到未定时间的大待办清单里;
      • 如果不必做,但执行意愿较高,标记为搁置,移动到未定时间的大待办清单里;
      • 如果不必做,且执行意愿不高,直接删除。
    • 同场景 1,如何减轻这个过程的心理压力?
  5. 用户需要定期回顾不定日期的大待办清单,对于每个事项:
    1. 评估执行意愿和必要性,删除执行意愿不高且不必做的事项,留下其他事项;
    2. 评估事项的紧急性,将紧急事项插入到当天或者第二天的计划中;
    3. 对于余下的事项,如果有新的想法和细节补充,可以做备注。
    • 长期滞留在大待办清单里的事项要怎么办?

不难发现,在上述描述中,用户与系统的边界很模糊,有很多事情是需要用户参与决策的。用户也是系统的一部分,甚至说制定系统就难以避免教育用户以某种方式做事。再者,这些场景中也有些模糊和疑惑的点。

无妨,需求本身就难以捉摸。需求工程中还有一个步骤叫作需求验证,常见的方法是追踪需求的跟踪关系(也就是能不能通过功能需求倒推到具体的用户需求,用户需求能不能推到最初的业务需求)、同行评审和…… 找用户确认。

那么就让我问问自己,这些用户需求是否合理,其中的疑惑应该如何解决。

  • 如何减轻处理推迟计划的心理压力?
    • “如果能专注「怎么把所有的未完成事项都通过某种方式消除」,不管是做完的、推迟的还是取消的都一视同仁,就能减轻负担。就像消消乐一样,无论是连线消除的还是用道具消除的,消完了都会觉得爽。”
    • “不过这的确把责任交给用户了,是不是好事,大概要按情况评估。上述做法对不少用户可能无用,如果能接受用户群体缩小或部分用户不满,也是可以做这个取舍的。我认为这个体现在项目理念和用户手册里就好了。”
  • 如何评估意愿?
    • “啊,真是个刁钻的问题。想做就是想做,不想做就是不想做,这是很主观的体验欸。我认为主要问题是用户可能不会主动评估自己的意愿,习惯纠结事项本身的难易程度、紧急程度和重要程度,而很少听自己的想法。这类用户就是我们用户画像和业务需求里描述的那一类。”
    • “所以,与其尝试用另一个外在的指标量化「意愿」,不如在流程上引导用户自己做出判断。比如提问用户「从 0~5 打分,你觉得你自己有多想做这件事情?」或者「在事项 A 和 B 当中,你宁愿做哪个?」”
  • 所有事项的执行意愿都低下的边界情况怎么处理?
    • “同理,总能选出更愿意做的事情。我想系统本身也提供了一个有限的选项池,让用户不至于在一两件不想做的事情和一大堆未知、模糊的可能性里做选择,在少数事项里选择更不讨厌的,可能更容易把执行意愿提上来一点。”
    • “另外,之前业务需求的分析里还提到,目标用户容易出现精力骤降的情况。如果执行意愿都很低,可能是临时的精力问题。我们的业务需求之一就是不让用户在执行过程中感到压力,这种情况下要允许用户不做某些事情。我认为这些事情都可以通过已有的回收和推迟机制解决,也就是把事项挪到第二天或者大清单里。”
  • 长期滞留在大待办清单里的事项要怎么办?
    • “滞留会带来什么风险吗?我认为是没有的,就像系统回收站不清理,除了占用磁盘空间之外也没别的害处了,而磁盘空间可能并没有那么重要。”
    • “如果出现大量想做但短期内执行意愿不高的事项滞留,这确实是先前没有考虑过的场景,值得后续讨论。不过因为风险较低,现阶段可以先搁置,实现已有的需求就好。”

好了,已经收集完一波同行评审和用户反馈了——奇怪,回答问题这人怎么既是需求分析师,又是目标用户呢? 不过,现有的方案如果贸然投入成本开发,可能不会满足需求,因为仍有不少待定的点。然而,这些点即便是在内部反复讨论,可能也不会有突破,貌似只有投入开发才能够收集到真实的数据和反馈啊。

那就投入较小成本,做个原型产品试试看吧!

原型开发和迭代

我们其实还没有完成系统建模,这也是需求分析的一部分,但是一直纠结某个活动属于生命周期的哪个部分实在是太无聊了。不多言,直接来画状态机吧。(机什么?)

前文反复提到「记录」和「标记」的概念,一条记录可以被标记为「搁置」,可以被「移动」,还可以被「删除」。状态的变更也是规则的。简单来说,可以给系统中的记录或者说事项规定以下状态:

事项可以从「待办」状态转变为「进行中」,由「进行中」转变为「完成」,也可以由「进行中」直接变成「完成」;此外还可以从「待办」变成「取消」「搁置」或「推迟」;还有名为 item(事项)和 note(笔记)的特殊标记

事项的状态变化图,使用 Excalidraw 绘制

这张图里的方框代表待办(todo),画了一捺的方框代表正在进行(doing),打勾自然表示完成(done),删除或者说取消(cancelled)也应该是不言自明的。值得注意的是推迟(postponed)和搁置(suspended)的区别,推迟表示这件事情已经被移动到之后具体的某一天的计划里了,有了明确的去处,而搁置则与古希腊哲学的悬隔(Epoché,或者 suspension of judgement)有些关联,表示这件事情还没有去处,当下也不打算做出判断,不删也不做,应该移动到不定时间的大待办清单上。

此外,还有两个不是方框的标记,分别是事项(item)和笔记(note),前者记录那些没有计划(也不需要计划)但是做了的事情,留档记录;后者记录需要今天或这周晚些时候回顾的事情,其实更像是备忘(memo)。这看似是方框和非方框的两种形式,但其实都是对记录(record)的标记(mark)而已。一条记录由标记和描述组成,就只有这么一种直观的形式。

嗯?你问我这么设计的依据是什么?是这样的,我们观察目标用户很久了,他的笔记本上用的就是这些记号。他以前也有过别的标记体系,但最终留下来的是这么一套,对他而言足够简单直观。这难道不比花里胡哨的理论和看似严密的逻辑,更具说服力吗?

噢等等,等等,架构师先生,请您冷静一下。我知道您的函数式技术倾向让您认为状态是邪恶的,但难道您不认为我们要开发的这套系统是事件驱动的,必须要关心状态变更吗?难道你要让任务一开始就单线地递归执行到底,就为了维持清晰的数据流吗?你不觉得我们的目标用户其实并没有像猴子一样多的精力吗?嗯?你问我为什么要像英美文学里的小说人物一样说话?好吧,先生,我想我只能告诉你,写这段话的人最近不务正业,看小说看得有点太多了。

如此一来功能需求其实也比较清晰了,那么来选择技术栈吧!架构师先生,您别纠结要用 Emacs Lisp 还是 Lua 写了,客户点名儿要用 Spring Boot。唉?唉唉唉!我开玩笑的,您别掐自己人中啊!害,您瞧瞧这事儿办的。其实业务需求里写得已经很清楚了,目标用户需要感官刺激可控的,也不喜欢电子设备,我觉得呀,纸和笔就够啦。至于尺寸,咱也就别用那 A4 大夹板,也别用 A5 大活页了,客户反馈说他连 A5Slim 都写不满,还嫌重呢!A6!A6 就够啦!哎呀,您就别问我说话为什么这么像北京人啦,你又不是不知道这写字儿的人整天都在发什么神经。

我们先来看看 MVP 的效果怎么样吧。

我某两天的笔记页面

恕我直言,女士,这就是两页非常普通,谁都能涂涂画画写出来的纸!

可先生,客户反馈他用这套方法感觉自己比以前任何时候都觉得轻松,完成的事情也不少,这难道我不是更符合需求的系统吗?先生,恕我直言,我觉得是时候拿出这张图了。

一个正态分布图,左侧是使用记事本的大脑萎缩之人,中间是使用 Notion、Anki、Roam Research、Obsidian、Readwise、Pocket、Google Doc、OneNote 等笔记软件形成复杂知识体系的人,而右边,是同样适用记事本的大智若愚者

某知识管理圈子里流传的神秘迷因图

可是你看,那两页纸上还有很多不规范的地方,比如星号明明是用来标记笔记和备忘的,却也被用来给待办事项分区。笔记中间还有各种飘来飘去的箭头,根本不知道是什么意思!这系统未免有点混乱。

好问题,可这不正是让我们了解到了之前没能发现的需求吗?显然只定义记录和标记并没能解决目标用户的所有问题,他还有先前未发现的,给记录分区、分类的需求。至于那些箭头,很有可能也是我们需要建模在系统里的新要素。至于这会不会让系统变得过于复杂,难以拓展,留下技术债务,就是要进一步讨论的话题了。

CI/CD 和文档

还记得软件装在盒子里售卖的年代吗?我没经历过那个年代,我开始接触编程的时候,人们就已经完全拥抱 Web 了(如今反倒有逐渐抛弃 Web 的趋势),一切都部署在服务器上。我记得不久前还有好多人说 PHP 是最好的语言呢,如今…… 呃,PHP2Shell 的确让整个互联网都见识到了 PHP 的强悍。

再新一点的概念应该是 CI/CD,也就是持续集成,持续部署,把软件的生命周期变得更灵活了一些。软件没必要打磨到完美才发售,因为软件不再装在盒子里了,更新的成本很低。如果完全部署在云上,配合自动化工作流,也能很快把新的版本编译、打包、部署到服务器上。我认为,速度并不是 CI/CD 以及与之相关的软件开发模式的最大优势,而是能够一边开发一边收集反馈,可以快速收集和验证需求,及时调整开发方向,避免在前期投入大量成本。

效率系统也是,即便是纸笔,也可以很灵活。前期可以用易于调整的活页本运行原型系统,试验一两周之后收集和整理反馈(自己问自己也是很重要的),发现问题后一条条列出来,然后一条条找到解决方案。而且,别忘了,有问题的大概率不会是用户,而是系统。所以,用到难用的软件要大大方方(但清楚有条理)地指出问题,效率系统执行不下去也不该责备自己。这种时候应该迭代一个新版本,编译后部署到自己的笔记本或者别的什么载体上。

文档是软件的一部分,大的架构决策应该要有 ADR(架构决策记录)背书,也方便后来者理解「为什么要这么做?」,系统的各个组件应该要有文档描述其内部结构。我还听过一个说法,每一行代码都是有文档的,因为每行代码提交时都有写 commit 信息(前提是你真的有好好写这个东西)。即便是自己一个人的项目,我也喜欢手写 Issues 和 Pull Request 信息,设置以计划发布的版本号命名的里程碑(Milestone),这能帮助我思考和规划,也能帮我在后来追溯当时的想法。

单独在本子的某个地方,或者别的载体(比如博客)上写清楚系统这么设计的前因后果,也有助于自己梳理思路。此外,无论是描述效率系统还是开发软件,写文档都能让我对自己的系统有新的认识,偶尔也会因此蹦出新的想法来。

当然,也要警惕,无论是沉溺与架构决策还是挖掘新需求,都有可能系统偏离原本的需求和目的,最重要的是,这样一定会忽略大量的细节问题,而细节与全局同样重要。我想 Vibe-coder 们是没有想过这些问题的,他们的软件大多是做完就丢的东西,持续时间不会很长。一上来就把系统设计得庞大又复杂,就跟跑马拉松一上来就把配速拉到极限一样。除非你根本就不打算跑马拉松,否则我建议你认真对待细节,尽可能消除危险的模糊性。

最后

嗯?你问我这套系统怎么检索?怎么和其他系统集成在一起?怎么一键导入 Anki 制作成记忆卡片,一键扫描归档进 Zettelkasten,怎么把涂画变成 Mermaid 流程图?——为什么需要那些东西?

不过话说回来,数字笔记的确有很多好处,我会在我真的需要检索、回顾和快速捕捉的场景下使用数字笔记,比方说我最近干运维的活儿6比较多,为了帮助我记住自己在哪台服务器上改了哪个配置,把哪个文件放在了哪台机子上,某个服务应该用哪个端口访问,这个端口有没有被 ufw 或者 fp7 挡住等等,我会在做了某些操作之后立刻在 Logseq 里留下一条记录。

选择 Logseq 的原因也很简单,写标签很快,后续索引也方便,大纲式笔记也很适合做事件记录。更何况 Logseq 自带日记功能,我要记录只需要打开界面,直接把某条记录写进日记页,打上标签以后就能检索到。另外,它是用 Clojure 写的,我已经不知道是第几次提到这点了。8

就这样吧,祝你也迭代出适合自己的系统。


  1. 这仅仅是针对我自己的情况而言,详情请阅读后文。我真的好害怕有 Emacs 用户跑过来教我 Emacs 和 Org Mode 的正确打开方式啊。 ↩︎

  2. Garbage collector(垃圾收集器),指代清理废旧、过期数据的机制,一般指编程语言的内存回收。 ↩︎

  3. 另称「自闭症」,也叫孤独症光谱障碍(Autism Spectrum Disorder, ASD) ↩︎

  4. 求求 Emacs 用户不要过来告诉我很多 Emacs Mode 都可以把数据写成 Org Mode,放在一个 .org 文件里管理,形式上也很统一。 One man’s meat is another man’s poison. ↩︎

  5. 我至今不能原谅 cl-annot …… ↩︎

  6. 并不是公司的活儿,而是管理我自己的 VPS,我也把升级自己个人电脑的软件包当作运维工作。 ↩︎

  7. 分别是 Ubuntu 的 Uncomplicated Firewall,和 OpenBSD 的 Packet Filter。 ↩︎

  8. 至于缺点…… 他们仍然没有支持 XDG 基本目录规范,并且现在也包含 LLM 生成的代码,再者,ClojureScript 的性能真的不算好。 ↩︎

声势浩大的知识产权盗窃

2026-10-26 13:41:18

之所以说「声势浩大」,是因为我成语储备不够,找不到「不要脸」的文雅说法。

蛛丝马迹

这几天我停用了 NetCup 的一台机子,换到了 SKRIME,装了 OpenBSD 系统愉快地玩了起来。结果,正好赶上 eMudhra 根证书密钥泄露 1和 PHP2Shell 漏洞,于是赶紧在几台机器上更新 ca-certificates,手动移除泄露的根证书。

然后我在其中一台机器上执行了 htop。

“奇怪,我 VPS 上跑的服务 CPU 占用都应该很低才对,为什么现在涨到 60% 了?”当时的我光顾着四处更新证书和排查漏洞,便没有细想。再后来,我给那台新机子装上了 beszel-agent 配置监控和告警,瞟了一眼那台怪怪的机器——“我去,负载怎么红了?”从监控页面上来看,CPU 占用最高的容器是我的 Forgejo 实例。怪了,就算是我在编译软件,那 CPU 早该爆满了也应该是 Forgejo Runner 容器占用高才对。

前几天我的 Continuwuity 实例突然暴毙,数据库也挂了,还得运行紧急修复,当时发现磁盘已经写满了。当时以为是 Akkoma 数据库没清理,毕竟我联邦的实例确实很多,跑了些基本的清理任务就没管了。可几天后,同样的事情又再次发生了。我扫描了一遍 /var 和 /home,才发现占满我磁盘的是 Forgejo 的 repo-archive 目录。

等等,那是什么东西?

these are archives that can be created for any reference (tags, commits, branches, …) if you don’t use them, I would recommend to disable them on a public facing gitea/foregejo instance since they seem to be very popular among scrapers.

这些是可以给任何引用(tag、commits、branch……)创建的归档文件。如果你不用的话,我建议在公开的 gitea/forgejo 实例上禁用,因为好像爬虫很爱爬。

—— 一位路过的热心 Fedi 网友

Forgejo 作为 Git 服务器,可以给各个版本的仓库创建无 Git 历史的 .zip 和 .tar.gz 压缩包。一般只有用户尝试下载,Forgejo 才会打包,打包之后就缓存在磁盘上。由于爬虫会贪婪地抓取网站上的每一个链接,所以几乎每个 commit、分支和版本的压缩包都被缓存到了磁盘上。

我立马关掉了这个功能,在考虑要不要架设 Anubis 或者调整 WAF 规则,可还没等我来得及想清楚,我就发现那些爬虫仍然穷追不舍。

CDN 后台的数据统计,从十月 21 日开始我的宽带用量就明显上升,而且绝大部分都是缓存未命中的流量,命中率只有 0.9% 左右

我的 CDN 服务提供商的数据统计视图

可以看到十月 21 日起,宽带用量异常上升,而十月 23 日,也就是我关掉 Forgejo 打包功能的不久后,宽带用量有所下降,但依旧高得不正常。

与此同时,我的 CPU 占用依旧居高不下。

我尝试把 WAF 规则调到最严格的那档,效果仅仅是让我自己加载不出 Forgejo 的 JavaScript 脚本,而爬虫还在猖狂地进攻,对我的反爬措施完全不为所动。说句玩笑话,蝗虫入侵估计也就就这个场面吧。

我想,既然 CPU 能扛住,我的其他服务也照常运行,何不就此装上 Iocaine ,给大语言模型下毒呢?

我的确装上了 Iocaine,不过,从统计数据来看,命中规则的只有…… 一个爬虫。剩下的请求数量,数字位数太大我已经懒得数,可能来自几家(甚至同一家)如饥似渴地想要拿到真人产出的训练数据的模型公司,但因为太会伪装,所以 Iocaine 默认的规则已经无法分辨——等等,你们这个 默认规则 就仅仅是匹配 User-Agent 而已吗!?这个谁绕不开啊?

此外我还发现了一个严重的问题,我的 CDN 账单真在肉眼可见地上涨,我是没钱跟这些没有道德底线的科技公司闹的。

I did it the Linux Kernel way

Linux 内核管理者 Konstantin Ryabitsev 今年八月底发了一篇 文章 。众所周知,Linux 内核的源代码在他们自己管理的 git.kernel.org 上,而这上面除了人类手写2的新鲜代码,还有邮件列表里人类产出的新鲜对话。这对 LLM 爬虫来说可相当诱人。

想都不用想,他们的情况只会更糟:

At any one time, across 5 geo-distributed nodes, there are 14 CPU cores doing nothing but rendering git commits as html.

在任何时间,横跨五个地区分布的节点,有 14 个 CPU 核心除了把 git commit 渲染成 HTML 页面以外什么也没做。

而 git clone 占用的才不过 10 个 CPU 核心,爬虫消耗的资源已经超过了真实用户消耗的资源。

他们的做法是用 Anubis 挡住,随着爬虫一波又一波地来,他们也一次又一次地提高 PoW 的难度,尽量让爬虫的每次请求都消耗更长的时间,也消耗它们的 CPU 资源。用户怨声载道,因为难度高了,真实用户的设备解起来也很慢,很多移动设备的 CPU 较差,甚至会发热。

但是这也没有挡住爬虫。

爬虫一直在用最蠢的方式爬 git.kernel.org——访问每一个版本、每一个分支的 commit 页面。不直接把代码克隆下来,就要爬混杂着无用 HTML 的代码,顶着 Anubis 的资源消耗也要硬爬。

最后他们无奈,只能关掉了 commit 页面的渲染。

我也只能这么做了:

# block commit urls

@blockCommit path */commit/* 
respond @blockCommit "Due to relentless LLM scrapers, I have turned off commits urls. Clone the repository if you must." 200

顺带一提,我把状态码设置成了 200,这样爬虫就会以为他们成功爬取到内容了。希望你们喜欢清洗数据。

无止尽的猫鼠游戏

其实我很早之前就做了 Cookie Monster 验证,也就是默认拒绝所有请求,返回空壳页面。空壳页面里带 <script> 标签,脚本做的事情就是帮用户设置一个 Cookie,然后刷新页面。带有这个 Cookie 的请求不会被拒绝。

这种做法利用的是大部分爬虫不会执行 JavaScript,就算执行也不会开启 Cookies 的特性。Iocaine 默认除了 User-Agent 验证,还做了浏览器特征识别,但现在用无头浏览器的爬虫也到处都是了,并不稀奇。显然,这一波蝗虫轻而易举地越过了这些简单的反爬措施,持续攻击我的 CPU。

我现在做的工作也是爬虫相关的3,所以对反爬与规避反爬的猫鼠游戏有相当程度的了解。还好我司的业务并不包含训练模型,爬虫是做别的事情用的,这才不至于让我良心难安。

一个 HTTP 请求发送到服务器(以及经过某些中间件),会携带很多与设备相关的信息,通过这些信息就能推断出请求是自动程序的概率。这些信息与网络环境(IP 地址)、HTTP 客户端、网络层协议等有关。

最简单直接也最容易伪装的特征是 User-Agent。一般带说,有道德的爬虫都会带上表明自己的身份的 UA 信息,比方说 CommonCrawl 爬虫的 UA 是 CCBot。常见 HTTP 客户端的 UA 默认固定,比方说 curl 的 UA 就是 curl/版本号,而浏览器的 UA 则复杂一些,包含操作系统、浏览器引擎版本、浏览器版本等等:

Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36

可以访问 WhatMyUserAgent.com 查看自己的 UA。

不少博客在评论区显示访客的操作系统和浏览器,多半就是用 UA 识别的。UA 非常容易伪装,不少编程语言的 HTTP 客户端库都可以直接设置,比如 Go 语言:

func main() {
 client := &http.Client{}

 req, err := http.NewRequest("GET", "http://httpbin.org/user-agent", nil)
 if err != nil {
 log.Fatalln(err)
 }

 req.Header.Set("User-Agent", "I_Am_Not_A_Bot-I_Promise")

 resp, err := client.Do(req)
 if err != nil {
 log.Fatalln(err)
 }

 // ...
}

靠 UA 检查某个请求是不是爬虫显然不可靠,有没有别的不能改的特征呢?

有的。网络层协议 TLS 和 SSL 在建立连接时,客户端会在完成 TCP 握手后发送 Client Hello 报文,这个报文并不是加密的(因为此时加密连接还没有建立)。尽管协议规定了报文应该包含哪些字段,但并没有包含字段应该以何种顺序排列。每种不同的 TLS/SSL 客户端实现,都有可能在字段顺序和部分细节上有差异,而且没人会想到去改字段顺序的。底层网络库的使用者没办法像改 UA 一样改这些字段顺序,毕竟这些都是 TLS/SSL 协议的实现细节。

于是,就可以根据字段出现的顺序生成哈希字符串,判断加密请求时由什么客户端发出的。这种指纹叫作 JA3 。所有用 Python 写的爬虫,如果调用的是同一个库,JA3 指纹都是一样的,能够立马识别。

要伪装 JA3 指纹也不难,只需要重新实现 TLS/SSL 客户端就好了。现成的库也老多了,比如 Go 语言的 uTLS ,如今的爬虫都会做 JA3 伪装。伪装的对象就是 Chrome 等真实浏览器的 TLS 特征,此外还要把操作系统、浏览器内核信息等设备信息伪装到位,比方说 TLS 层是 Firefox 的特征,但 UA 是 Chrome,甚至是 iOS 设备4,那必然是不可能的。

另一个改装成本比较大的特征是 IP。众所周知,如果数据中心的 IP 段信誉不好,从这个 IP 发出来的邮件就很容易被拒收,这也是为什么很多 VPS 提供商默认禁用 SMTP 端口,就是为了防止用户发垃圾邮件搞坏 IP 信誉。服务器在建立请求时也可以根据 IP 判断这个请求来自数据中心还是真人用户,用公司网络或者某些小代理提供商的代理服务上网,很有可能被反爬系统拦截,就是因为 IP 看起来很可疑。

住宅区的 IP 和数据中心的 IP 很容易区分,而大型爬虫一般都是部署在数据中心上的,所以对数据中心 IP 段的请求施加更严格的反爬措施就能够抵挡大部分爬虫…… 当然这是理论上的说法。

现实情况是,有种东西叫作住宅代理,爬虫通过这种代理发送请求就可以伪装成来自住宅区的真实用户。这些 IP 是怎么来的呢?答案是 智能家电 。想不到吧?你的电视盒子就可能安装了住宅代理提供商的 SDK,悄悄地用你的网络给爬虫提供服务呢。

我没了解过住宅代理的价格,但购入几十个 IP 做成旋转代理,就应该能够支撑相当高并发的爬虫,这对企业来说并不难负担。IP 一旦多起来,即便是严格的速率限制和封禁也拦不住它们了。

除了在 TLS/SSL 层伪装成浏览器,爬虫还可以用真的浏览器。预设爬虫不会运行 JavaScript 也不会存 Cookies 的想法已经过时了,爬 Linux 内核的那群人还愿意用无头浏览器硬解高难度的 PoW 算法。富翁们都愿意给 LLM 砸钱,泡沫越来越大,这点成本对他们来说不足为奇,尽管 Anthropic 和 OpenAI 都远远没有盈利,但 “AI 实在是太好用啦!”。

简单来说,那些爬虫完全有能力伪装成看起来完全真实的用户,并且以极大的规模和速率把你的整个网站都爬个遍。自建的 Git 服务器之所以成为重灾区,是因为人写的代码对他们来说最有价值,而且就算是小型的 Git 服务器,各种 commit 和 branch 的 URL 也非常多。

这就是在 2026 年维护网站的现状。

这就是偷窃

微软高管称 这种爬虫是人类历史上最大的劳动成果窃贼 。事实也确实如此,OpenAI 和 Anthropic 等模型训练公司完全不在乎训练数据的获取是否合乎道德(即,有多少网站为他们的爬虫支付了更高的成本,在用户体验上做了妥协),也不在乎这些训练数据的使用是否合法合规(即,训练数据的授权协议是否允许使用者训练模型、是否需要署名、是否需要把衍生作品用同样的协议发布、是否允许用副本及衍生作品盈利)。

我也得为他们辩解,他们的确是有正当性的。他们的正当性来自 美国特朗普政府的辩护 ,毕竟 “AI 实在是太好用啦!”“国家不搞 AI 就要落后于人啦!”

无论怎么样,他们就是在耍流氓:

  1. LLM 公司贪婪地爬取整个互联网,规模之大,让许多服务器承受压力、站长承受账单;就算没有被爬,现在租赁服务器的价格也因为他们贪婪地购买硬件而变得难以支付。
  2. LLM 公司既无视道德又无视法律,顶着 没打完的官司 继续爬数据,还要 摧毁珍贵书籍 ,滥用著佐权也践踏著作权。

最后我想提醒读者,通过任何渠道(无论是直接订阅他们的模型还是通过 OpenRouter 这样的分销商)给他们提供金钱,都是在向市场发送「我们容忍这种行为」「我们用金钱支持这种行为」的信号。如今能避免 LLM 的人可能很少了,或许只有送钱送得多还是少的区别,但这依旧是区别。


  1. 顺带一提,他们其中一个证书的密钥是 4。我十一月份的正刊一定会写这件事的。 ↩︎

  2. 如今手写的比例正在逐渐下降,你知道 Linus Torvalds 也用 Claude Code 写 Bug 吗? ↩︎

  3. 要是回去发现是自家公司的爬虫干的就好玩了。 ↩︎

  4. iOS 上没有真 Chromium,全都是 AppleWebKit。 ↩︎