diff options
| author | eltrac <hi@eltr.ac> | 2026-08-23 12:11:14 +0800 |
|---|---|---|
| committer | eltrac <hi@eltr.ac> | 2026-08-23 12:11:14 +0800 |
| commit | 010e895957722815e8c52e7f35b0c252afb3f8cc (patch) | |
| tree | 0d3f1de2421fe301002b0ceaec53bf5f98b798c2 /content | |
| parent | 6a95d2945805a02422f8848d34a33075371b50d1 (diff) | |
[draft] update
Diffstat (limited to 'content')
| -rw-r--r-- | content/article/skeleton-programing.md | 105 |
1 files changed, 97 insertions, 8 deletions
diff --git a/content/article/skeleton-programing.md b/content/article/skeleton-programing.md index 60bff357..3472eef6 100644 --- a/content/article/skeleton-programing.md +++ b/content/article/skeleton-programing.md @@ -335,7 +335,6 @@ type EntryJudge interface { (defrecord Ody [home over? crawl-queue] Odysseus (start [_this] - (println "Odysseus is finding way home to: " home) (hop [] over? home crawl-queue))) ``` @@ -343,32 +342,122 @@ type EntryJudge interface { 现在还剩下的问题是,尽管爬虫是异步的,与核心逻辑解耦了,但核心逻辑本身是串行的,一次递归执行完毕之后才执行下一个,同时也只会有一个爬取任务被传入 `crawl-queue`,根本利用不了并发资源。所以,要把 `hop` 也改成并行的。 -如果只是给 `hop` 套上一层 `go`,它的确会并发,但内部的递归逻辑就要修改了。因为 `(go (hop ...))` 返回的是 `channel` 而非 `hop` 的返回值,必须要想办法读取这些 `channel` 才行。 +本 Go 程序员写 Go 程序写得太多了,一开始也想用 `go` 让 `hop` 并行,然后便踩了坑,写出了很复杂的代码。实际上 Go 标准库里的 `future` 就足够好用。`future` 同样异步执行,返回一个引用 `ref`,之后 `deref`(或者用语法糖 `@`)就能拿到结果,如果 `future` 没有完成,`deref` 的调用者就会原地等待。 ```clojure {hl_lines=[2,"11-13"]} (defn- hop [journey over? home crawl-queue] - (go (let [journey (if (empty? journey) [home] journey)] + (future (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)))] + (>!! crawl-queue [(last journey) receive-chan]) + (let [urls (<!! receive-chan) + futures (mapv #(hop (conj journey %) over? home crawl-queue) urls) + results (mapv deref futures)] (apply concat results))))))) ``` +现在就可以了,`hop` 并发了更多的递归 `hop`,之后再逐个等待结果。看起来并发之后立刻阻塞并等待,好像和直接调用没有区别,但关键的区别在于,如果把递归结构看成一棵树的话,我们就不是在进行深度优先遍历,而是并发地对每个子树同时进行遍历,效率会高很多。由于我们用了有缓冲的 `channel`,`hop` 在把任务放进爬取队列时就可能因为缓冲区已满而阻塞,只要设置合理的缓冲区大小,就不用担心性能问题。 + +不过,即便是并发,网络操作仍然很慢,我们不能等程序把所有的环路都找到了之后才打印结果吧?是不是应该每找到一个结果就打印一次?要在 `hop` 里调用 `println`?不不不,如果这个软件要做成 Web 程序、GUI 程序呢?要怎么收集到这些结果?显然 `println` 是具体实现。那只要换成一个抽象函数就好了。 + +```clojure {hl_lines=[1,5,11]} +(defn- hop [journey over? home crawl-queue put-result] + (future (let [journey (if (empty? journey) [home] journey)] + (if (over? journey) + ;; if journey's over, print the only one + (put-result journey) + [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) + futures (mapv #(hop (conj journey %) over? home crawl-queue put-result) urls) + results (mapv deref futures)] + (apply concat results))))))) +``` + +很好,现在骨架只知道「要把结果放到某个地方」,并不关心放在那里。最简单的实现是把结果放进 `channel` 里,让消费者自取,这样的话我们甚至不需要写函数定义,在 `put-result` 的位置传入 `(partial put! result-queue)` 就好,`partial` 返回的是预先填好了几个参数的函数。这么说的话,我们也不必把 `crawl-queue` 这种细节暴露给 `hop`,也可以改为抽象函数。由于两个都和传送数据的通道相关,不妨放进一个 `vtable` 里,就叫 `pipelines`(管线)吧。事已至此,就干脆把这个包里的 `clojure.core.async` 引用移除掉,所有关于 `channel` 的操作都是具体的,应该交由外部实现来解决。 + +我又发现 `home` 这个参数完全没有意义,我为什么不能一开始就传入 `[home]` 而不是空向量 `[]` 呢?于是也顺带改了。 + +在添加新功能(每找到一个环路就上报一次)的同时顺带重构了代码,现在 `hop` 函数看起来干净了很多: + +```clojure {hl_lines=[1,6,10,12]} +(defn- hop [journey over? pipelines] + (future (if (over? journey) + ;; if journey's over, print the only one + (do + ((:put-result pipelines) journey) + [journey]) + ;; if not, keep crawling and find fresh urls + ;; start multiple hop forks and concat their result together + (let [urls ((:get-crawl pipelines) (last journey)) + futures (mapv #(hop (conj journey %) over? pipelines) urls) + results (mapv deref futures)] + (apply concat results))))) +``` + +我把「把爬取任务放入管道,然后等待结果」都封装到一个 `:get-crawl` 函数里了。这个 `pipelines` 其实完全可以写成协议,也就是之前看到的 `defprotocol` 和 `defrecord`,不过目前软件里还没有很复杂的管线,用一个简单的数据结构足矣。 + 最后一个问题,目前看来 `hop` 如果没有找到满足 `over?` 条件的路径,就会一直执行,是不是应该写一个最大跳数的限制?不不不,别忘了 `over?` 函数是抽象的,最大跳数的限制完全可以由 `over?` 的具体实现去处理,不必为此让核心逻辑变得复杂。 -至此,核心业务逻辑基本完成了。即便写了 `defprotocol` 定义,也只有**不到一百行**代码(不过这很大程度上要归功于 Clojure 语法的简洁性,而且为了写出这么简洁的代码我可真是想破了脑袋啊)。在给这个轻巧的骨架填充血肉之前,还有一件重要的事情要做。 +至此,我通过只思考抽象问题,逐步求精,写完了核心的业务逻辑。即便写了 `defprotocol` 定义,也只有**不到一百行**代码——包括注释和 `require` 语句,`ody.clj` 只有 19 行,`workers.clj` 只有 17 行,各种协议定义 `protocols.clj` 有 15 行(不过这很大程度上要归功于 Clojure 语法的简洁性,而且为了写出这么简洁的代码我可真是想破了脑袋啊)。这 51 行代码就是这个软件的骨架,在填充血肉之前,还有一件重要的事情要做。 ### 测试骨架 +在我们把 HTTP 客户端、HTML 解析、URL 规范化和对比、`robots.txt` 解析、结果输出、用户界面等一系列无底洞般的细节引入到软件中之前,应当先保证骨架本身有效。Lisp 程序员们可以用 REPL 命令行里直接调用软件内部的函数进行测试,测试的过程中随手定义一些假的实现喂给核心逻辑也非常容易。我虽然写了很多 Clojure 代码(不过才五十几行吧),但本文实际上是与语言无关的,所以我还是用更通用的方式来测试——写测试文件。 + +不过老实说,更好的做法是测试驱动开发(TDD),即先写测试,再写代码。但…… 我都已经写完了还能怎么办呢? + +要测试 `ody.clj` 很简单,只需要保证递归函数 `hop` 的正确性。`hop` 预期的正常行为是:输入 `[home]`,通过 `home` 发散出更多的 URL,递归出更多的分支,把每条完成的分支写入管线,最后返回全部分支。注意,「完成」,也就是 `over?` 函数是抽象的,这个完成的条件不一定是「找到和 `home` 一样的 URL」。完成的条件判断得准不准、是否有缺陷,是实现 `over?` 函数的组件的工作,应该在测试那个组件的时候才考虑。也就是说,此时我们完全不必考虑 `hop` 能不能给出我们想要的环路,我们只要保证它能完成预期的行为就好了。甚至啊甚至,这个 `home` 和最后得到的 `journey`,都不一定得是 URL。 + +```clojure +(deftest hop-happy-path-test + (testing "Happy path of the recursive hop function." + (let [result-chan (chan) + pls {:put-result (partial put! result-chan) + :get-crawl (fn [num] (mapv #(+ % num) [1 2 3]))} + ody (->Ody 0 #(>= (last %) 5) pls) + want #{[0 1 2 3 4 5] [0 1 2 3 4 6] [0 1 2 3 4 7] [0 1 2 3 5] [0 1 2 3 6] [0 1 2 4 5] [0 1 2 4 6] [0 1 2 4 7] [0 1 2 5] [0 1 3 4 5] [0 1 3 4 6] [0 1 3 4 7] [0 1 3 5] [0 1 3 6] [0 1 4 5] [0 1 4 6] [0 1 4 7] [0 2 3 4 5] [0 2 3 4 6] [0 2 3 4 7] [0 2 3 5] [0 2 3 6] [0 2 4 5] [0 2 4 6] [0 2 4 7] [0 2 5] [0 3 4 5] [0 3 4 6] [0 3 4 7] [0 3 5] [0 3 6]}] + ;; match journey results + (is (= (set @(start ody)) want)) + ;; collect channel and count + (loop [counter 0] + (if (poll! result-chan) + (recur (inc counter)) + (is (= counter (count want)))))))) +``` + +这个测试中的 `journey` 不是 URL 向量,我把「爬取和发散 URL」变成了「累加数字」。初始 URL 变成了初始数字 `0`。测试 `Ody` 的时候我没有启动爬虫,注意我传入的管线 `pipelines`,其中 `:get-crawl` 并没有把新 URL 放进 `crawl-queue` 里再等待结果,而是对数字进行了三次加法运算,发散出 `n+1`、`n+2` 和 `n+3` 三个数字。再看 `over?` 的具体实现 `#(>= (last %) 5)`,当累加 `journey` 的最后一个数字大于等于 `5`,就算做结束。 + +由于并发的执行顺序不定,为了避免最终结果的顺序影响断言,我把它转换成了集合类型(`set`),然后与包含所有累加路径的集合对比。最后再把 `result-chan` 里的内容读出来,看看 `Ody` 是否传入了正确数量的路径结果。 + +用 Leinigen 运行测试: + +```shell +➜ ody git:(feat_core) ✗ lein test + +lein test ody.ody-test + +Ran 1 tests containing 2 assertions. +0 failures, 0 errors. +``` + +测试通过!接下来应该考虑边界条件,写更多的测试,然后还要写 `workers.clj` 的单元测试,看它能不能正常启动 N 个爬虫,能不能正确消耗 `crawl-queue` 里的任务等等。具体的就省略了。 + +总之,合理的抽象、接口和骨架式的编程不仅可以让人在开发核心功能时只关注核心逻辑,而不被技术细节困扰,还能让单元测试变得容易,不必等到整个项目近乎完成时才做集成测试。 + ### 填充血肉 +那些跟 LLM Agent 一起工作得不亦乐乎的人,其实已经可以放手,把写好的接口定义和核心业务逻辑丢给 Agent,让机器实现人没有实现的接口,在 `main` 组件中装配起来,然后就能跑了。跟 Vibe Coding 不同的是,开发者已经对软件的核心逻辑、接线点、输入输出和内部结构了如指掌,不会面对大量安全性、可读性和质量堪忧的代码发懵。开发者不会像 Vibe Coders 那样因为不理解代码而事事求助于机器,不会让自然堆积的屎山代码填满 LLM 的上下文,燃烧大把大把的美钞。再者,由于开发者对代码的理解很充分,他可以写出更清晰的提示词,甚至精确到代码行,让 Agent 消耗更少的 Token 和时间完成任务,而不必把大量的 Token 都用在「推测用户意图」上。 + +不过,LLM 生成代码的版权状态未知,我不知道 Agent 填进我仓库的代码是从谁哪里偷的,应该遵从什么开源协议,应该署谁的名字,所以我不会用 LLM 生成的代码。接下来我将手写接口实现,把所有组件都装配起来,产生可运行的软件原型。 + ## 推荐阅读 本文的部分观点来自以下书籍: |
