summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoreltrac <hi@eltr.ac>2026-08-23 00:10:54 +0800
committereltrac <hi@eltr.ac>2026-08-23 00:10:54 +0800
commit6a95d2945805a02422f8848d34a33075371b50d1 (patch)
tree3e46f8894f4c6daad5bfa8ce9bb387a4751697c4
parenta684a88043c29a4218e3197594688dfab60e6909 (diff)
[draft] update
-rw-r--r--content/article/skeleton-programing.md219
1 files changed, 214 insertions, 5 deletions
diff --git a/content/article/skeleton-programing.md b/content/article/skeleton-programing.md
index b6414fe0..60bff357 100644
--- a/content/article/skeleton-programing.md
+++ b/content/article/skeleton-programing.md
@@ -34,7 +34,7 @@ type EntryStorage interface {
接下来业务逻辑只要依赖这个接口就好了:
-```go {hl_lines=[3,6,11]}
+```go {hl_lines=[3,6,13]}
package receiver
import "example.com/interfaces"
@@ -46,7 +46,7 @@ type Engine struct {
// ReceiveEntry 检查数据条目是否合格,如果合格就存储
// 并返回 true,如果不合格就返回 false
func (e *Engine) ReceiveEntry(entry Entry) bool {
- if (isQualifiedEntry(entry)) {
+ if (isQualified(entry)) {
e.Storage.PutEntry(entry)
return true
}
@@ -93,7 +93,7 @@ func TestReceiveEntryQualified() {
engine := Engine{ Storage: storage }
// 注入假数据并测试
- qualifiedEntry := makeQualifiedFakeEntry()
+ entry := makeQualifiedFakeEntry()
ok := engine.ReceiveEntry(entry)
if !ok {
@@ -142,7 +142,7 @@ element.onclick = (event) => {
同样地,如果要测试这个程序,只需要传入一个假的 `storage`。甚者,由于不需要实现接口,也就不需要实现接口中所有的函数,我们可以让 `fake-storage` 只包含这个测试文件用到的函数。再甚,我们都不需要真的实现任何形式的存储。
```clojure
-(def fake-storage {:put-entry #(println "Stored!")})
+(def fake-storage {:put-entry #(println % "已保存!")})
(def fake-engine {:storage fake-storage})
(def entry (make-qualified-fake-entry))
@@ -158,7 +158,216 @@ element.onclick = (event) => {
而且你看看没有接口和类型定义的 Clojure 代码有多简短吧!
-##
+## 软件的骨架
+
+如你所见,通过编写接口或使用回调函数,我们让软件中某些具体的部分变成了**插件**。还是以 JavaScript 举例,网页中的一个按钮点击之后具体会发生什么就是具体实现,而按钮可以点击这个事实就是一个接口,或者说协议。这个协议可以被更清晰地阐述为:点击了按钮之后就会发生某些事情。所谓的「某些事情」是抽象的、不定的、可替换的、可插拔的。JavaScript 通过回调函数实现这个协议。
+
+编程实际上就是类似于「点击按钮之后会发生某些事情」的对目标系统的描述,只不过使用了特定的编程语言。如 Frederick Brooks 在《No Silver Bullet》中所言,软件的内在性问题之一是:软件易变。如何应对软件需要经常改变的事实?把相对不容易改变的东西和容易改变的东西分离开来,不容易改变的就是最抽象、最高层次的业务逻辑,容易改变的东西就是数据库、按钮做的「某些事情」的具体实现。网页上每个按钮的行为都不一样,可以说按钮的行为是具体且易变的,那为什么每次有人要做一个按钮的时候,我们不需要修改按钮内部的代码?那是因为我们已经用回调函数把抽象的和具体的隔离开来了。
+
+再回顾前文有关存储层的例子。假设我现在已经实现了基于 MySQL 的存储,但用户突然要求兼容 PostgreSQL,我们只需要用 PostgreSQL 的相关库实现 `EntryStorage` 接口(也就是那三个函数),然后在装配 `Engine` 时,把 MySQL 的具体实现换成了 PostgreSQL 的具体实现。存储层是可插拔的。
+
+再假设,你在开发这个软件的期间一直使用 `FakeStorage` 把数据存在内存里,等核心的逻辑都完成时,你突然意识到这个软件要存储的数据体量非常小,而且读写的频率很低,连 SQLite 都不用上,用 JSONL 文件存储就行了。这下你就不必设计数据库 Schema 和编写 SQL 语句了,用 Go 语言自带的 JSON 标准库读写磁盘文件就好了。接下来你用几行代码实现了 `JsonEntryStorage`,插入 `Engine`,完全不用修改 `Engine` 里面的代码。
+
+软件的其他部分也都可以这么做,让各种组件都变成核心业务逻辑的插件,一方面把复杂度外包出去,软件最中心、最内部的组件只需要描述「点击按钮之后会发生某些事情」这类简单且清晰的逻辑;另一方面,面对需求的变更,只要不涉及最核心的业务逻辑,可以把变更集中外围的一两个插件当中。再者,在开发的初期阶段,你完全可以把精力放在最重要、最核心的逻辑上,把数据库等底层细节都抛之脑后。
+
+开发者只需要在编写核心业务逻辑的时候留下几个必要的接口,调试时可以使用假的、简化的实现,之后再将真实的实现装配和注入到核心组件中。虽然很不愿意称赞这门邪恶的语言,但是 Java 生态里的 Spring 框架做的就是这件事情。Java/Spring 程序员往往不需要让组件之间相互依赖,Spring 会自动将具体实现装配到对应的类当中。有一说一,这种自动化的依赖注入和控制反转(DI/IoC)框架过于方便,导致我观察到的很多学艺不精的 Java 程序员对基本的架构设计原则毫无认知。
+
+在我看来,这让编程变得跟写伪代码一样简单,描述某个软件行为却发现自己需要用到另一个组件时,或者想要把某个组件分离出去时,只需要假设某个类、某个函数存在,然后直接写就好。比方说,前文我就假设了 `isQualified(entry Entry) bool` 存在(在 Clojure 的例子中,我用的是 `(qualified? entry)`),但之前的写法并没有留出接口,让我稍作修改:
+
+```go {hl_lines=[]}
+package receiver
+
+import "example.com/interfaces"
+
+type Engine struct {
+ Storage EntryStorage
+ Judge EntryJudge
+}
+
+func (e *Engine) ReceiveEntry(entry Entry) bool {
+ if (e.Judge.IsQualified(entry)) {
+ e.Storage.PutEntry(entry)
+ return true
+ }
+ return false
+}
+```
+
+`EntryJudge` 的定义自然是:
+
+```go
+type EntryJudge interface {
+ IsQualified(entry Entry) bool
+}
+```
+
+这是 Go 推崇的「小接口」,更方便组合复用。上面的例子中,我把多个接口组合在一个结构体里,供核心业务逻辑 `ReceiveEntry()` 使用。如果遇到更复杂的逻辑,也可以拆出更多的抽象接口,而我们在一开始只需要面向接口编程,不需要考虑肮脏的底层细节扰乱心智,也不会因为迟迟解决不了某个数据库连接问题而在一开始就感到挫败。
+
+对于那些愿意与 LLM Agent 合作的人,手写接口定义和最抽象的核心业务逻辑并不困难,而留下了明确的接口、函数签名、使用场景和预期行为之后,LLM 也更有可能生成准确的代码。而且,即便没有审查 LLM 生成的具体实现(不推荐不审查就交付代码),也不会因为代码量增长而失去对整个代码库的理解,因为最核心的逻辑是开发者自己手写的。
+
+我把这种近似伪代码的编程方式称作「骷髅编程」,至于后续是人还是 LLM 来填充血肉,都不影响骨架的正确性。
+
+## 实例:寻找互联网中的环路
+
+### 搭建骨架
+
+我最近在考虑写这样一个软件,背景是这样的:既然写博客的人有不少都喜欢相互引用,如果跟随这些引用,有没有可能最终回到自己的网站呢?如果可能,那需要经过多久呢?在路上会遇到什么样的网站?具体的需求就是:给定一个网站,找到这个网站的一些外链,分别爬取这些外链并找到他们引用的外链,这样一直跟随外链,直到找到链接到自己网站的外链。
+
+好,既然需求已经清晰了,就直接开写吧。我选择用 Clojure 手写。之前我用 Clojure 写的例子都是用 `vtable` 实现的可插拔架构,其实这门语言也有接口(或者说协议),可以用 `defprotocol` 定义。
+
+其实我希望我的核心业务逻辑本身就是一个接口,如果我的业务逻辑有了很大的改变,我也可以写新的实现来替换,抑或是我需要给用户提供两种截然不同的模式来完成这个需求,也可以根据配置装配不同的接口实现来实现模式切换。
+
+```clojure
+;; 我给这个核心组件取名为奥德修斯(Odysseus)
+;; 因为我们要做的事情就是送它出去,然后让他找到回家的路
+(defprotocol Odysseus
+ "Core engine, into which other components are loaded."
+ (start [this] "Get Odysseus on his way."))
+
+;; 以下是我们的具体实现
+;; 它现在连骨架都没有,还只是个空壳
+(defrecord Ody []
+ Odysseus
+ (start [_this]
+ (println "Starting Ody...")))
+
+;; main 函数是程序的入口,它创建 Odysseus 引擎
+;; 然后调用 start 函数启动它
+;; 之后我们也会把新增的组件装配在这里
+(defn -main
+ [& args]
+ (let [ody (Ody.)]
+ (start ody)))
+```
+现在执行这个程序只会打印 `Starting Ody...`,是时候搭建骨架了。在此之前,回顾我们的需求描述。
+
+> 给定一个网站,找到这个网站的一些外链,分别爬取这些外链并找到他们引用的外链,这样一直跟随外链,直到找到链接到自己网站的外链。
+
+那么这个骨架应该包含:
+
+1. 获取这个给定的网站
+2. 获取给定网站中包含的外链
+3. 获取外链的外链
+4. 如果外链的外链指向一开始给定的网站,就给出一条结果
+
+我已经看出循环和递归的结构了,这个程序就是一直在提取外链而已。不少程序员的直觉可能是立刻动手开始编写获取 HTML 网页的逻辑,考虑应该使用正则匹配还是类似 [Beautiful Soup](https://beautiful-soup-4.readthedocs.io/en/latest/) 的解析工具,获取到一条环路之后应该存哪这些问题。*But you and I should know better.*
+
+照着我现在的理解,先写一段代码试试看:
+
+```clojure {hl_lines=["5-11"]}
+(defrecord Ody [home over? crawler ]
+ Odysseus
+ (start [_this]
+ (println "Starting Ody...")
+ (loop [journey []]
+ (when (empty? journey)
+ (recur [home]))
+ (when (over? journey)
+ journey)
+ (let [ways (find-ways crawler (last journey))]
+ ;; ...
+ ))))
+```
+
+我在写的时候遇到了一个问题,但在讨论这个问题之前,先来看看我写了些什么。
+
+`loop` 是 Clojure 中的循环结构,它的第一个参数是可变数量的 `binding`,装在一个向量类型(也就是 `[]`)里面。你可以理解为我们构造了一个函数,而在这个 `binding` 里填入的是函数的参数和参数的初始值。`loop` 体中有 `recur` 函数,这就是递归的意思。`recur` 让程序重新回到 `loop` 体的开头,并且重新给 `binding` 赋值——这其实就是递归函数,只不过用了循环的写法,而且 Clojure 给 `recur` 做了优化,即便多次递归也不会让函数调用栈溢出。
+
+在这个递归函数里,程序先判断 `journey` 是否为空,如果为空就递归并传入 `[home]` 作为 `journey` 的新值。这个 `home` 就是环路的起点也是终点。*由于在写代码的过程中意识到我需要 `home` 的值作为起点,所以我在 `Ody` 的字段里添加了 `home`*。我用向量类型表示一条路径,向量的第一个元素是起点,中间的元素是路途中经过的链接,最后一个元素是终点,也就是说,当第一个元素和最后一个元素相等时,这个 `journey` 就可以作为结果返回了。
+
+不过,我没有写 `(= (first journey) (last journey))`,而是引入了回调函数 `over?`,使用它判断是否结束。前者是具体的,后者是抽象的。还有一个考量是,判断环路是否找到的标准不定,是找到完全一样的 URL 才算呢?还是找到同一个域名就算呢?还是找到同一个子域的也算呢?不知道,不关心,写个抽象的函数先用着吧。*由于在写代码的过程中意识到要有一个判断路途是否结束的函数,所以我在 `Ody` 的字段里添加了 `over?`*。
+
+如果 `journey` 既不是空的,也没有被判定结束,那么就说明搜寻还在进行当中。这个阶段要做的事情,就是爬取最新的一条 URL(`(last journey)`),找到新的外链。一如既往,*我意识到自己需要一个爬虫,所以添加了 `crawler` 字段*,调用它将会提供的 `find-ways` 函数获取外链。这之后,只要用其中一个 URL 作为新的 `(last journey)`,再次递归就好了。
+
+等等,「其中一个」?如果一个页面有多个外链,我要怎么确定应该用哪一个?话说回来,我肯定不能让程序只找到一条环路就停下吧?这样简单的操作完全可以并发执行,可如果要并发执行,而每递归一次都会得到多个外链的话,并发数不会指数级上升吗?就算控制全局并发数,似乎也有太多任务了,根本跑不完,看起来应该做剪枝和限制,比方说剔除掉不太可能引用自己的网站(Microsoft、Google 等),除了自己的网站,后面的每一条最多发散出 3 条外链等等。
+
+不管不管,太复杂了,先抽象出一个门卫组件干这个事情吧,具体实现之后再说。
+
+```clojure
+(let [ways (->> (last journey)
+ (find-ways crawler)
+ (filter-ways gatekeeper))]
+ ;; ...
+)
+```
+
+至于并发控制,Clojure 支持和 Go 一样的并发模型,所以我们只需要一个固定缓冲大小的 `channel` 就好,此时就要考虑两种模式:
+
+1. 把 `channel` 当作信号量(semaphore)来用,初始化时存入几个空值当作信号量,每当有人试图并发一个新的爬取任务时,都要尝试获取信号量,如果信号量消耗完了,就要原地等待。
+2. 把待爬取的 URL 放进固定缓冲的 `channel` 里,让几个独立的爬虫协程读这个 `channel`;当缓冲写满,而爬虫的消费能力不足时,尝试写入 `channel` 的协程就会被阻塞。爬取一直在进行,而 `gatekeeper` 和 `over?` 等组件,包括 `Odysseus` 本身,都自动地根据爬虫的消费能力调整任务进行的速度。
+
+第二种模式还解决了任务分发的问题,看起来也很干净。实际上这还把爬虫 `crawler` 与核心逻辑 `Odysseus` 解耦了,我只需要把爬取请求放进 `channel` 里,然后等待结果就好了。不过这引发了新的问题:没有函数调用就没有返回值,我要怎么拿到爬取结果呢?
+
+这个好解决,我们可以塞一个向量给 `channel`,向量的第一个元素是要爬取的 URL,第二个元素是一个新的 `channel`,等爬取任务完成之后,爬虫就把结果写入这个 `channel`,而 `Odysseus` 这边只需要等待就好了。另外,如果决定把 `crawler` 分离出去,那在收到 `crawler` 的结果之后再用 `gatekeeper` 筛一遍,似乎有些没道理,可以把 `gatekeeper` 和 `crawler` 装配到一起,而 `crawler` 只返回已经过滤之后的结果——幸好我们的组件都很小巧抽象,做这样的改动并不会很麻烦。
+
+```clojure
+(defn- start-worker [crawler gatekeeper crawl-queue]
+ (go-loop [task (<! crawl-queue)]
+ ;; task is a vector like [url channel]
+ (let [urls (->> (first task)
+ (find-ways crawler)
+ (filter-ways gatekeeper))]
+ (>! (last task) urls))
+ (recur (<! crawl-queue))))
+
+(defrecord workers [crawler gatekeeper crawl-queue] Workers
+ (start-workers [_this n]
+ (dotimes [_ n]
+ (start-worker crawler gatekeeper crawl-queue))))
+```
+
+如你所见,这段代码可以启动 `n` 个 `worker`,而每个 `worker` 都是一个异步的循环,一直尝试从 `crawl-queue` 中读取任务。一旦读取到任务,就用 `crawler` 爬取外链,用 `gatekeeper` 过滤外链,然后把结果通过 `channel` 返回。至于核心逻辑 `Odysseus` 这边,只需要把任务传给 `crawl-queue`,然后从自己设定的 `channel` 中取出数据就好了。
+
+不过我很快发现 `loop` 和 `recur` 已经不适合 `Odysseus` 了,因为每次循环都可能发散出多个 URL,但一个 `loop` 只能用一次 `recur`,也就是只能传递一条新的 `journey` 路径给下一级递归。为了产生多条分支,是时候请出真正的递归函数了!
+
+```clojure
+(defn- hop [journey over? home crawl-queue]
+ (let [journey (if (empty? journey) [home] journey)]
+ (if (over? journey)
+ ;; if journey's over, print the only one
+ [journey]
+ ;; if not, keep crawling and find fresh urls
+ ;; start multiple hop forks and concat their result together
+ (let [receive-chan (chan)]
+ (>!! crawl-queue [(last journey) receive-chan])
+ (let [urls (<!! receive-chan)]
+ (mapcat #(hop (conj journey %) over? home crawl-queue) urls))))))
+
+(defrecord Ody [home over? crawl-queue] Odysseus
+ (start [_this]
+ (println "Odysseus is finding way home to: " home)
+ (hop [] over? home crawl-queue)))
+```
+
+其实一开始我就犯了个小错误,`Odysseus` 返回的不应该是一条 `journey`,应该是它能找到的所有环路。这里提取出来的递归函数是 `hop`(跳)。如果 `journey` 是空的就直接改成 `[home]`,没必要再递归一次。如果判定结束了,就返回只包含一个环路的向量 `[journey]`。如果没结束就继续往下,把 URL 用 `channel` 交给爬虫 `workers`。拿到多个 URL 结果之后,对每个 `URL` 执行一次 `hop` 递归,最后把子 `hop` 的结果全部拼接起来——这一步用 `mapcat` 就可以做到,非常省事。
+
+现在还剩下的问题是,尽管爬虫是异步的,与核心逻辑解耦了,但核心逻辑本身是串行的,一次递归执行完毕之后才执行下一个,同时也只会有一个爬取任务被传入 `crawl-queue`,根本利用不了并发资源。所以,要把 `hop` 也改成并行的。
+
+如果只是给 `hop` 套上一层 `go`,它的确会并发,但内部的递归逻辑就要修改了。因为 `(go (hop ...))` 返回的是 `channel` 而非 `hop` 的返回值,必须要想办法读取这些 `channel` 才行。
+
+```clojure {hl_lines=[2,"11-13"]}
+(defn- hop [journey over? home crawl-queue]
+ (go (let [journey (if (empty? journey) [home] journey)]
+ (if (over? journey)
+ ;; if journey's over, print the only one
+ [journey]
+ ;; if not, keep crawling and find fresh urls
+ ;; start multiple hop forks and concat their result together
+ (let [receive-chan (chan)]
+ (>! crawl-queue [(last journey) receive-chan])
+ (let [urls (<! receive-chan)
+ subchans (mapv #(hop (conj journey %) over? home crawl-queue) urls)
+ results (vec (for [c subchans] (<! c)))]
+ (apply concat results)))))))
+```
+
+最后一个问题,目前看来 `hop` 如果没有找到满足 `over?` 条件的路径,就会一直执行,是不是应该写一个最大跳数的限制?不不不,别忘了 `over?` 函数是抽象的,最大跳数的限制完全可以由 `over?` 的具体实现去处理,不必为此让核心逻辑变得复杂。
+
+至此,核心业务逻辑基本完成了。即便写了 `defprotocol` 定义,也只有**不到一百行**代码(不过这很大程度上要归功于 Clojure 语法的简洁性,而且为了写出这么简洁的代码我可真是想破了脑袋啊)。在给这个轻巧的骨架填充血肉之前,还有一件重要的事情要做。
+
+### 测试骨架
+
+### 填充血肉
## 推荐阅读